Compare commits

..
Author SHA1 Message Date
whyour 301efa9e72 fix(scripts): validate destinations immediately before file writes 2026-10-12 01:13:53 +08:00
whyour 5eaff6ba90 fix(storage): release file locks on failed writes 2026-10-12 00:23:20 +08:00
copilot-swe-agent[bot]andwhyour 855f591992 Improve type safety for fileHandle and release variables
- Add explicit FileHandle type annotation for fileHandle variable
- Change fileHandle check from truthy to explicit undefined check
- Change release type from null to undefined for better type safety

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:49:27 +00:00
copilot-swe-agent[bot]andwhyour d30eb2008c Add error handling for file handle close operation
- Wrap fileHandle.close() in try-catch to prevent masking original errors
- Log close errors without throwing to preserve error context

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:46:51 +00:00
copilot-swe-agent[bot]andwhyour e1ce0f3fa9 Improve error handling based on code review feedback
- Use finally block to ensure file handle is closed even on error
- Extract error messages properly using instanceof Error check
- Improve error message formatting for better debugging

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:45:34 +00:00
copilot-swe-agent[bot]andwhyour 82514e65e1 Add server timeout configuration and improve error handling for script save operations
- Configure HTTP server timeouts (requestTimeout: 5min, headersTimeout: 2min, keepAliveTimeout: 65s)
- Add better error logging in PUT /scripts endpoint
- Improve writeFileWithLock error handling with descriptive messages and proper cleanup
- Ensure lock is always released even on error

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:43:11 +00:00
copilot-swe-agent[bot] 31261190f0 Initial plan 2025-12-27 08:35:20 +00:00
158 changed files with 1462 additions and 11833 deletions
-3
View File
@@ -21,6 +21,3 @@ JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
# Python 虚拟环境路径,留空则使用系统 Python
# PYTHON_VENV_DIR=./.venv
-87
View File
@@ -8,8 +8,6 @@ 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"
@@ -96,12 +94,6 @@ jobs:
run: |
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
- name: publish verified upgrade download sizes
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
@@ -205,8 +197,6 @@ 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
@@ -272,7 +262,6 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -294,8 +283,6 @@ 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
@@ -360,7 +347,6 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -571,76 +557,3 @@ 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"
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
run: |
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.3.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
- name: Export native Debian image for isolated dev validation
if: matrix.image == 'debian'
run: |
-9
View File
@@ -38,12 +38,3 @@ __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/
-10
View File
@@ -1,10 +0,0 @@
# Node.js 原生部署与 Python venv
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。
- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。
- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。
该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。
+1 -2
View File
@@ -1,6 +1,5 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
import {
diagnoseClientIp,
getTrustProxyConfig,
@@ -37,7 +36,7 @@ export default (app: Router) => {
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : t('配置更新失败'),
message: error instanceof Error ? error.message : '配置更新失败',
});
}
},
+23 -2
View File
@@ -35,6 +35,23 @@ function isPathAllowed(targetPath: string): boolean {
);
}
async function writeScriptFile(
targetPath: string,
content: string,
): Promise<boolean> {
const target = resolve(targetPath);
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const prefix = root.endsWith(sep) ? root : root + sep;
// Check the actual write target, including filename, at the write boundary.
if (!target.startsWith(prefix)) continue;
if (!isPathAllowed(target)) return false;
await writeFileWithLock(target, content);
return true;
}
return false;
}
async function existingWritableRealPath(targetPath: string): Promise<string> {
if (!isPathAllowed(targetPath)) {
throw new ScriptHistoryError(403, '暂无权限');
@@ -373,7 +390,9 @@ export default (app: Router) => {
await rmPath(originFilePath);
}
}
await writeFileWithLock(filePath, content);
if (!(await writeScriptFile(filePath, content))) {
return res.send({ code: 403, message: t('暂无权限') });
}
return res.send({ code: 200 });
} catch (e) {
return historyError(e, res, next);
@@ -513,7 +532,9 @@ export default (app: Router) => {
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await writeFileWithLock(filePath, content || '');
if (!(await writeScriptFile(filePath, content || ''))) {
return res.send({ code: 403, message: t('暂无权限') });
}
const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath);
-24
View File
@@ -205,17 +205,6 @@ 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({
@@ -394,19 +383,6 @@ export default (app: Router) => {
},
);
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
-2
View File
@@ -5,7 +5,6 @@ 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';
@@ -28,7 +27,6 @@ class Application {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
await initializeLanguageSync();
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
-1
View File
@@ -27,7 +27,6 @@ export const SAMPLE_FILES = [
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
export const NotificationModeStringMap = {
0: 'gotify',
+102 -223
View File
@@ -1,25 +1,18 @@
import * as fs from 'fs/promises';
import { statSync } from 'fs';
import * as path from 'path';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { shellQuote } from '../shared/shellQuote';
import { resolveFileAccess } from '../shared/fileAccess';
import {
mirrorWriteCommand,
normalizeMirror,
rewriteMirrorSources,
} from '../shared/linuxMirror';
export * from './share';
@@ -677,9 +670,7 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
throw new Error('Invalid timezone');
}
await promiseExec(
maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
);
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true;
@@ -689,177 +680,61 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
}
}
// Helper function to check if a name is a GitHub URL
function getPythonExecutable(): string {
return PYTHON_VENV_DIR
? shellQuote(path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'))
: 'python3';
}
function getPythonPip(): string {
return PYTHON_VENV_DIR ? `${getPythonExecutable()} -m pip` : 'pip3';
}
function isGitHubUrl(name: string): boolean {
// Support git+https://, git+http://, https://, and http:// URLs
// This covers GitHub URLs and other git-compatible repositories
return !!name.match(/^(git\+https?:\/\/|https?:\/\/)/i);
}
// Helper function to check if a name is a requirements file
function isRequirementsFile(name: string): boolean {
return !!name.match(/requirements.*\.(txt|in)$/i);
}
// Helper function to check if a name is a pyproject.toml file
function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function isPythonSourceDependency(name: string): boolean {
const source = name.trim();
return (
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
);
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const trimmedName = name.trim();
// For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) {
if (isPythonSourceDependency(trimmedName)) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
}
}
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
`from importlib.metadata import version, PackageNotFoundError
import importlib.util
import sys
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
try:
from packaging.requirements import Requirement
except ImportError:
from pip._vendor.packaging.requirements import Requirement
requirement = Requirement(sys.argv[1])
if requirement.marker and not requirement.marker.evaluate():
print('not applicable')
else:
try:
installed = version(requirement.name)
except PackageNotFoundError:
if not requirement.specifier and not requirement.extras and not requirement.url:
if importlib.util.find_spec(requirement.name):
print(requirement.name)
else:
if requirement.specifier.contains(installed, prereleases=True):
print(installed)
except Exception:
pass`,
)} ${shellQuote(trimmedName)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
: maybeSudo(`dpkg-query -s ${trimmedName}`),
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: maybeSudo(`dpkg-query -s ${name}`),
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
if (type === DependenceTypes.python3) {
const prefix =
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
: '';
const pip = `${getPythonPip()} install --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
}${prefix}`;
name = name.trim();
if (isRequirementsFile(name) || isPyprojectToml(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return isRequirementsFile(name)
? `${pip} -r ${shellQuote(file)}`
: `${pip} ${shellQuote(path.dirname(file))}`;
}
return `${pip} -- ${shellQuote(name)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
if (type === DependenceTypes.python3) {
name = name.trim();
if (isRequirementsFile(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -r ${shellQuote(file)}`;
}
if (isPyprojectToml(name) || isGitHubUrl(name)) {
// A source URL/project can install several distributions and has no safe
// inverse. Removing its panel entry leaves installed packages intact.
return `printf '%s\\n' ${shellQuote(
'Remove installed Python distributions separately by package name.',
)}`;
}
const packageName =
name.match(
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
)?.[1] || name;
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(packageName)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
@@ -931,73 +806,77 @@ export async function detectOS(): Promise<
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirror: string,
mirrorDomainWithScheme: string,
): Promise<string> {
const defaults: Record<string, string> = {
Debian: 'http://deb.debian.org/debian',
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
};
if (!defaults[osType])
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
osType === 'Alpine'
? '/etc/apk/repositories'
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
let content = '';
try {
content = await fs.readFile(file, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') throw error;
if (osType !== 'Alpine') {
try {
file = '/etc/apt/sources.list';
content = await fs.readFile(file, 'utf8');
} catch (legacyError: any) {
if (legacyError.code !== 'ENOENT') throw legacyError;
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const S = isInContainer() ? 'sudo ' : '';
// APT otherwise returns zero when index downloads fail with transient errors.
const aptUpdate = `${S}apt-get update --error-on=any`;
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
}
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
if (!content.trim()) {
const release = await fs.readFile('/etc/os-release', 'utf8');
if (osType === 'Alpine') {
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
if (!version)
throw new Error('Cannot determine Alpine repository version');
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
} else {
// The fallback may have read an empty legacy .list. Deb822 stanzas must
// be written to a .sources file, leaving the legacy file untouched.
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
if (!codename)
throw new Error('Cannot determine APT repository codename');
const ubuntu = osType === 'Ubuntu';
content = `Types: deb\nURIs: ${
defaults[osType]
}\nSuites: ${codename} ${codename}-updates${
ubuntu ? ` ${codename}-backports` : ''
}\nComponents: ${
ubuntu ? 'main restricted universe multiverse' : 'main'
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
if (!ubuntu)
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
}
}
return mirrorWriteCommand(
file,
rewriteMirrorSources(content, target),
S,
update,
);
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
-16
View File
@@ -28,12 +28,8 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
}
export const NotifyStatus = LoginStatus;
export type NotifyStatus = LoginStatus;
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
@@ -57,17 +53,6 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo {
value: string;
timestamp: number;
@@ -103,7 +88,6 @@ export interface AuthInfo {
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>;
export interface SystemInstance
+3 -10
View File
@@ -6,7 +6,7 @@ import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite';
import validationErrors from '../middlewares/validationErrors';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
@@ -179,14 +179,7 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (req, res, next) => {
// Don't serve index.html for API routes
if (
req.path === config.api.prefix ||
req.path.startsWith(`${config.api.prefix}/`)
) {
return res.status(404).json({ code: 404, message: 'Not Found' });
}
app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
@@ -197,7 +190,7 @@ export default ({ app }: { app: Application }) => {
});
});
app.use(validationErrors);
app.use(errors());
app.use(uniqueConstraintError);
app.use(
+2 -2
View File
@@ -63,7 +63,7 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
@@ -78,7 +78,7 @@ export default async () => {
assertSubscriptionAlias(doc.alias);
await subscriptionService.handleTask(doc, !sub.is_disabled);
} catch (error) {
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
}
}
};
-18
View File
@@ -1,18 +0,0 @@
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;
-10
View File
@@ -260,15 +260,6 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3;
}
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -280,7 +271,6 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+1 -175
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v6.31.1
// protoc v3.17.3
// source: back/protos/api.proto
/* eslint-disable */
@@ -417,15 +417,6 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined;
}
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem {
return {
id: undefined,
@@ -4305,146 +4296,6 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
},
};
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService;
export const ApiService = {
getEnvs: {
@@ -4537,15 +4388,6 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail",
requestStream: false,
@@ -4640,7 +4482,6 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4803,21 +4644,6 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail(
request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+3 -3
View File
@@ -5,7 +5,7 @@ import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { t, tf } from '../shared/i18n';
import { 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(t('[schedule][准备运行任务] 命令: %s'), item.command);
Logger.info('[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(
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
id,
jobs.length,
);
+2 -25
View File
@@ -16,7 +16,6 @@ import {
MoveEnvRequest,
Response,
SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
@@ -239,8 +238,7 @@ export const systemNotify = async (
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
@@ -248,31 +246,10 @@ export const systemNotify = async (
}
};
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron =
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...cron,
labels: cron.labels ?? [],
+15 -16
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data';
@@ -10,24 +9,24 @@ const delCron = (
) => {
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
Logger.info(
'[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(
t('[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(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id);
}
}
+7 -7
View File
@@ -641,7 +641,7 @@ export default class CronService {
stopped.add(pid);
} catch (error) {
this.logger.error(
t('[panel][停止任务失败] PID: %s, 错误: %s'),
'[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(
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
);
}
}
@@ -791,20 +791,20 @@ export default class CronService {
const result = await completed;
if (result.error) {
this.logger.error(
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
id,
result.error.message
);
}
this.logger.info(
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
id,
result.code
);
return { ...cron, pid: cp.pid, ...result } as any;
} catch (error) {
this.logger.error(
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
'[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(
t('[panel][关闭任务日志失败] %s'),
'[panel][关闭任务日志失败] %s',
asError(error).message
);
}
@@ -845,7 +845,7 @@ export default class CronService {
);
} catch (error) {
this.logger.error(
t('[panel][清理任务状态失败] %s'),
'[panel][清理任务状态失败] %s',
asError(error).message
);
}
+24 -69
View File
@@ -20,7 +20,6 @@ import {
getInstallCommand,
getUninstallCommand,
getGetCommand,
isPythonSourceDependency,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@@ -33,11 +32,10 @@ export default class DependenceService {
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
) {}
) { }
public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => {
getInstallCommand(x.type, x.name);
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
return tab;
});
@@ -61,7 +59,6 @@ export default class DependenceService {
...other,
status: DependenceStatus.queued,
});
getInstallCommand(tab.type, tab.name);
const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]);
return newDoc;
@@ -77,25 +74,21 @@ export default class DependenceService {
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
(x) => x.status === DependenceStatus.installed,
);
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: installedDeps.map((x) => x.id!) } },
{ where: { id: ids } },
);
this.installDependenceOneByOne(installedDeps, false, force);
this.installDependenceOneByOne(docs, false, force);
}
return docs;
}
@@ -161,15 +154,9 @@ export default class DependenceService {
},
});
const checks = await concurrentRun(
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
@@ -188,10 +175,7 @@ export default class DependenceService {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -312,26 +296,6 @@ export default class DependenceService {
force: boolean = false,
) {
return taskLimit.runDependeny(dependency, () => {
let validatedCommand: string;
try {
validatedCommand = isInstall
? getInstallCommand(dependency.type, dependency.name.trim())
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.update(
{
status: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed,
log: [String(error)],
},
{ where: { id: dependency.id } },
).then(() => null);
}
return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null);
@@ -381,7 +345,9 @@ export default class DependenceService {
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
let command = validatedCommand;
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
@@ -404,13 +370,7 @@ export default class DependenceService {
this.updateLog(depIds, message);
// 判断是否已经安装过依赖
// pip must resolve optional dependencies even if the base distribution
// is already present; its version alone cannot prove extras are ready.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
if (isInstall && !force) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
@@ -418,7 +378,7 @@ export default class DependenceService {
}
let depVersion = '';
if (!isPythonDependence && depName.includes(depVersionStr)) {
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -510,20 +470,15 @@ export default class DependenceService {
const message =
'\n' +
tf(
'依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed
? isInstall
? DependenceStatus.installed
: DependenceStatus.removed
: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
this.sockService.sendMessage({
type: socketMessageType,
message,
+3 -4
View File
@@ -2,10 +2,8 @@ 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;
@@ -24,11 +22,12 @@ 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,
+28 -12
View File
@@ -12,7 +12,7 @@ export class HttpServerService {
async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [
config.bindHost,
...(config.bindHost === '::' ? ['0.0.0.0'] : [])
...(config.bindHost === '::' ? ['0.0.0.0'] : []),
];
let lastError: Error | null = null;
@@ -20,16 +20,21 @@ export class HttpServerService {
for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
Logger.debug(
`[boot] HTTP service started successfully on ${host}:${port}`,
);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
host,
});
this.server = server;
return server;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
Logger.warn(
`Failed to bind HTTP on ${host}:${port}, trying next...`,
err,
);
}
}
@@ -37,17 +42,28 @@ export class HttpServerService {
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
private async tryListen(
expressApp: express.Application,
port: number,
host: string,
): Promise<Server> {
return new Promise((resolve, reject) => {
// There is one HTTP worker; accepting here avoids primary IPC handoff
// for every connection. Restore shared listening for custom clusters.
const server = expressApp.listen({
port,
host,
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
}, () => {
resolve(server);
});
const server = expressApp.listen(
{
port,
host,
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
},
() => {
resolve(server);
},
);
server.requestTimeout = 300000;
server.headersTimeout = 120000;
server.keepAliveTimeout = 65000;
server.on('error', (err: Error) => {
server.close();
+1 -2
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
@@ -127,7 +126,7 @@ export class MetricsService {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info(t('性能指标报告:'), report);
Logger.info('性能指标报告:', report);
return report;
}
}
+26 -42
View File
@@ -1,4 +1,3 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
@@ -56,33 +55,27 @@ export default class NotificationService {
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
let type: string | undefined;
let success = false;
try {
const saved = await this.userService.getNotificationMode();
type = notificationInfo?.type || saved.type;
if (type) {
this.title = title;
this.content = content;
const { type: _, ...overrides } = notificationInfo || {};
const { type: _saved, ...rest } = saved;
this.params = { ...rest, ...overrides };
try {
success = !!(await this.modeMap.get(type)?.call(this));
} catch (error) {
console.error(error);
}
}
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
console.error(error);
}
}
return false;
}
public async testNotify(
@@ -91,23 +84,14 @@ export default class NotificationService {
content: string,
) {
const { type, ...rest } = info;
if (!type) return true;
let success = false;
try {
if (type) {
this.title = title;
this.content = content;
this.params = rest;
success = !!(await this.modeMap.get(type)?.call(this));
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
const notificationModeAction = this.modeMap.get(type);
return await notificationModeAction?.call(this);
}
return true;
}
private parseMailRecipients(value?: string) {
@@ -937,8 +921,7 @@ export default class NotificationService {
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl =
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
@@ -991,4 +974,5 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error);
}
}
}
+12 -13
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createCronJob, CronJob } from '../shared/cronScheduler';
@@ -84,7 +83,7 @@ export default class ScheduleService {
let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null };
try {
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
@@ -104,7 +103,7 @@ export default class ScheduleService {
if (result.error) {
this.logger.error(
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
others.id,
result.error.message,
);
@@ -112,7 +111,7 @@ export default class ScheduleService {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
t('[panel][任务错误回调失败] %s'),
'[panel][任务错误回调失败] %s',
asError(error).message,
);
}
@@ -128,12 +127,12 @@ export default class ScheduleService {
} catch (error) {
result.error ??= asError(error);
this.logger.error(
t('[panel][任务结束回调失败] %s'),
'[panel][任务结束回调失败] %s',
asError(error).message,
);
}
this.logger.info(
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
others.id,
result.code,
);
@@ -142,7 +141,7 @@ export default class ScheduleService {
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
@@ -157,7 +156,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id,
schedule,
name,
@@ -184,7 +183,7 @@ export default class ScheduleService {
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
_id,
schedule,
asError(error).message,
@@ -205,7 +204,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -220,7 +219,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id,
name,
command,
@@ -237,7 +236,7 @@ export default class ScheduleService {
},
(err) => {
this.logger.error(
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
@@ -265,7 +264,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id,
name,
);
+10 -21
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs/promises';
@@ -62,10 +61,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(t('生成私钥文件失败'), error);
this.logger.error('生成私钥文件失败', error);
}
}
@@ -74,7 +73,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error(t('删除私钥文件失败'), error);
this.logger.error('删除私钥文件失败', error);
}
}
@@ -83,13 +82,9 @@ 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`;
@@ -97,10 +92,7 @@ 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,
@@ -116,7 +108,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
this.logger.error(`删除ssh配置文件${alias}失败`, error);
}
}
@@ -155,7 +147,7 @@ export default class SshKeyService {
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
}
}
}
@@ -175,10 +167,7 @@ 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,
+64 -123
View File
@@ -1,11 +1,10 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { request } from 'undici';
import { Agent, request } from 'undici';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
@@ -33,8 +32,6 @@ import {
SystemInstance,
SystemModel,
SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
@@ -56,7 +53,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
) { }
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -161,9 +158,7 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy=${shellQuote(
info.dependenceProxy,
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -282,24 +277,26 @@ export default class SystemService {
}
public async checkUpdate() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const currentVersionContent = await parseVersion(config.versionFile);
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');
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;
}
return {
@@ -317,10 +314,8 @@ export default class SystemService {
} catch (error: any) {
return {
code: 400,
message: t('检查更新失败,请检查网络后重试'),
message: error.message,
};
} finally {
clearTimeout(timeout);
}
}
@@ -344,51 +339,45 @@ export default class SystemService {
}
public async updateSystem() {
return this.startUpgradeJob('download');
}
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
private upgradeJobs() {
return require(path.join(
config.rootPath,
'cli/src/internal/maintenance/panelUpgrade.cjs',
));
}
cp.stdout.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.stderr.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
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('升级任务启动失败,请检查日志后重试') };
}
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 };
}
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',
@@ -418,13 +407,11 @@ export default class SystemService {
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: t('通知发送成功') };
} else {
@@ -432,36 +419,16 @@ export default class SystemService {
}
}
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
order: [['id', 'DESC']],
limit: 200,
});
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{ command, logPath }: { command: string; logPath?: string },
callback: TaskCallbacks,
) {
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
this.scheduleService.runTask(
`${logPathPrefix} real_time=true ${command}`,
callback,
{
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
},
);
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
}
public async stop({ command, pid }: { command: string; pid: number }) {
@@ -492,20 +459,8 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const allowed = new Set([
'db',
'upload',
'config',
'scripts',
'log',
'deps',
'syslog',
'dep_cache',
'raw',
'repo',
'ssh.d',
'script-history',
]);
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
@@ -515,13 +470,9 @@ export default class SystemService {
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)(
'tar',
['-zcf', config.dataTgzFile, '--', ...dataPaths],
{
cwd: path.dirname(config.dataPath),
},
);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
@@ -612,17 +563,7 @@ 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) {
// 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();
}
await rmPath(path.join(config.systemLogPath, log.title));
}
}
+2 -2
View File
@@ -173,7 +173,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
t(address) +
address +
' ' +
req.platform +
t('端') +
@@ -219,7 +219,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
t(address) +
address +
' ' +
req.platform +
t('端') +
+9 -66
View File
@@ -22,7 +22,6 @@ 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.
@@ -59,73 +58,17 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
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 normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.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 =
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)) {
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))) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
+2 -3
View File
@@ -1,4 +1,3 @@
import { t } from './i18n';
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
@@ -37,7 +36,7 @@ export function createCronJob(
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
options.name,
date.toISOString(),
);
@@ -46,7 +45,7 @@ export function createCronJob(
await callback(date);
} catch (error) {
options.logger.error(
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
'[schedule][定时回调失败] 任务: %s, 错误: %s',
options.name,
error instanceof Error ? error.message : String(error),
);
+92 -221
View File
@@ -1,231 +1,112 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'升级任务启动失败,请检查日志后重试':
'Failed to start the update. Check the logs and try again.',
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
'[schedule][Stopping running task] Task ID: %s, PID: %s',
'接口已下线,请使用 /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',
'资源已存在,请检查重复的名称或值': '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 ',
端: ' client',
内网IP: 'Private IP',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
端: '',
登录失败: 'login failed',
',ip地址': ', IP: ',
'任务#%s': 'Task#%s',
@@ -250,13 +131,12 @@ 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',
'调度器注册失败,任务更新已回滚':
@@ -272,18 +152,9 @@ const messages: Record<string, Record<string, string>> = {
let currentLang: string = 'zh';
export function setLang(lang: string, persist = true) {
export function setLang(lang: string) {
currentLang = lang || 'zh';
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 }, () => {});
}
});
shareStore.setLang(currentLang);
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
-32
View File
@@ -1,32 +0,0 @@
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);
}
-121
View File
@@ -1,121 +0,0 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
-48
View File
@@ -1,48 +0,0 @@
import { Op } from 'sequelize';
import {
SystemModel,
AuthDataType,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
// Both backend sends and script reports use the same bounded, best-effort sink.
export async function persistNotificationAudit(
info: NotifyLogInfo,
): Promise<void> {
try {
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: String(info.title || '').slice(0, 512),
content: String(info.content || '').slice(0, 8192),
status:
info.status === NotifyStatus.success
? NotifyStatus.success
: NotifyStatus.fail,
notifyType: String(info.notifyType || '').slice(0, 64),
source: info.source === 'script' ? 'script' : 'system',
scriptName: String(info.scriptName || '').slice(0, 512),
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
},
});
const oldest = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
attributes: ['id'],
order: [['id', 'DESC']],
offset: 200,
limit: 1,
});
if (oldest[0]?.id) {
await SystemModel.destroy({
where: {
type: AuthDataType.notifyLog,
id: { [Op.lte]: oldest[0].id },
},
});
}
} catch (error) {
console.error('Failed to persist notification audit log');
}
}
+10 -18
View File
@@ -79,36 +79,29 @@ class TaskLimit {
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('active', () => {
Logger.info(
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount + 1,
this.cronLimitPendingCount,
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
});
this.cronLimit.on('error', (error) => {
Logger.error(
t('[schedule][任务处理错误] 参数 %s'),
JSON.stringify(error),
);
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
});
this.cronLimit.on('next', () => {
Logger.info(
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('idle', () => {
Logger.info(t(`[schedule][任务队列] 空闲中...`));
Logger.info(`[schedule][任务队列] 空闲中...`);
});
}
@@ -179,14 +172,13 @@ class TaskLimit {
(err, res) => {
if (err) {
Logger.error(
t('[schedule][任务重复运行] 通知失败 %s'),
JSON.stringify(err),
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
);
}
},
);
}
Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
return;
}
this.queuedCrons.set(cron.id, result);
+8 -14
View File
@@ -1,4 +1,3 @@
import { t } from './i18n';
import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
@@ -38,9 +37,7 @@ export function runCron(
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
cron.id,
existingCron.pid,
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
@@ -62,9 +59,7 @@ export function runCron(
}
} catch (error) {
Logger.error(
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
cron.id,
error,
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
throw error;
}
@@ -72,18 +67,17 @@ export function runCron(
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
t('[schedule][开始执行任务] 参数 %s'),
JSON.stringify({
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
command: cmd,
}),
})}`,
);
const cp = spawn(cmd, { shell: '/bin/bash' });
const { completed } = observeChildProcess(cp, {
onStderr: async (message) => {
Logger.info(
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
'[schedule][任务标准错误] 命令: %s, 信息: %s',
cmd,
message,
);
@@ -92,20 +86,20 @@ export function runCron(
const result = await completed;
if (result.error) {
Logger.error(
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
'[schedule][执行任务失败] 命令: %s, 错误: %s',
cmd,
result.error.message,
);
}
Logger.info(
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
cron.id,
result.code,
);
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) {
Logger.error(
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
'[schedule][创建任务失败] 命令: %s, 错误: %s',
cmd,
asError(error).message,
);
-41
View File
@@ -1,41 +0,0 @@
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,
});
+4 -5
View File
@@ -1,7 +1,6 @@
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';
@@ -31,10 +30,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(t('trust proxy 配置格式无效'));
throw new Error('trust proxy 配置格式无效');
}
if (/^\d+$/.test(setting) && Number(setting) > 20) {
throw new Error(t('代理层数不能超过 20'));
throw new Error('代理层数不能超过 20');
}
const probe = express();
@@ -74,7 +73,7 @@ export async function initializeTrustProxy(app: Application) {
export async function updateTrustProxy(value: string) {
if (getEnvironmentSetting()) {
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
}
const trustProxy = validateTrustProxy(value);
@@ -82,7 +81,7 @@ export async function updateTrustProxy(value: string) {
where: { type: AuthDataType.systemConfig },
});
if (!doc) {
throw new Error(t('系统配置不存在'));
throw new Error('系统配置不存在');
}
const plain = doc.get({ plain: true });
+8 -5
View File
@@ -21,7 +21,7 @@ export async function writeFileWithLock(
}
if (!(await fileExist(filePath))) {
const fileHandle = await open(filePath, 'w');
fileHandle.close();
await fileHandle.close();
}
const lockfilePath = getUniqueLockPath(filePath);
@@ -34,9 +34,12 @@ export async function writeFileWithLock(
},
lockfilePath,
});
await writeFile(filePath, content, { encoding: 'utf8', ...options });
if (options?.mode) {
await chmod(filePath, options.mode);
try {
await writeFile(filePath, content, { encoding: 'utf8', ...options });
if (options?.mode) {
await chmod(filePath, options.mode);
}
} finally {
await release();
}
await release();
}
-4
View File
@@ -17,10 +17,6 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
);
fs.copyFileSync(
path.join(root, 'src/internal/maintenance/platformBundle.cjs'),
path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
);
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
outfile: path.join(root, 'dist/shared/cli/commander.js'),
+8 -8
View File
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
"printf '%s\\\\n' '__sitecustomize__' &&",
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
" if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
" if (task_before) console.log('执行前置命令\\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(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
" console.log('执行前置命令结束\\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(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
" console.log('执行前置命令结束\\n');",
' }',
' }',
'',
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
' ',
' task_before = os.getenv("task_before", "")',
' if task_before:',
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
' print("执行前置命令\\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("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\n")',
'',
' except subprocess.CalledProcessError as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\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("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\n")',
' except Exception as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\n")',
'',
' import task_before',
'',
@@ -1,479 +0,0 @@
// Persistent panel jobs run independently of HTTP requests and browser sessions.
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const artifacts = require('./upgradeArtifacts.cjs');
const active = new Set(['starting', 'running', 'restarting']);
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log');
const launches = new Map();
async function withPanelLifecycle(root, tmp, env, operation, signal) {
if (!env.QL_CLI_ROOT)
return artifacts.withLifecycleHooks(root, tmp, env, operation);
if (!path.isAbsolute(env.QL_CLI_ROOT))
throw new Error('QL_CLI_ROOT must be absolute');
const internal = path.join(env.QL_CLI_ROOT, 'dist/internal');
// Keep the selected installation's operators loaded across file replacement.
const { createContext, sourceEnvironment } = require(path.join(
internal,
'runtime/context.js',
));
const { cancellableOperation } = require(path.join(
internal,
'runtime/cancellation.js',
));
const operator = require(path.join(internal, 'maintenance/operator.js'));
const { prepareUpgradeSelection } = require(path.join(
internal,
'maintenance/upgradeSelection.js',
));
const context = createContext(
{ root, 'data-dir': env.dir_data || env.QL_DATA_DIR },
env,
);
context.env = await sourceEnvironment(
context.env,
[
context.paths.file_config_user,
path.join(context.paths.dir_preload, 'lang_env.sh'),
],
[],
{ signal },
);
const hook = (name, args = [], signal) =>
cancellableOperation(signal, () => {
if (name === 'install')
return operator.installPanelDependencies(context, args[0]);
if (name === 'stop') return operator.stopPanel(context);
if (name === 'start') return operator.startPanel(context);
throw new Error('Invalid lifecycle operation');
});
return operation(hook, (selected) =>
prepareUpgradeSelection(context, selected.source, selected.static),
);
}
async function targetVersion(source) {
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
if (!version) throw new Error('Invalid update version');
return version;
}
async function readRunningBuild(env) {
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
base = base.replace(/\/$/, '');
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => request.destroy(new Error('Version check timed out')),
5000,
);
const request = http.get(
{
hostname: '127.0.0.1',
port: Number(env.QlPort || 5700),
path: `${base}/api/health`,
},
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
if (body.length > 65536)
request.destroy(new Error('Invalid version response'));
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok'
)
throw new Error('后端启动检查失败。');
resolve(result.data.build);
} catch (error) {
reject(error);
}
});
},
);
request.on('error', reject);
request.on('close', () => clearTimeout(timer));
});
}
async function verifyRunningVersion(env, expected, previousBuild) {
const build = await readRunningBuild(env);
if (
!build ||
!build.instanceId ||
build.instanceId === previousBuild?.instanceId ||
(previousBuild?.parentPid !== undefined &&
build.parentPid === previousBuild.parentPid) ||
build.version !== expected.version ||
build.sourceCommit !== expected.sourceCommit ||
build.backendSha256 !== expected.backendSha256
)
throw new Error('启动版本与更新包不一致');
}
async function stagedMetadata(selected) {
const manifest = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'),
);
return {
version: await targetVersion(selected.source),
sourceCommit: manifest.sourceCommit,
backendSha256: manifest.files['build/app.js'],
stageDirectory: path.basename(path.dirname(selected.source)),
};
}
function assertSelected(expected, actual) {
for (const key of [
'sourceCommit',
'backendSha256',
'stageDirectory',
'version',
]) {
if (expected[key] !== actual[key])
throw new Error('暂存更新已变化,请重新下载更新。');
}
}
async function save(tmp, state) {
const temporary = `${stateFile(tmp)}.${randomUUID()}`;
await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
await fs.rename(temporary, stateFile(tmp));
}
async function readState(tmp) {
try {
return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return { status: 'idle' };
throw error;
}
}
async function getStatus(tmp) {
const state = await readState(tmp);
if (active.has(state.status)) {
let alive = false;
if (Number.isSafeInteger(state.pid) && state.pid > 0) {
try {
process.kill(state.pid, 0);
alive = true;
} catch (error) {
alive = error.code === 'EPERM';
}
} else alive = Date.now() - state.startedAt < 10000;
if (!alive) {
state.status = 'failed';
state.error = '升级任务意外中断,请重试';
}
}
// Bound reads even when an installer produces a very large log.
const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined);
if (handle) {
try {
const { size } = await handle.stat();
const buffer = Buffer.alloc(Math.min(size, 32768));
const { bytesRead } = await handle.read(
buffer,
0,
buffer.length,
Math.max(0, size - buffer.length),
);
state.log = buffer.subarray(0, bytesRead).toString();
} finally {
await handle.close();
}
}
return state;
}
async function startJob({ root, tmp, env = process.env }, action) {
if (!['download', 'apply'].includes(action))
throw new Error('Invalid upgrade action');
await fs.mkdir(tmp, { recursive: true });
const preceding = launches.get(tmp) || Promise.resolve();
const launching = preceding
.catch(() => {})
.then(() =>
artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const previous = await getStatus(tmp);
if (active.has(previous.status)) {
if (previous.action !== action)
throw new Error('已有更新或重载正在执行。');
return previous;
}
let selected;
let metadata;
if (action === 'apply') {
// Fail before launching a process or stopping the working panel.
try {
selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
);
metadata = await stagedMetadata(selected);
if (previous.status === 'ready')
assertSelected(previous, metadata);
} catch (error) {
const { log, ...record } = previous;
await save(tmp, {
...record,
status: 'failed',
phase: 'verifying',
error: error.message,
});
throw error;
}
} else if (previous.status === 'ready') return previous;
const state = {
id: randomUUID(),
action,
status: 'starting',
phase: action === 'apply' ? 'verifying' : 'downloading',
startedAt: Date.now(),
...metadata,
};
await save(tmp, state);
const log = await fs.open(logFile(tmp), 'w', 0o600);
try {
const child = spawn(
process.execPath,
[__filename, 'launch', state.id],
{
env: { ...env, QL_DIR: root, dir_tmp: tmp },
detached: true,
stdio: ['ignore', log.fd, log.fd],
},
);
await new Promise((resolve, reject) => {
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else
reject(
new Error(`Upgrade launcher exited (${signal || code})`),
);
});
child.once('error', reject);
});
child.unref();
} catch (error) {
await save(tmp, {
...state,
status: 'failed',
error: error.message,
});
throw error;
} finally {
await log.close();
}
return state;
},
env,
),
);
launches.set(tmp, launching);
try {
return await launching;
} finally {
if (launches.get(tmp) === launching) launches.delete(tmp);
}
}
async function runJob({ root, tmp, env = process.env }, id) {
let state = await readState(tmp);
if (state.id !== id || state.status !== 'starting')
throw new Error('Stale upgrade job');
const controller = new AbortController();
const abort = () => controller.abort(new Error('升级任务意外中断,请重试'));
process.once('SIGTERM', abort);
process.once('SIGINT', abort);
// Serialize progress writes so an older tick cannot overwrite ready/completed.
let writes = Promise.resolve();
const report = (patch) => {
state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() };
const snapshot = { ...state };
writes = writes.then(() => save(tmp, snapshot));
return writes;
};
try {
await report({ status: 'running' });
let terminal;
await artifacts.withUpgradeLock(
tmp,
() =>
withPanelLifecycle(
root,
tmp,
env,
async (hook, prepareSelection) => {
const run = (program, args, capture) =>
artifacts.runProcess(
env,
program,
args,
capture,
controller.signal,
);
if (state.action === 'download') {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env,
mirror: 'github',
fallbackMirror: 'gitee',
run,
signal: controller.signal,
onProgress: report,
install: (source) =>
hook('install', [source], controller.signal),
});
terminal = {
status: 'ready',
phase: 'ready',
...(await stagedMetadata(selected)),
};
} else {
const selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
);
assertSelected(state, await stagedMetadata(selected));
const previousBuild = await readRunningBuild(env).catch(
() => undefined,
);
const selection = await prepareSelection?.(selected);
try {
await artifacts.reloadSystem({
root,
tmp,
env,
data:
env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config:
env.dir_config ||
path.join(
env.QL_DATA_DIR || path.join(root, 'data'),
'config',
),
lifecycle: {
signal: controller.signal,
stop: async () => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook('stop');
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
env,
60000,
recover ? undefined : controller.signal,
);
if (!recover)
await verifyRunningVersion(env, state, previousBuild);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
terminal = { status: 'completed', phase: 'completed' };
}
},
controller.signal,
),
env,
);
await report(terminal);
} catch (error) {
console.error(error);
// A failed write must not prevent recording the final failure.
await writes.catch(() => {});
writes = Promise.resolve();
await report({ status: 'failed', error: error.message });
} finally {
process.removeListener('SIGTERM', abort);
process.removeListener('SIGINT', abort);
await writes;
}
}
module.exports = {
getStatus,
startJob,
runJob,
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, [__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;
});
}
@@ -1,471 +0,0 @@
// 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);
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, ['-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 = {
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
+23 -39
View File
@@ -11,21 +11,13 @@ 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,
env?: NodeJS.ProcessEnv,
): Promise<unknown>;
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
waitForHealth(
env: NodeJS.ProcessEnv,
timeout?: number,
signal?: AbortSignal,
): Promise<void>;
withUpgradeLock<T>(
tmp: string,
operation: () => Promise<T>,
env?: NodeJS.ProcessEnv,
): Promise<T>;
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
stageUpgrade(options: {
root: string;
tmp: string;
@@ -38,7 +30,6 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
selectedUpgrade(
tmp: string,
branch: string,
env?: NodeJS.ProcessEnv,
): Promise<{ source: string; static: string }>;
};
@@ -78,20 +69,17 @@ 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),
}),
context.env,
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),
}),
);
}
@@ -101,7 +89,6 @@ async function selectedUpgrade(
return artifacts.selectedUpgrade(
context.paths.dir_tmp!,
releaseBranch(context),
context.env,
);
}
@@ -272,7 +259,7 @@ export async function reloadPanel(
const staticRoot = selected.static;
await validateTree(source, context.env);
await validateTree(staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot);
const entries = await fs.readdir(source);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relativeData = path.relative(context.root, context.data);
@@ -297,18 +284,15 @@ 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;
},
}),
context.env,
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;
},
}),
);
} finally {
if (selection)
+89 -343
View File
@@ -7,41 +7,11 @@ 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, env = process.env) {
async function manifestAt(staticRoot) {
const manifest = JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
);
@@ -54,20 +24,22 @@ async function manifestAt(staticRoot, env = process.env) {
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files)
)
throw new Error(upgradeMessage('构建清单无效。', env));
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
for (const required of ['build/app.js', 'dist/index.html'])
if (!Object.hasOwn(manifest.files, required))
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
return manifest;
}
async function verifyUpgrade(source, staticRoot, env = process.env) {
const manifest = await manifestAt(staticRoot, env);
async function verifyUpgrade(source, staticRoot) {
const manifest = await manifestAt(staticRoot);
if (
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
throw new Error(
'Source and build lockfiles differ / 源码与构建依赖不一致。',
);
await fs.access(path.join(source, 'package.json'));
await fs.access(path.join(source, 'sample/config.sample.sh'));
const files = {};
@@ -78,7 +50,8 @@ async function verifyUpgrade(source, staticRoot, env = process.env) {
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(upgradeMessage('构建包含非法文件: %s', env, name));
else
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
}
}
await visit(staticRoot);
@@ -88,11 +61,13 @@ async function verifyUpgrade(source, staticRoot, env = process.env) {
JSON.stringify(actual) !== JSON.stringify(expected) ||
actual.some((name) => files[name] !== manifest.files[name])
)
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
throw new Error(
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
);
return manifest;
}
async function withUpgradeLock(tmp, operation, env = process.env) {
async function withUpgradeLock(tmp, operation) {
await fs.mkdir(tmp, { recursive: true });
const lock = path.join(tmp, 'upgrade.lock');
const token = randomUUID();
@@ -148,7 +123,9 @@ async function withUpgradeLock(tmp, operation, env = process.env) {
}
}
if (!stale)
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
throw new Error(
'Another upgrade is running / 已有更新或重载正在执行。',
);
// 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.
@@ -173,219 +150,31 @@ async function withUpgradeLock(tmp, operation, env = process.env) {
}
}
async function downloadWithProgress(
run,
archive,
url,
artifact,
onProgress,
quiet = true,
expected,
) {
const headers = `${archive}.headers`;
await fs.rm(archive, { force: true });
await fs.rm(headers, { force: true });
let sampling = Promise.resolve();
const sample = async (finished = false) => {
const bytes = (await fs.stat(archive).catch(() => undefined))?.size || 0;
const response = (await fs.readFile(headers, 'utf8').catch(() => ''))
.split(/(?=HTTP\/\S+ \d{3})/)
.at(-1);
const length = response?.match(/^content-length:\s*(\d+)\s*$/im);
const total =
expected?.size ||
(length && Number(length[1]) > 0 ? Number(length[1]) : null);
await onProgress({
phase: 'downloading',
artifact,
downloadedBytes: bytes,
totalBytes: total,
percent: total
? finished
? 100
: Math.min(99, Math.floor((bytes * 100) / total))
: null,
});
};
const timer = setInterval(() => {
sampling = sampling.then(() => sample());
// Observe failures immediately; propagate after the transfer has closed.
sampling.catch(() => {});
}, 1000);
try {
await run('curl', [
'--fail',
'--location',
...(quiet ? ['--silent', '--show-error'] : []),
'--proto',
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
? '=http,https'
: '=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--speed-limit',
'1024',
'--speed-time',
'30',
'--dump-header',
headers,
'--output',
archive,
url,
]);
clearInterval(timer);
await sampling;
if (expected) {
const bytes = await fs.readFile(archive);
if (bytes.length !== expected.size || hash(bytes) !== expected.sha256)
throw new Error('Upgrade archive size or checksum mismatch');
}
await sample(true);
} finally {
clearInterval(timer);
await sampling;
}
}
async function stageUpgrade({
root,
tmp,
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 () => {});
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env);
try {
// Metadata is optional for older releases and unavailable mirrors. Never
// invent a percentage when neither metadata nor HTTP supplies a total.
let info;
if (onProgress && mirror === 'github') {
try {
const readJson = async (url) => {
const filename = path.join(workspace, 'metadata.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'5',
'--max-time',
'10',
'--max-filesize',
'1048576',
'--dump-header',
`${filename}.headers`,
'--output',
filename,
url,
]);
return JSON.parse(await fs.readFile(filename, 'utf8'));
};
const base = 'https://raw.githubusercontent.com/whyour/qinglong-static';
const current = await readJson(
`${base}/${branch}/build-info.json?t=${Date.now()}`,
);
if (!/^[a-f0-9]{40}$/.test(current.sourceCommit))
throw new Error('Invalid commit');
const candidate = await readJson(
`${base}/upgrade-info/${current.sourceCommit}.json`,
);
if (
candidate.version !== 1 ||
candidate.sourceCommit !== current.sourceCommit ||
!/^[a-f0-9]{40}$/.test(candidate.staticCommit) ||
!['qinglong-static', 'qinglong'].every((repo) => {
const entry = candidate.archives?.[repo];
return (
Number.isSafeInteger(entry?.size) &&
entry.size > 0 &&
/^[a-f0-9]{64}$/.test(entry.sha256)
);
})
)
throw new Error('Invalid archive metadata');
info = candidate;
} catch {
signal?.throwIfAborted();
}
}
async function extract(repository, ref) {
const archive = path.join(workspace, `${repository}.zip`);
const url =
mirror === 'github'
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
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,
});
await run('curl', [
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--output',
archive,
url,
]);
const listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean);
@@ -400,30 +189,21 @@ async function stageUpgrade({
name.split('/')[0] !== expected,
)
)
throw new Error(upgradeMessage('升级归档路径无效。', env));
throw new Error('Invalid archive paths / 升级归档路径无效。');
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(upgradeMessage('升级归档包含符号链接。', env));
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
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 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 staticRoot = await extract('qinglong-static', branch);
const manifest = await manifestAt(staticRoot);
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source);
await report({ phase: 'verifying', percent: null });
await verifyUpgrade(source, staticRoot, env);
await verifyUpgrade(source, staticRoot);
let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
@@ -440,10 +220,9 @@ async function stageUpgrade({
)
dependenciesChanged = true;
if (dependenciesChanged) {
await report({ phase: 'dependencies', percent: null });
await install(source);
await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot, env);
await verifyUpgrade(source, staticRoot);
}
signal?.throwIfAborted();
const selected = { source, static: staticRoot };
@@ -451,7 +230,6 @@ async function stageUpgrade({
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged,
}),
@@ -491,7 +269,7 @@ async function stageUpgrade({
}
}
async function selectedUpgrade(tmp, branch, env = process.env) {
async function selectedUpgrade(tmp, branch) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -499,7 +277,7 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
typeof pointer.directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
)
throw new Error(upgradeMessage('暂存升级指针无效。', env));
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
const workspace = path.join(tmp, pointer.directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
@@ -508,46 +286,22 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
for (const directory of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error(upgradeMessage('暂存目录无效。', env));
throw new Error('Invalid staging directory / 暂存目录无效。');
}
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(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = await verifyUpgrade(selected.source, selected.static, env);
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
const manifest = await verifyUpgrade(selected.source, selected.static);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
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, env = process.env) {
async function replaceAndReload(replacements, lifecycle) {
const changed = [];
let attemptedStart = false;
lifecycle.signal?.throwIfAborted();
@@ -619,7 +373,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) {
await fs
.rm(item.backup, { recursive: true, force: true })
.catch(() =>
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
console.error(`Retained backup / 保留备份: ${item.backup}`),
);
}
@@ -631,9 +385,8 @@ async function reloadSystem({
config,
env,
lifecycle,
additionalReplacements = [],
}) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
const selected = await selectedUpgrade(tmp, releaseBranch(env));
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relative = path.relative(root, data);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
@@ -651,8 +404,7 @@ async function reloadSystem({
target: path.join(config, 'config.sample.sh'),
},
);
replacements.push(...additionalReplacements);
await replaceAndReload(replacements, lifecycle, env);
await replaceAndReload(replacements, lifecycle);
// Cleanup after a committed installation must not report a failed update.
await fs
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
@@ -720,7 +472,7 @@ async function waitForHealth(env, timeout = 60000, signal) {
}
}
}
throw new Error(upgradeMessage('后端启动检查失败。', env));
throw new Error('Backend failed health check / 后端启动检查失败。');
}
function runProcess(env, program, args, capture = false, signal) {
@@ -823,7 +575,9 @@ esac
await fs
.rm(directory, { recursive: true, force: true })
.catch(() =>
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
console.error(
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
),
);
}
}
@@ -841,57 +595,49 @@ 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(
env,
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
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,
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({
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
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,
mirror: process.argv[3],
run,
signal: controller.signal,
install: (source) => hook('install', [source], controller.signal),
});
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
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('更新及启动检查成功...');
}),
);
}
module.exports = {
downloadWithProgress,
upgradeMessage,
releaseBranch,
verifyUpgrade,
withUpgradeLock,
@@ -906,6 +652,6 @@ module.exports = {
};
if (require.main === module)
main().catch((error) => {
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
console.error(`更新失败: ${error.message}`);
process.exitCode = 1;
});
@@ -214,13 +214,8 @@ async function reconcile(
if (added || removed) {
try {
await api.call('system/notify', 'PUT', {
title: translate(context.env, '%s 订阅同步', namespace),
content: translate(
context.env,
'新增 %s 个任务,删除 %s 个任务',
added,
removed,
),
title: `${namespace} 订阅同步`,
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
});
} catch {
operationOutput(
+1 -13
View File
@@ -12,12 +12,6 @@ export interface OpenOperation {
anonymous?: boolean;
}
export const openOperations: readonly OpenOperation[] = [
{
name: 'system notification-logs',
method: 'GET',
path: 'system/notify-log',
},
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
{
name: 'task view-create',
@@ -416,7 +410,6 @@ 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' },
@@ -485,12 +478,7 @@ export const openOperations: readonly OpenOperation[] = [
path: 'system/config/dependence-clean',
body: 'json',
},
{
name: 'dashboard record',
method: 'POST',
path: 'dashboard/record',
body: 'json',
},
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
+8 -15
View File
@@ -1,13 +1,8 @@
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
export const english: Record<string, string> = {
'%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.',
'需要双因素验证,请调用 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 升级载荷。':
@@ -157,12 +152,9 @@ 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状态再考虑重试。':
@@ -177,7 +169,8 @@ export const english: Record<string, string> = {
'API request rejected (code %s). Check %s.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
'CLI 配置无效,请运行 ql login。':
'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
'Cannot read CLI config. Check file permissions and run ql login.',
+2 -2
View File
@@ -276,7 +276,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
'utf8',
),
),
{ ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
{ ...staged, 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(),
[
@@ -409,7 +409,7 @@ test('cron metadata precedence agrees with unchanged legacy add_cron', (t) => {
'--noprofile',
'--norc',
'-c',
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s:%s:%s:%s' "$1" "$2" "$3" "$4"; }\nadd_cron "$dir_scripts/list" repo`,
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s' "$1"; }\nadd_cron "$dir_scripts/list" repo`,
],
{
encoding: 'utf8',
+1 -1
View File
@@ -23,7 +23,7 @@ test(
assert.notEqual(before.dev, (await fs.stat(root)).dev);
const context = createContext(
{ root, 'data-dir': data },
{ PATH: '/nonexistent', QL_LANG: 'en' },
{ PATH: '/nonexistent' },
);
t.after(async () => {
await stopPanel(context);
+1 -1
View File
@@ -23,7 +23,7 @@ test(
[
'--test',
'--test-reporter=tap',
path.resolve(__dirname, '../internal/preload.test.cjs'),
path.resolve(__dirname, '../preload.test.cjs'),
],
{
env,
+1 -9
View File
@@ -43,7 +43,6 @@ test(
context.paths.dir_tmp,
);
installations.push(directory);
await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true });
},
);
const controller = new AbortController();
@@ -71,16 +70,9 @@ 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,
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
},
staged,
);
assert.equal(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
+1 -4
View File
@@ -11,7 +11,6 @@ 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
@@ -71,7 +70,6 @@ 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 && \
@@ -116,8 +114,7 @@ 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 \
&& test "$(pnpm --version)" = "8.3.1"
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
-23
View File
@@ -1,23 +0,0 @@
# 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 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)' && \
apk del .ql-bundle-build; \
fi
RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then export QEMU_CPU=arm1176; fi; \
node /ql/scripts/build-upgrade-bundle.cjs /ql /out
FROM scratch
COPY --from=bundle /out/ /
+2 -2
View File
@@ -119,12 +119,12 @@ reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >"$dir_log/bot.log" 2>&1 &
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >"$dir_log/extra.log" 2>&1 &
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
-13
View File
@@ -10,10 +10,6 @@ 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、导入和执行使用同一套表达式校验。
@@ -35,7 +31,6 @@ 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 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
@@ -52,11 +47,3 @@ 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 跳过,后端编译通过。本次为确定性语法与日历测试,不涉及生产环境压力或长期稳定性验证。
-11
View File
@@ -1,11 +0,0 @@
# 通知日志
通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。
日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。
所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。
脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。
自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。
@@ -1,47 +0,0 @@
# 平台完整升级包
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 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
@@ -1,7 +0,0 @@
# Python 依赖来源
依赖管理中可手动添加 Python 包名、`git+https://` 等 pip 支持的源 URL,或脚本/订阅仓库目录内的 requirements 文件、pyproject.toml 路径。文件路径支持目录空格,必须位于持久化 scripts 或 repo 根目录内;越界与指向根目录外的符号链接会被拒绝。
requirements 使用 `pip install -r`,pyproject 使用完整项目目录安装,保留相对依赖文件和项目源码。拉取订阅不会自动安装仓库声明的依赖。
删除 requirements 条目会调用 `pip uninstall -r`。源 URL 或 pyproject 条目无法可靠逆向推断其安装的所有分发包,因此删除该条目只移除面板记录;需另外按包名卸载已安装的包,日志会明确提示。
+7 -12
View File
@@ -28,8 +28,7 @@
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true",
"start:simple": "bash shell/start-simplify.sh"
"postinstall": "max setup 2>/dev/null || true"
},
"gitHooks": {
"pre-commit": "lint-staged"
@@ -77,9 +76,7 @@
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
"websocket-driver": "0.7.5",
"brace-expansion@1": "1.1.21",
"brace-expansion@2": "2.1.7"
"websocket-driver": "0.7.5"
}
},
"dependencies": {
@@ -111,9 +108,9 @@
"jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.17.21",
"multer": "2.4.0",
"multer": "2.3.0",
"node-cron": "4.6.0",
"nodemailer": "^10.0.9",
"nodemailer": "^8.0.1",
"p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
@@ -127,8 +124,7 @@
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"tar": "7.5.20"
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@ant-design/icons": "^5.0.1",
@@ -175,7 +171,7 @@
"concurrently": "^7.0.0",
"file-saver": "2.0.2",
"lint-staged": "^13.0.3",
"moment": "2.31.0",
"moment": "2.30.1",
"monaco-editor": "0.33.0",
"nodemon": "^3.0.1",
"prettier": "^2.5.1",
@@ -202,6 +198,5 @@
"typescript": "5.2.2",
"vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0"
},
"qinglongUpgradeFormat": 2
}
}
+48 -371
View File
@@ -6,8 +6,6 @@ overrides:
'@codemirror/view': 6.39.16
proxy-addr: 2.0.8
websocket-driver: 0.7.5
brace-expansion@1: 1.1.21
brace-expansion@2: 2.1.7
dependencies:
'@ant-design/plots':
@@ -30,7 +28,7 @@ dependencies:
version: 12.0.1
body-parser:
specifier: ^1.20.3
version: 1.20.6
version: 1.20.3
celebrate:
specifier: ^15.0.3
version: 15.0.3
@@ -75,7 +73,7 @@ dependencies:
version: 1.2.0
http-proxy-middleware:
specifier: ^3.0.3
version: 3.0.7
version: 3.0.3
iconv-lite:
specifier: ^0.6.3
version: 0.6.3
@@ -84,7 +82,7 @@ dependencies:
version: 2.3.0(@types/node@17.0.45)
js-yaml:
specifier: ^4.1.0
version: 4.3.2
version: 4.1.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@@ -95,14 +93,14 @@ dependencies:
specifier: ^4.17.21
version: 4.17.21
multer:
specifier: 2.4.0
version: 2.4.0
specifier: 2.3.0
version: 2.3.0
node-cron:
specifier: 4.6.0
version: 4.6.0
nodemailer:
specifier: ^10.0.9
version: 10.0.9
specifier: ^8.0.1
version: 8.0.1
p-queue-cjs:
specifier: 7.3.4
version: 7.3.4
@@ -127,9 +125,6 @@ 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
@@ -138,7 +133,7 @@ dependencies:
version: 0.10.0
undici:
specifier: ^7.9.0
version: 7.29.1
version: 7.9.0
uuid:
specifier: ^11.0.3
version: 11.0.3
@@ -269,7 +264,7 @@ devDependencies:
version: 4.23.0(antd@4.24.16)(react-dom@18.3.1)(react@18.3.1)
axios:
specifier: ^1.4.0
version: 1.20.0
version: 1.7.8
compression-webpack-plugin:
specifier: 9.2.0
version: 9.2.0
@@ -283,8 +278,8 @@ devDependencies:
specifier: ^13.0.3
version: 13.3.0
moment:
specifier: 2.31.0
version: 2.31.0
specifier: 2.30.1
version: 2.30.1
monaco-editor:
specifier: 0.33.0
version: 0.33.0
@@ -881,7 +876,7 @@ packages:
'@babel/runtime': 7.26.0
antd: 4.24.16(react-dom@18.3.1)(react@18.3.1)
classnames: 2.5.1
moment: 2.31.0
moment: 2.30.1
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -2884,7 +2879,7 @@ packages:
globals: 13.24.0
ignore: 5.3.2
import-fresh: 3.3.0
js-yaml: 4.3.2
js-yaml: 4.1.0
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -3056,6 +3051,7 @@ packages:
dependencies:
minipass: 7.1.3
dev: false
optional: true
/@istanbuljs/load-nyc-config@1.1.0:
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
@@ -3064,7 +3060,7 @@ packages:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
js-yaml: 3.15.2
js-yaml: 3.14.1
resolve-from: 5.0.0
dev: true
@@ -5373,7 +5369,7 @@ packages:
fast-deep-equal: 3.1.3
intl: 1.2.5
lodash: 4.17.21
moment: 2.31.0
moment: 2.30.1
qiankun: 2.10.16
react-intl: 3.12.1(react@18.3.1)
react-redux: 8.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1)(react@18.3.1)(redux@4.2.1)
@@ -5681,6 +5677,7 @@ packages:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
dev: false
/agentkeepalive@4.5.0:
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
@@ -5870,7 +5867,7 @@ packages:
classnames: 2.5.1
copy-to-clipboard: 3.3.3
lodash: 4.17.21
moment: 2.31.0
moment: 2.30.1
rc-cascader: 3.7.3(react-dom@18.3.1)(react@18.3.1)
rc-checkbox: 3.0.1(react-dom@18.3.1)(react@18.3.1)
rc-collapse: 3.4.2(react-dom@18.3.1)(react@18.3.1)
@@ -6361,6 +6358,18 @@ packages:
big-integer: 1.6.52
dev: true
/brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
/brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
dependencies:
balanced-match: 1.0.2
dev: true
/braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -6625,6 +6634,7 @@ packages:
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
@@ -8292,7 +8302,7 @@ packages:
is-glob: 4.0.3
is-path-inside: 3.0.3
js-sdsl: 4.4.2
js-yaml: 4.3.2
js-yaml: 4.1.0
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
@@ -8503,7 +8513,7 @@ packages:
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
body-parser: 1.20.6
body-parser: 1.20.3
content-disposition: 0.5.4
content-type: 1.0.5
cookie: 0.7.1
@@ -8653,7 +8663,7 @@ packages:
/file-stream-rotator@0.6.1:
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
dependencies:
moment: 2.31.0
moment: 2.30.1
dev: false
/fill-range@7.1.1:
@@ -9324,6 +9334,7 @@ packages:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
dev: false
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
@@ -10576,7 +10587,7 @@ packages:
engines: {node: '>=8.6'}
dependencies:
braces: 3.0.3
picomatch: 2.3.2
picomatch: 2.3.1
/miller-rabin@4.0.1:
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
@@ -10643,19 +10654,19 @@ packages:
/minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
brace-expansion: 1.1.21
brace-expansion: 1.1.12
dev: true
/minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
dependencies:
brace-expansion: 1.1.21
brace-expansion: 1.1.12
/minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
dependencies:
brace-expansion: 2.1.7
brace-expansion: 2.0.2
dev: true
/minimist-options@4.1.0:
@@ -10702,6 +10713,7 @@ packages:
dependencies:
minipass: 7.1.3
dev: false
optional: true
/mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
@@ -10723,12 +10735,11 @@ packages:
/moment-timezone@0.5.46:
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
dependencies:
moment: 2.31.0
moment: 2.30.1
dev: false
/moment@2.31.0:
resolution:
integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
/moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
/monaco-editor@0.33.0:
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
@@ -10859,7 +10870,7 @@ packages:
semver: 7.7.4
tar: 7.5.20
tinyglobby: 0.2.17
undici: 6.29.0
undici: 6.27.0
which: 6.0.1
dev: false
optional: true
@@ -12715,7 +12726,7 @@ packages:
classnames: 2.5.1
date-fns: 2.30.0
dayjs: 1.11.13
moment: 2.31.0
moment: 2.30.1
rc-trigger: 5.3.4(react-dom@18.3.1)(react@18.3.1)
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
@@ -13942,7 +13953,7 @@ packages:
dottie: 2.0.6
inflection: 1.13.4
lodash: 4.17.21
moment: 2.31.0
moment: 2.30.1
moment-timezone: 0.5.46
pg-connection-string: 2.7.0
retry-as-promised: 7.0.4
@@ -14706,6 +14717,7 @@ packages:
minizlib: 3.1.0
yallist: 5.0.0
dev: false
optional: true
/terser@5.36.0:
resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==}
@@ -15726,6 +15738,7 @@ packages:
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
@@ -15781,342 +15794,6 @@ packages:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
dev: true
/brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
dependencies:
balanced-match: 1.0.2
dev: true
/brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
/axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
dependencies:
follow-redirects: 1.16.1(debug@4.4.3)
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
dev: true
/proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
dev: true
/form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
dev: true
/hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
dependencies:
function-bind: 1.1.2
/es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
dev: true
/get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
/math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
/gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
/get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
/es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
/dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
/call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
/es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
/follow-redirects@1.16.1(debug@4.4.3):
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dependencies:
debug: 4.4.3
/undici@7.29.1:
resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
engines: {node: '>=20.18.1'}
dev: false
/undici@6.29.0:
resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==}
engines: {node: '>=18.17'}
dev: false
optional: true
/nodemailer@10.0.9:
resolution: {integrity: sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==}
engines: {node: '>=20.0.0'}
dev: false
/multer@2.4.0:
resolution: {integrity: sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==}
engines: {node: '>= 10.16.0'}
dependencies:
append-field: 1.0.0
busboy: 1.6.0
type-is: 1.6.18
dev: false
/js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
dependencies:
argparse: 2.0.1
/js-yaml@3.15.2:
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
hasBin: true
dependencies:
argparse: 1.0.10
esprima: 4.0.1
dev: true
/http-proxy-middleware@3.0.7:
resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==}
engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@types/http-proxy': 1.17.17
debug: 4.4.3
http-proxy: 1.18.1(debug@4.4.3)
is-glob: 4.0.3
is-plain-object: 5.1.0
micromatch: 4.0.8
transitivePeerDependencies:
- supports-color
dev: false
/picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
/is-plain-object@5.1.0:
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
engines: {node: '>=0.10.0'}
/http-proxy@1.18.1(debug@4.4.3):
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.16.1(debug@4.4.3)
requires-port: 1.0.0
transitivePeerDependencies:
- debug
dev: false
/@types/http-proxy@1.17.17:
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
dependencies:
'@types/node': 17.0.45
dev: false
/body-parser@1.20.6:
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.15.3
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: false
/raw-body@2.5.3:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.4.24
unpipe: 1.0.0
/http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
/statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
/qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
dev: false
/side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
/side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
/side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
/object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
/call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
/side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+136 -309
View File
File diff suppressed because it is too large Load Diff
+27 -162
View File
@@ -10,8 +10,6 @@ import threading
import time
import urllib.parse
import smtplib
import subprocess
from typing import Optional
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr
@@ -163,11 +161,10 @@ for k in push_config:
push_config[k] = v
def bark(title: str, content: str) -> Optional[bool]:
def bark(title: str, content: str) -> None:
"""
使用 bark 推送消息。
"""
audit_results = []
if not push_config.get("BARK_PUSH"):
return
print("bark 服务启动")
@@ -203,28 +200,22 @@ def bark(title: str, content: str) -> Optional[bool]:
).json()
if response["code"] == 200:
audit_results.append(True)
print("bark 推送成功!")
else:
audit_results.append(False)
print("bark 推送失败!")
return all(audit_results) if audit_results else None
def console(title: str, content: str) -> Optional[bool]:
def console(title: str, content: str) -> None:
"""
使用 控制台 推送消息。
"""
audit_results = []
print(f"{title}\n\n{content}")
return True
def dingding_bot(title: str, content: str) -> Optional[bool]:
def dingding_bot(title: str, content: str) -> None:
"""
使用 钉钉机器人 推送消息。
"""
audit_results = []
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
return
print("钉钉机器人 服务启动")
@@ -245,19 +236,15 @@ def dingding_bot(title: str, content: str) -> Optional[bool]:
).json()
if not response["errcode"]:
audit_results.append(True)
print("钉钉机器人 推送成功!")
else:
audit_results.append(False)
print("钉钉机器人 推送失败!")
return all(audit_results) if audit_results else None
def feishu_bot(title: str, content: str) -> Optional[bool]:
def feishu_bot(title: str, content: str) -> None:
"""
使用 飞书机器人 推送消息。
"""
audit_results = []
if not push_config.get("FSKEY"):
return
print("飞书 服务启动")
@@ -281,19 +268,15 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0:
audit_results.append(True)
print("飞书 推送成功!")
else:
audit_results.append(False)
print("飞书 推送失败!错误信息如下:\n", response)
return all(audit_results) if audit_results else None
def go_cqhttp(title: str, content: str) -> Optional[bool]:
def go_cqhttp(title: str, content: str) -> None:
"""
使用 go_cqhttp 推送消息。
"""
audit_results = []
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
return
print("go-cqhttp 服务启动")
@@ -302,19 +285,15 @@ def go_cqhttp(title: str, content: str) -> Optional[bool]:
response = requests.get(url).json()
if response["status"] == "ok":
audit_results.append(True)
print("go-cqhttp 推送成功!")
else:
audit_results.append(False)
print("go-cqhttp 推送失败!")
return all(audit_results) if audit_results else None
def gotify(title: str, content: str) -> Optional[bool]:
def gotify(title: str, content: str) -> None:
"""
使用 gotify 推送消息。
"""
audit_results = []
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
return
print("gotify 服务启动")
@@ -328,19 +307,15 @@ def gotify(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=data).json()
if response.get("id"):
audit_results.append(True)
print("gotify 推送成功!")
else:
audit_results.append(False)
print("gotify 推送失败!")
return all(audit_results) if audit_results else None
def iGot(title: str, content: str) -> Optional[bool]:
def iGot(title: str, content: str) -> None:
"""
使用 iGot 推送消息。
"""
audit_results = []
if not push_config.get("IGOT_PUSH_KEY"):
return
print("iGot 服务启动")
@@ -351,19 +326,15 @@ def iGot(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=data, headers=headers).json()
if response["ret"] == 0:
audit_results.append(True)
print("iGot 推送成功!")
else:
audit_results.append(False)
print(f'iGot 推送失败!{response["errMsg"]}')
return all(audit_results) if audit_results else None
def serverJ(title: str, content: str) -> Optional[bool]:
def serverJ(title: str, content: str) -> None:
"""
通过 serverJ 推送消息。
"""
audit_results = []
if not push_config.get("PUSH_KEY"):
return
print("serverJ 服务启动")
@@ -380,19 +351,15 @@ def serverJ(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=data).json()
if response.get("errno") == 0 or response.get("code") == 0:
audit_results.append(True)
print("serverJ 推送成功!")
else:
audit_results.append(False)
print(f'serverJ 推送失败!错误码:{response["message"]}')
return all(audit_results) if audit_results else None
def pushdeer(title: str, content: str) -> Optional[bool]:
def pushdeer(title: str, content: str) -> None:
"""
通过PushDeer 推送消息
"""
audit_results = []
if not push_config.get("DEER_KEY"):
return
print("PushDeer 服务启动")
@@ -409,19 +376,15 @@ def pushdeer(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=data).json()
if len(response.get("content").get("result")) > 0:
audit_results.append(True)
print("PushDeer 推送成功!")
else:
audit_results.append(False)
print("PushDeer 推送失败!错误信息:", response)
return all(audit_results) if audit_results else None
def chat(title: str, content: str) -> Optional[bool]:
def chat(title: str, content: str) -> None:
"""
通过Chat 推送消息
"""
audit_results = []
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
return
print("chat 服务启动")
@@ -430,19 +393,15 @@ def chat(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=data)
if response.status_code == 200:
audit_results.append(True)
print("Chat 推送成功!")
else:
audit_results.append(False)
print("Chat 推送失败!错误信息:", response)
return all(audit_results) if audit_results else None
def pushplus_bot(title: str, content: str) -> Optional[bool]:
def pushplus_bot(title: str, content: str) -> None:
"""
通过 pushplus 推送消息。
"""
audit_results = []
if not push_config.get("PUSH_PLUS_TOKEN"):
return
print("PUSHPLUS 服务启动")
@@ -465,9 +424,7 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
code = response["code"]
if code == 200:
audit_results.append(True)
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
audit_results.append(True)
print(
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
)
@@ -480,20 +437,16 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url_old, data=body, headers=headers).json()
if response["code"] == 200:
audit_results.append(True)
print("PUSHPLUS(hxtrip) 推送成功!")
else:
audit_results.append(False)
print("PUSHPLUS 推送失败!")
return all(audit_results) if audit_results else None
def weplus_bot(title: str, content: str) -> Optional[bool]:
def weplus_bot(title: str, content: str) -> None:
"""
通过 微加机器人 推送消息。
"""
audit_results = []
if not push_config.get("WE_PLUS_BOT_TOKEN"):
return
print("微加机器人 服务启动")
@@ -516,19 +469,15 @@ def weplus_bot(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, data=body, headers=headers).json()
if response["code"] == 200:
audit_results.append(True)
print("微加机器人 推送成功!")
else:
audit_results.append(False)
print("微加机器人 推送失败!")
return all(audit_results) if audit_results else None
def qmsg_bot(title: str, content: str) -> Optional[bool]:
def qmsg_bot(title: str, content: str) -> None:
"""
使用 qmsg 推送消息。
"""
audit_results = []
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
return
print("qmsg 服务启动")
@@ -538,19 +487,15 @@ def qmsg_bot(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, params=payload).json()
if response["code"] == 0:
audit_results.append(True)
print("qmsg 推送成功!")
else:
audit_results.append(False)
print(f'qmsg 推送失败!{response["reason"]}')
return all(audit_results) if audit_results else None
def wecom_app(title: str, content: str) -> Optional[bool]:
def wecom_app(title: str, content: str) -> None:
"""
通过 企业微信 APP 推送消息。
"""
audit_results = []
if not push_config.get("QYWX_AM"):
return
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
@@ -576,12 +521,9 @@ def wecom_app(title: str, content: str) -> Optional[bool]:
response = wx.send_mpnews(title, content, media_id, touser)
if response == "ok":
audit_results.append(True)
print("企业微信推送成功!")
else:
audit_results.append(False)
print("企业微信推送失败!错误信息如下:\n", response)
return all(audit_results) if audit_results else None
class WeCom:
@@ -646,11 +588,10 @@ class WeCom:
return respone["errmsg"]
def wecom_bot(title: str, content: str) -> Optional[bool]:
def wecom_bot(title: str, content: str) -> None:
"""
通过 企业微信机器人 推送消息。
"""
audit_results = []
if not push_config.get("QYWX_KEY"):
return
print("企业微信机器人服务启动")
@@ -667,19 +608,15 @@ def wecom_bot(title: str, content: str) -> Optional[bool]:
).json()
if response["errcode"] == 0:
audit_results.append(True)
print("企业微信机器人推送成功!")
else:
audit_results.append(False)
print("企业微信机器人推送失败!")
return all(audit_results) if audit_results else None
def telegram_bot(title: str, content: str) -> Optional[bool]:
def telegram_bot(title: str, content: str) -> None:
"""
使用 telegram 机器人 推送消息。
"""
audit_results = []
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
return
print("tg 服务启动")
@@ -715,19 +652,15 @@ def telegram_bot(title: str, content: str) -> Optional[bool]:
).json()
if response["ok"]:
audit_results.append(True)
print("tg 推送成功!")
else:
audit_results.append(False)
print("tg 推送失败!")
return all(audit_results) if audit_results else None
def aibotk(title: str, content: str) -> Optional[bool]:
def aibotk(title: str, content: str) -> None:
"""
使用 智能微秘书 推送消息。
"""
audit_results = []
if (
not push_config.get("AIBOTK_KEY")
or not push_config.get("AIBOTK_TYPE")
@@ -755,19 +688,15 @@ def aibotk(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, data=body, headers=headers).json()
print(response)
if response["code"] == 0:
audit_results.append(True)
print("智能微秘书 推送成功!")
else:
audit_results.append(False)
print(f'智能微秘书 推送失败!{response["error"]}')
return all(audit_results) if audit_results else None
def smtp(title: str, content: str) -> Optional[bool]:
def smtp(title: str, content: str) -> None:
"""
使用 SMTP 邮件 推送消息。
"""
audit_results = []
if (
not push_config.get("SMTP_SERVER")
or not push_config.get("SMTP_SSL")
@@ -807,19 +736,15 @@ def smtp(title: str, content: str) -> Optional[bool]:
message.as_bytes(),
)
smtp_server.close()
audit_results.append(True)
print("SMTP 邮件 推送成功!")
except Exception as e:
audit_results.append(False)
print(f"SMTP 邮件 推送失败!{e}")
return all(audit_results) if audit_results else None
def pushme(title: str, content: str) -> Optional[bool]:
def pushme(title: str, content: str) -> None:
"""
使用 PushMe 推送消息。
"""
audit_results = []
if not push_config.get("PUSHME_KEY"):
return
print("PushMe 服务启动")
@@ -839,19 +764,15 @@ def pushme(title: str, content: str) -> Optional[bool]:
response = requests.post(url, data=data)
if response.status_code == 200 and response.text == "success":
audit_results.append(True)
print("PushMe 推送成功!")
else:
audit_results.append(False)
print(f"PushMe 推送失败!{response.status_code} {response.text}")
return all(audit_results) if audit_results else None
def chronocat(title: str, content: str) -> Optional[bool]:
def chronocat(title: str, content: str) -> None:
"""
使用 CHRONOCAT 推送消息。
"""
audit_results = []
if (
not push_config.get("CHRONOCAT_URL")
or not push_config.get("CHRONOCAT_QQ")
@@ -886,27 +807,21 @@ def chronocat(title: str, content: str) -> Optional[bool]:
response = requests.post(url, headers=headers, data=json.dumps(data))
if response.status_code == 200:
if chat_type == 1:
audit_results.append(True)
print(f"QQ个人消息:{ids}推送成功!")
else:
audit_results.append(True)
print(f"QQ群消息:{ids}推送成功!")
else:
if chat_type == 1:
audit_results.append(False)
print(f"QQ个人消息:{ids}推送失败!")
else:
audit_results.append(False)
print(f"QQ群消息:{ids}推送失败!")
return all(audit_results) if audit_results else None
def ntfy(title: str, content: str) -> Optional[bool]:
def ntfy(title: str, content: str) -> None:
"""
通过 Ntfy 推送消息
"""
audit_results = []
def encode_rfc2047(text: str) -> str:
"""将文本编码为符合 RFC 2047 标准的格式"""
encoded_bytes = base64.b64encode(text.encode("utf-8"))
@@ -938,15 +853,12 @@ def ntfy(title: str, content: str) -> Optional[bool]:
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers)
if response.status_code == 200: # 使用 response.status_code 进行检查
audit_results.append(True)
print("Ntfy 推送成功!")
else:
audit_results.append(False)
print("Ntfy 推送失败!错误信息:", response.text)
return all(audit_results) if audit_results else None
def wxpusher_bot(title: str, content: str) -> Optional[bool]:
def wxpusher_bot(title: str, content: str) -> None:
"""
通过 wxpusher 推送消息。
支持的环境变量:
@@ -954,7 +866,6 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
"""
audit_results = []
if not push_config.get("WXPUSHER_APP_TOKEN"):
return
@@ -998,21 +909,17 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000:
audit_results.append(True)
print("wxpusher 推送成功!")
else:
audit_results.append(False)
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
return all(audit_results) if audit_results else None
def wxpusher_spt(title: str, content: str) -> Optional[bool]:
def wxpusher_spt(title: str, content: str) -> None:
"""
通过 wxpusher 极简推送(SPT)推送消息。
支持的环境变量:
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
"""
audit_results = []
if not push_config.get("WXPUSHER_SPT_LIST"):
return
@@ -1049,21 +956,17 @@ def wxpusher_spt(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000:
audit_results.append(True)
print("wxpusher SPT 推送成功!")
else:
audit_results.append(False)
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
return all(audit_results) if audit_results else None
def wpush(title: str, content: str) -> Optional[bool]:
def wpush(title: str, content: str) -> None:
"""
通过 WPUSH 推送消息。
官方文档: https://wpush.cn/docs
"""
audit_results = []
if not push_config.get("WPUSH_APIKEY"):
return
@@ -1083,15 +986,12 @@ def wpush(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
if response.get("code") == 0:
audit_results.append(True)
print("WPUSH 推送成功!")
else:
audit_results.append(False)
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}')
return all(audit_results) if audit_results else None
def openilink(title: str, content: str) -> Optional[bool]:
def openilink(title: str, content: str) -> None:
"""
通过 OpeniLink 推送消息。
支持的环境变量:
@@ -1099,7 +999,6 @@ def openilink(title: str, content: str) -> Optional[bool]:
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
"""
audit_results = []
if not push_config.get("OPENILINK_APP_TOKEN"):
return
@@ -1124,12 +1023,9 @@ def openilink(title: str, content: str) -> Optional[bool]:
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("ok"):
audit_results.append(True)
print("OpeniLink 推送成功!")
else:
audit_results.append(False)
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
return all(audit_results) if audit_results else None
def parse_headers(headers):
@@ -1183,11 +1079,10 @@ def parse_body(body, content_type, value_format_fn=None):
return parsed
def custom_notify(title: str, content: str) -> Optional[bool]:
def custom_notify(title: str, content: str) -> None:
"""
通过 自定义通知 推送消息。
"""
audit_results = []
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
return
@@ -1219,12 +1114,9 @@ def custom_notify(title: str, content: str) -> Optional[bool]:
)
if response.status_code == 200:
audit_results.append(True)
print("自定义通知推送成功!")
else:
audit_results.append(False)
print(f"自定义通知推送失败!{response.status_code} {response.text}")
return all(audit_results) if audit_results else None
def one() -> str:
@@ -1312,33 +1204,6 @@ def add_notify_function():
return notify_function
def report_notification(channel, title, content, success):
ql_dir = os.getenv("QL_DIR")
if not ql_dir:
return
payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192],
"success": success, "notifyType": channel,
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")}
code = """const fs=require('node:fs'),path=require('node:path');
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
try:
subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
capture_output=True, timeout=3)
except Exception:
pass # Audit availability never changes notification delivery.
def audited_notification(mode, title, content):
result = None
try:
result = mode(title, content)
except Exception:
result = False
print(f"{mode.__name__} 推送失败")
report_notification(mode.__name__, title, content, result is True)
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
if kwargs:
global push_config
@@ -1363,7 +1228,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
notify_function = add_notify_function()
ts = [
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__)
threading.Thread(target=mode, args=(title, content), name=mode.__name__)
for mode in notify_function
]
[t.start() for t in ts]
-22
View File
@@ -1,22 +0,0 @@
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');
-112
View File
@@ -1,112 +0,0 @@
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 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,
});
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),
);
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'],
);
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 };
if (require.main === module)
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
(e) => {
console.error(e);
process.exitCode = 1;
},
);
-152
View File
@@ -1,152 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
sha256,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
const platforms = [
'linux-x64-musl',
'linux-armv6-musl',
'linux-armv7-musl',
'linux-arm64-musl',
'linux-ppc64-musl',
'linux-s390x-musl',
'linux-ia32-musl',
'linux-x64-glibc',
'linux-armv7-glibc',
'linux-arm64-glibc',
'linux-ppc64-glibc',
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository) {
if (!/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository))
throw new Error('Invalid release source');
const packages = {};
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const record = JSON.parse(
fs.readFileSync(path.join(directory, filename + '.json')),
);
const bytes = fs.readFileSync(path.join(directory, filename));
if (
record.format !== 2 ||
record.sourceCommit !== commit ||
record.target !== target ||
record.filename !== filename ||
record.size !== bytes.length ||
record.sha256 !== sha256(bytes)
)
throw new Error(`Unverified platform bundle: ${target}`);
packages[target] = {
size: record.size,
sha256: record.sha256,
url: `https://github.com/${repository}/releases/download/upgrade-${commit}/${filename}`,
};
}
return { format: 2, sourceCommit: commit, packages };
}
module.exports = { releaseIndex, platforms };
if (require.main === module) {
const [directory, commit, repository, branch] = process.argv.slice(2);
const index = releaseIndex(directory, commit, repository);
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
index.installer = {
size: installer.length,
sha256: sha256(installer),
url: `https://github.com/${repository}/releases/download/upgrade-${commit}/install-bundle.cjs`,
};
if (!['develop', 'master'].includes(branch))
throw new Error('Invalid release channel');
fs.writeFileSync(
path.join(directory, 'latest.json'),
JSON.stringify(index) + '\n',
);
const gh = (...args) =>
execFileSync('gh', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const tag = `upgrade-${commit}`;
try {
gh('release', 'view', tag, '--repo', repository);
} catch {
gh(
'release',
'create',
tag,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--title',
`Platform upgrade ${commit.slice(0, 12)}`,
'--notes',
'Verified platform bundles with production dependencies. Existing Node runtime is retained.',
);
}
const assets = fs
.readdirSync(directory)
.filter((n) => /\.(?:tar\.gz|cjs|json)$/.test(n) && n !== 'latest.json')
.map((n) => path.join(directory, n));
const published = JSON.parse(
gh('api', `repos/${repository}/releases/tags/${tag}`),
);
const missing = [];
for (const file of [...assets, path.join(directory, 'latest.json')]) {
const bytes = fs.readFileSync(file);
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (
previous.size !== bytes.length ||
previous.digest !== `sha256:${sha256(bytes)}`
)
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
// An older, slower run may publish immutable assets, but must not regress a channel.
if (
gh(
'api',
`repos/${repository}/git/ref/heads/${branch}`,
'--jq',
'.object.sha',
).trim() !== commit
) {
console.log('Newer source exists; leaving channel unchanged');
} else {
const channel = `upgrade-${branch}`;
try {
gh('release', 'view', channel, '--repo', repository);
} catch {
gh(
'release',
'create',
channel,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--title',
`Upgrade channel ${branch}`,
'--notes',
'The latest.json asset selects immutable, fully verified platform bundles.',
);
}
gh(
'release',
'upload',
channel,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
}
}
-136
View File
@@ -1,136 +0,0 @@
// 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;
}
}
+7 -18
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
create_token() {
local token_command="ts-node-transpile-only ${dir_root}/back/token.ts"
local token_file="${dir_root}/static/build/token.js"
if [[ -f $token_file ]]; then
__ql_token__=$(node "$token_file")
else
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
token_command="node ${token_file}"
fi
__ql_token__=$(eval "$token_command")
}
get_token() {
@@ -85,13 +85,9 @@ add_cron_api() {
sub_id="null"
fi
local payload
payload=$(jq -cn --arg name "$name" --arg command "$command" \
--arg schedule "$schedule" --argjson sub_id "$sub_id" \
'{name: $name, command: $command, schedule: $schedule, sub_id: $sub_id}') || return $?
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
--data-raw "$payload" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
-X POST)
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
@@ -116,14 +112,10 @@ update_cron_api() {
local id="$4"
fi
local payload
payload=$(jq -cn --arg name "$name" --arg command "$command" \
--arg schedule "$schedule" --arg id "$id" \
'{name: $name, command: $command, schedule: $schedule, id: $id}') || return $?
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X PUT \
--data-raw "$payload")
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}")
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then
@@ -143,13 +135,10 @@ update_cron_command_api() {
local id="$2"
fi
local payload
payload=$(jq -cn --arg command "$command" --arg id "$id" \
'{command: $command, id: $id}') || return $?
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X PUT \
--data-raw "$payload")
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}")
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then
@@ -236,7 +225,7 @@ find_cron_api() {
update_auth_config() {
local body="$1"
local tip=$(t "$2")
local tip="$2"
local currentTimeStamp=$(date +%s)
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
-193
View File
@@ -1,193 +0,0 @@
#!/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 { 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');
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');
try {
return await artifacts.withUpgradeLock(
tmp,
async () => {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env: environment,
run: (program, args, capture) =>
artifacts.runProcess(environment, program, args, capture),
onProgress: async (p) => console.log(p.phase),
install: async () => {
throw new Error('Offline bundles never install npm dependencies');
},
});
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.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);
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;
}
return 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: {
stop: () => hook('stop'),
start: async (recover) => {
await hook('start');
await artifacts.waitForHealth(environment);
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,
});
}
},
);
},
environment,
);
} finally {
if (freshWorkspace)
await fs.rm(freshWorkspace, { recursive: true, force: true });
}
}
module.exports = { installBundle };
if (require.main === module)
installBundle().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
-38
View File
@@ -108,42 +108,4 @@ 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'
)
+36 -38
View File
@@ -39,44 +39,44 @@ random_delay() {
gen_array_scripts() {
local dir_current=$(pwd)
local i="-1"
cd "$dir_scripts" || return $?
for file in *; do
cd $dir_scripts
for file in $(ls); do
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
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]="$(t '<未识别出活动名称>')"
array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g")
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]="<未识别出活动名称>"
fi
done
cd "$dir_current" || return $?
cd $dir_current
}
## 使用说明
usage() {
gen_array_scripts
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"
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 <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行"
if [[ ${#array_scripts[*]} -gt 0 ]]; then
t '\n当前有以下脚本可以运行:'
echo -e "\n当前有以下脚本可以运行:"
for ((i = 0; i < ${#array_scripts[*]}; i++)); do
echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}"
done
else
t '\n暂无脚本可以执行'
echo -e "\n暂无脚本可以执行"
fi
}
## run nohup,$1:文件名,不含路径,带后缀
run_nohup() {
local file_name=$1
nohup node "$file_name" &>"$log_path" &
nohup node $file_name &>$log_path &
}
env_str_to_array() {
. "$file_env"
. $file_env
local IFS="&"
read -ra array <<<"${!env_param}"
array_length=${#array[@]}
@@ -122,7 +122,7 @@ enter_script_workdir() {
_target_dir="${dir_scripts}/${work_dir}"
fi
if [[ -d "${_target_dir}" ]]; then
cd "${_target_dir}" || return $?
cd "${_target_dir}"
if [[ ${file_param} =~ "/" ]]; then
local script_name="${file_param##*/}"
if [[ "${use_dot_prefix}" == "true" ]]; then
@@ -136,13 +136,13 @@ enter_script_workdir() {
t '警告:工作目录不存在 %s' "${_target_dir}"
fi
cd "$dir_scripts" || return $?
cd $dir_scripts
if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}"
local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then
cd "$script_dir" || return $?
cd ${script_dir}
if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}"
else
@@ -159,12 +159,12 @@ run_normal() {
random_delay "$file_param"
fi
enter_script_workdir || return $?
enter_script_workdir
if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env
fi
run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
run_task_command $which_program $file_param "${script_params[@]}"
}
handle_env_split() {
@@ -175,7 +175,7 @@ handle_env_split() {
env_str_to_array
local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g")
local runArr=($(eval echo $tempArr))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}"))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]}))
}
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
@@ -192,10 +192,10 @@ run_concurrent() {
time=$(date "+$mtime_format")
single_log_time=$(format_log_time "$mtime_format" "$time")
enter_script_workdir || return $?
enter_script_workdir
local j=0
for i in "${array_run[@]}"; do
for i in ${array_run[@]}; do
single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
let j++
@@ -203,16 +203,16 @@ run_concurrent() {
export "${env_param}=${array[$i - 1]}"
clear_non_sh_env
fi
envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path &
done
wait
local k=0
for i in "${array_run[@]}"; do
for i in ${array_run[@]}; do
single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
let k++
cat "$single_log_path"
[[ -f $single_log_path ]] && rm -f "$single_log_path"
cat $single_log_path
[[ -f $single_log_path ]] && rm -f $single_log_path
done
}
@@ -229,7 +229,7 @@ run_designated() {
if [[ $isJsOrPythonFile == 'false' ]]; then
local n=0
for i in "${array_run[@]}"; do
for i in ${array_run[@]}; do
array_str[n]=${array[$i - 1]}
let n++
done
@@ -241,9 +241,9 @@ run_designated() {
clear_non_sh_env
fi
enter_script_workdir || return $?
enter_script_workdir
envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}"
}
## 运行其他命令
@@ -273,7 +273,7 @@ run_else() {
if [[ -n "$script_for_dir" ]]; then
local saved_file_param="$file_param"
file_param="$script_for_dir"
enter_script_workdir true || return $?
enter_script_workdir true
local adjusted_script="$file_param"
file_param="$saved_file_param"
@@ -289,17 +289,17 @@ run_else() {
set -- "${new_args[@]}"
else
# 没有找到脚本参数,只 cd 到 scripts 目录
enter_script_workdir true || return $?
enter_script_workdir true
shift
fi
else
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
enter_script_workdir true || return $?
enter_script_workdir true
shift
fi
clear_non_sh_env
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
run_task_command $which_program $file_param "$@"
}
check_file() {
@@ -313,14 +313,12 @@ check_file() {
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
export PREV_PYTHONPATH="${PYTHONPATH:=}"
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
local preload_path=${file_preload_js//\\/\\\\}
preload_path=${preload_path//\"/\\\"}
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}"
else
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
fi
else
. "$file_env"
. $file_env
fi
fi
}
-1
View File
@@ -47,7 +47,6 @@ class GrpcClient {
'updateEnvNames',
'getEnvById',
'systemNotify',
'recordNotifyLog',
'getCronDetail',
'createCron',
'updateCron',
-43
View File
@@ -1,43 +0,0 @@
const path = require('node:path');
const fs = require('node:fs');
// A reporting failure must never terminate a script or change its send result.
module.exports = async function reportNotificationAudit(payload) {
let client;
try {
if (!process.env.QL_DIR) return;
const grpc = require('@grpc/grpc-js');
const loader = require('@grpc/proto-loader');
const certDir = path.join(
process.env.QL_DATA_DIR || path.join(process.env.QL_DIR, 'data'),
'config/grpc',
);
const credentials = grpc.credentials.createSsl(
fs.readFileSync(path.join(certDir, 'ca.crt')),
fs.readFileSync(path.join(certDir, 'client.key')),
fs.readFileSync(path.join(certDir, 'client.crt')),
);
const definition = loader.loadSync(
path.join(process.env.QL_DIR, 'back/protos/api.proto'),
{ keepCase: true, defaults: true },
);
const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api;
client = new Api(
`localhost:${process.env.GRPC_PORT || '5500'}`,
credentials,
{ 'grpc.enable_http_proxy': 0 },
);
await new Promise((resolve) => {
client.RecordNotifyLog(
payload,
new grpc.Metadata(),
{ deadline: new Date(Date.now() + 1500) },
() => resolve(),
);
});
} catch (_) {
// Missing certificates, old panels and offline services are best effort.
} finally {
if (client) client.close();
}
};
+3 -3
View File
@@ -82,7 +82,7 @@ function run() {
].filter(Boolean);
if (task_before) {
console.log(process.env.QL_LANG === 'en' ? 'Executing before command\n' : '执行前置命令\n');
console.log('执行前置命令\n');
}
const res = execSync(commands.join(' && '), {
@@ -122,7 +122,7 @@ function run() {
console.log(output);
}
if (task_before) {
console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
console.log('执行前置命令结束\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(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
console.log('执行前置命令结束\n');
}
}
+5 -5
View File
@@ -56,7 +56,7 @@ def run():
if task_before:
escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$")
commands.append(f"eval '{escaped_task_before}'")
print("Executing before command\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\n")
print("执行前置命令\n")
commands.append(f"echo -e '{split_str}'")
@@ -88,12 +88,12 @@ def run():
if len(output) > 0:
print(output)
if task_before:
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
print("执行前置命令结束\n")
except subprocess.CalledProcessError as error:
print(f"\ue926 run task before error: {error}")
if task_before:
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
print("执行前置命令结束\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("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
print("执行前置命令结束\n")
except Exception as error:
print(f"\ue926 run task before error: {error}")
if task_before:
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
print("执行前置命令结束\n")
import task_before
+2 -8
View File
@@ -1,12 +1,4 @@
#!/usr/bin/env bash
# BSD grep accepts \d as a digit; GNU grep treats it as a literal d.
string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1
if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then
printf 'Invalid release version: %s\n' "$string" >&2
exit 1
fi
version="v$string"
echo -e "开始发布"
echo -e "切换master分支"
@@ -17,6 +9,8 @@ git push --set-upstream origin master -f
echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
version="v$string"
echo -e "当前版本$version"
echo -e "删除已经存在的本地tag"
+16 -21
View File
@@ -3,47 +3,42 @@
days=$1
remove_js_log() {
local log log_date log_timestamp diff_time log_path encoded_path result
local now
now=$(date +%s) || return $?
while IFS= read -r -d '' log; do
log_date=${log##*/}
log_date=${log_date:0:10}
local log_full_path_list=$(find $dir_log -name "*.log")
local diff_time
for log in $log_full_path_list; do
local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10)
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
if [[ $is_macos -eq 1 ]]; then
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log")
else
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
fi
fi
if [[ $is_macos -eq 1 ]]; then
# BSD date otherwise fills the omitted time from the current clock.
log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s)))
else
log_timestamp=$(date +%s -d "$log_date") || continue
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
fi
diff_time=$((now - log_timestamp))
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
log_path=${log#"$dir_log"/}
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
result=$(find_cron_api "log_path=$encoded_path")
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
local result=$(find_cron_api "log_path=$log_path")
t '查询文件 %s' "$log_path"
if [[ -z $result ]]; then
t '删除中~'
rm -vf -- "$log"
rm -vf $log
else
t '正在被 %s 使用,跳过~' "$result"
fi
fi
done < <(find "$dir_log" -type f -name "*.log" -print0)
done
}
remove_empty_dir() {
local dir
for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do
[[ -d "$dir" ]] || continue
# rmdir also protects a directory populated between discovery and removal.
rmdir -- "$dir" 2>/dev/null || true
cd $dir_log
for dir in $(ls); do
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
rm -rf $dir
fi
done
}
+43 -47
View File
@@ -77,19 +77,16 @@ load_ql_envs() {
}
import_config() {
[[ -f $file_config_user ]] && . "$file_config_user"
[[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh"
[[ -f $file_config_user ]] && . $file_config_user
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
# Language packs need global associative arrays (Bash 4.2+). Probe the
# capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback.
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
local lang=${QL_LANG:-zh}
local lang_file="$dir_shell/lang/${lang}.sh"
if (declare -gA __ql_lang_probe) 2>/dev/null; then
if [[ -f $lang_file ]]; then
. "$lang_file"
elif [[ -f "$dir_shell/lang/zh.sh" ]]; then
. "$dir_shell/lang/zh.sh"
fi
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
. $lang_file
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
. "$dir_shell/lang/zh.sh"
fi
load_ql_envs
@@ -135,7 +132,7 @@ unset_proxy() {
make_dir() {
local dir=$1
if [[ ! -d $dir ]]; then
mkdir -p -- "$dir"
mkdir -p $dir
fi
}
@@ -179,57 +176,57 @@ define_cmd() {
}
fix_config() {
make_dir "$dir_tmp"
make_dir "$dir_static"
make_dir "$dir_data"
make_dir "$dir_config"
make_dir "$dir_log"
make_dir "$dir_db"
make_dir "$dir_scripts"
make_dir "$dir_list_tmp"
make_dir "$dir_repo"
make_dir "$dir_raw"
make_dir "$dir_update_log"
make_dir "$dir_dep"
make_dir $dir_tmp
make_dir $dir_static
make_dir $dir_data
make_dir $dir_config
make_dir $dir_log
make_dir $dir_db
make_dir $dir_scripts
make_dir $dir_list_tmp
make_dir $dir_repo
make_dir $dir_raw
make_dir $dir_update_log
make_dir $dir_dep
if [[ ! -s $file_config_user ]]; then
cp -f "$file_config_sample" "$file_config_user"
cp -f $file_config_sample $file_config_user
fi
if [[ ! -f $file_task_before ]]; then
cp -f "$file_task_sample" "$file_task_before"
cp -f $file_task_sample $file_task_before
fi
if [[ ! -f $file_task_after ]]; then
cp -f "$file_task_sample" "$file_task_after"
cp -f $file_task_sample $file_task_after
fi
if [[ ! -f $file_extra_shell ]]; then
cp -f "$file_extra_sample" "$file_extra_shell"
cp -f $file_extra_sample $file_extra_shell
fi
if [[ ! -s $file_notify_py ]]; then
cp -f "$file_notify_py_sample" "$file_notify_py"
cp -f $file_notify_py_sample $file_notify_py
fi
if [[ ! -s $file_notify_js ]]; then
cp -f "$file_notify_js_sample" "$file_notify_js"
cp -f $file_notify_js_sample $file_notify_js
fi
if [[ ! -s $file_test_js ]]; then
cp -f "$file_test_js_sample" "$file_test_js"
cp -f $file_test_js_sample $file_test_js
fi
if [[ ! -s $file_test_py ]]; then
cp -f "$file_test_py_sample" "$file_test_py"
cp -f $file_test_py_sample $file_test_py
fi
if [[ ! -s $dep_notify_js ]]; then
cp -f "$file_notify_js_sample" "$dep_notify_js"
cp -f $file_notify_js_sample $dep_notify_js
fi
if [[ ! -s $dep_notify_py ]]; then
cp -f "$file_notify_py_sample" "$dep_notify_py"
cp -f $file_notify_py_sample $dep_notify_py
fi
}
@@ -249,17 +246,17 @@ npm_install_2() {
local dir_current=$(pwd)
local dir_work=$1
cd "$dir_work" || return $?
cd $dir_work
t '安装 %s 依赖包...\n' "$dir_work"
npm_install_sub
cd "$dir_current" || return $?
cd $dir_current
}
diff_and_copy() {
local copy_source=$1
local copy_to=$2
if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then
cp -f "$copy_source" "$copy_to"
if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then
cp -f $copy_source $copy_to
fi
}
@@ -268,13 +265,12 @@ git_clone_scripts() {
local dir="$2"
local branch="$3"
local proxy="$4"
local branch_args=()
[[ $branch ]] && branch_args=(-b "$branch")
[[ $branch ]] && local part_cmd="-b $branch "
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
set_proxy "$proxy"
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
git clone -q --depth=1 $part_cmd $url $dir
exit_status=$?
unset_proxy
@@ -287,7 +283,7 @@ random_range() {
}
delete_pm2() {
cd "$dir_root" || return $?
cd $dir_root
# Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
@@ -295,7 +291,7 @@ delete_pm2() {
}
reload_pm2() {
cd "$dir_root" || return $?
cd $dir_root
restore_env_vars
# Try to start PM2, but handle failures gracefully
@@ -314,7 +310,7 @@ reload_pm2() {
if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 &
fi
local node_pid=$!
@@ -395,14 +391,14 @@ handle_task_start() {
if [[ $ID ]]; then
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
if [[ $error ]]; then
error_message=$(t ', 任务状态更新失败(%s)' "$error")
error_message=", 任务状态更新失败(${error})"
fi
fi
t '## 开始执行... %s\n' "${begin_time}${error_message}"
}
run_task_before() {
. "$file_task_before" "$@"
. $file_task_before "$@"
if [[ ${task_before:=} ]]; then
t '执行前置命令\n'
@@ -412,7 +408,7 @@ run_task_before() {
}
run_task_after() {
. "$file_task_after" "$@"
. $file_task_after "$@"
if [[ ${task_after:=} ]]; then
t '\n执行后置命令\n'
@@ -437,7 +433,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=$(t ', 状态更新失败(%s)' "$error")
error_message=", 状态更新失败(${error})"
fi
fi
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -e
: "${QL_DIR:?Please set QL_DIR}"
: "${QL_DATA_DIR:?Please set QL_DATA_DIR}"
[[ "$QL_DIR" = /* && "$QL_DATA_DIR" = /*/data ]] || { echo "QL_DIR and QL_DATA_DIR must be absolute paths (data directory ends in /data)" >&2; exit 1; }
command="$1"
export PNPM_HOME="${PNPM_HOME:-${QL_DATA_DIR}/dep_cache/node}"
if [[ -n "${PYTHON_VENV_DIR:-}" ]]; then
[[ "$PYTHON_VENV_DIR" = /* ]] || PYTHON_VENV_DIR="${QL_DIR}/${PYTHON_VENV_DIR}"
export PYTHON_VENV_DIR
if [[ ! -x "$PYTHON_VENV_DIR/bin/python3" ]]; then
python3 -m venv "$PYTHON_VENV_DIR"
fi
[[ -x "$PYTHON_VENV_DIR/bin/python3" ]] || { echo "Invalid Python venv" >&2; exit 1; }
unset PYTHON_HOME PYTHONUSERBASE PYTHONPATH
export PATH="${PYTHON_VENV_DIR}/bin:${PATH}:${PNPM_HOME}"
export PIP_CACHE_DIR="${PYTHON_VENV_DIR}/pip"
else
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
export PYTHON_HOME="${PYTHON_HOME:-${QL_DATA_DIR}/dep_cache/python3}"
export PYTHONUSERBASE="$PYTHON_HOME"
export PATH="${PATH}:${PNPM_HOME}:${PYTHON_HOME}/bin"
export PIP_CACHE_DIR="${PYTHON_HOME}/pip"
export PYTHONPATH="${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages"
fi
export NODE_PATH="${NODE_PATH:-/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules}"
mkdir -p "$PIP_CACHE_DIR"
cd "${QL_DIR}"
if [[ ! -e .env ]]; then
# Preserve user configuration, including a file created concurrently.
(umask 077; set -C; cat .env.example > .env)
fi
chmod u+x "${QL_DIR}"/shell/*.sh
. "${QL_DIR}/shell/share.sh"
. "${QL_DIR}/shell/env.sh"
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
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" "$(t '🔄 2. 启动 nginx...')"
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "$(t '⚙️ 3. 启动 pm2 服务...')"
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
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" "$(t '🛠️ 5. 执行自定义脚本...')"
nohup ql extra >"$dir_log/extra.log" 2>&1 &
fi
pm2 startup
pm2 save
fi
log_with_style "SUCCESS" "$(t '🎉 启动成功!')"
+72 -49
View File
@@ -4,56 +4,31 @@
set -e
set -x
# Configuration and language helpers are not loaded during bootstrap errors.
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
}
if [[ ! $QL_DIR ]]; then
npm_dir=$(npm root -g 2>/dev/null) || npm_dir=""
pnpm_dir=""
if command -v pnpm >/dev/null 2>&1; then
pnpm_dir=$(pnpm root -g 2>/dev/null) || pnpm_dir=""
fi
if [[ -n "$npm_dir" && -d "$npm_dir/@whyour/qinglong" ]]; then
npm_dir=$(npm root -g)
pnpm_dir=$(pnpm root -g)
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
QL_DIR="$npm_dir/@whyour/qinglong"
elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
QL_DIR="$pnpm_dir/@whyour/qinglong"
else
startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
fi
if [[ $QL_DIR ]]; then
startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
fi
exit 1
fi
if [[ $QL_DIR != /* ]]; then
startup_message 'QL_DIR 必须为绝对路径,例如 /ql'
exit 1
fi
if [[ ! $QL_DATA_DIR ]]; then
startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
exit 1
fi
if [[ $QL_DATA_DIR != /*/data ]]; then
startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
if [[ $QL_DATA_DIR != */data ]]; then
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
exit 1
fi
@@ -63,11 +38,7 @@ if [[ $command != "reload" ]]; then
# 安装依赖
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
if [[ -r /etc/os-release ]]; then
os_name=$(source /etc/os-release && echo "$ID")
else
os_name=$(uname -s)
fi
os_name=$(source /etc/os-release && echo "$ID")
fi
# 非 root 用户使用 sudo
@@ -98,7 +69,7 @@ if [[ $command != "reload" ]]; then
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
;;
*)
startup_message '暂不支持此系统部署 %s' "$os_name"
t '暂不支持此系统部署 %s' "$os_name"
exit 1
;;
esac
@@ -106,13 +77,65 @@ if [[ $command != "reload" ]]; then
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5
fi
if [[ "$command" != "reload" ]]; then
if [[ -n "${PYTHON_VENV_DIR:-}" ]]; then
[[ "$PYTHON_VENV_DIR" = /* ]] || export PYTHON_VENV_DIR="${QL_DIR}/${PYTHON_VENV_DIR}"
[[ -x "$PYTHON_VENV_DIR/bin/python3" ]] || python3 -m venv "$PYTHON_VENV_DIR"
"$PYTHON_VENV_DIR/bin/python3" -m pip install requests
else
pip3 install --prefix "${PYTHON_HOME:-${QL_DATA_DIR}/dep_cache/python3}" requests
fi
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
export PNPM_HOME=${QL_DIR}/data/dep_cache/node
export PYTHON_HOME=${QL_DIR}/data/dep_cache/python3
export PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin
export NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules
export PIP_CACHE_DIR=${PYTHON_HOME}/pip
export PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
if [[ $command != "reload" ]]; then
pip3 install --prefix ${PYTHON_HOME} requests
fi
exec /bin/bash "${QL_DIR}/shell/start-simplify.sh" "$@"
cd "${QL_DIR}"
if [[ ! -e .env ]]; then
# Preserve user configuration, including a file created concurrently.
(umask 077; set -C; cat .env.example > .env)
fi
chmod 777 ${QL_DIR}/shell/*.sh
. ${QL_DIR}/shell/share.sh
. ${QL_DIR}/shell/env.sh
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
pm2 startup
pm2 save
fi
log_with_style "SUCCESS" "🎉 启动成功!"
+10 -22
View File
@@ -36,31 +36,19 @@ run_task_command() {
) &
timeout_watcher=$!
set +m
finish_task_timeout() {
local code="$1"
trap - EXIT
{
# Bash can report a killed job between any two cleanup commands.
# Keep redirection active until the watchdog is fully reaped.
kill -KILL -- "-$timeout_watcher" || :
wait "$timeout_watcher" || :
} 2>/dev/null
trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || :
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
wait "$timeout_worker" 2>/dev/null || :
rm -rf -- "$timeout_dir"
exit "$code"
}
# Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call
# cleanup explicitly on completion/signals, retaining EXIT as a fallback.
trap 'finish_task_timeout "$?"' EXIT
trap 'finish_task_timeout 130' INT
trap 'finish_task_timeout 143' TERM
trap 'finish_task_timeout 129' HUP
trap 'finish_task_timeout 131' QUIT
wait "$timeout_watcher" 2>/dev/null || :
rm -rf -- "$timeout_dir"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
trap 'exit 131' QUIT
# Do not clean up a timer whose process group has not initialized yet.
while [[ ! -f "$timeout_dir/ready" ]]; do
kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125
kill -0 "$timeout_watcher" 2>/dev/null || exit 125
sleep 0.001
done
wait "$timeout_worker"
@@ -68,8 +56,8 @@ run_task_command() {
if [[ -f "$timeout_dir/expired" ]]; then
# Let the timer finish escalation even if the task leader exited first.
wait "$timeout_watcher" 2>/dev/null || :
finish_task_timeout 124
exit 124
fi
finish_task_timeout "$timeout_code"
exit "$timeout_code"
)
}
+7 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
dir_shell=$QL_DIR/shell
. "$dir_shell/share.sh"
. "$dir_shell/api.sh"
. "$dir_shell/task-timeout.sh"
. $dir_shell/share.sh
. $dir_shell/api.sh
. $dir_shell/task-timeout.sh
trap 'single_hanle SIGINT' INT
trap 'single_hanle SIGTERM' TERM
@@ -48,7 +48,7 @@ handle_log_path() {
fi
if [[ -z ${ID:=} ]]; then
ID=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
fi
local suffix=""
if [[ ! -z $ID ]]; then
@@ -89,10 +89,10 @@ handle_log_path() {
log_path="$real_log_path"
fi
printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path"
cmd="2>&1 | tee -a $dir_log/$log_path"
make_dir "$dir_log/$log_dir"
if [[ "${no_tee:=}" == "true" ]]; then
printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
cmd=">> $dir_log/$log_path 2>&1"
fi
if [[ "${real_time:=}" == "true" ]]; then
@@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}"
handle_log_path "${task_shell_params[@]}"
init_begin_time
eval '. "$dir_shell/otask.sh"' "$cmd"
eval . $dir_shell/otask.sh "$cmd"
exit 0
+38 -52
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
dir_shell=$QL_DIR/shell
. "$dir_shell/share.sh"
. "$dir_shell/api.sh"
. $dir_shell/share.sh
. $dir_shell/api.sh
load_ql_envs
. "$dir_shell/env.sh"
. $dir_shell/env.sh
send_mark=$dir_shell/send_mark
@@ -31,7 +31,7 @@ diff_cron() {
## 输出是否有新的或失效的定时任务,$1:新的或失效的任务清单文件路径,$2:新/失效
output_list_add_drop() {
local list=$1
local type=$(t "$2")
local type=$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 "$(t '%s 删除任务%s' "$path" "$result")" "$detail"
notify_api "$path 删除任务${result}" "$detail"
fi
}
@@ -99,9 +99,7 @@ add_cron() {
cron_name=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
[[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs)
[[ -z $cron_name ]] && cron_name=$(basename "$file_name")
cron_name=${cron_name//$'\r'/}
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${file}" "$cron_name" "$SUB_ID")
result=$(add_cron_api "${cron_line}:${cmd_task} ${file}:${cron_name}:${SUB_ID}")
echo -e "$result"
if [[ $detail ]]; then
detail="${detail}${result}\n"
@@ -110,7 +108,7 @@ add_cron() {
fi
fi
done
notify_api "$(t '%s 新增任务' "$path")" "$detail"
notify_api "$path 新增任务" "$detail"
}
## 更新仓库
@@ -165,20 +163,17 @@ update_raw() {
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
set_proxy "$proxy"
if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
exit_status=0
else
exit_status=$?
fi
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
exit_status=$?
unset_proxy
if [[ $exit_status -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
if [[ $? -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
t '下载 %s 成功...\n' "${raw_file_name}"
cd "$dir_raw" || return $?
cd $dir_raw
local filename="raw_${raw_file_name}"
local cron_id=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $?
local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
cp -f $raw_file_name $dir_scripts/${filename}
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
cron_line=$(
perl -ne "{
@@ -195,17 +190,14 @@ update_raw() {
[[ -z $cron_line ]] && cron_line=$(grep "cron:" $raw_file_name | awk -F ":" '{print $2}' | head -1 | xargs)
[[ -z $cron_line ]] && cron_line=$(grep "cron " $raw_file_name | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs)
[[ -z $cron_line ]] && cron_line="$default_cron"
cron_name=${cron_name//$'\r'/}
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
result=$(add_cron_api "${cron_line}:${cmd_task} ${filename}:${cron_name}:${SUB_ID}")
echo -e "$result\n"
notify_api "$(t '新增任务通知')" "\n$result"
notify_api "新增任务通知" "\n$result"
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
fi
else
t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
return "$exit_status"
fi
}
@@ -213,7 +205,7 @@ update_raw() {
## 调用用户自定义的extra.sh
run_extra_shell() {
if [[ -f $file_extra_shell ]]; then
. "$file_extra_shell"
. $file_extra_shell
else
t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
fi
@@ -270,25 +262,24 @@ show_service_logs() {
# Winston writes the active runtime log by date. Follow the filename so
# size-based rotations are handled without involving the PM2 log files.
local current_system_log="$system_log_dir/$(date +%F).log"
# GNU and BSD tail both support -F; --retry is GNU-only and implied by -F.
tail -n 0 -F "$current_system_log"
tail -n 0 --retry -F "$current_system_log"
}
## 脚本用法
usage() {
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 '修改登录用户名')"
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 # 修改登录用户名"
}
reload_qinglong() {
@@ -489,9 +480,9 @@ main() {
local log_path="${log_dir}/${log_time}.log"
local file_path="$dir_log/$log_path"
printf -v cmd '2>&1 | tee -a %q' "$file_path"
cmd="2>&1 | tee -a $file_path"
if [[ "$no_tee" == "true" ]]; then
printf -v cmd '>> %q 2>&1' "$file_path"
cmd=">> $file_path 2>&1"
fi
if [[ "$real_time" == "true" ]]; then
cmd=""
@@ -504,9 +495,7 @@ main() {
local begin_time=$(format_time "$time_format" "$time")
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
local start_message
printf -v start_message '%q' "$(t '## 开始执行... %s\n' "$begin_time")"
eval "printf '%s\n' $start_message $cmd"
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
fi
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
@@ -539,20 +528,19 @@ main() {
get_uniq_path "$p2"
if [[ -n $p2 ]]; then
update_raw "$p2" "$p3" "$p4" "$p5"
command_status=$?
else
t '命令输入错误...\n'
eval usage $cmd
fi
;;
rmlog)
eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd"
eval . $dir_shell/rmlog.sh "$p2" $cmd
;;
bot)
eval '. "$dir_shell/bot.sh"' "$cmd"
eval . $dir_shell/bot.sh $cmd
;;
check)
eval '. "$dir_shell/check.sh"' "$cmd"
eval . $dir_shell/check.sh $cmd
;;
resetlet)
eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd
@@ -579,9 +567,7 @@ main() {
[[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time"
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
local end_message
printf -v end_message '%q' "$(t '\n## 执行结束... %s 耗时 %s 秒' "$end_time" "$diff_time")     "
eval "printf '%s\n' $end_message $cmd"
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd
fi
return "$command_status"
}
+1 -1
View File
@@ -3,6 +3,6 @@ const upgrade = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
module.exports = upgrade;
if (require.main === module)
upgrade.main().catch((error) => {
console.error(upgrade.upgradeMessage('更新失败: %s', process.env, error.message));
console.error(`更新失败: ${error.message}`);
process.exitCode = 1;
});
-60
View File
@@ -1,60 +0,0 @@
import { CloudDownloadOutlined } from '@ant-design/icons';
import { Modal, Tooltip } from 'antd';
import { useState } from 'react';
import intl from 'react-intl-universal';
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade';
import CheckUpdate from '@/pages/setting/checkUpdate';
export default function UpgradeNotice({
collapsed,
systemInfo,
}: {
collapsed: boolean;
systemInfo: any;
}) {
const { job, connected } = useUpgrade();
const [open, setOpen] = useState(false);
const showNotice =
activeStatuses.includes(job.status) ||
job.status === 'ready' ||
job.status === 'failed';
if (!showNotice && !open) return null;
const text = !connected
? intl.get('连接已断开,正在重新连接')
: job.status === 'failed'
? intl.get('更新失败')
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
const label =
job.phase === 'downloading' && job.percent != null
? `${text} ${job.percent}%`
: text;
return (
<>
{showNotice && (
<Tooltip title={label} placement="right">
<button
type="button"
className={`upgrade-notice${
collapsed ? ' upgrade-notice-collapsed' : ''
}`}
onClick={() => setOpen(true)}
aria-label={label}
>
<CloudDownloadOutlined />
{!collapsed && <span>{label}</span>}
</button>
</Tooltip>
)}
<Modal
open={open}
title={intl.get('更新')}
footer={null}
onCancel={() => setOpen(false)}
width={650}
destroyOnClose
>
<CheckUpdate systemInfo={systemInfo} />
</Modal>
</>
);
}
-138
View File
@@ -1,138 +0,0 @@
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);
}
+4 -128
View File
@@ -354,50 +354,20 @@ 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 {
flex-shrink: 0;
&:hover {
color: #1890ff;
}
.side-menu-collapse-button:hover {
color: #1890ff;
}
.side-menu-user-wrapper {
flex: 1;
overflow: hidden;
display: flex;
align-items: center;
}
}
.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;
@@ -505,105 +475,11 @@ 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;
+16 -43
View File
@@ -18,9 +18,6 @@ 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;
@@ -51,14 +48,10 @@ 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,
@@ -167,18 +160,6 @@ 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();
}, []);
@@ -238,17 +219,19 @@ export default function () {
}, [user]);
useEffect(() => {
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 }));
}
});
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}`,
);
};
}, []);
if (initLoading) {
@@ -338,12 +321,6 @@ export default function () {
</>
}
title={false}
menuFooterRender={(props) => (
<UpgradeNotice
collapsed={Boolean(props?.collapsed)}
systemInfo={systemInfo}
/>
)}
menuItemRender={(menuItemProps: any, defaultDom: any) => {
if (
menuItemProps.isUrl ||
@@ -374,9 +351,7 @@ export default function () {
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''
}
/>
<span className="side-menu-username" title={user.username}>
{user.username}
</span>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span>
</Dropdown>
)
@@ -402,9 +377,7 @@ export default function () {
: ''
}
/>
<span className="side-menu-username" title={user.username}>
{user.username}
</span>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span>
</Dropdown>
)}
+4 -61
View File
@@ -1,19 +1,4 @@
{
"选中条数": "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+",
@@ -338,7 +323,7 @@
"显示": "Display",
"智能微秘书": "Smart WeChat Assistant",
"暂无数据": "No data",
"暂无日志": "No logs yet",
"暂无日志": "No logs available",
"暂无脚本": "No scripts available",
"暂无运行中任务": "No running tasks",
"暗色": "Dark",
@@ -410,7 +395,7 @@
"父目录": "Parent Directory",
"版本": "Version",
"状态": "Status",
"环境变量": "Environment variable",
"环境变量": "Environment Variables",
"生成数据中...": "Generating data...",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/",
@@ -649,6 +634,7 @@
"保存配置": "Save",
"当前生效值": "Effective value",
"默认配置": "Default",
"环境变量": "Environment variable",
"客户端 IP 诊断": "Client IP diagnostics",
"重新诊断": "Run again",
"Socket 地址": "Socket address",
@@ -713,48 +699,5 @@
"仍然保存": "Save anyway",
"已保存,本次未记录历史版本": "Saved without a history snapshot",
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
"保存失败": "Unable to save",
"通知日志": "Notification log",
"标题": "Title",
"内容": "Content",
"发送时间": "Sent at",
"推送渠道": "Channel",
"发送状态": "Delivery status",
"通知来源": "Notification source",
"脚本通知": "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",
"控制台诗句": "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 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."
"保存失败": "Unable to save"
}
+2 -59
View File
@@ -1,19 +1,4 @@
{
"选中条数": "已选择 {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小时未运行",
@@ -649,6 +634,7 @@
"保存配置": "保存配置",
"当前生效值": "当前生效值",
"默认配置": "默认配置",
"环境变量": "环境变量",
"客户端 IP 诊断": "客户端 IP 诊断",
"重新诊断": "重新诊断",
"Socket 地址": "Socket 地址",
@@ -713,48 +699,5 @@
"仍然保存": "仍然保存",
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败",
"通知日志": "通知日志",
"标题": "标题",
"内容": "内容",
"发送时间": "发送时间",
"推送渠道": "推送渠道",
"发送状态": "发送状态",
"通知来源": "通知来源",
"脚本通知": "脚本通知",
"系统通知": "系统通知",
"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路径",
"控制台诗句": "忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。",
"运行环境信息": "青龙运行环境:\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秒后自动刷新",
"运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。",
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
"启动版本与更新包不一致": "启动版本与更新包不一致",
"下载完成,等待重启": "下载完成,等待重启",
"升级成功": "升级成功",
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
"保存失败": "保存失败"
}
+10 -4
View File
@@ -364,10 +364,13 @@ const CronDetailModal = ({
const enabledOrDisabledCron = () => {
Modal.confirm({
title: intl.get('确认操作', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }),
title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认操作对象', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '}
{intl.get('确认')}
{currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')}
{intl.get('定时任务')}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
@@ -395,10 +398,13 @@ const CronDetailModal = ({
const pinOrUnPinCron = () => {
Modal.confirm({
title: intl.get('确认操作', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }),
title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认操作对象', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '}
{intl.get('确认')}
{currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
{intl.get('定时任务')}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
+18 -16
View File
@@ -544,13 +544,13 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认操作', {
operation:
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'),
}),
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认操作对象', { operation: record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '}
{intl.get('确认')}
{record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')}
{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: intl.get('确认操作', {
operation:
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'),
}),
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '}
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
{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: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
}),
title: `确认${OperationName[operationStatus]}`,
content: (
<>
{intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('定时任务') })}
{intl.get('确认')}
{OperationName[operationStatus]}
{intl.get('选中的定时任务吗')}
</>
),
onOk() {
@@ -1035,7 +1035,9 @@ const Crontab = () => {
{intl.get('批量修改标签')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('选中条数', { count: selectedRowIds.length })}
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
{intl.get('项')}
</span>
</div>
)}
@@ -1049,7 +1051,7 @@ const Crontab = () => {
simple: isPhone,
total,
showTotal: (total: number, range: number[]) =>
intl.get('分页总数', { start: range[0], end: range[1], total }),
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort(
(a, b) => a - b,
),
+2 -2
View File
@@ -167,7 +167,7 @@ const Dashboard = () => {
const runtimePagination = {
pageSize: 5,
showSizeChanger: false,
showTotal: (total: number) => intl.get('总条数', { total }),
showTotal: (total: number) => `共 ${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: intl.get('内存使用'),
name: '内存使用率',
}}
style={{
textY: '70%',
+3 -1
View File
@@ -536,7 +536,9 @@ const Dependence = () => {
{intl.get('批量强制删除')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('选中条数', { count: selectedRowIds.length })}
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
{intl.get('项')}
</span>
</div>
)}
+2 -18
View File
@@ -22,9 +22,6 @@ const DependenceModal = ({
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [selectedType, setSelectedType] = useState(
DependenceTypes[defaultType as any],
);
const handleOk = async (values: any) => {
setLoading(true);
@@ -93,7 +90,7 @@ const DependenceModal = ({
label={intl.get('依赖类型')}
initialValue={DependenceTypes[defaultType as any]}
>
<Select onChange={(value) => setSelectedType(value)}>
<Select>
{config.dependenceTypes.map((x, i) => (
<Option key={i} value={i}>
{x}
@@ -124,24 +121,11 @@ const DependenceModal = ({
whitespace: true,
},
]}
tooltip={
selectedType === DependenceTypes.python3
? intl.get(
'Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径',
)
: undefined
}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={
selectedType === DependenceTypes.python3
? intl.get(
'支持包名、GitHub链接、requirements.txt或pyproject.toml路径',
)
: intl.get('请输入依赖名称')
}
placeholder={intl.get('请输入依赖名称')}
/>
</Form.Item>
<Form.Item name="remark" label={intl.get('备注')}>
+22 -18
View File
@@ -279,13 +279,16 @@ const Env = () => {
const enabledOrDisabledEnv = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认操作', {
operation:
record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'),
}),
title: `确认${
record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认操作对象', { operation: record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'), object: 'Env' })}{' '}
{intl.get('确认')}
{record.status === Status.已禁用
? intl.get('启用')
: intl.get('禁用')}
Env{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
@@ -340,13 +343,14 @@ const Env = () => {
const pinOrUnpinEnv = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认操作', {
operation:
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'),
}),
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: 'Env' })}{' '}
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
Env{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
@@ -539,12 +543,12 @@ const Env = () => {
const operateEnvs = (operationStatus: number) => {
Modal.confirm({
title: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
}),
title: `确认${OperationName[operationStatus]}`,
content: (
<>
{intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('环境变量') })}
{intl.get('确认')}
{OperationName[operationStatus]}
{intl.get('选中的变量吗')}
</>
),
onOk() {
@@ -599,9 +603,7 @@ const Env = () => {
);
if (code === 200) {
message.success(
`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`,
);
message.success(`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`);
getEnvs();
}
setImportLoading(false);
@@ -706,7 +708,9 @@ const Env = () => {
{intl.get('批量取消置顶')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('选中条数', { count: selectedRowIds.length })}
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
{intl.get('项')}
</span>
</div>
)}
+8 -2
View File
@@ -79,10 +79,16 @@ const Error = () => {
description={
<Typography.Text type="danger">
<div>{intl.get('请先按如下方式修复:')}</div>
<div>{intl.get('容器更新修复命令')}</div>
<div>
1. 宿主机执行 docker run --rm -v
/var/run/docker.sock:/var/run/docker.sock
containrrr/watchtower -cR &lt;容器名&gt;
</div>
<div>{intl.get('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>

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