Compare commits

...
Author SHA1 Message Date
whyour eac7a47f30 fix: run ARMv6 archive workers without V8 JIT and verify offline installation 2026-10-17 23:58:52 +08:00
whyour 71a13a1779 fix: allow bounded slow-link transfers for full upgrade bundles 2026-10-17 22:57:11 +08:00
whyour 4040747936 fix: declare isolated CLI archive build dependency and label full downloads 2026-10-17 22:47:56 +08:00
whyour 035fe8c5d2 fix: verify archived permissions and coordinate offline upgrade state 2026-10-17 22:42:25 +08:00
whyour aa150f051e feat: ship verified platform upgrade bundles with production dependencies 2026-10-17 20:51:07 +08:00
whyour cb5dfec2b5 fix: preserve cron semantics during node-cron adaptation
Preserve supported expression fields instead of stringifying them, and probe individual fields only when whole-expression validation fails. Convert unsupported syntax and weekday ranges ending in 7 while preserving legacy date/weekday rules and valid OR branches.

Add execution regressions for issue #3099 and 64 calendar boundary cases in UTC and Asia/Shanghai. Ensure timezone child tests actually run under the Node test runner.

Validation: 745 tests passed, 8 skipped; final targeted regressions passed; backend compilation passed. Differential calendar checks matched 142,360 execution timestamps.

Fixes #3099
2026-10-17 20:00:33 +08:00
whyour 286088a32c 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.
2026-10-17 17:14:15 +08:00
whyour a1104ef47c fix: report page load timing after load handlers complete 2026-10-17 09:03:50 +08:00
whyour 1a152575cc fix: preserve active system log files when clearing logs 2026-10-17 07:42:10 +08:00
whyour ef348b445b fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
2026-10-17 07:07:09 +08:00
whyour 8305825ffd Merge Python dependency and Shell regression fixes
fix: Python dependency status, GNU awk compatibility and dev regressions
2026-10-15 23:41:16 +08:00
108 changed files with 8015 additions and 782 deletions
+87
View File
@@ -8,6 +8,8 @@ on:
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "cli/src/internal/maintenance/platformBundle.cjs"
- "cli/src/internal/maintenance/panelUpgrade.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
@@ -94,6 +96,12 @@ jobs:
run: |
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
- name: publish verified upgrade download sizes
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
@@ -197,6 +205,8 @@ jobs:
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04
permissions:
packages: write
@@ -262,6 +272,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -283,6 +294,8 @@ jobs:
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04
permissions:
packages: write
@@ -347,6 +360,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -557,3 +571,76 @@ jobs:
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-bundles:
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=ghcr.io/${GITHUB_REPOSITORY}@${digest}" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+9
View File
@@ -38,3 +38,12 @@ __pycache__
# Local security audit artifacts
/audit/
# Local browser tests, screenshots, reports, and temporary test output
/.playwright-cli/
/output/
/playwright-report/
/test-results/
/blob-report/
/coverage/
/.nyc_output/
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
import {
diagnoseClientIp,
getTrustProxyConfig,
@@ -36,7 +37,7 @@ export default (app: Router) => {
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : '配置更新失败',
message: error instanceof Error ? error.message : t('配置更新失败'),
});
}
},
+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(
'/reload',
celebrate({
+2
View File
@@ -5,6 +5,7 @@ import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { errStack } from './shared/errors';
import { initializeLanguageSync } from './shared/languageSync';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
@@ -27,6 +28,7 @@ class Application {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
await initializeLanguageSync();
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
+2 -2
View File
@@ -6,7 +6,7 @@ import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import validationErrors from '../middlewares/validationErrors';
import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
@@ -197,7 +197,7 @@ export default ({ app }: { app: Application }) => {
});
});
app.use(errors());
app.use(validationErrors);
app.use(uniqueConstraintError);
app.use(
+2 -2
View File
@@ -63,7 +63,7 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
@@ -78,7 +78,7 @@ export default async () => {
assertSubscriptionAlias(doc.alias);
await subscriptionService.handleTask(doc, !sub.is_disabled);
} catch (error) {
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
}
}
};
+18
View File
@@ -0,0 +1,18 @@
import { errors, isCelebrateError } from 'celebrate';
import { ErrorRequestHandler } from 'express';
import { t } from '../shared/i18n';
const respond = errors();
// Schemas are compiled once at startup; resolve their messages per request so
// changing the system language also changes errors from those existing schemas.
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
if (isCelebrateError(error)) {
for (const detail of error.details.values()) {
detail.message = detail.details.map((item) => t(item.message)).join('. ');
}
}
return respond(error, req, res, next);
};
export default validationErrors;
+3 -3
View File
@@ -5,7 +5,7 @@ import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
import { t, tf } from '../shared/i18n';
// Validate the entire batch before replacing any existing jobs.
const isValidCronField = (cron: string): boolean => {
@@ -67,7 +67,7 @@ const addCron = (
createCronJob(
schedule,
async () => {
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command);
await runCron(
item.command,
item,
@@ -104,7 +104,7 @@ const addCron = (
scheduleStacks.set(id, jobs);
jobs.forEach((job) => job.start());
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
id,
jobs.length,
);
+16 -15
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data';
@@ -9,24 +10,24 @@ const delCron = (
) => {
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(
'[schedule][取消定时任务] 任务ID: %s',
id,
);
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
// 防御性过滤历史调度栈中的空任务,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks
.get(id)
?.filter((x) => x != null)
.forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id);
}
}
+7 -7
View File
@@ -641,7 +641,7 @@ export default class CronService {
stopped.add(pid);
} catch (error) {
this.logger.error(
'[panel][停止任务失败] PID: %s, 错误: %s',
t('[panel][停止任务失败] PID: %s, 错误: %s'),
pid,
asError(error).message
);
@@ -695,7 +695,7 @@ export default class CronService {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
);
}
}
@@ -791,20 +791,20 @@ export default class CronService {
const result = await completed;
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
id,
result.error.message
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
id,
result.code
);
return { ...cron, pid: cp.pid, ...result } as any;
} catch (error) {
this.logger.error(
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
cronId,
asError(error).message
);
@@ -813,7 +813,7 @@ export default class CronService {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
'[panel][关闭任务日志失败] %s',
t('[panel][关闭任务日志失败] %s'),
asError(error).message
);
}
@@ -845,7 +845,7 @@ export default class CronService {
);
} catch (error) {
this.logger.error(
'[panel][清理任务状态失败] %s',
t('[panel][清理任务状态失败] %s'),
asError(error).message
);
}
+4 -3
View File
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
import Logger from '../loaders/logger';
import cronClient from '../schedule/client';
import { HttpServerService } from './http';
import { runtimeBuild } from '../shared/runtimeBuild';
interface HealthStatus {
build: typeof runtimeBuild;
status: 'ok' | 'error';
services: {
http: boolean;
@@ -22,12 +24,11 @@ interface HealthStatus {
export class HealthService {
private startTime = Date.now();
constructor(
private httpServerService: HttpServerService,
) {}
constructor(private httpServerService: HttpServerService) {}
async check(): Promise<HealthStatus> {
const status: HealthStatus = {
build: runtimeBuild,
status: 'ok',
services: {
http: true,
+2 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
@@ -126,7 +127,7 @@ export class MetricsService {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info('性能指标报告:', report);
Logger.info(t('性能指标报告:'), report);
return report;
}
}
+13 -12
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createCronJob, CronJob } from '../shared/cronScheduler';
@@ -83,7 +84,7 @@ export default class ScheduleService {
let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null };
try {
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
@@ -103,7 +104,7 @@ export default class ScheduleService {
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
others.id,
result.error.message,
);
@@ -111,7 +112,7 @@ export default class ScheduleService {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
'[panel][任务错误回调失败] %s',
t('[panel][任务错误回调失败] %s'),
asError(error).message,
);
}
@@ -127,12 +128,12 @@ export default class ScheduleService {
} catch (error) {
result.error ??= asError(error);
this.logger.error(
'[panel][任务结束回调失败] %s',
t('[panel][任务结束回调失败] %s'),
asError(error).message,
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
others.id,
result.code,
);
@@ -141,7 +142,7 @@ export default class ScheduleService {
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
@@ -156,7 +157,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
_id,
schedule,
name,
@@ -183,7 +184,7 @@ export default class ScheduleService {
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
_id,
schedule,
asError(error).message,
@@ -204,7 +205,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -219,7 +220,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
_id,
name,
command,
@@ -236,7 +237,7 @@ export default class ScheduleService {
},
(err) => {
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
command,
err,
);
@@ -264,7 +265,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
_id,
name,
);
+21 -10
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs/promises';
@@ -61,10 +62,10 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
} catch {}
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
} catch (error) {
this.logger.error('生成私钥文件失败', error);
this.logger.error(t('生成私钥文件失败'), error);
}
}
@@ -73,7 +74,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
this.logger.error(t('删除私钥文件失败'), error);
}
}
@@ -82,9 +83,13 @@ export default class SshKeyService {
host: string,
proxy?: string,
) {
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
if (
!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))
) {
throw Object.assign(new Error('Invalid SSH host or proxy'), {
status: 400,
});
}
if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
@@ -92,7 +97,10 @@ export default class SshKeyService {
const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock(
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
@@ -108,7 +116,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
}
}
@@ -147,7 +155,7 @@ export default class SshKeyService {
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
}
}
}
@@ -167,7 +175,10 @@ export default class SshKeyService {
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
+66 -50
View File
@@ -5,7 +5,7 @@ import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
import { request } from 'undici';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
@@ -282,26 +282,24 @@ export default class SystemService {
}
public async checkUpdate() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const currentVersionContent = await parseVersion(config.versionFile);
let lastVersionContent;
try {
const { body } = await request(
`${config.lastVersionFile}?t=${Date.now()}`,
{
dispatcher: new Agent({
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 30000,
}),
},
);
const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) {}
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
const { body, statusCode } = await request(
`${config.lastVersionFile}?t=${Date.now()}`,
{ signal: controller.signal },
);
const text = await body.text();
if (statusCode !== 200) throw new Error('Version request failed');
const lastVersionContent = parseContentVersion(text);
if (
!lastVersionContent ||
typeof lastVersionContent.version !== 'string' ||
!/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
) {
throw new Error('Invalid version response');
}
return {
@@ -319,8 +317,10 @@ export default class SystemService {
} catch (error: any) {
return {
code: 400,
message: error.message,
message: t('检查更新失败,请检查网络后重试'),
};
} finally {
clearTimeout(timeout);
}
}
@@ -344,45 +344,51 @@ export default class SystemService {
}
public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
return this.startUpgradeJob('download');
}
cp.stdout.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
private upgradeJobs() {
return require(path.join(
config.rootPath,
'cli/src/internal/maintenance/panelUpgrade.cjs',
));
}
cp.stderr.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
public async getUpgradeStatus() {
return {
code: 200,
data: await this.upgradeJobs().getStatus(config.tmpPath),
};
}
cp.on('error', (err) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: JSON.stringify(err),
status: 'failed',
});
});
cp.on('exit', (code) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: '',
status: code === 0 ? 'success' : 'failed',
});
});
return { code: 200 };
private async startUpgradeJob(action: 'download' | 'apply') {
try {
const data = await this.upgradeJobs().startJob(
{
root: config.rootPath,
tmp: config.tmpPath,
env: {
...process.env,
dir_data: config.dataPath,
dir_config: config.configPath,
QlPort: String(config.port),
QlBaseUrl: config.baseUrl,
},
},
action,
);
return { code: 200, data };
} catch (error: any) {
this.logger.error('[upgrade] %s', error.message);
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
}
}
public async reloadSystem(target?: 'system' | 'data') {
if (target && !['system', 'data'].includes(target)) {
return { code: 400, message: t('参数错误') };
}
if (target === 'system') return this.startUpgradeJob('apply');
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
@@ -606,7 +612,17 @@ export default class SystemService {
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) {
await rmPath(path.join(config.systemLogPath, log.title));
// All workers keep append handles open; unlinking strands future writes
// in a deleted inode until rotation. Preserve the file while clearing it.
const file = await fs.promises.open(
path.join(config.systemLogPath, log.title),
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
);
try {
await file.truncate(0);
} finally {
await file.close();
}
}
}
+2 -2
View File
@@ -173,7 +173,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
t(address) +
' ' +
req.platform +
t('端') +
@@ -219,7 +219,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
t(address) +
' ' +
req.platform +
t('端') +
+66 -9
View File
@@ -22,6 +22,7 @@ export interface CronSchedule {
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
@@ -58,17 +59,73 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.length;
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
// step does not land on 7. node-cron accepts those ranges with different days.
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
normalized[5] = expression.fields.dayOfWeek.values.join(',');
}
// Most rules are already supported; only inspect individual fields on failure.
if (!validate(normalized.join(' '))) {
const fields = [
expression.fields.second,
expression.fields.minute,
expression.fields.hour,
expression.fields.dayOfMonth,
expression.fields.month,
expression.fields.dayOfWeek,
];
normalized = normalized.map((value, index) => {
// Preserve supported lists, ranges and names. Expand only fields node-cron
// cannot consume, such as numeric/name-start steps; never compress them.
const probe = ['0', '0', '0', '*', '*', '*'];
probe[index] = value;
if (validate(probe.join(' '))) return value;
const values = fields[index].values;
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
if (values.length === 8) return '*';
// # applies to a single weekday; normalize its numeric spelling only.
return values.length === 1
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
: value;
}
return values.join(',');
});
}
const months = expression.fields.month.values;
// cron-parser 4 clipped dates outside a single selected month's maximum
// before deciding whether the day field covered the whole month.
const dayCount = expression.fields.dayOfMonth.values.filter(
(day) =>
months.length !== 1 ||
typeof day !== 'number' ||
day <= daysInMonth[months[0] - 1],
).length;
const weekCount = expression.fields.dayOfWeek.values.length;
// Preserve the legacy count-based wildcard rules, including repeated Sunday
// and L tokens: an ignored day field must not remain an AND constraint.
const weekdayPattern = [
...normalized.slice(0, 3),
'*',
...normalized.slice(4),
].join(' ');
const patterns =
dayCount < 31 && weekCount < 8
? [
[...normalized.slice(0, 5), '*'].join(' '),
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
]
: [normalized.join(' ')];
if (!patterns.every((pattern) => validate(pattern))) {
weekCount === 8
? [[...normalized.slice(0, 5), '*'].join(' ')]
: dayCount < 31
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
: [weekdayPattern];
const valid = patterns.map((pattern) => validate(pattern));
if (patterns.length === 2 && !valid[0] && valid[1]) {
// A date branch can be impossible for all chosen months while the weekday
// branch still runs. Drop it only if its syntax is valid with any month.
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
patterns.shift();
valid.shift();
}
}
if (!valid.every(Boolean)) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
+3 -2
View File
@@ -1,3 +1,4 @@
import { t } from './i18n';
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
@@ -36,7 +37,7 @@ export function createCronJob(
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
options.name,
date.toISOString(),
);
@@ -45,7 +46,7 @@ export function createCronJob(
await callback(date);
} catch (error) {
options.logger.error(
'[schedule][定时回调失败] 任务: %s, 错误: %s',
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
options.name,
error instanceof Error ? error.message : String(error),
);
+221 -92
View File
@@ -1,112 +1,231 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
'文件无法访问': 'File not accessible',
'文件不存在': 'File not found',
'路径不存在': 'Path does not exist',
'路径不正确': 'Invalid path',
'升级任务启动失败,请检查日志后重试':
'Failed to start the update. Check the logs and try again.',
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
'[schedule][Stopping running task] Task ID: %s, PID: %s',
'接口已下线,请使用 /configs/detail 接口':
'This endpoint has been retired. Use /configs/detail instead.',
'接口已下线,请使用 /logs/detail 接口':
'This endpoint has been retired. Use /logs/detail instead.',
'接口已下线,请使用 /scripts/detail 接口':
'This endpoint has been retired. Use /scripts/detail instead.',
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
请先初始化: 'Please initialize the panel first',
请先关闭两步验证: 'Please disable two-factor authentication first',
'失败次数过多,请稍后重试':
'Too many failed attempts. Please try again later.',
配置更新失败: 'Failed to update configuration',
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
脚本不存在: 'Script does not exist',
请选择脚本文件: 'Please select a script file',
'历史版本读取失败,请检查历史文件':
'Failed to read version history. Check the history file.',
历史版本不存在或已清理:
'This history version does not exist or has been removed',
'文件已变化,请刷新历史版本后重试':
'The file has changed. Refresh version history and try again.',
'历史版本容量不足,无法安全保存':
'Version history storage is full. The file cannot be saved safely.',
'跳过订阅 %s 的无效定时配置: %s':
'Skipping invalid schedule for subscription %s: %s',
'[schedule][准备运行任务] 命令: %s':
'[schedule][Preparing task] Command: %s',
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
'[schedule][取消定时任务] 任务ID: %s':
'[schedule][Cancelling scheduled task] Task ID: %s',
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
'[panel][停止任务失败] PID: %s, 错误: %s':
'[panel][Failed to stop task] PID: %s, error: %s',
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
'[panel][Task failed] Task ID: %s, error: %s',
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
'[panel][Task finished] Task ID: %s, exit code: %j',
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
'[panel][Failed to create task] Task ID: %s, error: %s',
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
'性能指标报告:': 'Performance metrics:',
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
'[panel][任务结束回调失败] %s':
'[panel][Task completion callback failed] %s',
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
'[panel][取消定时任务] 任务名: %s':
'[panel][Cancelling scheduled task] Name: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
'[panel][Task failed] Command: %s, error: %j',
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
'[panel][Cancelling interval task] Task ID: %s, name: %s',
生成私钥文件失败: 'Failed to generate private key file',
删除私钥文件失败: 'Failed to delete private key file',
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
'跳过订阅 %s 的无效 SSH 配置: %s':
'Skipping invalid SSH configuration for subscription %s: %s',
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
'[schedule][Running late task] Task: %s, scheduled time: %s',
'[schedule][定时回调失败] 任务: %s, 错误: %s':
'[schedule][Scheduled callback failed] Task: %s, error: %s',
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task queued] Running: %s, waiting: %s',
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Processing task] Running: %s, waiting: %s',
'[schedule][任务处理成功] 参数 %s':
'[schedule][Task succeeded] Arguments: %s',
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task finished] Running: %s, waiting: %s',
'[schedule][任务重复运行] 通知失败 %s':
'[schedule][Duplicate task] Notification failed: %s',
'[schedule][任务重复运行] 参数 %s':
'[schedule][Duplicate task] Arguments: %s',
'[schedule][停止已运行任务] 任务ID: %s':
'[schedule][Stopping running task] Task ID: %s',
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to check running task] Task ID: %s, error: %s',
'[schedule][开始执行任务] 参数 %s':
'[schedule][Starting task] Arguments: %s',
'[schedule][任务标准错误] 命令: %s, 信息: %s':
'[schedule][Task stderr] Command: %s, message: %s',
'[schedule][执行任务失败] 命令: %s, 错误: %s':
'[schedule][Task failed] Command: %s, error: %s',
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
'[schedule][Task finished] Task ID: %s, exit code: %j',
'[schedule][创建任务失败] 命令: %s, 错误: %s':
'[schedule][Failed to create task] Command: %s, error: %s',
'检查更新失败,请检查网络后重试':
'Failed to check for updates. Check your network and try again.',
'资源已存在,请检查重复的名称或值':
'Resource already exists; check for duplicate names or values',
暂无权限: 'Access denied',
参数错误: 'Invalid parameter',
参数不正确: 'Invalid parameter',
文件无法访问: 'File not accessible',
文件不存在: 'File not found',
路径不存在: 'Path does not exist',
路径不正确: 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
'通知发送成功': 'Notification sent successfully',
'设置时区失败': 'Failed to set timezone',
'任务未找到': 'Task not found',
'密码不能设置为admin': 'Password cannot be admin',
'更新成功': 'Update successful',
'未知错误': 'Unknown error',
'验证失败': 'Verification failed',
'实例不存在或已停止': 'Instance does not exist or stopped',
'实例已停止': 'Instance stopped',
'确认停止实例': 'Confirm to stop instance',
'确认停止运行实例': 'Confirm to stop running instance',
'确认停止': 'Confirm to stop',
'确认停止定时任务': 'Confirm to stop scheduled task',
'确认删除': 'Confirm to delete',
'确认删除定时任务': 'Confirm to delete scheduled task',
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
'确认运行': 'Confirm to run',
'确认运行定时任务': 'Confirm to run scheduled task',
'确认保存': 'Confirm to save',
'确认保存文件': 'Confirm to save file',
'确认重启': 'Confirm restart',
'确认启用': 'Confirm to enable',
'确认禁用': 'Confirm to disable',
'确认': 'Confirm',
'删除成功': 'Deleted successfully',
'操作成功': 'Operation successful',
'参数不完整': 'Incomplete parameters',
'默认路径不支持删除': 'Default path cannot be deleted',
'必须在日志目录下': 'Must be within log directory',
'通知发送失败,请检查系统设置/通知配置':
'Notification failed, check system settings',
通知发送成功: 'Notification sent successfully',
设置时区失败: 'Failed to set timezone',
任务未找到: 'Task not found',
密码不能设置为admin: 'Password cannot be admin',
更新成功: 'Update successful',
未知错误: 'Unknown error',
验证失败: 'Verification failed',
实例不存在或已停止: 'Instance does not exist or stopped',
实例已停止: 'Instance stopped',
确认停止实例: 'Confirm to stop instance',
确认停止运行实例: 'Confirm to stop running instance',
确认停止: 'Confirm to stop',
确认停止定时任务: 'Confirm to stop scheduled task',
确认删除: 'Confirm to delete',
确认删除定时任务: 'Confirm to delete scheduled task',
确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
确认运行: 'Confirm to run',
确认运行定时任务: 'Confirm to run scheduled task',
确认保存: 'Confirm to save',
确认保存文件: 'Confirm to save file',
确认重启: 'Confirm restart',
确认启用: 'Confirm to enable',
确认禁用: 'Confirm to disable',
确认: 'Confirm',
删除成功: 'Deleted successfully',
操作成功: 'Operation successful',
参数不完整: 'Incomplete parameters',
默认路径不支持删除: 'Default path cannot be deleted',
必须在日志目录下: 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
'系统将在': 'System will',
'秒后自动刷新': 'refresh in seconds',
系统将在: 'System will',
秒后自动刷新: 'refresh in seconds',
'生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
'不支持当前依赖类型': 'Unsupported dependency type',
'依赖已存在': 'Dependency already exists',
'依赖不存在': 'Dependency does not exist',
'该脚本正在运行中': 'Script is running',
'该脚本未在运行中': 'Script is not running',
'文件内容为空': 'File content is empty',
'文件名不能为空': 'File name cannot be empty',
'标签不能为空': 'Label cannot be empty',
'名称不能为空': 'Name cannot be empty',
'名称不能为保留关键字': 'Name cannot be reserved keyword',
'名称已存在': 'Name already exists',
'密码错误': 'Incorrect password',
'用户不存在': 'User does not exist',
'请输入用户名和密码': 'Please enter username and password',
'无权访问': 'Access denied',
'登录成功': 'Login successful',
'退出成功': 'Logout successful',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式':
'Each entry must have name and value, see export format',
不支持当前依赖类型: 'Unsupported dependency type',
依赖已存在: 'Dependency already exists',
依赖不存在: 'Dependency does not exist',
该脚本正在运行中: 'Script is running',
该脚本未在运行中: 'Script is not running',
文件内容为空: 'File content is empty',
文件名不能为空: 'File name cannot be empty',
标签不能为空: 'Label cannot be empty',
名称不能为空: 'Name cannot be empty',
名称不能为保留关键字: 'Name cannot be reserved keyword',
名称已存在: 'Name already exists',
密码错误: 'Incorrect password',
用户不存在: 'User does not exist',
请输入用户名和密码: 'Please enter username and password',
无权访问: 'Access denied',
登录成功: 'Login successful',
退出成功: 'Logout successful',
'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token',
'两步骤验证已开启': '2FA enabled',
'两步骤验证已关闭': '2FA disabled',
'验证码错误': 'Invalid verification code',
'验证码已过期': 'Verification code expired',
'请先开启两步骤验证': 'Please enable 2FA first',
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
'用户已存在': 'User already exists',
'用户名不能为admin': 'Username cannot be admin',
'不能删除自己': 'Cannot delete yourself',
'不能禁用自己': 'Cannot disable yourself',
'文件路径无效': 'Invalid file path',
'保存成功': 'Saved successfully',
两步骤验证已开启: '2FA enabled',
两步骤验证已关闭: '2FA disabled',
验证码错误: 'Invalid verification code',
验证码已过期: 'Verification code expired',
请先开启两步骤验证: 'Please enable 2FA first',
两步骤验证密钥不能为空: '2FA secret cannot be empty',
用户已存在: 'User already exists',
用户名不能为admin: 'Username cannot be admin',
不能删除自己: 'Cannot delete yourself',
不能禁用自己: 'Cannot disable yourself',
文件路径无效: 'Invalid file path',
保存成功: 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
'订阅执行完成': 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
订阅执行完成: 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null',
'请先登录': 'Please login first',
请先登录: 'Please login first',
'运行中...': 'Running...',
'日志不存在...': 'Log does not exist...',
'未分类': 'Uncategorized',
'任务重复运行': 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
未分类: 'Uncategorized',
任务重复运行: 'Duplicate task execution',
日志设置为忽略: 'Log set to ignore',
定时规则不能为空: 'Schedule rule cannot be empty',
无效的定时规则: 'Invalid schedule rule',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试':
'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked',
'已加入 IP 黑名单': 'IP address added to the blacklist',
'已移出 IP 黑名单': 'IP address removed from the blacklist',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
端: '',
青龙快讯: 'QingLong',
登录通知: 'Login Notification',
你于: 'You at ',
在: ' in ',
端: ' client',
内网IP: 'Private IP',
登录失败: 'login failed',
',ip地址': ', IP: ',
'任务#%s': 'Task#%s',
@@ -131,12 +250,13 @@ const messages: Record<string, Record<string, string>> = {
青龙: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long',
'生成token': 'Generate token',
'删除日志': 'Delete logs',
生成token: 'Generate token',
删除日志: 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
'## 执行结束... %s 耗时 %s 秒':
'## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚':
@@ -152,9 +272,18 @@ const messages: Record<string, Record<string, string>> = {
let currentLang: string = 'zh';
export function setLang(lang: string) {
export function setLang(lang: string, persist = true) {
currentLang = lang || 'zh';
shareStore.setLang(currentLang);
if (!persist) return;
// Translation is also used by standalone scheduler and CLI helpers. Load
// persistent storage only when changing the language, not when reading it.
const { shareStore } = require('./store') as typeof import('./store');
const language = currentLang;
return shareStore.setLang(language).then(() => {
if (currentLang === language && process.connected && process.send) {
process.send({ type: 'language-changed', lang: language }, () => {});
}
});
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
+32
View File
@@ -0,0 +1,32 @@
import cluster from 'cluster';
import { setLang, systemLang } from './i18n';
function languageMessage(
message: unknown,
): message is { type: string; lang: string } {
const value = message as { type?: string; lang?: string } | null;
return (
value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!)
);
}
// Each worker owns a translation cache. Relay saved settings to live workers;
// a replacement worker restores the persisted language before serving requests.
export async function initializeLanguageSync() {
if (cluster.isPrimary) {
cluster.on('message', (_worker, message) => {
if (!languageMessage(message)) return;
setLang(message.lang, false);
for (const worker of Object.values(cluster.workers || {})) {
if (worker?.isConnected()) worker.send(message, () => {});
}
});
return;
} else {
process.on('message', (message) => {
if (languageMessage(message)) setLang(message.lang, false);
});
}
const { shareStore } = await import('./store');
setLang((await shareStore.getLang()) || systemLang(), false);
}
+18 -10
View File
@@ -79,29 +79,36 @@ class TaskLimit {
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
);
});
this.cronLimit.on('active', () => {
Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount + 1,
this.cronLimitPendingCount,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
});
this.cronLimit.on('error', (error) => {
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
Logger.error(
t('[schedule][任务处理错误] 参数 %s'),
JSON.stringify(error),
);
});
this.cronLimit.on('next', () => {
Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
);
});
this.cronLimit.on('idle', () => {
Logger.info(`[schedule][任务队列] 空闲中...`);
Logger.info(t(`[schedule][任务队列] 空闲中...`));
});
}
@@ -172,13 +179,14 @@ class TaskLimit {
(err, res) => {
if (err) {
Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
t('[schedule][任务重复运行] 通知失败 %s'),
JSON.stringify(err),
);
}
},
);
}
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
return;
}
this.queuedCrons.set(cron.id, result);
+14 -8
View File
@@ -1,3 +1,4 @@
import { t } from './i18n';
import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
@@ -37,7 +38,9 @@ export function runCron(
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
cron.id,
existingCron.pid,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
@@ -59,7 +62,9 @@ export function runCron(
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
cron.id,
error,
);
throw error;
}
@@ -67,17 +72,18 @@ export function runCron(
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
t('[schedule][开始执行任务] 参数 %s'),
JSON.stringify({
...cron,
command: cmd,
})}`,
}),
);
const cp = spawn(cmd, { shell: '/bin/bash' });
const { completed } = observeChildProcess(cp, {
onStderr: async (message) => {
Logger.info(
'[schedule][任务标准错误] 命令: %s, 信息: %s',
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
cmd,
message,
);
@@ -86,20 +92,20 @@ export function runCron(
const result = await completed;
if (result.error) {
Logger.error(
'[schedule][执行任务失败] 命令: %s, 错误: %s',
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
cmd,
result.error.message,
);
}
Logger.info(
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
cron.id,
result.code,
);
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误: %s',
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
cmd,
asError(error).message,
);
+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,
});
+5 -4
View File
@@ -1,6 +1,7 @@
import express, { Application, Request } from 'express';
import { AuthDataType, SystemModel } from '../data/system';
import { normalizeClientIp } from './clientIp';
import { t } from './i18n';
const DEFAULT_TRUST_PROXY = 'loopback';
@@ -30,10 +31,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
function validateTrustProxy(value: string): string {
const setting = normalizeSetting(value);
if (setting.length > 500 || /[\r\n]/.test(setting)) {
throw new Error('trust proxy 配置格式无效');
throw new Error(t('trust proxy 配置格式无效'));
}
if (/^\d+$/.test(setting) && Number(setting) > 20) {
throw new Error('代理层数不能超过 20');
throw new Error(t('代理层数不能超过 20'));
}
const probe = express();
@@ -73,7 +74,7 @@ export async function initializeTrustProxy(app: Application) {
export async function updateTrustProxy(value: string) {
if (getEnvironmentSetting()) {
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
}
const trustProxy = validateTrustProxy(value);
@@ -81,7 +82,7 @@ export async function updateTrustProxy(value: string) {
where: { type: AuthDataType.systemConfig },
});
if (!doc) {
throw new Error('系统配置不存在');
throw new Error(t('系统配置不存在'));
}
const plain = doc.get({ plain: true });
+76 -2
View File
@@ -7,6 +7,7 @@
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
},
@@ -14,12 +15,12 @@
"@types/node": "22.19.0",
"commander": "15.0.0",
"esbuild": "0.28.2",
"tar": "7.5.20",
"typescript": "5.2.2"
},
"engines": {
"node": ">=22.12.0"
},
"license": "Apache-2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
@@ -463,6 +464,19 @@
"node": ">=18"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
@@ -473,6 +487,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
@@ -525,6 +549,46 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -545,6 +609,16 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}
}
}
+2 -1
View File
@@ -26,7 +26,8 @@
"commander": "15.0.0",
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0"
"@types/node": "22.19.0",
"tar": "7.5.20"
},
"license": "Apache-2.0",
"repository": {
+11
View File
@@ -17,6 +17,17 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
);
// The operational CLI may live outside the panel and has no panel node_modules.
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
outfile: path.join(root, 'dist/shared/cli/commander.js'),
+8 -8
View File
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
"printf '%s\\\\n' '__sitecustomize__' &&",
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
" if (task_before) console.log('执行前置命令\\n');",
" if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
@@ -115,7 +115,7 @@ export const nodePreloadSource: string = [
' console.log(output);',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
' }',
' } catch (error) {',
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
@@ -124,7 +124,7 @@ export const nodePreloadSource: string = [
' // environment variable is too large',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
' }',
' }',
'',
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
' ',
' task_before = os.getenv("task_before", "")',
' if task_before:',
' print("执行前置命令\\n")',
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
'source "$file" "$@" &&',
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
@@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [
' if len(output) > 0:',
' print(output)',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
'',
' except subprocess.CalledProcessError as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' except OSError as error:',
' error_message = str(error)',
' if "Argument list too long" not in error_message:',
@@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [
' # else:',
' # environment variable is too large',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' except Exception as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
'',
' import task_before',
'',
@@ -0,0 +1,485 @@
// 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 { nodeRuntimeArgs } = require('./platformBundle.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,
saveState: save,
startJob,
runJob,
withPanelLifecycle,
readRunningBuild,
verifyRunningVersion,
stagedMetadata,
};
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,
[...nodeRuntimeArgs(), __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;
});
}
@@ -0,0 +1,478 @@
// One installed verifier handles release downloads and local offline archives.
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const os = require('node:os');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const tar = require('tar');
const execute = promisify(execFile);
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
// upgrade workers interpreted without changing the installed Node or server.
const nodeRuntimeArgs = (runtime = process) =>
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
? ['--jitless']
: [];
const sha256 = (bytes) =>
crypto.createHash('sha256').update(bytes).digest('hex');
const inside = (root, filename) => {
const relative = path.relative(root, filename);
return (
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
};
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
function atLeast(actual, minimum) {
const a = versionParts(actual),
b = versionParts(minimum);
return (
b.every((n) => Number.isFinite(n)) &&
a.every((n) => Number.isFinite(n)) &&
(a[0] > b[0] ||
(a[0] === b[0] &&
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
);
}
async function runtimeTarget() {
let libc, libcVersion;
if (process.platform === 'linux') {
const header = process.report.getReport().header;
if (header.glibcVersionRuntime) {
libc = 'glibc';
libcVersion = header.glibcVersionRuntime;
} else {
let text = '';
try {
text = (await execute('ldd', ['--version'])).stdout;
} catch (error) {
text = `${error.stdout || ''}\n${error.stderr || ''}`;
}
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
if (!match)
throw new Error(
'Cannot identify Linux libc; refusing a native dependency bundle',
);
libc = 'musl';
libcVersion = match[1];
}
}
const armVersion =
process.arch === 'arm'
? Number(process.config.variables.arm_version)
: undefined;
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
throw new Error('Cannot identify ARM instruction set');
const arch =
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
const armFloatAbi =
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
if (
process.arch === 'arm' &&
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
)
throw new Error('Cannot identify ARM floating-point ABI');
return {
platform: process.platform,
arch,
libc,
libcVersion,
endianness: os.endianness(),
armFloatAbi,
};
}
function targetKey(target) {
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
}
function assertCompatible(manifest, target, node = process.versions.node) {
if (
manifest.format !== 2 ||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
!manifest.files ||
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files) ||
!Number.isSafeInteger(manifest.nodeApi) ||
manifest.nodeApi < 6
)
throw new Error('Invalid platform bundle manifest');
if (targetKey(manifest.target || {}) !== targetKey(target))
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
if (
!atLeast(node, manifest.nodeMinimum) ||
Number(process.versions.napi) < manifest.nodeApi
)
throw new Error(
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
);
if (
manifest.target.endianness !== target.endianness ||
manifest.target.armFloatAbi !== target.armFloatAbi
)
throw new Error(
'Bundle native byte order or ARM floating-point ABI mismatch',
);
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
throw new Error(
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
);
}
async function inventory(roots) {
const files = {};
async function visit(directory, prefix) {
for (const name of (await fs.readdir(directory)).sort()) {
const filename = path.join(directory, name),
relative = `${prefix}/${name}`;
const stat = await fs.lstat(filename);
if (stat.isDirectory()) await visit(filename, relative);
else if (stat.isFile())
files[relative] = {
type: 'file',
mode: stat.mode & 0o777,
sha256: sha256(await fs.readFile(filename)),
};
else if (stat.isSymbolicLink()) {
const link = await fs.readlink(filename);
const boundary = roots[prefix.split('/')[0]];
if (
path.isAbsolute(link) ||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
)
throw new Error(`Bundle link escapes its payload: ${relative}`);
files[relative] = { type: 'link', link };
} else throw new Error(`Unsupported bundle entry: ${relative}`);
}
}
for (const [name, root] of Object.entries(roots)) await visit(root, name);
return files;
}
async function verifyPayload(selected, manifest, target) {
assertCompatible(manifest, target);
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
if (
await fs.lstat(path.join(selected.source, name)).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return null;
})
)
throw new Error(`Reserved installation path in bundle: ${name}`);
}
const actual = await inventory({
source: selected.source,
static: selected.static,
});
const names = Object.keys(actual).sort(),
expected = Object.keys(manifest.files).sort();
if (
JSON.stringify(names) !== JSON.stringify(expected) ||
names.some(
(name) =>
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
)
)
throw new Error(
'Platform bundle files or dependency checksums do not match',
);
if (
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error('Platform bundle lockfile mismatch');
await fs.access(
path.join(selected.source, 'node_modules/sqlite3/package.json'),
);
}
async function smokeDependencies(source, signal) {
// This probe is installed verifier code, never a verifier supplied by an archive.
const probe = `
const path = require('node:path'),fs = require('node:fs');
const root = fs.realpathSync(process.cwd());
const r = require('node:module').createRequire(path.join(root,'package.json'));
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
r(name);
}
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
`;
await execute(process.execPath, [...nodeRuntimeArgs(), '-e', probe], {
cwd: source,
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
timeout: 30000,
signal,
});
}
async function extractArchive(archive, destination) {
const entries = new Map(),
links = new Set();
let unpacked = 0,
invalid;
await tar.t({
file: archive,
strict: true,
onReadEntry(entry) {
try {
const name = entry.path.replace(/\/$/, '');
if (
!name ||
name.includes('\\') ||
/[\x00-\x1f\x7f]/.test(name) ||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
entries.has(name) ||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
)
throw new Error(`Unsafe bundle archive entry: ${name}`);
unpacked += entry.size;
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
throw new Error('Bundle archive exceeds extraction limits');
entries.set(name, entry.type);
if (entry.type === 'SymbolicLink') {
if (
path.posix.isAbsolute(entry.linkpath) ||
entry.linkpath.includes('\\') ||
!inside(
'/qinglong',
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
)
)
throw new Error('Unsafe bundle symlink');
links.add(name);
}
} catch (error) {
invalid ||= error;
}
},
});
if (invalid) throw invalid;
for (const name of entries.keys()) {
let parent = path.posix.dirname(name);
while (parent !== '.') {
if (links.has(parent))
throw new Error('Archive writes through a symlink');
parent = path.posix.dirname(parent);
}
}
await tar.x({
file: archive,
cwd: destination,
strict: true,
preservePaths: false,
noChown: true,
umask: 0,
});
}
async function stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic,
download,
probe = smokeDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
if (!local) {
const current = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
}
const target = await runtimeTarget();
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
? 'develop'
: 'master';
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const report = onProgress || (async () => {});
try {
const archive = path.join(workspace, 'bundle.tar.gz');
let expectedCommit;
if (local) {
if (!path.isAbsolute(local))
throw new Error('Offline bundle path must be absolute');
// Snapshot the local input so replacing it cannot race validation/extraction.
await fs.copyFile(local, archive);
} else {
const indexUrl =
env.QL_UPGRADE_INDEX_URL ||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
const checkUrl = (url) => {
const parsed = new URL(url);
if (
parsed.protocol !== 'https:' &&
!(
parsed.protocol === 'http:' &&
['127.0.0.1', '[::1]'].includes(parsed.hostname)
)
)
throw new Error('Upgrade URLs require HTTPS');
};
checkUrl(indexUrl);
const indexFile = path.join(workspace, 'index.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
'--proto-redir',
'=https',
'--header',
'Cache-Control: no-cache',
'--connect-timeout',
'10',
'--max-time',
'30',
'--max-filesize',
'1048576',
'--output',
indexFile,
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
const entry = index.packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
!entry ||
!Number.isSafeInteger(entry.size) ||
entry.size <= 0 ||
entry.size > 512 * 1024 ** 2 ||
!/^[a-f0-9]{64}$/.test(entry.sha256)
)
throw new Error(`No verified update bundle for ${targetKey(target)}`);
checkUrl(entry.url);
expectedCommit = index.sourceCommit;
await report({
phase: 'downloading',
artifact: 'qinglong-bundle',
downloadedBytes: 0,
totalBytes: entry.size,
percent: 0,
});
await download(
run,
archive,
entry.url,
'qinglong-bundle',
report,
true,
entry,
);
}
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
throw new Error('Bundle is too large');
await report({
phase: 'extracting',
artifact: 'qinglong-bundle',
percent: null,
});
await extractArchive(archive, workspace);
const incoming = path.join(workspace, 'qinglong');
const manifest = JSON.parse(
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
);
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
throw new Error('Update index and bundle commit differ');
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
await fs.rename(path.join(incoming, 'source'), selected.source);
await fs.rename(path.join(incoming, 'static'), selected.static);
await report({ phase: 'verifying', percent: null });
await verifyPayload(selected, manifest, target);
const build = await verifyStatic(selected.source, selected.static, env);
if (build.sourceCommit !== manifest.sourceCommit)
throw new Error('Bundle and build commit differ');
await report({ phase: 'dependencies', percent: null });
await probe(selected.source, signal);
await fs.writeFile(
path.join(workspace, 'bundle.json'),
JSON.stringify(manifest),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
bundle: true,
}),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{ flag: 'wx', mode: 0o600 },
);
signal?.throwIfAborted();
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
const previous = await fs
.readFile(readyPointer, 'utf8')
.catch(() => 'null');
await fs.rename(pointer, readyPointer);
try {
const old = JSON.parse(previous)?.directory;
if (
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
old !== path.basename(workspace)
)
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
} catch {
/* Committed readiness must survive cleanup failure. */
}
return selected;
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
async function verifyStagedBundle(selected) {
const manifest = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'bundle.json'),
'utf8',
),
);
await verifyPayload(selected, manifest, await runtimeTarget());
await smokeDependencies(selected.source);
}
module.exports = {
nodeRuntimeArgs,
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
+39 -23
View File
@@ -11,13 +11,21 @@ import { installPanelDependencies, startPanel, stopPanel } from './operator';
// The Bash image updater and local CLI must enforce the same build provenance.
const artifacts = require('./upgradeArtifacts.cjs') as {
releaseBranch(env: NodeJS.ProcessEnv): string;
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
verifyUpgrade(
source: string,
staticRoot: string,
env?: NodeJS.ProcessEnv,
): Promise<unknown>;
waitForHealth(
env: NodeJS.ProcessEnv,
timeout?: number,
signal?: AbortSignal,
): Promise<void>;
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
withUpgradeLock<T>(
tmp: string,
operation: () => Promise<T>,
env?: NodeJS.ProcessEnv,
): Promise<T>;
stageUpgrade(options: {
root: string;
tmp: string;
@@ -30,6 +38,7 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
selectedUpgrade(
tmp: string,
branch: string,
env?: NodeJS.ProcessEnv,
): Promise<{ source: string; static: string }>;
};
@@ -69,17 +78,20 @@ export async function stageUpgrade(
context: LocalContext,
mirror: 'github' | 'gitee',
): Promise<{ source: string; static: string }> {
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
artifacts.stageUpgrade({
root: context.root,
tmp: context.paths.dir_tmp!,
env: context.env,
mirror,
signal: operationSignal(),
run: (program, args, capture) =>
checkedProcess(program, args, { env: context.env, capture }),
install: (source) => installPanelDependencies(context, source),
}),
return artifacts.withUpgradeLock(
context.paths.dir_tmp!,
() =>
artifacts.stageUpgrade({
root: context.root,
tmp: context.paths.dir_tmp!,
env: context.env,
mirror,
signal: operationSignal(),
run: (program, args, capture) =>
checkedProcess(program, args, { env: context.env, capture }),
install: (source) => installPanelDependencies(context, source),
}),
context.env,
);
}
@@ -89,6 +101,7 @@ async function selectedUpgrade(
return artifacts.selectedUpgrade(
context.paths.dir_tmp!,
releaseBranch(context),
context.env,
);
}
@@ -259,7 +272,7 @@ export async function reloadPanel(
const staticRoot = selected.static;
await validateTree(source, context.env);
await validateTree(staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot);
await artifacts.verifyUpgrade(source, staticRoot, context.env);
const entries = await fs.readdir(source);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relativeData = path.relative(context.root, context.data);
@@ -284,15 +297,18 @@ export async function reloadPanel(
if (selection)
replacements.push({ source: selection.source, target: selection.target });
try {
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
replaceAndReload(context, replacements, {
stop: stopPanel,
start: async (ctx) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
return service;
},
}),
return await artifacts.withUpgradeLock(
context.paths.dir_tmp!,
() =>
replaceAndReload(context, replacements, {
stop: stopPanel,
start: async (ctx) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
return service;
},
}),
context.env,
);
} finally {
if (selection)
+347 -89
View File
@@ -7,11 +7,41 @@ const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const upgradeEnglish = {
'更新包下载及校验成功...': 'Update package downloaded and verified...',
'更新及启动检查成功...':
'Update and startup checks completed successfully...',
'更新失败: %s': 'Update failed: %s',
'构建清单无效。': 'Invalid or dirty build manifest',
'缺少构建产物: %s': 'Missing build output: %s',
'源码与构建依赖不一致。': 'Source and build lockfiles differ',
'构建包含非法文件: %s': 'Unsupported build entry: %s',
'构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch',
'已有更新或重载正在执行。': 'Another upgrade is running',
'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee',
'升级归档路径无效。': 'Invalid archive paths',
'升级归档包含符号链接。': 'Archive contains symlinks',
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
'暂存目录无效。': 'Invalid staging directory',
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
'运行依赖已变化,请重新下载更新。':
'Installed dependencies changed; download the update again',
'保留备份: %s': 'Retained backup: %s',
'后端启动检查失败。': 'Backend failed health check',
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
};
function upgradeMessage(key, env = process.env, ...values) {
const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key;
let index = 0;
return template.replace(/%s/g, () => String(values[index++] ?? ''));
}
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const releaseBranch = (env) =>
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
async function manifestAt(staticRoot) {
async function manifestAt(staticRoot, env = process.env) {
const manifest = JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
);
@@ -24,22 +54,20 @@ async function manifestAt(staticRoot) {
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files)
)
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
throw new Error(upgradeMessage('构建清单无效。', env));
for (const required of ['build/app.js', 'dist/index.html'])
if (!Object.hasOwn(manifest.files, required))
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
return manifest;
}
async function verifyUpgrade(source, staticRoot) {
const manifest = await manifestAt(staticRoot);
async function verifyUpgrade(source, staticRoot, env = process.env) {
const manifest = await manifestAt(staticRoot, env);
if (
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error(
'Source and build lockfiles differ / 源码与构建依赖不一致。',
);
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
await fs.access(path.join(source, 'package.json'));
await fs.access(path.join(source, 'sample/config.sample.sh'));
const files = {};
@@ -50,8 +78,7 @@ async function verifyUpgrade(source, staticRoot) {
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(filename, `${name}/`);
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
else
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
}
}
await visit(staticRoot);
@@ -61,13 +88,11 @@ async function verifyUpgrade(source, staticRoot) {
JSON.stringify(actual) !== JSON.stringify(expected) ||
actual.some((name) => files[name] !== manifest.files[name])
)
throw new Error(
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
);
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
return manifest;
}
async function withUpgradeLock(tmp, operation) {
async function withUpgradeLock(tmp, operation, env = process.env) {
await fs.mkdir(tmp, { recursive: true });
const lock = path.join(tmp, 'upgrade.lock');
const token = randomUUID();
@@ -123,9 +148,7 @@ async function withUpgradeLock(tmp, operation) {
}
}
if (!stale)
throw new Error(
'Another upgrade is running / 已有更新或重载正在执行。',
);
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
// Removing the observed unique marker is a compare-and-delete. A
// contender cannot unlink the marker belonging to a replacement lock.
// Legacy pid files are supported; new owners never reuse that filename.
@@ -150,31 +173,223 @@ async function withUpgradeLock(tmp, operation) {
}
}
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
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',
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
? '=http,https'
: '=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
String(
artifact === 'qinglong-bundle' && expected?.size
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
: 600,
),
'--speed-limit',
'1024',
'--speed-time',
'30',
'--dump-header',
headers,
'--output',
archive,
url,
]);
clearInterval(timer);
await sampling;
if (expected) {
const bytes = await fs.readFile(archive);
if (bytes.length !== expected.size || hash(bytes) !== expected.sha256)
throw new Error('Upgrade archive size or checksum mismatch');
}
await sample(true);
} finally {
clearInterval(timer);
await sampling;
}
}
async function stageUpgrade({
root,
tmp,
env,
mirror,
fallbackMirror,
run,
install,
signal,
onProgress,
}) {
const currentPackage = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
return require('./platformBundle.cjs').stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic: verifyUpgrade,
download: downloadWithProgress,
});
}
const report = onProgress || (async () => {});
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env);
try {
// Metadata is optional for older releases and unavailable mirrors. Never
// invent a percentage when neither metadata nor HTTP supplies a total.
let info;
if (onProgress && mirror === 'github') {
try {
const readJson = async (url) => {
const filename = path.join(workspace, 'metadata.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'5',
'--max-time',
'10',
'--max-filesize',
'1048576',
'--dump-header',
`${filename}.headers`,
'--output',
filename,
url,
]);
return JSON.parse(await fs.readFile(filename, 'utf8'));
};
const base = 'https://raw.githubusercontent.com/whyour/qinglong-static';
const current = await readJson(
`${base}/${branch}/build-info.json?t=${Date.now()}`,
);
if (!/^[a-f0-9]{40}$/.test(current.sourceCommit))
throw new Error('Invalid commit');
const candidate = await readJson(
`${base}/upgrade-info/${current.sourceCommit}.json`,
);
if (
candidate.version !== 1 ||
candidate.sourceCommit !== current.sourceCommit ||
!/^[a-f0-9]{40}$/.test(candidate.staticCommit) ||
!['qinglong-static', 'qinglong'].every((repo) => {
const entry = candidate.archives?.[repo];
return (
Number.isSafeInteger(entry?.size) &&
entry.size > 0 &&
/^[a-f0-9]{64}$/.test(entry.sha256)
);
})
)
throw new Error('Invalid archive metadata');
info = candidate;
} catch {
signal?.throwIfAborted();
}
}
async function extract(repository, ref) {
const archive = path.join(workspace, `${repository}.zip`);
const url =
mirror === 'github'
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
await run('curl', [
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--output',
archive,
url,
]);
const download = async (target, expected) => {
await report({
phase: 'downloading',
artifact: repository,
downloadedBytes: 0,
totalBytes: expected?.size || null,
percent: expected ? 0 : null,
});
await downloadWithProgress(
run,
archive,
target,
repository,
report,
Boolean(onProgress),
expected,
);
};
try {
await download(
url,
mirror === 'github' ? info?.archives[repository] : undefined,
);
} catch (error) {
signal?.throwIfAborted();
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env));
await download(
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
);
}
await report({
phase: 'extracting',
artifact: repository,
percent: null,
});
const listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean);
@@ -189,21 +404,30 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
name.split('/')[0] !== expected,
)
)
throw new Error('Invalid archive paths / 升级归档路径无效。');
throw new Error(upgradeMessage('升级归档路径无效。', env));
const modes = await run('unzip', ['-Z', '-l', archive], true);
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
throw new Error(upgradeMessage('升级归档包含符号链接。', env));
await run('unzip', ['-oq', archive, '-d', workspace]);
return path.join(workspace, expected);
}
// Static branches are mutable snapshots. Their manifest pins the exact
// source revision, avoiding a race between two independent branch reads.
const staticRoot = await extract('qinglong-static', branch);
const manifest = await manifestAt(staticRoot);
const downloadedStatic = await extract(
'qinglong-static',
info?.staticCommit || branch,
);
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
if (downloadedStatic !== staticRoot)
await fs.rename(downloadedStatic, staticRoot);
const manifest = await manifestAt(staticRoot, env);
if (info && manifest.sourceCommit !== info.sourceCommit)
throw new Error(upgradeMessage('构建清单无效。', env));
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source);
await verifyUpgrade(source, staticRoot);
await report({ phase: 'verifying', percent: null });
await verifyUpgrade(source, staticRoot, env);
let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
@@ -220,9 +444,10 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
)
dependenciesChanged = true;
if (dependenciesChanged) {
await report({ phase: 'dependencies', percent: null });
await install(source);
await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot);
await verifyUpgrade(source, staticRoot, env);
}
signal?.throwIfAborted();
const selected = { source, static: staticRoot };
@@ -230,6 +455,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged,
}),
@@ -269,7 +495,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
}
}
async function selectedUpgrade(tmp, branch) {
async function selectedUpgrade(tmp, branch, env = process.env) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -277,7 +503,7 @@ async function selectedUpgrade(tmp, branch) {
typeof pointer.directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
)
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
throw new Error(upgradeMessage('暂存升级指针无效。', env));
const workspace = path.join(tmp, pointer.directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
@@ -286,22 +512,46 @@ async function selectedUpgrade(tmp, branch) {
for (const directory of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error('Invalid staging directory / 暂存目录无效。');
throw new Error(upgradeMessage('暂存目录无效。', env));
}
const ready = JSON.parse(
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
);
if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
const manifest = await verifyUpgrade(selected.source, selected.static);
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = await verifyUpgrade(selected.source, selected.static, env);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules'));
else {
const root = ready.root || env.QL_DIR;
if (
!root ||
!(
await fs.stat(path.join(root, 'node_modules')).catch(() => undefined)
)?.isDirectory()
)
throw new Error(upgradeMessage('运行依赖已变化,请重新下载更新。', env));
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
.readFile(path.join(root, name))
.catch(() => undefined);
if (
!current ||
!(await fs.readFile(path.join(selected.source, name))).equals(current)
)
throw new Error(
upgradeMessage('运行依赖已变化,请重新下载更新。', env),
);
}
}
return selected;
}
async function replaceAndReload(replacements, lifecycle) {
async function replaceAndReload(replacements, lifecycle, env = process.env) {
const changed = [];
let attemptedStart = false;
lifecycle.signal?.throwIfAborted();
@@ -373,7 +623,7 @@ async function replaceAndReload(replacements, lifecycle) {
await fs
.rm(item.backup, { recursive: true, force: true })
.catch(() =>
console.error(`Retained backup / 保留备份: ${item.backup}`),
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
);
}
@@ -385,8 +635,9 @@ async function reloadSystem({
config,
env,
lifecycle,
additionalReplacements = [],
}) {
const selected = await selectedUpgrade(tmp, releaseBranch(env));
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relative = path.relative(root, data);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
@@ -404,7 +655,8 @@ async function reloadSystem({
target: path.join(config, 'config.sample.sh'),
},
);
await replaceAndReload(replacements, lifecycle);
replacements.push(...additionalReplacements);
await replaceAndReload(replacements, lifecycle, env);
// Cleanup after a committed installation must not report a failed update.
await fs
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
@@ -472,7 +724,7 @@ async function waitForHealth(env, timeout = 60000, signal) {
}
}
}
throw new Error('Backend failed health check / 后端启动检查失败。');
throw new Error(upgradeMessage('后端启动检查失败。', env));
}
function runProcess(env, program, args, capture = false, signal) {
@@ -575,9 +827,7 @@ esac
await fs
.rm(directory, { recursive: true, force: true })
.catch(() =>
console.error(
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
),
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
);
}
}
@@ -595,49 +845,57 @@ async function main() {
);
const run = (program, args, capture) =>
runProcess(env, program, args, capture, controller.signal);
await withUpgradeLock(tmp, () =>
withLifecycleHooks(root, tmp, env, async (hook) => {
const lifecycle = {
signal: controller.signal,
stop: () => hook('stop'),
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
await withUpgradeLock(
tmp,
() =>
withLifecycleHooks(root, tmp, env, async (hook) => {
const lifecycle = {
signal: controller.signal,
stop: () => hook('stop'),
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
env,
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
mirror: process.argv[3],
// The Bash updater chooses its source automatically. An explicitly
// selected CLI mirror does not opt into this network-only failover.
fallbackMirror: 'gitee',
run,
signal: controller.signal,
install: (source) => hook('install', [source], controller.signal),
});
console.log(upgradeMessage('更新包下载及校验成功...', env));
if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.');
await reloadSystem({
root,
tmp,
env,
mirror: process.argv[3],
run,
signal: controller.signal,
install: (source) => hook('install', [source], controller.signal),
data: env.dir_data || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
console.log('更新包下载及校验成功...');
if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.');
await reloadSystem({
root,
tmp,
env,
data: env.dir_data || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
console.log('更新及启动检查成功...');
}),
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
);
}
module.exports = {
downloadWithProgress,
upgradeMessage,
releaseBranch,
verifyUpgrade,
withUpgradeLock,
@@ -652,6 +910,6 @@ module.exports = {
};
if (require.main === module)
main().catch((error) => {
console.error(`更新失败: ${error.message}`);
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
process.exitCode = 1;
});
@@ -214,8 +214,13 @@ async function reconcile(
if (added || removed) {
try {
await api.call('system/notify', 'PUT', {
title: `${namespace} 订阅同步`,
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
title: translate(context.env, '%s 订阅同步', namespace),
content: translate(
context.env,
'新增 %s 个任务,删除 %s 个任务',
added,
removed,
),
});
} catch {
operationOutput(
+1
View File
@@ -416,6 +416,7 @@ export const openOperations: readonly OpenOperation[] = [
body: 'json',
},
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
{ name: 'system update-status', method: 'GET', path: 'system/update-status' },
{ name: 'system update', method: 'PUT', path: 'system/update' },
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
+15 -8
View File
@@ -1,8 +1,13 @@
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
export const english: Record<string, string> = {
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
'%s 订阅同步': '%s subscription sync',
'新增 %s 个任务,删除 %s 个任务': 'Added %s tasks, removed %s tasks',
'需要双因素验证,请调用 user two-factor-login。':
'Two-factor authentication required; use user two-factor-login.',
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。':
'Provide QL_URL and QL_ACCESS_TOKEN together.',
'接口返回 JSON,未保存下载文件。':
'The endpoint returned JSON; no download was saved.',
'已选择的 CLI 安装路径必须为绝对路径。':
'The selected CLI installation path must be absolute.',
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
@@ -152,9 +157,12 @@ export const english: Record<string, string> = {
'应用凭据和 %s 权限': 'application credentials and %s permission',
未知: 'unknown',
'请检查%s。': 'Check %s.',
'资源冲突,请检查重复的名称或值。': 'Resource conflict; check for duplicate names or values.',
'服务端错误,请检查服务状态和日志。': 'Server error; check service status and logs.',
'请求未成功,请检查请求参数和服务日志。': 'Request unsuccessful; check request parameters and service logs.',
'资源冲突,请检查重复的名称或值。':
'Resource conflict; check for duplicate names or values.',
'服务端错误,请检查服务状态和日志。':
'Server error; check service status and logs.',
'请求未成功,请检查请求参数和服务日志。':
'Request unsuccessful; check request parameters and service logs.',
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
' 执行结果可能未知,请先检查%s状态再考虑重试。':
@@ -169,8 +177,7 @@ export const english: Record<string, string> = {
'API request rejected (code %s). Check %s.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置无效,请运行 ql login。':
'Invalid CLI config. Run ql login.',
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
'Cannot read CLI config. Check file permissions and run ql login.',
+2 -2
View File
@@ -276,7 +276,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
'utf8',
),
),
{ ...staged, sourceCommit, dependenciesChanged: false },
{ ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
);
await fs.symlink(
'../../outside',
@@ -287,7 +287,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
{ cwd: payloads },
);
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks|符号链接/);
assert.deepEqual(
(await fs.readdir(ctx.paths.dir_tmp)).sort(),
[
+35
View File
@@ -0,0 +1,35 @@
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 { spawnSync } = require('node:child_process');
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filename = path.join(directory, 'platformBundle.cjs');
fs.copyFileSync(
path.resolve(
__dirname,
'../../dist/internal/maintenance/platformBundle.cjs',
),
filename,
);
const code = `
const Module=require('node:module'),load=Module._load;
Module._load=function(name,...args){
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
return load.call(this,name,...args);
};
const bundle=require(process.argv[1]);
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
`;
const result = spawnSync(process.execPath, ['-e', code, filename], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
});
+1 -1
View File
@@ -23,7 +23,7 @@ test(
assert.notEqual(before.dev, (await fs.stat(root)).dev);
const context = createContext(
{ root, 'data-dir': data },
{ PATH: '/nonexistent' },
{ PATH: '/nonexistent', QL_LANG: 'en' },
);
t.after(async () => {
await stopPanel(context);
+1 -1
View File
@@ -23,7 +23,7 @@ test(
[
'--test',
'--test-reporter=tap',
path.resolve(__dirname, '../preload.test.cjs'),
path.resolve(__dirname, '../internal/preload.test.cjs'),
],
{
env,
+9 -1
View File
@@ -43,6 +43,7 @@ test(
context.paths.dir_tmp,
);
installations.push(directory);
await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true });
},
);
const controller = new AbortController();
@@ -70,9 +71,16 @@ test(
),
);
assert.equal(pointer.directory, path.basename(workspace));
const manifest = JSON.parse(
await fs.readFile(path.join(staged.static, 'build-info.json'), 'utf8'),
);
assert.deepEqual(
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
staged,
{
...staged,
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
},
);
assert.equal(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
+4 -1
View File
@@ -11,6 +11,7 @@ RUN set -x && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
test "$(pnpm --version)" = "8.3.1" && \
cd /tmp/build && \
pnpm install --prod --frozen-lockfile
@@ -70,6 +71,7 @@ RUN set -x && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
test "$(pnpm --version)" = "8.3.1" && \
npm cache clean --force && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
@@ -114,7 +116,8 @@ USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
&& chmod +x /usr/local/bin/task /usr/local/bin/ql \
&& test "$(pnpm --version)" = "8.3.1"
WORKDIR ${QL_DIR}
+30
View File
@@ -0,0 +1,30 @@
# The image digest is supplied by the same workflow run, never a rolling tag.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
USER root
# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon.
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \
cd "$(readlink -f /ql/node_modules/sqlite3)" && \
CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
CXXFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
npm_config_nodedir=/usr npm_config_build_from_source=true QEMU_CPU=arm1176 \
node -e 'require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})' && \
readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E 'Tag_CPU_arch: v6($|K)' && \
rm -rf build && \
apk del .ql-bundle-build; \
fi
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
export QEMU_CPU=arm1176; \
node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \
for archive in /out/*.tar.gz; do \
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \
rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \
done; \
else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi
FROM scratch
COPY --from=bundle /out/ /
+13
View File
@@ -10,6 +10,10 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
- 日期与星期同时指定时保留 OR 语义,不采用 node-cron 默认的 AND。两个原生任务分别处理日期和星期,并按计划时间过滤交集,避免重复执行;保留旧解析器的月份全日期范围及全局 nth-week 限制。
- 原生任务先暂停创建并验证,再替换旧任务。构造失败时销毁已准备的实例并保留旧快照。
- 进程内迟到的触发通过 `execution:missed` 加入既有执行队列,记录补执行日志。并发和单任务积压上限仍由青龙队列控制;这不提供进程退出期间的持久化补跑或分布式 exactly-once 保证。
- 保留 node-cron 已支持的列表、范围和名称,不调用 cron-parser 的 `stringify()` 重写整条表达式。先校验整条表达式,通过则跳过逐字段探测;失败时才检查并展开不支持的字段(如数字/名称起点步长)。保留原有 `?` 和 `#` 兼容处理,避免有效的 `6,12,18` 被压缩为不兼容的 `6/6`;日期/星期拆分后的最终校验仍然保留。
- 星期步长范围以 `7` 结尾时,先按 cron-parser 的解析值展开。例如 `2-7/4` 在现有解析器中包含星期日,而 node-cron 原样执行会遗漏星期日;仅通过语法校验不足以保证执行含义一致。
- 单月规则先剔除超出该月最大日期的值再计算日期数量,保留旧版日期/星期的通配计数语义。被判为通配的字段不再作为另一侧的 AND 约束,避免 `2-31 4 MON` 和包含 `L`、重复星期日的边界规则漏执行。
- 日期与星期按 OR 拆分后,若日期分支在所有指定月份中都不可达,而星期分支有效,则仅保留星期分支;移除前仍检查日期分支在任意月份下语法有效。`31 2,4 MON` 因而可以执行,单独的 `31 2,4 *` 仍拒绝。
- 标准化结果使用上限 512 条的只读缓存,仅缓存语法,不缓存运行实例或下次执行时间。避免大批相同表达式重复解析导致恢复 RPC 超时。
- 回调异常记录日志,后续调度继续;取消时调用原生 `destroy()` 释放计时器和注册表。
- 持久化无效任务在恢复时跳过并记录日志,无效订阅不会产生未处理的启动拒绝。API、导入和执行使用同一套表达式校验。
@@ -31,6 +35,7 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
- `test/fixtures/legacy-cron.json` 是迁移前使用 node-schedule 2.1.1 / cron-parser 4.9.0 保存的快照:269 条语法判定,18 组日历序列,每组 10 次执行。
- 真正的 node-cron 实例配合虚拟时钟验证日期/星期 OR、交集去重、闰年、L/#、8 秒迟到补执行、取消释放、回调拒绝和夏令时。
- `test/fixtures/cron-calendar-boundaries.json` 保存 64 组规则与起点,以独立 cron-parser 4.9.0 / luxon 3.7.2 或公历计算产生预期结果,每组在 UTC、Asia/Shanghai 下各核对 4 次实际执行,包括 2100 年不闰和长周期第五个星期日的边界。时区子测试清理继承的 `NODE_TEST_CONTEXT` 并检查子测试输出,防止嵌套测试入口被跳过而出现假通过。
- 注册构造失败回滚、无效订阅隔离、空表达式立即运行,以及现有调度恢复/503/子进程测试覆盖接入流程。
- 隔离的正式 Debian 镜像使用实际编译产物验证 10,000 条有效任务和 2 条错误任务的恢复、300 个同时到期的 Python 脚本、并发上限 16 及 HTTP 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
@@ -47,3 +52,11 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
完整回归 267 通过、3 项原有跳过;Node 20 的 10 项修复回归及 Alpine 镜像的 24 项兼容/宏测试通过。
额外使用 9,700 个不同表达式和 300 个同时到期任务验证恢复(另有 2 个无效任务),不再局限于共享两个表达式。300/300 脚本完成,无重复或遗漏,最大并发 16,270 个退出码 0、30 个预设退出码 3 均落库。恢复后健康检查 1,650/1,650 成功、任务列表 33/33 成功;启动恢复阶段另有 260 次健康检查失败,整批脚本耗时 60.27 秒。较大快照恢复仍需等待,不承诺启动阶段始终可用或任意规模都能在时限内恢复。
### 深度兼容性审计(2026-10-17)
保持 cron-parser 5.4.0 和 node-cron 4.6.0,扫描 19,811 个表达式,覆盖六个字段的数字、前导零、列表、范围、起点步长、名称、L/#/? 及日期/星期组合。发现并修复星期步长末尾 7、单月日期裁剪、通配计数边界和不可达日期 OR 分支问题;47 个初始失败样例已保存为回归数据。
UTC 和 Asia/Shanghai 各比较 17,795 条规则的连续 4 次实际触发,共核对 142,360 个触发时刻,修复后无偏差、无构造失败。每个时区另外有 71 个通配 `#` 组合沿用已明确保留的旧适配规则,不与原解析器直接对照;22 个原版拒绝的日期输入仍拒绝,11 个参考迭代器触及搜索上限的长周期规则改用独立公历预期验证。
持久化回归覆盖 64 组规则与起点、两个时区,共 128 组日历用例;还保留 H、裸 `/N`、非标准 L 写法及不可达规则的拒绝行为。完整回归 745 通过、8 跳过,后端编译通过。本次为确定性语法与日历测试,不涉及生产环境压力或长期稳定性验证。
@@ -0,0 +1,48 @@
# 平台完整升级包
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
## 在线升级
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。重启前重新验证完整载荷。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-<commit>` release 资产 URL。12 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。
## 手动离线安装和升级
从同一个 `upgrade-<commit>` release 下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`。独立安装器已内置解析依赖,在目标设备无需 npm、pnpm 或重新安装 SQLite。可对照同 release 的 `latest.json` 核对归档 SHA256。
已有安装(默认应用并重启):
```sh
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
ql reload system
```
安装到空目录:
```sh
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
QL_DIR=/opt/qinglong bash /opt/qinglong/shell/start-simplify.sh
```
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、PM2、nginx 等相应运行环境,不会在线安装或替换它们。服务启动和系统自启按原手动安装流程配置。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。依赖加载和 SQLite 读写仍由现有 Node 的独立进程验证。
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
+4 -2
View File
@@ -127,7 +127,8 @@
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
"winston-daily-rotate-file": "^5.0.0",
"tar": "7.5.20"
},
"devDependencies": {
"@ant-design/icons": "^5.0.1",
@@ -201,5 +202,6 @@
"typescript": "5.2.2",
"vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0"
}
},
"qinglongUpgradeFormat": 2
}
+3 -5
View File
@@ -127,6 +127,9 @@ dependencies:
sqlite3:
specifier: npm:@whyour/sqlite3@1.1.2
version: /@whyour/sqlite3@1.1.2
tar:
specifier: 7.5.20
version: 7.5.20
toad-scheduler:
specifier: ^3.0.1
version: 3.0.1
@@ -3053,7 +3056,6 @@ packages:
dependencies:
minipass: 7.1.3
dev: false
optional: true
/@istanbuljs/load-nyc-config@1.1.0:
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
@@ -6623,7 +6625,6 @@ packages:
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
@@ -10701,7 +10702,6 @@ packages:
dependencies:
minipass: 7.1.3
dev: false
optional: true
/mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
@@ -14706,7 +14706,6 @@ packages:
minizlib: 3.1.0
yallist: 5.0.0
dev: false
optional: true
/terser@5.36.0:
resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==}
@@ -15727,7 +15726,6 @@ packages:
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
+22
View File
@@ -0,0 +1,22 @@
const path = require('node:path');
require('../cli/node_modules/esbuild').buildSync({
entryPoints: [path.resolve(__dirname, '../shell/install-bundle.cjs')],
outfile: path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
const { spawnSync } = require('node:child_process');
const result = spawnSync(
process.execPath,
[path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs')],
{ encoding: 'utf8' },
);
if (
result.status !== 1 ||
!result.stderr.includes('Usage: node install-bundle.cjs')
)
throw new Error('Standalone offline installer did not start correctly');
+141
View File
@@ -0,0 +1,141 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const tar = require('tar');
const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs');
const {
verifyUpgrade,
} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
async function normalizeModes(directory) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) {
await fs.chmod(filename, 0o755);
await normalizeModes(filename);
} else if (entry.isFile()) {
const stat = await fs.stat(filename);
await fs.chmod(filename, 0o644 | (stat.mode & 0o111));
}
}
}
async function buildBundle(root, output) {
root = path.resolve(root);
output = path.resolve(output);
const target = await bundle.runtimeTarget();
if (process.platform !== 'linux')
throw new Error('Release bundles must be built and probed on Linux');
const build = await verifyUpgrade(root, path.join(root, 'static'));
await bundle.smokeDependencies(root);
await fs.mkdir(output, { recursive: true });
const workspace = await fs.mkdtemp(path.join(output, '.bundle-'));
try {
const payload = path.join(workspace, 'qinglong');
const source = path.join(payload, 'source'),
staticRoot = path.join(payload, 'static');
await fs.mkdir(source, { recursive: true });
const excluded = new Set([
'.git',
'.tmp',
'.env',
'data',
'static',
'output',
'node_modules',
]);
for (const name of await fs.readdir(root)) {
if (excluded.has(name)) continue;
await fs.cp(path.join(root, name), path.join(source, name), {
recursive: true,
verbatimSymlinks: true,
});
}
await fs.cp(
path.join(root, 'node_modules'),
path.join(source, 'node_modules'),
{ recursive: true, verbatimSymlinks: true },
);
await fs.cp(path.join(root, 'static'), staticRoot, {
recursive: true,
verbatimSymlinks: true,
});
await normalizeModes(source);
await normalizeModes(staticRoot);
const manifest = {
format: 2,
sourceCommit: build.sourceCommit,
version: JSON.parse(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
).version,
lockfileSha256: build.lockfileSha256,
target: {
platform: target.platform,
arch: target.arch,
libc: target.libc,
libcMinimum: target.libcVersion,
endianness: target.endianness,
armFloatAbi: target.armFloatAbi,
},
nodeMinimum: '20.18.1',
nodeApi: 6,
files: await bundle.inventory({ source, static: staticRoot }),
};
await fs.writeFile(
path.join(payload, 'bundle.json'),
JSON.stringify(manifest),
);
console.log('Bundle inventory verified; writing archive');
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
target,
)}.tar.gz`;
await tar.c(
{
gzip: true,
portable: true,
noMtime: true,
cwd: workspace,
file: path.join(output, filename),
},
['qinglong'],
);
// Verify the bytes users receive, not just the pre-archive build directory.
const roundtrip = path.join(workspace, 'roundtrip');
await fs.mkdir(roundtrip);
console.log('Verifying archived paths, permissions and checksums');
await bundle.extractArchive(path.join(output, filename), roundtrip);
const extracted = {
source: path.join(roundtrip, 'qinglong/source'),
static: path.join(roundtrip, 'qinglong/static'),
};
await bundle.verifyPayload(extracted, manifest, target);
await verifyUpgrade(extracted.source, extracted.static);
await bundle.smokeDependencies(extracted.source);
console.log('Archived production dependencies and SQLite verified');
const bytes = await fs.readFile(path.join(output, filename));
const record = {
format: 2,
sourceCommit: build.sourceCommit,
target: bundle.targetKey(target),
filename,
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
size: bytes.length,
sha256: bundle.sha256(bytes),
};
await fs.writeFile(
path.join(output, filename + '.json'),
JSON.stringify(record) + '\n',
);
console.log(JSON.stringify(record));
return record;
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
module.exports = { buildBundle, normalizeModes };
if (require.main === module)
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
(e) => {
console.error(e);
process.exitCode = 1;
},
);
+152
View File
@@ -0,0 +1,152 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
sha256,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
const platforms = [
'linux-x64-musl',
'linux-armv6-musl',
'linux-armv7-musl',
'linux-arm64-musl',
'linux-ppc64-musl',
'linux-s390x-musl',
'linux-ia32-musl',
'linux-x64-glibc',
'linux-armv7-glibc',
'linux-arm64-glibc',
'linux-ppc64-glibc',
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository) {
if (!/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository))
throw new Error('Invalid release source');
const packages = {};
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const record = JSON.parse(
fs.readFileSync(path.join(directory, filename + '.json')),
);
const bytes = fs.readFileSync(path.join(directory, filename));
if (
record.format !== 2 ||
record.sourceCommit !== commit ||
record.target !== target ||
record.filename !== filename ||
record.size !== bytes.length ||
record.sha256 !== sha256(bytes)
)
throw new Error(`Unverified platform bundle: ${target}`);
packages[target] = {
size: record.size,
sha256: record.sha256,
url: `https://github.com/${repository}/releases/download/upgrade-${commit}/${filename}`,
};
}
return { format: 2, sourceCommit: commit, packages };
}
module.exports = { releaseIndex, platforms };
if (require.main === module) {
const [directory, commit, repository, branch] = process.argv.slice(2);
const index = releaseIndex(directory, commit, repository);
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
index.installer = {
size: installer.length,
sha256: sha256(installer),
url: `https://github.com/${repository}/releases/download/upgrade-${commit}/install-bundle.cjs`,
};
if (!['develop', 'master'].includes(branch))
throw new Error('Invalid release channel');
fs.writeFileSync(
path.join(directory, 'latest.json'),
JSON.stringify(index) + '\n',
);
const gh = (...args) =>
execFileSync('gh', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const tag = `upgrade-${commit}`;
try {
gh('release', 'view', tag, '--repo', repository);
} catch {
gh(
'release',
'create',
tag,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--title',
`Platform upgrade ${commit.slice(0, 12)}`,
'--notes',
'Verified platform bundles with production dependencies. Existing Node runtime is retained.',
);
}
const assets = fs
.readdirSync(directory)
.filter((n) => /\.(?:tar\.gz|cjs|json)$/.test(n) && n !== 'latest.json')
.map((n) => path.join(directory, n));
const published = JSON.parse(
gh('api', `repos/${repository}/releases/tags/${tag}`),
);
const missing = [];
for (const file of [...assets, path.join(directory, 'latest.json')]) {
const bytes = fs.readFileSync(file);
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (
previous.size !== bytes.length ||
previous.digest !== `sha256:${sha256(bytes)}`
)
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
// An older, slower run may publish immutable assets, but must not regress a channel.
if (
gh(
'api',
`repos/${repository}/git/ref/heads/${branch}`,
'--jq',
'.object.sha',
).trim() !== commit
) {
console.log('Newer source exists; leaving channel unchanged');
} else {
const channel = `upgrade-${branch}`;
try {
gh('release', 'view', channel, '--repo', repository);
} catch {
gh(
'release',
'create',
channel,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--title',
`Upgrade channel ${branch}`,
'--notes',
'The latest.json asset selects immutable, fully verified platform bundles.',
);
}
gh(
'release',
'upload',
channel,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
}
}
+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;
}
}
+1 -1
View File
@@ -236,7 +236,7 @@ find_cron_api() {
update_auth_config() {
local body="$1"
local tip="$2"
local tip=$(t "$2")
local currentTimeStamp=$(date +%s)
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env node
// Also published as a standalone, bundled installer for devices with Node only.
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { randomUUID } = require('node:crypto');
const { promisify } = require('node:util');
const execute = promisify(require('node:child_process').execFile);
const { parseArgs } = require('node:util');
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs');
const {
nodeRuntimeArgs,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
async function runInstallerMain(argv = process.argv.slice(2)) {
const flags = nodeRuntimeArgs();
if (!flags.length || process.execArgv.includes('--jitless'))
return installBundle(argv);
const child = require('node:child_process').spawn(
process.execPath,
[...flags, __filename, ...argv],
{ stdio: 'inherit', env: process.env },
);
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
process.once('SIGINT', interrupt);
process.once('SIGTERM', terminate);
try {
await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`Offline installer exited: ${signal || code}`));
});
});
} finally {
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', terminate);
}
}
async function installBundle(argv = process.argv.slice(2), env = process.env) {
const { values } = parseArgs({
args: argv,
options: {
root: { type: 'string' },
file: { type: 'string' },
'download-only': { type: 'boolean' },
install: { type: 'boolean' },
},
});
if (!values.file || !(values.root || env.QL_DIR))
throw new Error(
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install]',
);
if (values.install && values['download-only'])
throw new Error('Choose --install or --download-only');
const root = path.resolve(values.root || env.QL_DIR);
const existing = await fs.readdir(root).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return [];
});
if (values.install && existing.length)
throw new Error('--install requires an empty installation directory');
if (!values.install) await fs.access(path.join(root, 'shell/share.sh'));
let environment = {
...env,
QL_DIR: root,
QL_UPGRADE_FILE: path.resolve(values.file),
};
if (!values.install) {
const captured = await execute(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0',
'ql-offline',
root,
],
{ env: environment, maxBuffer: 1024 * 1024 },
);
const loaded = Object.fromEntries(
captured.stdout.split('\0').flatMap((line) => {
const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line);
return match ? [[match[1], match[2]]] : [];
}),
);
environment = {
...environment,
...loaded,
QL_DIR: root,
QL_UPGRADE_FILE: path.resolve(values.file),
};
}
const freshWorkspace = values.install
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
: undefined;
const tmp = freshWorkspace || path.join(root, '.tmp');
const controller = new AbortController();
const abort = () => controller.abort(new Error('离线升级已中断'));
process.once('SIGINT', abort);
process.once('SIGTERM', abort);
try {
const operation = () =>
artifacts.withUpgradeLock(
tmp,
async () => {
let state;
const report = async (patch) => {
if (patch.phase) console.log(patch.phase);
if (state) {
state = {
...state,
...patch,
pid: process.pid,
updatedAt: Date.now(),
};
await jobs.saveState(tmp, state);
}
};
if (!values.install) {
const previous = await jobs.getStatus(tmp);
if (['starting', 'running', 'restarting'].includes(previous.status))
throw new Error('已有更新或重载正在执行。');
state = {
id: randomUUID(),
action: values['download-only'] ? 'download' : 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
};
await report({});
await fs.writeFile(
path.join(tmp, 'panel-upgrade.log'),
'Offline bundle verification\n',
{ mode: 0o600 },
);
}
try {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env: environment,
run: (program, args, capture) =>
artifacts.runProcess(
environment,
program,
args,
capture,
controller.signal,
),
signal: controller.signal,
onProgress: report,
install: async () => {
throw new Error(
'Offline bundles never install npm dependencies',
);
},
});
if (state)
await report({
status: 'ready',
phase: 'ready',
...(await jobs.stagedMetadata(selected)),
});
if (values['download-only']) {
console.log('更新已就绪;执行 ql reload system 应用更新。');
return selected;
}
if (values.install) {
// Build beside the destination, then atomically replace only an empty root.
await fs.mkdir(path.dirname(root), { recursive: true });
const publication = await fs.mkdtemp(
path.join(path.dirname(root), '.qinglong-install-'),
);
try {
await fs.chmod(publication, 0o755);
await fs.cp(selected.source, publication, {
recursive: true,
verbatimSymlinks: true,
});
await fs.cp(selected.static, path.join(publication, 'static'), {
recursive: true,
});
await fs.copyFile(
path.join(publication, '.env.example'),
path.join(publication, '.env'),
require('node:fs').constants.COPYFILE_EXCL,
);
await fs.chmod(path.join(publication, '.env'), 0o600);
controller.signal.throwIfAborted();
await fs.rename(publication, root);
} finally {
await fs.rm(publication, { recursive: true, force: true });
}
await fs.rm(path.dirname(selected.source), {
recursive: true,
force: true,
});
await fs.rm(
path.join(
tmp,
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
),
{ force: true },
);
console.log(
`离线安装完成,保留现有 Node ${
process.version
}。启动服务:QL_DIR=${JSON.stringify(
root,
)} bash ${JSON.stringify(
path.join(root, 'shell/start-simplify.sh'),
)}`,
);
return;
}
const result = await jobs.withPanelLifecycle(
root,
tmp,
environment,
async (hook, prepareSelection) => {
const expected = await jobs.stagedMetadata(selected);
const previous = await jobs
.readRunningBuild(environment)
.catch(() => undefined);
const selection = await prepareSelection?.(selected);
try {
return await artifacts.reloadSystem({
root,
tmp,
env: environment,
data: environment.dir_data || path.join(root, 'data'),
staticRoot: path.join(root, 'static'),
config:
environment.dir_config || path.join(root, 'data/config'),
lifecycle: {
signal: controller.signal,
stop: async () => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook('stop', [], controller.signal);
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
environment,
60000,
recover ? undefined : controller.signal,
);
if (!recover)
await jobs.verifyRunningVersion(
environment,
expected,
previous,
);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
},
controller.signal,
);
await report({ status: 'completed', phase: 'completed' });
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
return result;
} catch (error) {
if (state) await report({ status: 'failed', error: error.message });
throw error;
}
},
environment,
);
return values.install
? await operation()
: await artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
operation,
environment,
);
} finally {
process.removeListener('SIGINT', abort);
process.removeListener('SIGTERM', abort);
if (freshWorkspace)
await fs.rm(freshWorkspace, { recursive: true, force: true });
}
}
module.exports = { installBundle, runInstallerMain };
if (require.main === module)
runInstallerMain().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+38
View File
@@ -108,4 +108,42 @@ declare -gA LANG_MESSAGES=(
['暂不支持此系统部署 %s']='Unsupported system for deployment: %s'
# update.sh
['命令输入错误...\n']='Invalid command...\n'
['%s 删除任务%s']='%s removed tasks%s'
['%s 新增任务']='%s added tasks'
['新增任务通知']='New task notification'
['失效']='obsolete'
['新']='new'
['%s 命令使用方法:']='Usage: %s'
['更新并重启青龙']='Update and restart QingLong'
['运行自定义脚本']='Run custom scripts'
['更新单个脚本文件']='Update a script'
['更新单个仓库的脚本']='Update scripts from a repository'
['删除旧日志']='Delete old logs'
['启动tg-bot']='Start tg-bot'
['检测青龙环境并修复']='Check and repair the QingLong environment'
['查看青龙运行日志']='View QingLong service logs'
['重置登录错误次数']='Reset failed login attempts'
['禁用两步登录']='Disable two-factor login'
['修改登录密码']='Change login password'
['修改登录用户名']='Change login username'
['禁用两步验证']='Disable two-factor authentication'
['重置密码']='Reset password'
['重置用户名']='Reset username'
[', 任务状态更新失败(%s)']=', task status update failed (%s)'
[', 状态更新失败(%s)']=', status update failed (%s)'
['<未识别出活动名称>']='<Unrecognized task name>'
['task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:']='task runs scripts registered in crontab. Use an absolute path or a path relative to %s/ (use relative paths in scheduled tasks). Usage:'
['1.%s <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数']='1.%s <file_name> # Run sequentially, with the configured random delay'
['2.%s <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中']='2.%s <file_name> now # Run immediately, displaying and saving logs'
['3.%s <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行']='3.%s <file_name> conc <variable> [account-numbers...] # Run selected accounts concurrently, saving logs'
['4.%s <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行']='4.%s <file_name> desi <variable> <account-numbers...> # Run selected accounts immediately'
['\n当前有以下脚本可以运行:']='\nAvailable scripts:'
['\n暂无脚本可以执行']='\nNo scripts available'
['🚀 1. 检测配置文件...']='🚀 1. Checking configuration...'
['🔄 2. 启动 nginx...']='🔄 2. Starting nginx...'
['⚙️ 3. 启动 pm2 服务...']='⚙️ 3. Starting pm2 services...'
['🤖 4. 启动 bot...']='🤖 4. Starting bot...'
['🛠️ 5. 执行自定义脚本...']='🛠️ 5. Running custom scripts...'
['🎉 启动成功!']='🎉 Started successfully!'
['\n## 执行结束... %s 耗时 %s 秒']='\n## Execution finished... %s elapsed %s seconds'
)
+8 -8
View File
@@ -45,7 +45,7 @@ gen_array_scripts() {
let i++
array_scripts[i]=$file
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="$(t '<未识别出活动名称>')"
fi
done
cd "$dir_current" || return $?
@@ -54,18 +54,18 @@ gen_array_scripts() {
## 使用说明
usage() {
gen_array_scripts
echo -e "task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “$dir_scripts/” 目录后的相对路径(定时任务中请写作相对路径),用法为:"
echo -e "1.$cmd_task <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数"
echo -e "2.$cmd_task <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中"
echo -e "3.$cmd_task <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行"
echo -e "4.$cmd_task <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行"
t 'task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:' "$dir_scripts"
t '1.%s <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数' "$cmd_task"
t '2.%s <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中' "$cmd_task"
t '3.%s <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行' "$cmd_task"
t '4.%s <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行' "$cmd_task"
if [[ ${#array_scripts[*]} -gt 0 ]]; then
echo -e "\n当前有以下脚本可以运行:"
t '\n当前有以下脚本可以运行:'
for ((i = 0; i < ${#array_scripts[*]}; i++)); do
echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}"
done
else
echo -e "\n暂无脚本可以执行"
t '\n暂无脚本可以执行'
fi
}
+3 -3
View File
@@ -82,7 +82,7 @@ function run() {
].filter(Boolean);
if (task_before) {
console.log('执行前置命令\n');
console.log(process.env.QL_LANG === 'en' ? 'Executing before command\n' : '执行前置命令\n');
}
const res = execSync(commands.join(' && '), {
@@ -122,7 +122,7 @@ function run() {
console.log(output);
}
if (task_before) {
console.log('执行前置命令结束\n');
console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
}
} catch (error) {
if (!error.message.includes('spawnSync /bin/bash E2BIG')) {
@@ -131,7 +131,7 @@ function run() {
// environment variable is too large
}
if (task_before) {
console.log('执行前置命令结束\n');
console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
}
}
+5 -5
View File
@@ -56,7 +56,7 @@ def run():
if task_before:
escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$")
commands.append(f"eval '{escaped_task_before}'")
print("执行前置命令\n")
print("Executing before command\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\n")
commands.append(f"echo -e '{split_str}'")
@@ -88,12 +88,12 @@ def run():
if len(output) > 0:
print(output)
if task_before:
print("执行前置命令结束\n")
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
except subprocess.CalledProcessError as error:
print(f"\ue926 run task before error: {error}")
if task_before:
print("执行前置命令结束\n")
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
except OSError as error:
error_message = str(error)
if "Argument list too long" not in error_message:
@@ -101,11 +101,11 @@ def run():
# else:
# environment variable is too large
if task_before:
print("执行前置命令结束\n")
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
except Exception as error:
print(f"\ue926 run task before error: {error}")
if task_before:
print("执行前置命令结束\n")
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
import task_before
+2 -2
View File
@@ -395,7 +395,7 @@ handle_task_start() {
if [[ $ID ]]; then
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
if [[ $error ]]; then
error_message=", 任务状态更新失败(${error})"
error_message=$(t ', 任务状态更新失败(%s)' "$error")
fi
fi
t '## 开始执行... %s\n' "${begin_time}${error_message}"
@@ -437,7 +437,7 @@ handle_task_end() {
if [[ $ID ]]; then
local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code")
if [[ $error ]]; then
error_message=", 状态更新失败(${error})"
error_message=$(t ', 状态更新失败(%s)' "$error")
fi
fi
+6 -6
View File
@@ -46,28 +46,28 @@ log_with_style() {
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
log_with_style "INFO" "$(t '🚀 1. 检测配置文件...')"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
log_with_style "INFO" "$(t '⚙️ 3. 启动 pm2 服务...')"
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
log_with_style "INFO" "$(t '🤖 4. 启动 bot...')"
nohup ql bot >"$dir_log/bot.log" 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
log_with_style "INFO" "$(t '🛠️ 5. 执行自定义脚本...')"
nohup ql extra >"$dir_log/extra.log" 2>&1 &
fi
@@ -75,4 +75,4 @@ if [[ $command != "reload" ]]; then
pm2 save
fi
log_with_style "SUCCESS" "🎉 启动成功!"
log_with_style "SUCCESS" "$(t '🎉 启动成功!')"
+10
View File
@@ -8,6 +8,16 @@ set -x
startup_message() {
local format="$1"
shift
if [[ ${QL_LANG:-zh} == en ]]; then
case "$format" in
'未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装') format='Module not found. Run: npm i -g @whyour/qinglong' ;;
'请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务') format='Set export QL_DIR=%s in your system environment, then run qinglong to start the service' ;;
'QL_DIR 必须为绝对路径,例如 /ql') format='QL_DIR must be an absolute path, e.g. /ql' ;;
'请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量') format='Set QL_DATA_DIR to an absolute path ending in /data, e.g. /ql/data, in your system environment' ;;
'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中') format='QL_DATA_DIR must be an absolute path ending in /data, e.g. /ql/data. Move existing data into that directory' ;;
'暂不支持此系统部署 %s') format='Unsupported system for deployment: %s' ;;
esac
fi
printf -- "$format\n" "$@" >&2
}
+23 -19
View File
@@ -31,7 +31,7 @@ diff_cron() {
## 输出是否有新的或失效的定时任务,$1:新的或失效的任务清单文件路径,$2:新/失效
output_list_add_drop() {
local list=$1
local type=$2
local type=$(t "$2")
if [[ -s $list ]]; then
t '检测到有%s的定时任务:' "$type"
cat $list
@@ -67,7 +67,7 @@ del_cron() {
done
if [[ $ids ]]; then
result=$(del_cron_api "$ids")
notify_api "$path 删除任务${result}" "$detail"
notify_api "$(t '%s 删除任务%s' "$path" "$result")" "$detail"
fi
}
@@ -110,7 +110,7 @@ add_cron() {
fi
fi
done
notify_api "$path 新增任务" "$detail"
notify_api "$(t '%s 新增任务' "$path")" "$detail"
}
## 更新仓库
@@ -199,7 +199,7 @@ update_raw() {
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
echo -e "$result\n"
notify_api "新增任务通知" "\n$result"
notify_api "$(t '新增任务通知')" "\n$result"
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
fi
else
@@ -276,19 +276,19 @@ show_service_logs() {
## 脚本用法
usage() {
t "$cmd_update 命令使用方法:"
echo -e "1. $cmd_update update # 更新并重启青龙"
echo -e "2. $cmd_update extra # 运行自定义脚本"
echo -e "3. $cmd_update raw <fileurl> # 更新单个脚本文件"
echo -e "4. $cmd_update repo <repourl> <path> <blacklist> <dependence> <branch> <extensions> # 更新单个仓库的脚本"
echo -e "5. $cmd_update rmlog <days> # 删除旧日志"
echo -e "6. $cmd_update bot # 启动tg-bot"
echo -e "7. $cmd_update check # 检测青龙环境并修复"
echo -e "8. $cmd_update log [--lines <n>] [--nostream] # 查看青龙运行日志"
echo -e "9. $cmd_update resetlet # 重置登录错误次数"
echo -e "10. $cmd_update resettfa # 禁用两步登录"
echo -e "11. $cmd_update resetpwd # 修改登录密码"
echo -e "12. $cmd_update resetname # 修改登录用户名"
t '%s 命令使用方法:' "$cmd_update"
echo -e "1. $cmd_update update # $(t '更新并重启青龙')"
echo -e "2. $cmd_update extra # $(t '运行自定义脚本')"
echo -e "3. $cmd_update raw <fileurl> # $(t '更新单个脚本文件')"
echo -e "4. $cmd_update repo <repourl> <path> <blacklist> <dependence> <branch> <extensions> # $(t '更新单个仓库的脚本')"
echo -e "5. $cmd_update rmlog <days> # $(t '删除旧日志')"
echo -e "6. $cmd_update bot # $(t '启动tg-bot')"
echo -e "7. $cmd_update check # $(t '检测青龙环境并修复')"
echo -e "8. $cmd_update log [--lines <n>] [--nostream] # $(t '查看青龙运行日志')"
echo -e "9. $cmd_update resetlet # $(t '重置登录错误次数')"
echo -e "10. $cmd_update resettfa # $(t '禁用两步登录')"
echo -e "11. $cmd_update resetpwd # $(t '修改登录密码')"
echo -e "12. $cmd_update resetname # $(t '修改登录用户名')"
}
reload_qinglong() {
@@ -504,7 +504,9 @@ main() {
local begin_time=$(format_time "$time_format" "$time")
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
local start_message
printf -v start_message '%q' "$(t '## 开始执行... %s\n' "$begin_time")"
eval "printf '%s\n' $start_message $cmd"
fi
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
@@ -577,7 +579,9 @@ main() {
[[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time"
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd
local end_message
printf -v end_message '%q' "$(t '\n## 执行结束... %s 耗时 %s 秒' "$end_time" "$diff_time")     "
eval "printf '%s\n' $end_message $cmd"
fi
return "$command_status"
}
+1 -1
View File
@@ -3,6 +3,6 @@ const upgrade = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
module.exports = upgrade;
if (require.main === module)
upgrade.main().catch((error) => {
console.error(`更新失败: ${error.message}`);
console.error(upgrade.upgradeMessage('更新失败: %s', process.env, error.message));
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 {
display: flex;
width: 100%;
min-width: 0;
gap: 8px;
justify-content: space-between;
align-items: center;
color: @text-color;
.side-menu-collapse-button:hover {
color: #1890ff;
.side-menu-collapse-button {
flex-shrink: 0;
&:hover {
color: #1890ff;
}
}
.side-menu-user-wrapper {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
}
.side-menu-user-wrapper {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 5px;
.ant-avatar {
flex-shrink: 0;
}
.side-menu-username {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
body[data-mode='phone'] .side-menu-user-wrapper {
max-width: 50vw;
}
.side-menu-user-drop-menu {
position: relative;
text-align: left;
@@ -475,11 +505,105 @@ body[data-mode='phone'] header {
}
body[data-dark='true'] {
.upgrade-notice {
background: #3a3214;
border-color: #6b5415;
color: #ffd666;
}
.ant-popover-arrow-content {
--antd-arrow-background-color: rgb(24, 26, 27);
}
}
.upgrade-notice {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
min-height: 36px;
padding: 7px 10px;
margin: 8px 12px 12px;
border: 1px solid #ffe58f;
border-radius: 6px;
background: #fffbe6;
color: #ad6800;
font: inherit;
font-size: 12px;
line-height: 20px;
text-align: left;
cursor: pointer;
> .anticon {
display: inline-flex;
flex: 0 0 14px;
align-items: center;
justify-content: center;
line-height: 1;
}
> span:not(.anticon) {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:focus-visible {
outline: 2px solid #d48806;
outline-offset: 2px;
}
&.upgrade-notice-collapsed {
justify-content: center;
padding: 7px 0;
}
}
.upgrade-panel {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
.upgrade-status-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.upgrade-progress {
display: flex;
flex-direction: column;
gap: 8px;
.ant-progress {
margin: 0;
}
}
.upgrade-progress-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 12px;
font-variant-numeric: tabular-nums;
}
.upgrade-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.upgrade-log-link {
height: auto;
padding: 0;
line-height: 22px;
}
}
.ant-tabs-content-holder {
flex: 1;
overflow-y: auto;
+43 -16
View File
@@ -18,6 +18,9 @@ import defaultProps from './defaultProps';
import './index.less';
import { init } from '../utils/init';
import WebSocketManager from '../utils/websocket';
import { observePageLoadTiming } from '../utils/navigationTiming';
import UpgradeNotice from '@/components/UpgradeNotice';
import { completionKey, reloadKey, upgradeStore } from '@/hooks/useUpgrade';
export interface SharedContext {
headerStyle: React.CSSProperties;
@@ -48,10 +51,14 @@ export default function () {
const [loading, setLoading] = useState<boolean>(true);
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
const [siteTitle, setSiteTitle] = useState(
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
() =>
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
);
const [collapsed, setCollapsed] = useState(false);
const [initLoading, setInitLoading] = useState<boolean>(true);
const observeUpgrade =
Boolean(user.username) &&
!['/login', '/initialization'].includes(location.pathname);
const {
enable: enableDarkMode,
disable: disableDarkMode,
@@ -160,6 +167,18 @@ export default function () {
getHealthStatus();
}, []);
useEffect(() => {
// A reload during restart may initially land on the health error page.
// Keep recovering the accepted job even before the sidebar can mount.
if (
observeUpgrade ||
sessionStorage.getItem(reloadKey) ||
sessionStorage.getItem(completionKey)
) {
return upgradeStore.subscribe(() => {});
}
}, [observeUpgrade]);
useEffect(() => {
reloadSystemConfig();
}, []);
@@ -219,19 +238,17 @@ export default function () {
}, [user]);
useEffect(() => {
window.onload = () => {
const timing = performance.timing;
console.log(`白屏时间: ${timing.responseStart - timing.navigationStart}`);
console.log(
`请求完毕至DOM加载: ${timing.domInteractive - timing.responseEnd}`,
);
console.log(
`解释dom树耗时: ${timing.domComplete - timing.domInteractive}`,
);
console.log(
`从开始至load总耗时: ${timing.loadEventEnd - timing.navigationStart}`,
);
};
return observePageLoadTiming((durations) => {
const metrics = [
['白屏时间', durations.responseTime],
['请求完毕至DOM加载', durations.domReadyTime],
['解释DOM树耗时', durations.domParseTime],
['从开始至load总耗时', durations.loadTime],
] as const;
for (const [label, duration] of metrics) {
if (duration !== undefined) console.log(intl.get(label, { duration }));
}
});
}, []);
if (initLoading) {
@@ -321,6 +338,12 @@ export default function () {
</>
}
title={false}
menuFooterRender={(props) => (
<UpgradeNotice
collapsed={Boolean(props?.collapsed)}
systemInfo={systemInfo}
/>
)}
menuItemRender={(menuItemProps: any, defaultDom: any) => {
if (
menuItemProps.isUrl ||
@@ -351,7 +374,9 @@ export default function () {
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''
}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
<span className="side-menu-username" title={user.username}>
{user.username}
</span>
</span>
</Dropdown>
)
@@ -377,7 +402,9 @@ export default function () {
: ''
}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
<span className="side-menu-username" title={user.username}>
{user.username}
</span>
</span>
</Dropdown>
)}
+50 -2
View File
@@ -1,4 +1,19 @@
{
"选中条数": "Selected {count} items",
"登录失败次数": "Failed {count} time(s)",
"间隔定时规则": "Every {value} {unit}",
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
"HTTP/SOCK5代理,例如 http://127.0.0.1:1080": "HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:1080",
"确认操作对象": "Confirm {operation} {object}",
"确认批量操作": "Confirm {operation} selected {object}?",
"检查更新失败,请检查网络后重试": "Failed to check for updates. Check your network and try again.",
"确认操作": "Confirm {operation}",
"分页总数": "{start}-{end} of {total} items",
"总条数": "{total} items",
"内网IP": "Private IP",
"容器更新修复命令": "1. Run on the host: docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <container_name>",
"WxPusher(极简推送SPT-推荐)": "WxPusher (Simple Push SPT, recommended)",
"新版本可用,当前版本": "Version {version} is available. You are using {current}.",
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. Execute 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <container_name>' on the host machine",
"2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container",
"24小时未运行": "Idle 24h+",
@@ -323,7 +338,7 @@
"显示": "Display",
"智能微秘书": "Smart WeChat Assistant",
"暂无数据": "No data",
"暂无日志": "No logs available",
"暂无日志": "No logs yet",
"暂无脚本": "No scripts available",
"暂无运行中任务": "No running tasks",
"暗色": "Dark",
@@ -709,5 +724,38 @@
"脚本通知": "Script notification",
"系统通知": "System notification",
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path",
"控制台诗句": "A rain hat and cloak, a song among the hills. The moon, the breeze and I spend our idle days together.",
"运行环境信息": "QingLong runtime:\n\nSystem: {system}\nBrowser: {browser}\nEngine: {engine}",
"WebSocket错误": "WebSocket error:",
"白屏时间": "Time to first response: {duration} ms",
"请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms",
"解释DOM树耗时": "DOM completion time: {duration} ms",
"从开始至load总耗时": "Total load time: {duration} ms",
"正在解压更新包": "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 update package",
"下载源码": "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."
}
+49 -1
View File
@@ -1,4 +1,19 @@
{
"选中条数": "已选择 {count} 项",
"登录失败次数": "失败{count}次",
"间隔定时规则": "每{value}{unit}",
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
"HTTP/SOCK5代理,例如 http://127.0.0.1:1080": "HTTP/SOCK5代理,例如 http://127.0.0.1:1080",
"确认操作对象": "确认{operation}{object}",
"确认批量操作": "确认{operation}选中的{object}吗",
"检查更新失败,请检查网络后重试": "检查更新失败,请检查网络后重试",
"确认操作": "确认{operation}",
"分页总数": "第 {start}-{end} 条/总共 {total} 条",
"总条数": "共 {total} 个",
"内网IP": "内网IP",
"容器更新修复命令": "1. 宿主机执行 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <容器名>",
"WxPusher(极简推送SPT-推荐)": "WxPusher(极简推送SPT-推荐)",
"新版本可用,当前版本": "新版本 {version} 可用,你使用的版本为 {current}。",
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>",
"2. 容器内执行 ql check、ql update": "2. 容器内执行 ql check、ql update",
"24小时未运行": "24小时未运行",
@@ -709,5 +724,38 @@
"脚本通知": "脚本通知",
"系统通知": "系统通知",
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径",
"控制台诗句": "忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。",
"运行环境信息": "青龙运行环境:\n\n系统:{system}\n浏览器:{browser}\n内核:{engine}",
"WebSocket错误": "WebSocket错误:",
"白屏时间": "白屏时间: {duration} ms",
"请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms",
"解释DOM树耗时": "解释 DOM 树耗时: {duration} ms",
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
"正在解压更新包": "正在解压更新包",
"正在校验更新包": "正在校验更新包",
"正在准备依赖": "正在准备依赖",
"更新已就绪": "更新已就绪",
"正在重启并更新": "正在重启并更新",
"更新完成": "更新完成",
"更新失败": "更新失败",
"正在准备更新": "正在准备更新",
"连接已断开,正在重新连接": "连接已断开,正在重新连接",
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
"下载构建产物": "下载构建产物",
"下载更新包": "下载更新包",
"下载源码": "下载源码",
"正在获取下载大小": "正在获取下载大小",
"查看更新日志": "查看更新日志",
"重启并更新": "重启并更新",
"升级任务启动失败,请检查日志后重试": "升级任务启动失败,请检查日志后重试",
"升级任务意外中断,请重试": "升级任务意外中断,请重试",
"系统将在30秒后自动刷新": "系统将在30秒后自动刷新",
"运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。",
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
"启动版本与更新包不一致": "启动版本与更新包不一致",
"下载完成,等待重启": "下载完成,等待重启",
"升级成功": "升级成功",
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
}
+4 -10
View File
@@ -364,13 +364,10 @@ const CronDetailModal = ({
const enabledOrDisabledCron = () => {
Modal.confirm({
title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: intl.get('确认操作', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }),
content: (
<>
{intl.get('确认')}
{currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')}
{intl.get('定时任务')}{' '}
{intl.get('确认操作对象', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
@@ -398,13 +395,10 @@ const CronDetailModal = ({
const pinOrUnPinCron = () => {
Modal.confirm({
title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: intl.get('确认操作', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }),
content: (
<>
{intl.get('确认')}
{currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
{intl.get('定时任务')}{' '}
{intl.get('确认操作对象', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
+16 -18
View File
@@ -544,13 +544,13 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: intl.get('确认操作', {
operation:
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'),
}),
content: (
<>
{intl.get('确认')}
{record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')}
{intl.get('定时任务')}{' '}
{intl.get('确认操作对象', { operation: record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
@@ -584,13 +584,13 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: intl.get('确认操作', {
operation:
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'),
}),
content: (
<>
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
{intl.get('定时任务')}{' '}
{intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
@@ -745,12 +745,12 @@ const Crontab = () => {
const operateCrons = (operationStatus: number) => {
Modal.confirm({
title: `确认${OperationName[operationStatus]}`,
title: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
}),
content: (
<>
{intl.get('确认')}
{OperationName[operationStatus]}
{intl.get('选中的定时任务吗')}
{intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('定时任务') })}
</>
),
onOk() {
@@ -1035,9 +1035,7 @@ const Crontab = () => {
{intl.get('批量修改标签')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
{intl.get('项')}
{intl.get('选中条数', { count: selectedRowIds.length })}
</span>
</div>
)}
@@ -1051,7 +1049,7 @@ const Crontab = () => {
simple: isPhone,
total,
showTotal: (total: number, range: number[]) =>
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
intl.get('分页总数', { start: range[0], end: range[1], total }),
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort(
(a, b) => a - b,
),
+2 -2
View File
@@ -167,7 +167,7 @@ const Dashboard = () => {
const runtimePagination = {
pageSize: 5,
showSizeChanger: false,
showTotal: (total: number) => `共 ${total} 个`,
showTotal: (total: number) => intl.get('总条数', { total }),
};
if (loading) return <Spin size="large" style={{ display: 'block', marginTop: 100 }} />;
@@ -348,7 +348,7 @@ const Dashboard = () => {
data={{
target: Number(system.memUsagePercent),
total: 100,
name: '内存使用率',
name: intl.get('内存使用'),
}}
style={{
textY: '70%',
+1 -3
View File
@@ -536,9 +536,7 @@ const Dependence = () => {
{intl.get('批量强制删除')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
{intl.get('项')}
{intl.get('选中条数', { count: selectedRowIds.length })}
</span>
</div>
)}
+18 -22
View File
@@ -279,16 +279,13 @@ const Env = () => {
const enabledOrDisabledEnv = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用')
}`,
title: intl.get('确认操作', {
operation:
record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'),
}),
content: (
<>
{intl.get('确认')}
{record.status === Status.已禁用
? intl.get('启用')
: intl.get('禁用')}
Env{' '}
{intl.get('确认操作对象', { operation: record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'), object: 'Env' })}{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
@@ -343,14 +340,13 @@ const Env = () => {
const pinOrUnpinEnv = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: intl.get('确认操作', {
operation:
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'),
}),
content: (
<>
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
Env{' '}
{intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: 'Env' })}{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
@@ -543,12 +539,12 @@ const Env = () => {
const operateEnvs = (operationStatus: number) => {
Modal.confirm({
title: `确认${OperationName[operationStatus]}`,
title: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
}),
content: (
<>
{intl.get('确认')}
{OperationName[operationStatus]}
{intl.get('选中的变量吗')}
{intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('环境变量') })}
</>
),
onOk() {
@@ -603,7 +599,9 @@ const Env = () => {
);
if (code === 200) {
message.success(`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`);
message.success(
`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`,
);
getEnvs();
}
setImportLoading(false);
@@ -708,9 +706,7 @@ const Env = () => {
{intl.get('批量取消置顶')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
{intl.get('项')}
{intl.get('选中条数', { count: selectedRowIds.length })}
</span>
</div>
)}
+2 -8
View File
@@ -79,16 +79,10 @@ const Error = () => {
description={
<Typography.Text type="danger">
<div>{intl.get('请先按如下方式修复:')}</div>
<div>
1. 宿主机执行 docker run --rm -v
/var/run/docker.sock:/var/run/docker.sock
containrrr/watchtower -cR &lt;容器名&gt;
</div>
<div>{intl.get('容器更新修复命令')}</div>
<div>{intl.get('2. 容器内执行 ql check、ql update')}</div>
<div>
{intl.get(
'3. 如果无法解决,容器内执行 ql log,拷贝执行结果',
)}
{intl.get('3. 如果无法解决,容器内执行 ql log,拷贝执行结果')}
<Typography.Link href="https://github.com/whyour/qinglong/issues/new?assignees=&labels=&template=bug_report.yml">
{intl.get('提交 issue')}
</Typography.Link>
+1 -1
View File
@@ -129,7 +129,7 @@ const Initialization = () => {
>
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={`请输入${x.label}`}
placeholder={`${intl.get('请输入')} ${x.label}`}
/>
</Form.Item>
))}
+2 -2
View File
@@ -91,7 +91,7 @@ const Login = () => {
</div>
<div>
{intl.get('上次登录地点:')}
{lastaddr || '-'}
{lastaddr === '内网IP' ? intl.get('内网IP') : lastaddr || '-'}
</div>
<div>
{intl.get('上次登录IP:')}
@@ -103,7 +103,7 @@ const Login = () => {
</div>
<div>
{intl.get('上次登录状态:')}
{retries > 0 ? `失败${retries}次` : intl.get('成功')}
{retries > 0 ? intl.get('登录失败次数', { count: retries }) : intl.get('成功')}
</div>
</>
),
+204 -220
View File
@@ -1,237 +1,221 @@
import { disableBody } from "@/utils";
import config from "@/utils/config";
import { request } from "@/utils/http";
import WebSocketManager from "@/utils/websocket";
import Ansi from "ansi-to-react";
import { Button, Modal, Statistic, message } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import intl from "react-intl-universal";
import config from '@/utils/config';
import { request } from '@/utils/http';
import Ansi from 'ansi-to-react';
import { Button, Modal, Progress, Space, Typography, message } from 'antd';
import { useState } from 'react';
import useUpgrade, {
activeStatuses,
phaseLabels,
reloadKey,
upgradeStore,
} from '@/hooks/useUpgrade';
import intl from 'react-intl-universal';
const { Countdown } = Statistic;
const sizeLabel = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
const CheckUpdate = ({ systemInfo }: any) => {
const [updateLoading, setUpdateLoading] = useState(false);
const [value, setValue] = useState("");
const modalRef = useRef<any>();
const lastStatusRef = useRef<string | undefined>();
const { job, busy, connected } = useUpgrade();
const [details, setDetails] = useState(false);
const active = activeStatuses.includes(job.status);
const ready = job.status === 'ready';
const completed = job.status === 'completed';
const checkUpgrade = () => {
if (updateLoading) 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();
console.log(error);
})
.finally(() => {
setUpdateLoading(false);
const startUpdate = async () => {
if (busy || active) return;
if (!upgradeStore.beginMutation()) return;
try {
const result = await request.put(`${config.apiPrefix}system/update`);
if (result.code !== 200) throw new Error(result.message);
upgradeStore.setJob(result.data);
} catch {
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
} finally {
upgradeStore.finishMutation();
}
};
const checkUpgrade = async () => {
if (!upgradeStore.beginMutation()) return;
try {
const { code, data } = await request.put(
`${config.apiPrefix}system/update-check`,
);
if (code !== 200) throw new Error('Update check failed');
Modal.confirm({
width: 500,
title: intl.get(data.hasNewVersion ? '更新可用' : '已经是最新版了!'),
content: data.hasNewVersion ? (
<>
<p>
{intl.get('新版本可用,当前版本', {
version: data.lastVersion,
current: systemInfo.version,
})}
</p>
<pre>
<Ansi>{data.lastLog}</Ansi>
</pre>
</>
) : (
`${intl.get('青龙')} ${data.lastVersion}`
),
okText: intl.get(data.hasNewVersion ? '下载更新' : '重新下载'),
cancelText: intl.get('以后再说'),
onOk: startUpdate,
});
};
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() {
showUpdatingModal();
request
.put(`${config.apiPrefix}system/update`)
.then((_data: any) => { })
.catch((error: any) => {
console.log(error);
});
},
});
};
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("新版本")} {lastVersion}{" "}
{intl.get("可用,你使用的版本为")} {systemInfo.version}。
</div>
</>
),
content: (
<pre>
<Ansi>{lastLog}</Ansi>
</pre>
),
okText: intl.get("下载更新"),
cancelText: intl.get("以后再说"),
onOk() {
showUpdatingModal();
request
.put(`${config.apiPrefix}system/update`)
.then((_data: any) => { })
.catch((error: any) => {
console.log(error);
});
},
});
};
const showUpdatingModal = () => {
setValue("");
modalRef.current = Modal.info({
width: 600,
maskClosable: false,
closable: false,
keyboard: false,
okButtonProps: { disabled: true },
title: intl.get("下载更新中..."),
centered: true,
content: (
<pre>
<Ansi>{value}</Ansi>
</pre>
),
});
};
const reloadSystem = (type?: string) => {
request
.put(`${config.apiPrefix}update/${type}`)
.then((_data: any) => {
message.success({
content: (
<span>
{intl.get("系统将在")}
<Countdown
className="inline-countdown"
format="ss"
value={Date.now() + 1000 * 30}
/>
{intl.get("秒后自动刷新")}
</span>
),
duration: 30,
});
disableBody();
setTimeout(() => {
window.location.reload();
}, 30000);
})
.catch((error: any) => {
console.log(error);
});
};
const showReloadModal = () => {
Modal.confirm({
width: 600,
maskClosable: false,
title: intl.get("确认重启"),
centered: true,
content: intl.get("系统安装包下载成功,确认重启"),
okText: intl.get("重启"),
onOk() {
reloadSystem("system");
},
onCancel() {
modalRef.current.update({
maskClosable: true,
closable: true,
okButtonProps: { disabled: false },
});
},
});
};
useEffect(() => {
if (!value) return;
const updateFailed = lastStatusRef.current === 'failed';
modalRef.current.update({
maskClosable: updateFailed,
closable: updateFailed,
okButtonProps: { disabled: !updateFailed },
content: (
<>
<pre>
<Ansi>{value}</Ansi>
</pre>
<div id="log-identifier" style={{ paddingBottom: 5 }}></div>
</>
),
});
}, [value]);
const handleMessage = useCallback((payload: any) => {
const { message: _message, status } = payload;
if (status === 'failed') {
lastStatusRef.current = 'failed';
message.error(intl.get("更新失败,请检查网络及日志或稍后再试"));
} catch {
message.error(intl.get('检查更新失败,请检查网络后重试'));
} finally {
upgradeStore.finishMutation();
}
};
if (status === 'success') {
lastStatusRef.current = 'success';
setTimeout(() => {
showReloadModal();
}, 1000);
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();
}
};
setTimeout(() => {
document
.querySelector("#log-identifier")
?.scrollIntoView({ behavior: "smooth" });
}, 600);
const showReloadModal = () =>
Modal.confirm({
title: intl.get('确认重启'),
content: intl.get('更新已就绪,重启后应用新版本'),
okText: intl.get('重启并更新'),
cancelText: intl.get('以后再说'),
onOk: () => reloadSystem(true),
});
if (_message) {
setValue((p) => `${p}${_message}`);
}
}, []);
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe("updateSystemVersion", handleMessage);
return () => {
ws.unsubscribe("updateSystemVersion", handleMessage);
};
}, []);
return (
<>
<Button type="primary" onClick={checkUpgrade}>
{intl.get("检查更新")}
</Button>
const label =
job.status === 'failed'
? intl.get('更新失败')
: ready
? intl.get('下载完成,等待重启')
: completed
? intl.get('升级成功')
: intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
const hasJob = job.status !== 'idle' && job.status !== 'loading';
const actions = !active && (
<Space wrap>
<Button
type="primary"
onClick={() => reloadSystem("reload")}
style={{ marginLeft: 8 }}
loading={busy}
disabled={!connected || job.status === 'loading'}
onClick={ready ? showReloadModal : checkUpgrade}
>
{intl.get("重新启动")}
{intl.get(ready ? '重启并更新' : '检查更新')}
</Button>
</>
{!ready && !completed && (
<Button
disabled={busy || !connected || job.status === 'loading'}
onClick={() => reloadSystem(false)}
>
{intl.get('重新启动')}
</Button>
)}
</Space>
);
return (
<div className="upgrade-panel">
{!connected && (
<Typography.Text type="warning" role="status">
{intl.get('连接已断开,正在重新连接')}
</Typography.Text>
)}
{!hasJob && actions}
{hasJob && (
<div
className="upgrade-status-content"
role="status"
aria-live="polite"
>
<Typography.Text strong>
{label}
{job.version ? ` · v${job.version}` : ''}
</Typography.Text>
<Typography.Text
type={job.status === 'failed' ? 'danger' : 'secondary'}
>
{job.status === 'failed'
? intl.get(job.error || '更新失败,请检查网络及日志或稍后再试') ||
job.error
: intl.get(
completed
? '已重新启动并运行新版本,无需再次重启。'
: ready
? '更新已就绪,重启后应用新版本'
: '更新在后台进行,可以继续使用面板',
)}
</Typography.Text>
{job.phase === 'downloading' && active && (
<div className="upgrade-progress">
<div className="upgrade-progress-meta">
<Typography.Text type="secondary">
{intl.get(
job.artifact === 'qinglong-bundle'
? '下载更新包'
: 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>
);
};
+5
View File
@@ -33,6 +33,11 @@
gap: 40px;
}
.notify-log-table .ant-table-thead > tr > th,
.login-log-table .ant-table-thead > tr > th {
white-space: nowrap;
}
.ql-container-wrapper.ql-setting-container {
.ant-tabs-tabpane > div {
padding-left: 2px;
+6 -4
View File
@@ -47,7 +47,7 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
const columns = [
{
title: intl.get('序号'),
width: 50,
width: 90,
render: (text: string, record: any, index: number) => index + 1,
},
{
@@ -63,6 +63,7 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
dataIndex: 'address',
width: 120,
key: 'address',
render: (text: string) => (text === '内网IP' ? intl.get('内网IP') : text),
},
{
title: intl.get('登录IP'),
@@ -74,13 +75,13 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
title: intl.get('登录设备'),
dataIndex: 'platform',
key: 'platform',
width: 80,
width: 140,
},
{
title: intl.get('登录状态'),
dataIndex: 'status',
key: 'status',
width: 80,
width: 140,
render: (text: string, record: any) => (
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(LoginStatus[record.status])}
@@ -116,12 +117,13 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
return (
<>
<Table
className="login-log-table"
columns={columns}
pagination={false}
dataSource={data}
rowKey={(record) => `${record.ip}-${record.timestamp}`}
size="middle"
scroll={{ x: 1000, y: height }}
scroll={{ x: 1120, y: height }}
/>
</>
);
+5 -3
View File
@@ -2,6 +2,7 @@ import intl from 'react-intl-universal';
import React from 'react';
import { Table, Tag } from 'antd';
import dayjs from 'dayjs';
import './index.less';
interface NotifyLogItem {
id?: number;
@@ -40,7 +41,7 @@ const columns = [
},
{
title: intl.get('序号'),
width: 50,
width: 90,
render: (text: string, record: any, index: number) => {
return index + 1;
},
@@ -79,7 +80,7 @@ const columns = [
title: intl.get('发送状态'),
dataIndex: 'status',
key: 'status',
width: 90,
width: 140,
render: (text: string, record: NotifyLogItem) => {
const statusKey = record.status ?? 1;
return (
@@ -101,12 +102,13 @@ const NotifyLog = ({
return (
<>
<Table
className="notify-log-table"
columns={columns}
pagination={false}
dataSource={data}
rowKey="id"
size="middle"
scroll={{ x: 1000, y: height }}
scroll={{ x: 1240, y: height }}
/>
</>
);
+8 -9
View File
@@ -103,7 +103,7 @@ const Subscription = () => {
key: 'type',
width: 130,
render: (text: string, record: any) => {
return (SubscriptionType as any)[record.type];
return intl.get((SubscriptionType as any)[record.type]);
},
},
{
@@ -121,7 +121,7 @@ const Subscription = () => {
render: (text: string, record: any) => {
if (record.schedule_type === 'interval') {
const { type, value } = record.interval_schedule;
return `每${value}${(IntervalSchedule as any)[type]}`;
return intl.get('间隔定时规则', { value, unit: intl.get((IntervalSchedule as any)[type]) });
}
return record.schedule;
},
@@ -369,14 +369,13 @@ const Subscription = () => {
const enabledOrDisabledSubscription = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: intl.get('确认操作', {
operation:
record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'),
}),
content: (
<>
{intl.get('确认')}
{record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用')}
{intl.get('定时订阅')}{' '}
{intl.get('确认操作对象', { operation: record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时订阅') })}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
@@ -569,7 +568,7 @@ const Subscription = () => {
simple: isPhone,
defaultPageSize: 20,
showTotal: (total: number, range: number[]) =>
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
intl.get('分页总数', { start: range[0], end: range[1], total }),
pageSizeOptions: [20, 100, 500, 1000] as any,
}}
dataSource={value}
+4 -4
View File
@@ -150,10 +150,10 @@ const SubscriptionModal = ({
precision={0}
min={1}
value={intervalNumber}
style={{ width: 'calc(100% - 58px)' }}
style={{ width: 'calc(100% - 100px)' }}
onChange={numberChange}
/>
<Select value={intervalType} onChange={intervalTypeChange}>
<Select value={intervalType} onChange={intervalTypeChange} style={{ width: 100 }}>
<Option value="days">{intl.get('天')}</Option>
<Option value="hours">{intl.get('时')}</Option>
<Option value="minutes">{intl.get('分')}</Option>
@@ -527,8 +527,8 @@ const SubscriptionModal = ({
onPaste={onNamePaste}
placeholder={
type === 'private-repo'
? 'SOCK5代理,例如 IP:PORT'
: 'HTTP/SOCK5代理,例如 http://127.0.0.1:1080'
? intl.get('SOCK5代理,例如 IP:PORT')
: intl.get('HTTP/SOCK5代理,例如 http://127.0.0.1:1080')
}
/>
</Form.Item>
+2 -4
View File
@@ -80,7 +80,7 @@ export default {
{ value: 'pushPlus', label: 'PushPlus' },
{ value: 'wePlusBot', label: intl.get('微加机器人') },
{ value: 'wxPusherBot', label: 'wxPusher' },
{ value: 'wxPusherSpt', label: 'WxPusher(极简推送SPT-推荐)' },
{ value: 'wxPusherSpt', label: intl.get('WxPusher(极简推送SPT-推荐)') },
{ value: 'openiLink', label: 'OpeniLink' },
{ value: 'wpush', label: 'WPUSH' },
{ value: 'chat', label: intl.get('群晖chat') },
@@ -431,9 +431,7 @@ export default {
},
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
tip: intl.get('飞书群组机器人加签密钥,安全设置中开启签名校验后获得'),
},
],
email: [
+10 -6
View File
@@ -155,9 +155,9 @@ export default function browserType() {
shell === 'none'
? {}
: {
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
);
console.log(
@@ -165,11 +165,15 @@ export default function browserType() {
'color: blue;font-size: 14px;',
);
console.log(
'%c忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。\n',
`%c${intl.get('控制台诗句')}\n`,
'color: yellow;font-size: 18px;',
);
console.log(
`%c青龙运行环境:\n\n系统:${result.system}/${result.systemVs}\n浏览器:${result.supporter}/${result.supporterVs}\n内核:${result.engine}/${result.engineVs}`,
`%c${intl.get('运行环境信息', {
system: `${result.system}/${result.systemVs}`,
browser: `${result.supporter}/${result.supporterVs}`,
engine: `${result.engine}/${result.engineVs}`,
})}`,
'color: green;font-size: 14px;font-weight: bold;',
);
@@ -337,7 +341,7 @@ export function parseCrontab(schedule: string): Date | null {
if (time) {
return time.next().toDate();
}
} catch (error) { }
} catch (error) {}
return null;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { loader } from '@monaco-editor/react';
import config from './config';
import intl from 'react-intl-universal';
export function init(version: string) {
// monaco 编辑器配置cdn和locale
@@ -9,7 +10,7 @@ export function init(version: string) {
},
'vs/nls': {
availableLanguages: {
'*': 'zh-cn',
'*': intl.getInitOptions().currentLocale === 'en' ? 'i-default' : 'zh-cn',
},
},
});
+58
View File
@@ -0,0 +1,58 @@
export interface PageLoadDurations {
responseTime?: number;
domReadyTime?: number;
domParseTime?: number;
loadTime?: number;
}
export function observePageLoadTiming(
report: (durations: PageLoadDurations) => void,
): () => void {
let timer: number | undefined;
let disposed = false;
const schedule = () => {
// loadEventEnd is still zero inside the load handler. Read it on the next
// task, after every load listener has returned, including on late mounts.
timer = window.setTimeout(() => {
if (disposed) return;
const navigation = window.performance.getEntriesByType?.(
'navigation',
)[0] as PerformanceNavigationTiming | undefined;
const timing = navigation || window.performance.timing;
if (!timing) return;
const start = navigation
? navigation.startTime
: window.performance.timing.navigationStart;
if (!navigation && !(start > 0)) return;
const duration = (end: number, begin: number) =>
Number.isFinite(end) &&
Number.isFinite(begin) &&
begin >= 0 &&
end > 0 &&
end >= begin
? Math.round(end - begin)
: undefined;
report({
responseTime: duration(timing.responseStart, start),
domReadyTime: duration(timing.domInteractive, timing.responseEnd),
domParseTime: duration(timing.domComplete, timing.domInteractive),
loadTime: duration(timing.loadEventEnd, start),
});
}, 0);
};
if (document.readyState === 'complete') {
schedule();
} else {
window.addEventListener('load', schedule, { once: true });
}
return () => {
disposed = true;
window.removeEventListener('load', schedule);
if (timer !== undefined) window.clearTimeout(timer);
};
}
+15 -5
View File
@@ -1,11 +1,13 @@
import SockJS from 'sockjs-client';
import intl from 'react-intl-universal';
import { SockMessageType } from './type';
class WebSocketManager {
private static instance: WebSocketManager | null = null;
private url: string;
private socket: WebSocket | null = null;
private subscriptions: Map<SockMessageType, Set<(p: any) => void>> = new Map();
private subscriptions: Map<SockMessageType, Set<(p: any) => void>> =
new Map();
private options: {
maxReconnectAttempts: number;
reconnectInterval: number;
@@ -15,7 +17,10 @@ class WebSocketManager {
private heartbeatTimeout: NodeJS.Timeout | null = null;
private state: 'closed' | 'connecting' | 'open' = 'closed';
constructor(url: string, options: Partial<typeof WebSocketManager.prototype.options> = {}) {
constructor(
url: string,
options: Partial<typeof WebSocketManager.prototype.options> = {},
) {
this.url = url;
this.options = {
maxReconnectAttempts: options.maxReconnectAttempts || 5,
@@ -26,7 +31,10 @@ class WebSocketManager {
this.init();
}
public static getInstance(url: string = '', options?: Partial<typeof WebSocketManager.prototype.options>): WebSocketManager {
public static getInstance(
url: string = '',
options?: Partial<typeof WebSocketManager.prototype.options>,
): WebSocketManager {
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager(url, options);
}
@@ -47,7 +55,9 @@ class WebSocketManager {
this.socket = null;
this.reconnectAttempts++;
await new Promise((resolve) => setTimeout(resolve, this.options.reconnectInterval));
await new Promise((resolve) =>
setTimeout(resolve, this.options.reconnectInterval),
);
}
} catch (error) {
this.handleError(error);
@@ -138,7 +148,7 @@ class WebSocketManager {
}
private handleError(error: any) {
console.error('WebSocket错误:', error);
console.error(intl.get('WebSocket错误'), error);
this.emit('error', error);
}
@@ -0,0 +1,68 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const { createCronJob } = require('../../back/shared/cronScheduler');
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
const native = require('node-cron');
const fixture = require('../fixtures/cron-calendar-boundaries.json');
// Each deployment gets a fresh timezone formatter cache in node-cron.
const timezone = process.env.QL_CRON_BOUNDARY_TEST_TZ;
if (!timezone) {
for (const tz of ['UTC', 'Asia/Shanghai']) {
test(`cron calendar boundary regressions in ${tz}`, () => {
const env = { ...process.env, TZ: tz, QL_CRON_BOUNDARY_TEST_TZ: tz };
// A nested --test runner otherwise exits successfully without running tests.
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(
process.execPath,
['-r', 'ts-node/register/transpile-only', '--test', __filename],
{
env,
encoding: 'utf8',
},
);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /calendar boundary:/);
});
}
} else {
for (const { schedule, currentDate, next } of fixture.cases) {
test(`calendar boundary: ${schedule} from ${currentDate}`, async (t) => {
t.mock.timers.enable({
apis: ['Date', 'setTimeout', 'setInterval'],
now: new Date(currentDate),
});
const before = native.getTasks().size;
const actual = [];
const job = createCronJob(
schedule,
(date) => actual.push(date.toISOString()),
{ name: 'calendar-boundary', logger: { warn() {}, error() {} } },
);
t.after(() => job.cancel());
for (const [index, expected] of next[timezone].entries()) {
t.mock.timers.tick(new Date(expected).getTime() - Date.now() - 1);
await new Promise(setImmediate);
assert.deepEqual(actual, next[timezone].slice(0, index));
t.mock.timers.tick(1);
await new Promise(setImmediate);
assert.deepEqual(actual, next[timezone].slice(0, index + 1));
}
job.cancel();
assert.equal(native.getTasks().size, before);
});
}
test('unreachable dates and unsupported L spellings remain invalid', () => {
for (const schedule of [
'0 0 31 2,4 *',
'0 0 31 2,4 0-7',
'0 0 1L 2,4 MON',
'0 0 * * 05L',
]) {
assert.equal(isValidCronSchedule(schedule), false, schedule);
}
});
}
+4 -1
View File
@@ -7,10 +7,13 @@ const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
if (!process.env.QL_MACRO_TEST_TZ) {
for (const tz of ['UTC', 'Asia/Shanghai']) {
test(`macro execution in ${tz}`, () => {
const env = { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz };
delete env.NODE_TEST_CONTEXT;
const result = require('node:child_process').spawnSync(process.execPath,
['-r', 'ts-node/register/transpile-only', '--test', __filename],
{ env: { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz }, encoding: 'utf8' });
{ env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /@annually fires at the expected local times/);
});
}
}
@@ -0,0 +1,189 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { validate } = require('node-cron');
const {
parseCronSchedule,
isValidCronSchedule,
getInvalidCronSchedules,
} = require('../../back/shared/cronSchedule');
const { createCronJob } = require('../../back/shared/cronScheduler');
const logger = { warn() {}, error() {} };
// Issue #3099: cron-parser 5.4 stringifies these hours as 6/6, which
// node-cron rejects. Preserve supported fields and observe actual executions.
const cases = [
...[
'0 6,12,18 * * *',
'0 0 6,12,18 * * *',
'0 6-18/6 * * *',
'0 6/6 * * *',
].map((schedule) => ({
schedule,
next: [
'2026-10-17T06:00:00.000Z',
'2026-10-17T12:00:00.000Z',
'2026-10-17T18:00:00.000Z',
'2026-10-18T06:00:00.000Z',
],
})),
{
schedule: '5,15,25 * * * *',
next: [
'2026-10-17T00:05:00.000Z',
'2026-10-17T00:15:00.000Z',
'2026-10-17T00:25:00.000Z',
'2026-10-17T01:05:00.000Z',
],
},
{
schedule: '5,15,25 * * * * *',
next: [
'2026-10-17T00:00:05.000Z',
'2026-10-17T00:00:15.000Z',
'2026-10-17T00:00:25.000Z',
'2026-10-17T00:01:05.000Z',
],
},
{
schedule: '5/20 * * * * *',
next: [
'2026-10-17T00:00:05.000Z',
'2026-10-17T00:00:25.000Z',
'2026-10-17T00:00:45.000Z',
'2026-10-17T00:01:05.000Z',
],
},
{
schedule: '5/20 * * * *',
next: [
'2026-10-17T00:05:00.000Z',
'2026-10-17T00:25:00.000Z',
'2026-10-17T00:45:00.000Z',
'2026-10-17T01:05:00.000Z',
],
},
{
schedule: '0 0 1/10,L * MON',
next: [
'2026-10-19T00:00:00.000Z',
'2026-10-21T00:00:00.000Z',
'2026-10-26T00:00:00.000Z',
'2026-10-31T00:00:00.000Z',
'2026-11-01T00:00:00.000Z',
],
},
{
schedule: '0 0 1 JAN/3 *',
next: [
'2027-01-01T00:00:00.000Z',
'2027-04-01T00:00:00.000Z',
'2027-07-01T00:00:00.000Z',
'2027-10-01T00:00:00.000Z',
],
},
{
schedule: '0 0 * * MON/2',
next: [
'2026-10-18T00:00:00.000Z',
'2026-10-19T00:00:00.000Z',
'2026-10-21T00:00:00.000Z',
'2026-10-23T00:00:00.000Z',
'2026-10-25T00:00:00.000Z',
],
},
{
schedule: '0 0 * * 00#02',
next: [
'2026-11-08T00:00:00.000Z',
'2026-12-13T00:00:00.000Z',
'2027-01-10T00:00:00.000Z',
'2027-02-14T00:00:00.000Z',
],
},
...['*#2', '?#2'].map((weekday) => ({
schedule: `0 0 * * ${weekday}`,
next: [
'2026-10-18T00:00:00.000Z',
'2026-10-19T00:00:00.000Z',
'2026-10-20T00:00:00.000Z',
'2026-10-21T00:00:00.000Z',
],
})),
];
test('supported fields retain their lists, ranges, names and special days', () => {
for (const [schedule, patterns] of [
['0 6,12,18 * * *', ['0 0 6,12,18 * * *']],
['5,15,25 * * JAN-MAR MON-FRI', ['0 5,15,25 * * JAN-MAR MON-FRI']],
['0 0 L JAN *', ['0 0 0 L JAN *']],
['0 0 * JAN MON#2', ['0 0 0 * JAN MON#2']],
['0 0 1,15 JAN MON', ['0 0 0 1,15 JAN *', '0 0 0 * JAN MON']],
['0 0 ? * MON', ['0 0 0 * * MON']],
]) {
assert.deepEqual(parseCronSchedule(schedule).patterns, patterns, schedule);
}
});
for (const [schedule, patterns, maxValidations] of [
['0 6,12,18 * * *', ['0 0 6,12,18 * * *'], 2],
['0 0 1,15 JAN MON', ['0 0 0 1,15 JAN *', '0 0 0 * JAN MON'], 3],
]) {
test(`supported expressions skip field validation: ${schedule}`, () => {
const load = require('../helpers/load-security-module.cjs');
const validations = [];
const isolated = load('back/shared/cronSchedule.ts', {
'node-cron': {
validate(pattern) {
validations.push(pattern);
return validate(pattern);
},
},
});
assert.deepEqual(isolated.parseCronSchedule(schedule).patterns, patterns);
assert.ok(validations.length <= maxValidations, validations.join('\n'));
assert.equal(validations[0], `0 ${schedule}`);
});
}
for (const { schedule, next } of cases) {
test(`cron-parser normalization preserves executions: ${schedule}`, async (t) => {
const previousTZ = process.env.TZ;
process.env.TZ = 'UTC';
t.after(() => {
if (previousTZ === undefined) delete process.env.TZ;
else process.env.TZ = previousTZ;
});
t.mock.timers.enable({
apis: ['Date', 'setTimeout', 'setInterval'],
now: new Date('2026-10-17T00:00:00Z'),
});
assert.equal(isValidCronSchedule(schedule), true);
assert.ok(parseCronSchedule(schedule).patterns.every(validate));
assert.deepEqual(
getInvalidCronSchedules({
schedule,
extra_schedules: [{ schedule }],
}),
[],
);
const actual = [];
const job = createCronJob(
schedule,
(date) => actual.push(date.toISOString()),
{ name: 'issue-3099', logger },
);
t.after(() => job.cancel());
for (const [index, expected] of next.entries()) {
t.mock.timers.tick(new Date(expected).getTime() - Date.now() - 1);
await new Promise(setImmediate);
assert.deepEqual(actual, next.slice(0, index));
t.mock.timers.tick(1);
await new Promise(setImmediate);
assert.deepEqual(actual, next.slice(0, index + 1));
}
});
}
+175
View File
@@ -0,0 +1,175 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { Joi, CelebrateError } = require('celebrate');
const storePath = require.resolve('../../back/shared/store');
require.cache[storePath] = {
id: storePath,
filename: storePath,
loaded: true,
exports: { shareStore: { setLang: async () => {} } },
};
const { setLang, t } = require('../../back/shared/i18n');
const validationErrors =
require('../../back/middlewares/validationErrors').default;
const {
scheduleSchema,
commonCronSchema,
} = require('../../back/validation/schedule');
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
function responseFor(schema, value) {
const validation = schema.validate(value).error;
assert.ok(validation);
const error = new CelebrateError('Validation failed', { celebrated: true });
error.details.set('body', validation);
let response;
const res = {
status(code) {
assert.equal(code, 400);
return this;
},
send(body) {
response = body;
return this;
},
};
validationErrors(error, {}, res, (err) => {
throw err;
});
return response.validation.body.message;
}
test('compiled validation schemas follow live language changes without altering field paths', () => {
const schema = Joi.object({
schedule: scheduleSchema,
log_name: commonCronSchema.log_name,
});
for (const [lang, schedule, empty, logName] of [
[
'en',
'Invalid schedule rule',
'Schedule rule cannot be empty',
'Log name cannot exceed 100 characters',
],
['zh', '无效的定时规则', '定时规则不能为空', '日志名称不能超过100个字符'],
[
'en',
'Invalid schedule rule',
'Schedule rule cannot be empty',
'Log name cannot exceed 100 characters',
],
]) {
setLang(lang);
assert.equal(responseFor(schema, { schedule: 'invalid' }), schedule);
assert.equal(responseFor(schema, { schedule: '' }), empty);
assert.equal(
responseFor(schema, { schedule: '* * * * *', log_name: 'x'.repeat(101) }),
logName,
);
}
setLang('zh');
});
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
const keys = [
'请先初始化',
'请先关闭两步验证',
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片',
'历史版本容量不足,无法安全保存',
'文件已变化,请刷新历史版本后重试',
'trust proxy 配置格式无效',
];
setLang('en');
for (const key of keys) assert.doesNotMatch(t(key), /[\u4e00-\u9fff]/);
assert.equal(t('用户脚本自己的提示'), '用户脚本自己的提示');
setLang('zh');
for (const key of keys) assert.equal(t(key), key);
});
test('upgrade verification uses the supplied language and retains artifact details', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-locale-upgrade-'));
try {
await fs.writeFile(
path.join(root, 'build-info.json'),
JSON.stringify({ version: 1, dirty: true }),
);
await assert.rejects(
upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }),
/Invalid or dirty build manifest/,
);
await assert.rejects(
upgrade.verifyUpgrade(root, root, { QL_LANG: 'zh' }),
/构建清单无效/,
);
await fs.writeFile(
path.join(root, 'build-info.json'),
JSON.stringify({
version: 1,
dirty: false,
sourceCommit: 'a'.repeat(40),
lockfileSha256: 'b'.repeat(64),
files: {},
}),
);
await assert.rejects(
upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }),
/Missing build output: build\/app.js/,
);
assert.equal(
upgrade.upgradeMessage('更新失败: %s', { QL_LANG: 'en' }, '文件名.txt'),
'Update failed: 文件名.txt',
);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test('Shell help switches language and preserves percent characters in script paths', async () => {
const bash = fs.access('/opt/homebrew/bin/bash').then(
() => '/opt/homebrew/bin/bash',
() => 'bash',
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-help-%-'));
try {
const source = await fs.readFile(
path.resolve(__dirname, '../../shell/otask.sh'),
'utf8',
);
const functions = path.join(root, 'functions.sh');
await fs.writeFile(
functions,
source.slice(
0,
source.indexOf('\nhandle_task_start "${task_shell_params[@]}"'),
),
);
for (const lang of ['en', 'zh']) {
const result = spawnSync(
await bash,
[
'-c',
'. shell/share.sh; . "shell/lang/$QL_LANG.sh"; . "$2"; dir_scripts="$1"; cmd_task=task; array_scripts=(); array_scripts_name=(); usage',
'--',
root,
functions,
],
{
cwd: path.resolve(__dirname, '../..'),
env: { ...process.env, QL_LANG: lang },
encoding: 'utf8',
},
);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(root));
if (lang === 'en') assert.doesNotMatch(result.stdout, /[\u4e00-\u9fff]/);
else assert.match(result.stdout, /暂无脚本可以执行/);
}
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
+88
View File
@@ -0,0 +1,88 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
test('language changes reach live HTTP/scheduler workers and replacement workers restore the saved language', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-language-cluster-'));
const root = path.resolve(__dirname, '../..');
try {
const state = path.join(dir, 'language.json');
await fs.writeFile(state, '"en"');
const fixture = path.join(dir, 'cluster.cjs');
await fs.writeFile(
fixture,
`
require(${JSON.stringify(require.resolve('ts-node/register/transpile-only'))});
const cluster = require('node:cluster');
const fs = require('node:fs/promises');
const storePath = require.resolve(${JSON.stringify(
path.join(root, 'back/shared/store.ts'),
)});
require.cache[storePath] = { id: storePath, filename: storePath, loaded: true, exports: { shareStore: {
getLang: async () => JSON.parse(await fs.readFile(process.env.LANGUAGE_STATE, 'utf8')),
setLang: (lang) => fs.writeFile(process.env.LANGUAGE_STATE, JSON.stringify(lang)),
} } };
const { initializeLanguageSync } = require(${JSON.stringify(
path.join(root, 'back/shared/languageSync.ts'),
)});
const { setLang, t } = require(${JSON.stringify(
path.join(root, 'back/shared/i18n.ts'),
)});
(async () => {
await initializeLanguageSync();
if (cluster.isPrimary) {
const ready = new Map(), observed = new Set(); let phase = 'initial';
cluster.on('message', (worker, message) => {
if (message.kind === 'ready') {
ready.set(worker.id, message.value);
if (phase === 'initial' && ready.size === 2) {
if ([...ready.values()].some(value => value !== 'Update successful')) throw Error('Initial language was not restored');
phase = 'zh'; cluster.workers[1].send({ command: 'switch', lang: 'zh' });
} else if (phase === 'replacement' && ready.size === 3) {
if (message.value !== '更新成功') throw Error('Replacement lost saved language');
phase = 'en'; observed.clear(); cluster.workers[1].send({ command: 'switch', lang: 'en' });
}
}
if (message.kind === 'observed') {
if (message.value !== (phase === 'zh' ? '更新成功' : 'Update successful')) throw Error('Worker language did not change');
observed.add(worker.id);
if (phase === 'zh' && observed.size === 2) { phase = 'replacement'; cluster.fork(); }
else if (phase === 'en' && observed.size === 3) {
console.log(JSON.stringify({ initialWorkers: 2, updatedWorkers: 3, replacementRestored: true }));
for (const w of Object.values(cluster.workers)) w.kill();
}
}
});
cluster.fork(); cluster.fork();
} else {
process.on('message', async message => {
if (message.command === 'switch') await setLang(message.lang);
if (message.type === 'language-changed') process.send({ kind: 'observed', value: t('更新成功') });
});
process.send({ kind: 'ready', value: t('更新成功') });
}
})().catch(error => { console.error(error); process.exit(1); });
`,
);
const result = await promisify(execFile)(process.execPath, [fixture], {
cwd: root,
timeout: 15000,
env: {
...process.env,
TS_NODE_PROJECT: path.join(root, 'back/tsconfig.json'),
LANGUAGE_STATE: state,
},
});
assert.deepEqual(JSON.parse(result.stdout), {
initialWorkers: 2,
updatedWorkers: 3,
replacementRestored: true,
});
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -38,7 +38,7 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
'./data': { scheduleStacks: stacks },
'../shared/runCron': { runCron() {} },
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: format },
'../shared/i18n': { t: (s) => s, tf: format },
});
const crons = legacySchedules.map((schedule, index) => ({
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
+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);
});
+583
View File
@@ -0,0 +1,583 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const tar = require('tar');
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
const artifacts = require('../../shell/upgrade.cjs');
test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const preload = path.join(directory, 'armv6.cjs');
const observed = path.join(directory, 'child.json');
await fs.writeFile(
preload,
`if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify(
observed,
)},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`,
);
const { execFile } = require('node:child_process');
const result = await new Promise((resolve) =>
execFile(
process.execPath,
[path.resolve(__dirname, '../../shell/install-bundle.cjs')],
{
timeout: 10000,
env: {
...process.env,
NODE_OPTIONS: '--require ' + JSON.stringify(preload),
},
},
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
),
);
assert.equal(result.error.code, 1);
const child = JSON.parse(await fs.readFile(observed));
assert.ok(child.args.includes('--jitless'));
assert.equal(child.execPath, process.execPath);
assert.match(result.stderr, /Usage: node install-bundle.cjs/);
});
const {
writeManifest,
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
const {
releaseIndex,
platforms,
} = require('../../scripts/publish-upgrade-bundles.cjs');
async function fixture(t) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bundle-test-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const root = path.join(directory, 'installed'),
tmp = path.join(root, '.tmp');
const payload = path.join(directory, 'qinglong');
const source = path.join(payload, 'source'),
staticRoot = path.join(payload, 'static');
await fs.mkdir(tmp, { recursive: true });
await fs.mkdir(
path.join(source, 'node_modules/.pnpm/sqlite/node_modules/sqlite3'),
{ recursive: true },
);
await fs.symlink(
'.pnpm/sqlite/node_modules/sqlite3',
path.join(source, 'node_modules/sqlite3'),
);
await fs.writeFile(
path.join(source, 'node_modules/sqlite3/package.json'),
'{"name":"sqlite3"}',
);
await fs.mkdir(path.join(source, 'sample'));
await fs.writeFile(path.join(source, 'sample/config.sample.sh'), '# config');
await fs.writeFile(
path.join(source, 'package.json'),
'{"version":"2.24.0","qinglongUpgradeFormat":2}',
);
await fs.writeFile(
path.join(root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend');
writeManifest(source, staticRoot);
const target = await bundle.runtimeTarget();
const manifest = {
format: 2,
sourceCommit,
lockfileSha256: bundle.sha256(
await fs.readFile(path.join(source, 'pnpm-lock.yaml')),
),
target: { ...target, libcMinimum: target.libcVersion },
nodeMinimum: '20.18.1',
nodeApi: 6,
files: await bundle.inventory({ source, static: staticRoot }),
};
const archive = path.join(directory, 'package.tar.gz');
const pack = async () => {
await fs.writeFile(
path.join(payload, 'bundle.json'),
JSON.stringify(manifest),
);
await tar.c(
{
file: archive,
cwd: directory,
gzip: true,
portable: true,
noMtime: true,
},
['qinglong'],
);
};
await pack();
return {
directory,
root,
tmp,
source,
staticRoot,
target,
manifest,
archive,
pack,
};
}
test('online and offline stage the same complete payload, preserve pnpm links and never install dependencies', async (t) => {
const f = await fixture(t),
bytes = await fs.readFile(f.archive),
progress = [];
const entry = {
size: bytes.length,
sha256: bundle.sha256(bytes),
url: 'https://example.invalid/immutable.tar.gz',
};
let calls = 0,
probes = 0;
const run = async (program, args) => {
assert.equal(program, 'curl');
calls++;
const output = args[args.indexOf('--output') + 1];
if (args.at(-1).endsWith('latest.json'))
await fs.writeFile(
output,
JSON.stringify({
format: 2,
sourceCommit,
packages: { [bundle.targetKey(f.target)]: entry },
}),
);
else {
await fs.writeFile(output, bytes);
await fs.writeFile(
args[args.indexOf('--dump-header') + 1],
'HTTP/2 200 OK\r\n\r\n',
);
}
};
const options = {
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop' },
run,
verifyStatic: artifacts.verifyUpgrade,
download: artifacts.downloadWithProgress,
probe: async () => {
probes++;
},
onProgress: async (p) => progress.push(p),
};
const online = await bundle.stageBundle(options);
const first = await bundle.inventory(online);
assert.equal(calls, 2);
assert.equal(probes, 1);
assert.ok(progress.some((p) => p.percent === 100));
assert.ok(progress.some((p) => p.percent === 0));
const offline = await bundle.stageBundle({
...options,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
run: () => assert.fail('offline network access'),
});
assert.equal(probes, 2);
assert.deepEqual(await bundle.inventory(offline), first);
assert.equal(
await fs.readlink(path.join(offline.source, 'node_modules/sqlite3')),
'.pnpm/sqlite/node_modules/sqlite3',
);
await assert.rejects(fs.access(online.source));
});
test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains the existing Node requirement', async (t) => {
const f = await fixture(t),
target = {
platform: 'linux',
arch: 'armv6',
libc: 'musl',
libcVersion: '1.2.5',
};
const manifest = {
...f.manifest,
target: { ...target, libcMinimum: '1.2.4' },
};
bundle.assertCompatible(manifest, target, '20.20.2');
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
/platform mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, libc: 'glibc' }),
/platform mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, target, '18.20.0'),
/requires Node/,
);
assert.throws(
() =>
bundle.assertCompatible(manifest, { ...target, libcVersion: '1.1.24' }),
/requires musl/,
);
});
test('dependency tampering and payload link escapes are rejected', async (t) => {
const f = await fixture(t),
selected = { source: f.source, static: f.staticRoot };
await bundle.verifyPayload(selected, f.manifest, f.target);
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/package.json'),
'{"tampered":true}',
);
await assert.rejects(
bundle.verifyPayload(selected, f.manifest, f.target),
/checksums/,
);
await fs.symlink('/etc/passwd', path.join(f.source, 'outside'));
await assert.rejects(bundle.inventory(selected), /escapes/);
});
test('archive traversal, absolute links and writes through internal symlinks are rejected before extraction', async (t) => {
const f = await fixture(t),
dest = path.join(f.directory, 'extract');
await fs.mkdir(dest);
await fs.symlink('/etc', path.join(f.directory, 'qinglong/bad'));
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
'qinglong/bad',
]);
await assert.rejects(bundle.extractArchive(f.archive, dest), /symlink/);
await tar.c(
{ cwd: f.directory, file: f.archive, gzip: true, prefix: '../outside' },
['qinglong/source/package.json'],
);
await assert.rejects(bundle.extractArchive(f.archive, dest), /Unsafe/);
await fs.mkdir(path.join(f.directory, 'qinglong/real'));
await fs.writeFile(path.join(f.directory, 'qinglong/real/file'), 'value');
await fs.symlink('real', path.join(f.directory, 'qinglong/link'));
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
'qinglong/link',
'qinglong/link/file',
]);
await assert.rejects(
bundle.extractArchive(f.archive, dest),
/through a symlink/,
);
assert.deepEqual(await fs.readdir(dest), []);
});
test('an incomplete or corrupt platform download cannot replace an existing ready pointer', async (t) => {
const f = await fixture(t),
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
throw new Error('native addon failed');
},
}),
/native addon failed/,
);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.equal(
(await fs.readdir(f.tmp)).filter((n) => n.startsWith('upgrade-')).length,
1,
);
});
test('publishing requires every platform and validates all immutable archive hashes', async (t) => {
const f = await fixture(t),
repository = 'whyour/qinglong';
assert.throws(() => releaseIndex(f.directory, sourceCommit, repository));
for (const target of platforms) {
const filename = `qinglong-${sourceCommit}-${target}.tar.gz`,
bytes = Buffer.from(target);
await fs.writeFile(path.join(f.directory, filename), bytes);
await fs.writeFile(
path.join(f.directory, filename + '.json'),
JSON.stringify({
format: 2,
sourceCommit,
target,
filename,
size: bytes.length,
sha256: bundle.sha256(bytes),
}),
);
}
const index = releaseIndex(f.directory, sourceCommit, repository);
assert.equal(Object.keys(index.packages).length, 12);
await fs.appendFile(
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
'corrupt',
);
assert.throws(
() => releaseIndex(f.directory, sourceCommit, repository),
/Unverified/,
);
});
test('fresh offline installation needs no package manager, keeps Node and publishes a complete directory', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.source, '.env.example'), 'QlPort=5700\n');
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
`exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};`,
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
const destination = path.join(f.directory, 'fresh'),
node = process.execPath;
await require('../../shell/install-bundle.cjs').installBundle(
['--root', destination, '--file', f.archive, '--install'],
{ ...process.env, PATH: '', QL_BRANCH: 'develop' },
);
assert.equal(process.execPath, node);
assert.equal(
await fs.readFile(path.join(destination, '.env'), 'utf8'),
'QlPort=5700\n',
);
assert.equal(
(await fs.stat(path.join(destination, '.env'))).mode & 0o777,
0o600,
);
assert.equal(
await fs.readFile(path.join(destination, 'static/build/app.js'), 'utf8'),
'backend',
);
assert.ok(
(
await fs.stat(path.join(destination, 'node_modules/sqlite3/package.json'))
).isFile(),
);
assert.equal(
(await fs.readdir(f.directory)).filter((n) =>
n.startsWith('.qinglong-install-'),
).length,
0,
);
await fs.writeFile(path.join(destination, 'keep-user-data'), 'keep');
await assert.rejects(
require('../../shell/install-bundle.cjs').installBundle([
'--root',
destination,
'--file',
f.archive,
'--install',
]),
/empty installation directory/,
);
assert.equal(
await fs.readFile(path.join(destination, 'keep-user-data'), 'utf8'),
'keep',
);
});
test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};',
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
await fs.mkdir(path.join(f.root, 'shell'));
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
'import_config() { :; }\n',
);
await fs.writeFile(
path.join(f.root, 'shell/env.sh'),
'export QL_BRANCH=develop\n',
);
const stateFile = path.join(f.tmp, 'panel-upgrade.json');
await fs.writeFile(
stateFile,
JSON.stringify({
id: 'previous-online-job',
status: 'ready',
phase: 'ready',
}),
);
const install = require('../../shell/install-bundle.cjs').installBundle;
await install(['--root', f.root, '--file', f.archive, '--download-only']);
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
assert.equal(state.status, 'ready');
assert.equal(state.sourceCommit, sourceCommit);
assert.notEqual(state.id, 'previous-online-job');
assert.equal(state.version, '2.24.0');
const pointer = await fs.readFile(
path.join(f.tmp, 'upgrade-ready-develop.json'),
'utf8',
);
await fs.writeFile(
stateFile,
JSON.stringify({
id: 'live-online-job',
status: 'running',
pid: process.pid,
startedAt: Date.now(),
}),
);
await assert.rejects(
install(['--root', f.root, '--file', f.archive, '--download-only']),
/已有更新/,
);
assert.equal(
JSON.parse(await fs.readFile(stateFile, 'utf8')).id,
'live-online-job',
);
assert.equal(
await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'),
pointer,
);
});
test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => {
const f = await fixture(t);
const { spawn } = require('node:child_process');
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};',
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
await fs.mkdir(path.join(f.root, 'shell'));
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
'import_config() { :; }\n',
);
await fs.writeFile(
path.join(f.root, 'shell/env.sh'),
'export QL_BRANCH=develop\n',
);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-good-readiness');
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../shell/install-bundle.cjs'),
'--root',
f.root,
'--file',
f.archive,
'--download-only',
],
{ stdio: 'ignore' },
);
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const exited = new Promise((resolve, reject) => {
child.once('exit', resolve);
child.once('error', reject);
});
let seen = false;
for (let i = 0; i < 200; i++) {
const state = await fs
.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8')
.then(JSON.parse)
.catch(() => ({}));
if (state.phase === 'dependencies') {
seen = true;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
assert.ok(seen);
child.kill('SIGTERM');
assert.equal(await exited, 1);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness');
assert.equal(
JSON.parse(
await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'),
).status,
'failed',
);
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')));
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
);
});
test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => {
const f = await fixture(t);
const script = path.join(f.source, 'sample/config.sample.sh');
await fs.chmod(script, 0o777);
const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs');
await normalizeModes(f.source);
await normalizeModes(f.staticRoot);
assert.equal((await fs.stat(script)).mode & 0o777, 0o755);
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
const extracted = path.join(f.directory, 'roundtrip');
await fs.mkdir(extracted);
await bundle.extractArchive(f.archive, extracted);
const selected = {
source: path.join(extracted, 'qinglong/source'),
static: path.join(extracted, 'qinglong/static'),
};
await bundle.verifyPayload(selected, f.manifest, f.target);
await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777);
await assert.rejects(
bundle.verifyPayload(selected, f.manifest, f.target),
/checksums/,
);
});
test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => {
const f = await fixture(t);
let observed;
await assert.rejects(
artifacts.downloadWithProgress(
async (program, args) => {
assert.equal(program, 'curl');
observed = Number(args[args.indexOf('--max-time') + 1]);
assert.ok(observed > 600 && observed <= 7200);
assert.equal(args[args.indexOf('--speed-limit') + 1], '1024');
throw new Error('stop before transfer');
},
path.join(f.directory, 'slow.tar.gz'),
'https://example.invalid/package.tar.gz',
'qinglong-bundle',
async () => {},
true,
{ size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) },
),
/stop before transfer/,
);
assert.ok(observed);
});
+1 -1
View File
@@ -31,7 +31,7 @@ async function fixture() {
'../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) },
'../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' },
'./data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger,
'../shared/i18n': { tf: s => s },
'../shared/i18n': { t: (s) => s, tf: s => s },
});
const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger });
function add(id = '1', command = 'old', replace = false) {
+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);
}
});
+3 -3
View File
@@ -48,7 +48,7 @@ test('recovery reconciles a surviving scheduler after missed deletes and disable
},
'../shared/runCron': {},
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: (s) => s },
'../shared/i18n': { t: (s) => s, tf: (s) => s },
});
const client = {
addCron: async (crons, replace) => {
@@ -97,7 +97,7 @@ test('invalid replacement leaves the previous schedule intact', async () => {
'./data': { scheduleStacks: stacks },
'../shared/runCron': {},
'../loaders/logger': {},
'../shared/i18n': { tf: (s) => s },
'../shared/i18n': { t: (s) => s, tf: (s) => s },
});
await assert.rejects(
new Promise((resolve, reject) =>
@@ -122,7 +122,7 @@ test('invalid persisted schedules cannot block recovery, valid jobs or HTTP heal
'./data': { scheduleStacks: stacks },
'../shared/runCron': {},
'../loaders/logger': { info() {}, warn() {} },
'../shared/i18n': { tf: require('node:util').format },
'../shared/i18n': { t: (s) => s, tf: require('node:util').format },
});
const client = { addCron: (crons, replace) => new Promise((resolve, reject) => {
addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve());
+3 -2
View File
@@ -225,6 +225,7 @@ for (const branch of ['master', 'develop', 'debian', 'debian-dev'])
const f = await fixture(t, { branch, lockChange: true, tee: true });
let result = await f.execute('update', 'false');
assert.equal(result.code, 0, result.stdout + result.stderr);
assert.ok(result.stdout.includes('     '), 'update logs retain the completion marker');
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
assert.match(
@@ -295,7 +296,7 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => {
await withUpgradeLock(tmp, async () => {
await assert.rejects(
withUpgradeLock(tmp, async () => {}),
/Another upgrade/,
/Another upgrade|已有更新或重载/,
);
});
assert.deepEqual(fs.readdirSync(tmp), []);
@@ -307,6 +308,6 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => {
t.after(() => new Promise((resolve) => server.close(resolve)));
await assert.rejects(
waitForHealth({ QlPort: String(server.address().port) }, 20),
/health check/,
/health check|后端启动检查失败/,
);
});
+70
View File
@@ -0,0 +1,70 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const load = require('../helpers/load-security-module.cjs');
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-system-log-clear-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const SystemService = load('back/services/system.ts', {
'../config': { systemLogPath: root },
'../config/const': {},
'../config/util': {
readDirs: async () =>
(await fs.readdir(root)).map((title) => ({ title })),
rmPath: (file) => fs.rm(file),
},
'../data/dependence': {},
'../data/notify': {},
'../data/system': {},
'../shared/pLimit': {},
'../schedule/client': {},
'../shared/i18n': { t: (x) => x },
'./notify': class {},
'./schedule': class {},
'./sock': class {},
}).default;
return { root, service: new SystemService({}, {}, {}) };
}
test('clearing system logs preserves append handles used by existing workers', async (t) => {
const { root, service } = await fixture(t);
const active = path.join(root, '2026-10-17.log');
const rotated = path.join(root, '2026-10-16.log');
await fs.writeFile(active, 'old active logs\n');
await fs.writeFile(rotated, 'old rotated logs\n');
await fs.writeFile(path.join(root, 'audit.json'), 'rotation metadata');
const workers = await Promise.all([fs.open(active, 'a'), fs.open(active, 'a')]);
t.after(async () => {
await Promise.all(workers.map((worker) => worker.close()));
});
const inode = (await fs.stat(active)).ino;
for (let clear = 0; clear < 2; clear++) {
await service.deleteSystemLog();
assert.equal(await fs.readFile(active, 'utf8'), '');
assert.equal(await fs.readFile(rotated, 'utf8'), '');
assert.equal((await fs.stat(active)).ino, inode);
await workers[0].write('new scheduler log\n');
await workers[1].write('new queue log\n');
assert.equal(
await fs.readFile(active, 'utf8'),
'new scheduler log\nnew queue log\n',
);
}
assert.equal(
await fs.readFile(path.join(root, 'audit.json'), 'utf8'),
'rotation metadata',
);
});
test('clearing system logs refuses symlinks without truncating their target', async (t) => {
const { root, service } = await fixture(t);
const target = path.join(root, 'keep.txt');
await fs.writeFile(target, 'keep this content');
await fs.symlink(target, path.join(root, 'linked.log'));
await assert.rejects(service.deleteSystemLog(), { code: 'ELOOP' });
assert.equal(await fs.readFile(target, 'utf8'), 'keep this content');
});

Some files were not shown because too many files have changed in this diff Show More