mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac7a47f30 | ||
|
|
71a13a1779 | ||
|
|
4040747936 | ||
|
|
035fe8c5d2 | ||
|
|
aa150f051e | ||
|
|
cb5dfec2b5 | ||
|
|
286088a32c | ||
|
|
a1104ef47c | ||
|
|
1a152575cc | ||
|
|
ef348b445b | ||
|
|
8305825ffd |
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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('配置更新失败'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('端') +
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Generated
+76
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(),
|
||||
[
|
||||
|
||||
@@ -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}-`));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -23,7 +23,7 @@ test(
|
||||
[
|
||||
'--test',
|
||||
'--test-reporter=tap',
|
||||
path.resolve(__dirname, '../preload.test.cjs'),
|
||||
path.resolve(__dirname, '../internal/preload.test.cjs'),
|
||||
],
|
||||
{
|
||||
env,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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/ /
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Generated
+3
-5
@@ -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==}
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 '🎉 启动成功!')"
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade';
|
||||
import CheckUpdate from '@/pages/setting/checkUpdate';
|
||||
|
||||
export default function UpgradeNotice({
|
||||
collapsed,
|
||||
systemInfo,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
systemInfo: any;
|
||||
}) {
|
||||
const { job, connected } = useUpgrade();
|
||||
const [open, setOpen] = useState(false);
|
||||
const showNotice =
|
||||
activeStatuses.includes(job.status) ||
|
||||
job.status === 'ready' ||
|
||||
job.status === 'failed';
|
||||
if (!showNotice && !open) return null;
|
||||
const text = !connected
|
||||
? intl.get('连接已断开,正在重新连接')
|
||||
: job.status === 'failed'
|
||||
? intl.get('更新失败')
|
||||
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
|
||||
const label =
|
||||
job.phase === 'downloading' && job.percent != null
|
||||
? `${text} ${job.percent}%`
|
||||
: text;
|
||||
return (
|
||||
<>
|
||||
{showNotice && (
|
||||
<Tooltip title={label} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={`upgrade-notice${
|
||||
collapsed ? ' upgrade-notice-collapsed' : ''
|
||||
}`}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={label}
|
||||
>
|
||||
<CloudDownloadOutlined />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Modal
|
||||
open={open}
|
||||
title={intl.get('更新')}
|
||||
footer={null}
|
||||
onCancel={() => setOpen(false)}
|
||||
width={650}
|
||||
destroyOnClose
|
||||
>
|
||||
<CheckUpdate systemInfo={systemInfo} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import config from '@/utils/config';
|
||||
import { notification } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export interface UpgradeJob {
|
||||
id?: string;
|
||||
action?: 'download' | 'apply';
|
||||
status: string;
|
||||
phase?: string;
|
||||
artifact?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number | null;
|
||||
percent?: number | null;
|
||||
version?: string;
|
||||
error?: string;
|
||||
log?: string;
|
||||
}
|
||||
|
||||
export const activeStatuses = ['starting', 'running', 'restarting'];
|
||||
export const reloadKey = 'qinglong-upgrade-reload';
|
||||
export const completionKey = 'qinglong-upgrade-completed';
|
||||
export const phaseLabels: Record<string, string> = {
|
||||
downloading: '下载更新中...',
|
||||
extracting: '正在解压更新包',
|
||||
verifying: '正在校验更新包',
|
||||
dependencies: '正在准备依赖',
|
||||
ready: '更新已就绪',
|
||||
restarting: '正在重启并更新',
|
||||
completed: '更新完成',
|
||||
};
|
||||
|
||||
// The sidebar and settings share one poller and one mutation state.
|
||||
export function createUpgradeStore() {
|
||||
let snapshot = {
|
||||
job: { status: 'loading' } as UpgradeJob,
|
||||
busy: false,
|
||||
connected: true,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let revision = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
const publish = (patch: Partial<typeof snapshot>) => {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
const poll = async () => {
|
||||
const current = revision;
|
||||
const attempt = new AbortController();
|
||||
controller = attempt;
|
||||
const timeout = setTimeout(() => attempt.abort(), 5000);
|
||||
try {
|
||||
const token = localStorage.getItem(config.authKey);
|
||||
const response = await fetch(`${config.apiPrefix}system/update-status`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: attempt.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('Upgrade status unavailable');
|
||||
const result = await response.json();
|
||||
if (result.code !== 200 || !result.data)
|
||||
throw new Error('Invalid upgrade status');
|
||||
if (!listeners.size || current !== revision || attempt.signal.aborted)
|
||||
return;
|
||||
publish({ job: result.data, connected: true });
|
||||
if (
|
||||
result.data.status === 'completed' &&
|
||||
result.data.id === sessionStorage.getItem(reloadKey)
|
||||
) {
|
||||
sessionStorage.setItem(completionKey, result.data.id);
|
||||
sessionStorage.removeItem(reloadKey);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.data.status === 'completed' &&
|
||||
result.data.id === sessionStorage.getItem(completionKey)
|
||||
) {
|
||||
sessionStorage.removeItem(completionKey);
|
||||
notification.success({
|
||||
message: intl.get('升级成功'),
|
||||
description: intl.get('已重新启动并运行新版本,无需再次重启。'),
|
||||
duration: 8,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (listeners.size && current === revision && controller === attempt)
|
||||
publish({ connected: false });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (listeners.size && controller === attempt)
|
||||
timer = setTimeout(
|
||||
poll,
|
||||
!snapshot.connected || activeStatuses.includes(snapshot.job.status)
|
||||
? 1000
|
||||
: 15000,
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) poll();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
revision++;
|
||||
}
|
||||
};
|
||||
},
|
||||
beginMutation() {
|
||||
if (snapshot.busy) return false;
|
||||
revision++;
|
||||
publish({ busy: true });
|
||||
return true;
|
||||
},
|
||||
finishMutation() {
|
||||
revision++;
|
||||
controller?.abort();
|
||||
controller = undefined;
|
||||
clearTimeout(timer);
|
||||
publish({ busy: false });
|
||||
if (listeners.size) timer = setTimeout(poll, 1000);
|
||||
},
|
||||
setJob(job: UpgradeJob) {
|
||||
publish({ job });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const upgradeStore = createUpgradeStore();
|
||||
export default function useUpgrade() {
|
||||
return useSyncExternalStore(upgradeStore.subscribe, upgradeStore.getSnapshot);
|
||||
}
|
||||
+128
-4
@@ -354,20 +354,50 @@ select:-webkit-autofill:focus {
|
||||
|
||||
.side-menu-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: @text-color;
|
||||
|
||||
.side-menu-collapse-button:hover {
|
||||
color: #1890ff;
|
||||
.side-menu-collapse-button {
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.side-menu-user-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.side-menu-user-wrapper {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.ant-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.side-menu-username {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-mode='phone'] .side-menu-user-wrapper {
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.side-menu-user-drop-menu {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
@@ -475,11 +505,105 @@ body[data-mode='phone'] header {
|
||||
}
|
||||
|
||||
body[data-dark='true'] {
|
||||
.upgrade-notice {
|
||||
background: #3a3214;
|
||||
border-color: #6b5415;
|
||||
color: #ffd666;
|
||||
}
|
||||
.ant-popover-arrow-content {
|
||||
--antd-arrow-background-color: rgb(24, 26, 27);
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% - 24px);
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
margin: 8px 12px 12px;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 6px;
|
||||
background: #fffbe6;
|
||||
color: #ad6800;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
> .anticon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
> span:not(.anticon) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #d48806;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.upgrade-notice-collapsed {
|
||||
justify-content: center;
|
||||
padding: 7px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
|
||||
.upgrade-status-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.upgrade-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ant-progress {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-progress-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upgrade-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upgrade-log-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
+43
-16
@@ -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
@@ -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
@@ -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秒后自动刷新",
|
||||
"运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。",
|
||||
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
|
||||
"启动版本与更新包不一致": "启动版本与更新包不一致",
|
||||
"下载完成,等待重启": "下载完成,等待重启",
|
||||
"升级成功": "升级成功",
|
||||
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Vendored
+18
-22
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 <容器名>
|
||||
</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>
|
||||
|
||||
@@ -129,7 +129,7 @@ const Initialization = () => {
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 5 }}
|
||||
placeholder={`请输入${x.label}`}
|
||||
placeholder={`${intl.get('请输入')} ${x.label}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const syncFs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const {
|
||||
startJob,
|
||||
getStatus,
|
||||
runJob,
|
||||
} = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
const { downloadWithProgress } = require('../../shell/upgrade.cjs');
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../helpers/upgrade-fixture.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
|
||||
const root = path.join(base, 'panel'),
|
||||
tmp = path.join(root, '.tmp');
|
||||
const source = path.join(base, `qinglong-${sourceCommit}`);
|
||||
const staticRoot = path.join(base, 'qinglong-static-master');
|
||||
const write = async (name, contents) => {
|
||||
await fs.mkdir(path.dirname(name), { recursive: true });
|
||||
await fs.writeFile(name, contents);
|
||||
};
|
||||
for (const dir of [root, source]) {
|
||||
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
|
||||
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
||||
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
|
||||
}
|
||||
await write(
|
||||
path.join(root, 'node_modules/dependency'),
|
||||
'installed dependency',
|
||||
);
|
||||
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
|
||||
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
|
||||
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
|
||||
writeManifest(source, staticRoot);
|
||||
for (const repo of ['qinglong', 'qinglong-static'])
|
||||
execFileSync(
|
||||
'zip',
|
||||
[
|
||||
'-qr',
|
||||
path.join(base, `${repo}.zip`),
|
||||
repo === 'qinglong' ? path.basename(source) : path.basename(staticRoot),
|
||||
],
|
||||
{ cwd: base },
|
||||
);
|
||||
await write(
|
||||
path.join(root, 'shell/share.sh'),
|
||||
`
|
||||
import_config() { :; }
|
||||
delete_pm2() { printf stopped >> "$QL_DIR/stops"; }
|
||||
reload_pm2() { printf started >> "$QL_DIR/starts"; }
|
||||
npm_install_2() { mkdir -p "$1/node_modules"; printf installed > "$1/node_modules/dependency"; exit_status=0; }
|
||||
`,
|
||||
);
|
||||
await write(path.join(root, 'shell/env.sh'), '');
|
||||
await write(
|
||||
path.join(base, 'bin/curl'),
|
||||
`#!${process.execPath}
|
||||
const fs = require('node:fs'), path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
if (process.env.CHECK_WORKER_PARENT === 'true') {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const parent = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], { encoding: 'utf8' }).trim());
|
||||
fs.writeFileSync(path.join(process.env.ARCHIVES, 'worker-parent.json'), JSON.stringify({ worker: process.ppid, parent }));
|
||||
}
|
||||
const repo = args.at(-1).includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong';
|
||||
if (process.env.FAIL_DOWNLOAD === 'true') process.exit(22);
|
||||
const buffer = fs.readFileSync(path.join(process.env.ARCHIVES, repo + '.zip'));
|
||||
fs.writeFileSync(args[args.indexOf('--dump-header') + 1], 'HTTP/1.1 200 OK\\r\\nContent-Length: ' + buffer.length + '\\r\\n\\r\\n');
|
||||
fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
|
||||
`,
|
||||
);
|
||||
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
|
||||
const runtime = {
|
||||
version: '2.24.0',
|
||||
sourceCommit,
|
||||
instanceId: 'after-restart',
|
||||
parentPid: 100002,
|
||||
backendSha256: JSON.parse(
|
||||
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||
).files['build/app.js'],
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: req.url.endsWith('/api/system')
|
||||
? { version: runtime.version }
|
||||
: {
|
||||
status: 'ok',
|
||||
build: {
|
||||
...runtime,
|
||||
instanceId: syncFs.existsSync(path.join(root, 'starts'))
|
||||
? runtime.instanceId
|
||||
: 'before-restart',
|
||||
parentPid: syncFs.existsSync(path.join(root, 'starts'))
|
||||
? runtime.parentPid
|
||||
: 100001,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${base}/bin:${process.env.PATH}`,
|
||||
ARCHIVES: base,
|
||||
QL_BRANCH: 'master',
|
||||
QlPort: String(server.address().port),
|
||||
};
|
||||
t.after(async () => {
|
||||
server.close();
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
});
|
||||
return { root, tmp, env, base, runtime };
|
||||
}
|
||||
|
||||
async function finished(tmp) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const status = await getStatus(tmp);
|
||||
if (['ready', 'completed', 'failed'].includes(status.status)) return status;
|
||||
await delay(25);
|
||||
}
|
||||
assert.fail('Worker did not finish');
|
||||
}
|
||||
|
||||
test('panel jobs are reparented before running outside the initiating backend process tree', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.env.CHECK_WORKER_PARENT = 'true';
|
||||
await startJob(f, 'download');
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready', ready.log);
|
||||
const relationship = JSON.parse(
|
||||
await fs.readFile(path.join(f.base, 'worker-parent.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(relationship.worker, ready.pid);
|
||||
assert.notEqual(
|
||||
relationship.parent,
|
||||
process.pid,
|
||||
'PM2 must not find the upgrade worker in the backend process tree',
|
||||
);
|
||||
});
|
||||
|
||||
test('detached download survives its initiating request and is idempotent; applying is explicit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const [first, duplicate] = await Promise.all([
|
||||
startJob(f, 'download'),
|
||||
startJob(f, 'download'),
|
||||
]);
|
||||
assert.equal(first.id, duplicate.id);
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready', ready.log);
|
||||
assert.equal(ready.version, '2.24.0');
|
||||
assert.equal(ready.sourceCommit, sourceCommit);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal((await startJob(f, 'download')).id, ready.id);
|
||||
const applying = await startJob(f, 'apply');
|
||||
assert.notEqual(applying.id, ready.id);
|
||||
const complete = await finished(f.tmp);
|
||||
assert.equal(complete.status, 'completed', complete.log);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'stops'), 'utf8'),
|
||||
'stopped',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.24.0',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
|
||||
'installed dependency',
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid staged payload is rejected before spawning a restart', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(f.tmp, 'upgrade-ready-master.json'), 'utf8'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
|
||||
'corrupted',
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal((await getStatus(f.tmp)).status, 'failed');
|
||||
});
|
||||
|
||||
test('failed download persists its failure without stopping the panel and can be retried', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(
|
||||
{ ...f, env: { ...f.env, FAIL_DOWNLOAD: 'true' } },
|
||||
'download',
|
||||
);
|
||||
const failed = await finished(f.tmp);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.match(failed.log, /curl failed/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
});
|
||||
|
||||
test('orphaned jobs become retryable and status log reads are bounded', async (t) => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-orphan-job-'));
|
||||
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify({ status: 'starting', startedAt: 0 }),
|
||||
);
|
||||
await fs.writeFile(path.join(tmp, 'panel-upgrade.log'), 'x'.repeat(100000));
|
||||
const result = await getStatus(tmp);
|
||||
assert.equal(result.status, 'failed');
|
||||
assert.equal(Buffer.byteLength(result.log), 32768);
|
||||
});
|
||||
|
||||
test('a healthy backend with the wrong version rolls the update back', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
f.runtime.version = '2.23.0';
|
||||
await startJob(f, 'apply');
|
||||
const failed = await finished(f.tmp);
|
||||
assert.equal(failed.status, 'failed', failed.log);
|
||||
assert.match(failed.error, /版本/);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.23.0',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'starts'), 'utf8'),
|
||||
'startedstarted',
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, patch] of [
|
||||
['same version but old commit', { sourceCommit: 'b'.repeat(40) }],
|
||||
['same commit but wrong backend', { backendSha256: '0'.repeat(64) }],
|
||||
['process did not restart', { instanceId: 'before-restart' }],
|
||||
['only the HTTP worker restarted', { parentPid: 100001 }],
|
||||
])
|
||||
test(`a healthy backend cannot claim update success: ${name}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
Object.assign(f.runtime, patch);
|
||||
await startJob(f, 'apply');
|
||||
const status = await finished(f.tmp);
|
||||
assert.equal(status.status, 'failed', status.log);
|
||||
assert.match(status.error, /版本/);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.23.0',
|
||||
);
|
||||
});
|
||||
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml', 'node_modules'])
|
||||
test(`reused dependencies are checked again before stopping: ${name}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
if (name === 'node_modules')
|
||||
await fs.rm(path.join(f.root, name), { recursive: true });
|
||||
else await fs.writeFile(path.join(f.root, name), 'changed after download');
|
||||
await assert.rejects(startJob(f, 'apply'), /依赖已变化/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('changed dependencies are fully staged and installed instead of reusing the current tree', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock');
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
await startJob(f, 'apply');
|
||||
const status = await finished(f.tmp);
|
||||
assert.equal(status.status, 'completed', status.log);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
|
||||
'installed',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'pnpm-lock.yaml'), 'utf8'),
|
||||
'lock',
|
||||
);
|
||||
});
|
||||
|
||||
async function replaceStage(f) {
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-master.json');
|
||||
const selected = JSON.parse(await fs.readFile(pointer, 'utf8'));
|
||||
const directory = `upgrade-${randomUUID()}`;
|
||||
const workspace = path.join(f.tmp, directory);
|
||||
await fs.cp(path.join(f.tmp, selected.directory), workspace, {
|
||||
recursive: true,
|
||||
});
|
||||
const readyFile = path.join(workspace, 'ready.json');
|
||||
const ready = JSON.parse(await fs.readFile(readyFile, 'utf8'));
|
||||
ready.source = path.join(workspace, 'qinglong-master');
|
||||
ready.static = path.join(workspace, 'qinglong-static-master');
|
||||
await fs.writeFile(readyFile, JSON.stringify(ready));
|
||||
await fs.writeFile(pointer, JSON.stringify({ directory }));
|
||||
}
|
||||
|
||||
test('replacing even a same-commit stage requires explicit preparation again', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
await replaceStage(f);
|
||||
await assert.rejects(startJob(f, 'apply'), /暂存更新已变化/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('the worker revalidates its accepted target under the upgrade lock', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready');
|
||||
const accepted = {
|
||||
...ready,
|
||||
id: randomUUID(),
|
||||
status: 'starting',
|
||||
action: 'apply',
|
||||
};
|
||||
delete accepted.log;
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify(accepted),
|
||||
);
|
||||
await replaceStage(f);
|
||||
await runJob(f, accepted.id);
|
||||
const failed = await getStatus(f.tmp);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.match(failed.error, /暂存更新已变化/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('an in-progress download cannot be reported as an accepted restart', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.mkdir(f.tmp, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify({
|
||||
id: randomUUID(),
|
||||
status: 'running',
|
||||
action: 'download',
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /已有更新/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
async function selectCli(f) {
|
||||
const cliRoot = path.join(f.base, 'selected-cli');
|
||||
const write = async (relative, contents) => {
|
||||
const file = path.join(cliRoot, 'dist/internal', relative);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, contents);
|
||||
};
|
||||
await write(
|
||||
'runtime/context.js',
|
||||
`exports.createContext = (values, env) => ({ root: values.root, env, paths: { file_config_user: values.root + '/data/config/config.sh', dir_preload: values.root + '/shell/preload' } }); exports.sourceEnvironment = async env => env;`,
|
||||
);
|
||||
await write(
|
||||
'runtime/cancellation.js',
|
||||
`exports.cancellableOperation = async (signal, operation) => { signal?.throwIfAborted(); return operation(); };`,
|
||||
);
|
||||
await write(
|
||||
'maintenance/operator.js',
|
||||
`
|
||||
const fs = require('node:fs/promises'), path = require('node:path');
|
||||
exports.stopPanel = ctx => fs.appendFile(path.join(ctx.root, 'cli-stops'), 'stopped');
|
||||
exports.startPanel = ctx => fs.appendFile(path.join(ctx.root, 'starts'), 'started');
|
||||
exports.installPanelDependencies = async (ctx, source) => {
|
||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||
await fs.writeFile(path.join(source, 'node_modules/dependency'), 'selected-cli');
|
||||
};`,
|
||||
);
|
||||
await write(
|
||||
'maintenance/upgradeSelection.js',
|
||||
`
|
||||
const fs = require('node:fs/promises'), path = require('node:path');
|
||||
exports.prepareUpgradeSelection = async (ctx) => {
|
||||
const directory = await fs.mkdtemp(path.join(ctx.root, '.tmp/selected-loader-'));
|
||||
const target = path.join(ctx.root, 'static/build/loaders/deps.js');
|
||||
const source = path.join(directory, 'deps.js');
|
||||
await fs.copyFile(target, source);
|
||||
return { directory, source, target };
|
||||
};`,
|
||||
);
|
||||
const loader = path.join(f.root, 'static/build/loaders/deps.js');
|
||||
await fs.mkdir(path.dirname(loader), { recursive: true });
|
||||
await fs.writeFile(loader, '// selected CLI loader');
|
||||
f.env.QL_CLI_ROOT = cliRoot;
|
||||
return loader;
|
||||
}
|
||||
|
||||
for (const fail of [false, true])
|
||||
test(`selected CLI lifecycle and loader survive panel updating; rollback=${fail}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const loader = await selectCli(f);
|
||||
await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock');
|
||||
await startJob(f, 'download');
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready', ready.log);
|
||||
if (fail) f.runtime.sourceCommit = 'b'.repeat(40);
|
||||
await startJob(f, 'apply');
|
||||
const status = await finished(f.tmp);
|
||||
assert.equal(status.status, fail ? 'failed' : 'completed', status.log);
|
||||
assert.equal(await fs.readFile(loader, 'utf8'), '// selected CLI loader');
|
||||
assert.equal(
|
||||
syncFs.existsSync(path.join(f.root, 'stops')),
|
||||
false,
|
||||
'legacy broad shell stop was not used',
|
||||
);
|
||||
assert.match(
|
||||
await fs.readFile(path.join(f.root, 'cli-stops'), 'utf8'),
|
||||
/stopped/,
|
||||
);
|
||||
if (!fail)
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
|
||||
'selected-cli',
|
||||
);
|
||||
});
|
||||
|
||||
for (const knownSize of [true, false])
|
||||
test(`download progress measures real bytes and handles unknown totals; known=${knownSize}`, async (t) => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-progress-'));
|
||||
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
|
||||
const reports = [];
|
||||
await downloadWithProgress(
|
||||
async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
await fs.writeFile(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
`HTTP/1.1 302 Found\r\nContent-Length: 1\r\n\r\nHTTP/2 200 OK\r\n${
|
||||
knownSize ? 'Content-Length: 100\r\n' : ''
|
||||
}\r\n`,
|
||||
);
|
||||
const archive = args[args.indexOf('--output') + 1];
|
||||
await fs.writeFile(archive, Buffer.alloc(50));
|
||||
await delay(1150);
|
||||
await fs.writeFile(archive, Buffer.alloc(100));
|
||||
},
|
||||
path.join(tmp, 'file.zip'),
|
||||
'https://example.test/file.zip',
|
||||
'qinglong',
|
||||
async (report) => reports.push(report),
|
||||
);
|
||||
assert.ok(reports.some((r) => r.downloadedBytes === 50));
|
||||
assert.equal(reports[0].percent, knownSize ? 50 : null);
|
||||
assert.equal(reports.at(-1).downloadedBytes, 100);
|
||||
assert.equal(reports.at(-1).percent, knownSize ? 100 : null);
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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|后端启动检查失败/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user