From ce599d306f81e3ebb0f6eaa5b540d701a6d4085d Mon Sep 17 00:00:00 2001 From: rockymelody <939555035@qq.com> Date: Sun, 1 Mar 2026 17:44:03 +0800 Subject: [PATCH 001/123] =?UTF-8?q?=E9=9D=92=E9=BE=99=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E7=BB=95=E8=BF=87=E6=BC=8F=E6=B4=9E=E5=B7=B2?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20(#2935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已实施的安全加固措施 第一层防御:启用Express严格路由(第17-18行) app.set('case sensitive routing', true); // 路由大小写敏感 app.set('strict routing', true); // 严格路由匹配 第二层防御:路径标准化检查中间件(第23-37行) app.use((req, res, next) => { const originalPath = req.path; const normalizedPath = originalPath.toLowerCase(); // 检测并拦截大小写混淆攻击 if (originalPath !== normalizedPath && (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { return res.status(400).json({ code: 400, message: 'Invalid path format' }); } next(); }); 作用:主动检测并拒绝含有大小写变体的恶意请求 第三层防御:JWT中间件正则表达式修复(第59行) // 修复前: path: [...config.apiWhiteList, /^\/(?!api\/).*/], // 修复后:添加大小写不敏感标志 'i' path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], 作用:防御正则匹配层面的绕过 第四层防御:自定义Token中间件路径标准化(第74-87行) // 修复前: if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { // 修复后:统一转小写比较 const pathLower = req.path.toLowerCase(); if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) { } 作用:确保Token验证逻辑对所有路径变体生效 第五层防御:初始化接口路径检查修复(第122-123行) // 修复前: if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { // 修复后: const pathLower = req.path.toLowerCase(); if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) { --- back/loaders/express.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 63065a21..2807ece0 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -13,9 +13,29 @@ import { isValidToken } from '../shared/auth'; import path from 'path'; export default ({ app }: { app: Application }) => { + // Security: Enable strict routing to prevent case-insensitive path bypass + app.set('case sensitive routing', true); + app.set('strict routing', true); app.set('trust proxy', 'loopback'); app.use(cors()); + // Security: Path normalization middleware to prevent case variation attacks + app.use((req, res, next) => { + const originalPath = req.path; + const normalizedPath = originalPath.toLowerCase(); + + // Block requests with case variations on protected paths + if (originalPath !== normalizedPath && + (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { + return res.status(400).json({ + code: 400, + message: 'Invalid path format' + }); + } + + next(); + }); + // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness if (config.baseUrl) { @@ -36,7 +56,7 @@ export default ({ app }: { app: Application }) => { secret: config.jwt.secret, algorithms: ['HS384'], }).unless({ - path: [...config.apiWhiteList, /^\/(?!api\/).*/], + path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], }), ); @@ -51,19 +71,20 @@ export default ({ app }: { app: Application }) => { }); app.use(async (req: Request, res, next) => { - if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { + const pathLower = req.path.toLowerCase(); + if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) { return next(); } const headerToken = getToken(req); - if (req.path.startsWith('/open/')) { + if (pathLower.startsWith('/open/')) { const apps = await shareStore.getApps(); const doc = apps?.filter((x) => x.tokens?.find((y) => y.value === headerToken), )?.[0]; if (doc && doc.tokens && doc.tokens.length > 0) { const currentToken = doc.tokens.find((x) => x.value === headerToken); - const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/); + const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/); const key = keyMatch && keyMatch[1]; if ( doc.scopes.includes(key as any) && @@ -98,7 +119,8 @@ export default ({ app }: { app: Application }) => { }); app.use(async (req, res, next) => { - if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { + const pathLower = req.path.toLowerCase(); + if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) { return next(); } const authInfo = From 6bec52dca158481258315ba0fc2f11206df7b719 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:02:21 +0800 Subject: [PATCH 002/123] Fix /open/user/init auth bypass allowing credential reset on initialized systems (#2941) * Initial plan * fix: add /open/user/init paths to init guard to prevent auth bypass Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> Co-authored-by: whyour --- back/loaders/express.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 2807ece0..b5bf221b 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -120,7 +120,14 @@ export default ({ app }: { app: Application }) => { app.use(async (req, res, next) => { const pathLower = req.path.toLowerCase(); - if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) { + if ( + ![ + '/api/user/init', + '/api/user/notification/init', + '/open/user/init', + '/open/user/notification/init', + ].includes(req.path) + ) { return next(); } const authInfo = From 544c432f49ac417a9071a862f92030ddfaf31cda Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 1 Mar 2026 20:35:19 +0800 Subject: [PATCH 003/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PATH=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/loaders/initData.ts | 4 ++-- docker/310.Dockerfile | 5 +++-- docker/Dockerfile | 5 +++-- docker/docker-entrypoint.sh | 2 -- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index f8b5315a..37bbdd6b 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system'; import SystemService from '../services/system'; import UserService from '../services/user'; import { writeFile, readFile } from 'fs/promises'; -import { createRandomString, fileExist, safeJSONParse } from '../config/util'; +import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util'; import OpenService from '../services/open'; import { shareStore } from '../shared/store'; import Logger from './logger'; @@ -50,7 +50,7 @@ export default async () => { const [authConfig] = await SystemModel.findOrCreate({ where: { type: AuthDataType.authConfig }, }); - if (!authConfig?.info) { + if (!authConfig?.info || isDemoEnv()) { let authInfo = { username: 'admin', password: 'admin', diff --git a/docker/310.Dockerfile b/docker/310.Dockerfile index 8a092081..ebd0af81 100644 --- a/docker/310.Dockerfile +++ b/docker/310.Dockerfile @@ -69,9 +69,10 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ - PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 + PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ + HOME=/root -ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \ +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages diff --git a/docker/Dockerfile b/docker/Dockerfile index 6617b4ee..8ca6c947 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -69,9 +69,10 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ - PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 + PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ + HOME=/root -ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \ +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index eb2027f2..5d784cc3 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,7 +1,5 @@ #!/bin/bash -export PATH="$HOME/bin:$PATH" - dir_shell=/ql/shell . $dir_shell/share.sh From 275d8af4e2374e9e288ed16348e6d1ef23bb7eca Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 1 Mar 2026 20:35:25 +0800 Subject: [PATCH 004/123] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC=20?= =?UTF-8?q?v2.20.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- version.yaml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/version.yaml b/version.yaml index 2ab325e8..65648012 100644 --- a/version.yaml +++ b/version.yaml @@ -1,11 +1,6 @@ -version: 2.20.1 -changeLogLink: https://t.me/jiao_long/433 -publishTime: 2025-12-26 22:00 +version: 2.20.2 +changeLogLink: https://t.me/jiao_long/434 +publishTime: 2026-03-01 1800 changeLog: | - 1. 修复获取依赖管理列表 - 2. notify.js 修复 TG_PROXY_AUTH 参数拼接 - 3. QLAPI.notify larkSecret 参数 - 4. 修复 cron parser 定时规则校验 - 5. 修复设置 baseUrl 后无法访问 - 6. 修复环境变量排序 - 7. 修复定时任务无法停止 \ No newline at end of file + 1. 修复 path 安全漏洞(重要) + \ No newline at end of file From c39f4ef846c9373dc4149881dd4181fa1d9beeec Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 7 Mar 2026 21:31:24 +0800 Subject: [PATCH 005/123] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20multer?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=E5=86=B3=20cve=20=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- pnpm-lock.yaml | 819 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 565 insertions(+), 256 deletions(-) diff --git a/package.json b/package.json index 038f8c1d..5858eaf2 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "multer": "1.4.5-lts.1", + "multer": "2.1.1", "node-schedule": "^2.1.0", "nodemailer": "^6.9.16", "p-queue-cjs": "7.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c8e0b0f..54c1a45e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - overrides: sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.0.3 @@ -90,8 +86,8 @@ dependencies: specifier: ^4.17.21 version: 4.17.21 multer: - specifier: 1.4.5-lts.1 - version: 1.4.5-lts.1 + specifier: 2.1.1 + version: 2.1.1 node-schedule: specifier: ^2.1.0 version: 2.1.1 @@ -240,10 +236,10 @@ devDependencies: version: 8.3.4 '@uiw/codemirror-extensions-langs': specifier: ^4.21.9 - version: 4.23.6(@codemirror/autocomplete@6.18.3)(@codemirror/language-data@6.5.1)(@codemirror/language@6.10.6)(@codemirror/legacy-modes@6.4.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.21)(@lezer/lr@1.4.2) + version: 4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/language-data@6.5.2)(@codemirror/language@6.12.2)(@codemirror/legacy-modes@6.5.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) '@uiw/react-codemirror': specifier: ^4.21.9 - version: 4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.3)(@codemirror/language@6.10.6)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.35.0)(codemirror@6.0.1)(react-dom@18.3.1)(react@18.3.1) + version: 4.23.6(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.35.0)(codemirror@6.0.2)(react-dom@18.3.1)(react@18.3.1) '@umijs/max': specifier: ^4.4.4 version: 4.4.4(@types/node@17.0.45)(@types/react-dom@18.3.1)(@types/react@18.3.12)(prettier@2.8.8)(react-dom@18.3.1)(react@18.3.1)(sockjs-client@1.6.1)(typescript@5.2.2) @@ -934,7 +930,7 @@ packages: '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 convert-source-map: 2.0.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -957,7 +953,7 @@ packages: '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 convert-source-map: 2.0.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1350,6 +1346,11 @@ packages: regenerator-runtime: 0.14.1 dev: true + /@babel/runtime@7.28.6: + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/template@7.25.9: resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -1368,7 +1369,7 @@ packages: '@babel/parser': 7.26.2 '@babel/template': 7.25.9 '@babel/types': 7.26.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -1416,6 +1417,24 @@ packages: '@lezer/common': 1.2.3 dev: true + /@codemirror/autocomplete@6.20.1: + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + dev: true + + /@codemirror/commands@6.10.2: + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + dev: true + /@codemirror/commands@6.7.1: resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} dependencies: @@ -1436,6 +1455,17 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@codemirror/lang-angular@0.1.4: + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@codemirror/lang-cpp@6.0.2: resolution: {integrity: sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==} dependencies: @@ -1443,6 +1473,13 @@ packages: '@lezer/cpp': 1.1.2 dev: true + /@codemirror/lang-cpp@6.0.3: + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/cpp': 1.1.5 + dev: true + /@codemirror/lang-css@6.3.1(@codemirror/view@6.35.0): resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} dependencies: @@ -1455,16 +1492,28 @@ packages: - '@codemirror/view' dev: true - /@codemirror/lang-go@6.0.1(@codemirror/view@6.35.0): + /@codemirror/lang-go@6.0.1: resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) - '@codemirror/language': 6.10.6 + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.3 - '@lezer/go': 1.0.0 - transitivePeerDependencies: - - '@codemirror/view' + '@lezer/common': 1.5.1 + '@lezer/go': 1.0.1 + dev: true + + /@codemirror/lang-html@6.4.11: + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + '@lezer/html': 1.3.13 dev: true /@codemirror/lang-html@6.4.9: @@ -1488,6 +1537,13 @@ packages: '@lezer/java': 1.1.3 dev: true + /@codemirror/lang-java@6.0.2: + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/java': 1.1.3 + dev: true + /@codemirror/lang-javascript@6.2.2: resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==} dependencies: @@ -1500,6 +1556,28 @@ packages: '@lezer/javascript': 1.4.21 dev: true + /@codemirror/lang-javascript@6.2.5: + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + dev: true + + /@codemirror/lang-jinja@6.0.0: + resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==} + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@codemirror/lang-json@6.0.1: resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==} dependencies: @@ -1507,6 +1585,13 @@ packages: '@lezer/json': 1.0.2 dev: true + /@codemirror/lang-json@6.0.2: + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/json': 1.0.3 + dev: true + /@codemirror/lang-less@6.0.2(@codemirror/view@6.35.0): resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} dependencies: @@ -1541,6 +1626,19 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@codemirror/lang-liquid@6.3.2: + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@codemirror/lang-markdown@6.3.1: resolution: {integrity: sha512-y3sSPuQjBKZQbQwe3ZJKrSW6Silyl9PnrU/Mf0m2OQgIlPoSYTtOvEL7xs94SVMkb8f4x+SQFnzXPdX4Wk2lsg==} dependencies: @@ -1553,6 +1651,18 @@ packages: '@lezer/markdown': 1.3.2 dev: true + /@codemirror/lang-markdown@6.5.0: + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + '@lezer/markdown': 1.6.3 + dev: true + /@codemirror/lang-php@6.0.1: resolution: {integrity: sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==} dependencies: @@ -1563,6 +1673,16 @@ packages: '@lezer/php': 1.0.2 dev: true + /@codemirror/lang-php@6.0.2: + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@lezer/common': 1.5.1 + '@lezer/php': 1.0.5 + dev: true + /@codemirror/lang-python@6.1.6(@codemirror/view@6.35.0): resolution: {integrity: sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==} dependencies: @@ -1575,6 +1695,16 @@ packages: - '@codemirror/view' dev: true + /@codemirror/lang-python@6.2.1: + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@lezer/common': 1.5.1 + '@lezer/python': 1.1.18 + dev: true + /@codemirror/lang-rust@6.0.1: resolution: {integrity: sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==} dependencies: @@ -1582,6 +1712,13 @@ packages: '@lezer/rust': 1.0.2 dev: true + /@codemirror/lang-rust@6.0.2: + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + dependencies: + '@codemirror/language': 6.12.2 + '@lezer/rust': 1.0.2 + dev: true + /@codemirror/lang-sass@6.0.2(@codemirror/view@6.35.0): resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} dependencies: @@ -1594,6 +1731,17 @@ packages: - '@codemirror/view' dev: true + /@codemirror/lang-sql@6.10.0: + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/state': 6.4.1 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@codemirror/lang-sql@6.8.0(@codemirror/view@6.35.0): resolution: {integrity: sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==} dependencies: @@ -1638,44 +1786,44 @@ packages: '@lezer/xml': 1.0.5 dev: true - /@codemirror/lang-yaml@6.1.1(@codemirror/view@6.35.0): - resolution: {integrity: sha512-HV2NzbK9bbVnjWxwObuZh5FuPCowx51mEfoFT9y3y+M37fA3+pbxx4I7uePuygFzDsAmCTwQSc/kXh/flab4uw==} + /@codemirror/lang-yaml@6.1.2: + resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) - '@codemirror/language': 6.10.6 + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/yaml': 1.0.3 - transitivePeerDependencies: - - '@codemirror/view' + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/yaml': 1.0.4 dev: true - /@codemirror/language-data@6.5.1(@codemirror/view@6.35.0): - resolution: {integrity: sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==} + /@codemirror/language-data@6.5.2(@codemirror/view@6.35.0): + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} dependencies: - '@codemirror/lang-angular': 0.1.3 - '@codemirror/lang-cpp': 6.0.2 + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) - '@codemirror/lang-go': 6.0.1(@codemirror/view@6.35.0) - '@codemirror/lang-html': 6.4.9 - '@codemirror/lang-java': 6.0.1 - '@codemirror/lang-javascript': 6.2.2 - '@codemirror/lang-json': 6.0.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.0 + '@codemirror/lang-json': 6.0.2 '@codemirror/lang-less': 6.0.2(@codemirror/view@6.35.0) - '@codemirror/lang-liquid': 6.2.2 - '@codemirror/lang-markdown': 6.3.1 - '@codemirror/lang-php': 6.0.1 - '@codemirror/lang-python': 6.1.6(@codemirror/view@6.35.0) - '@codemirror/lang-rust': 6.0.1 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.35.0) - '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.35.0) + '@codemirror/lang-sql': 6.10.0 '@codemirror/lang-vue': 0.1.3 '@codemirror/lang-wast': 6.0.2 '@codemirror/lang-xml': 6.1.0 - '@codemirror/lang-yaml': 6.1.1(@codemirror/view@6.35.0) - '@codemirror/language': 6.10.6 - '@codemirror/legacy-modes': 6.4.2 + '@codemirror/lang-yaml': 6.1.2 + '@codemirror/language': 6.12.2 + '@codemirror/legacy-modes': 6.5.2 transitivePeerDependencies: - '@codemirror/view' dev: true @@ -1691,10 +1839,21 @@ packages: style-mod: 4.1.2 dev: true - /@codemirror/legacy-modes@6.4.2: - resolution: {integrity: sha512-HsvWu08gOIIk303eZQCal4H4t65O/qp1V4ul4zVa3MHK5FJ0gz3qz3O55FIkm+aQUcshUOjBx38t2hPiJwW5/g==} + /@codemirror/language@6.12.2: + resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} dependencies: - '@codemirror/language': 6.10.6 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.35.0 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + dev: true + + /@codemirror/legacy-modes@6.5.2: + resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} + dependencies: + '@codemirror/language': 6.12.2 dev: true /@codemirror/lint@6.8.4: @@ -1705,25 +1864,39 @@ packages: crelt: 1.0.6 dev: true - /@codemirror/search@6.5.8: - resolution: {integrity: sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==} + /@codemirror/lint@6.9.5: + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} dependencies: '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 crelt: 1.0.6 dev: true + /@codemirror/search@6.6.0: + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + dependencies: + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.39.16 + crelt: 1.0.6 + dev: true + /@codemirror/state@6.4.1: resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} dev: true - /@codemirror/theme-one-dark@6.1.2: - resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==} + /@codemirror/state@6.5.4: + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} dependencies: - '@codemirror/language': 6.10.6 + '@marijn/find-cluster-break': 1.0.2 + dev: true + + /@codemirror/theme-one-dark@6.1.3: + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + dependencies: + '@codemirror/language': 6.12.2 '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 - '@lezer/highlight': 1.2.1 + '@lezer/highlight': 1.2.3 dev: true /@codemirror/view@6.35.0: @@ -1734,6 +1907,15 @@ packages: w3c-keyname: 2.2.8 dev: true + /@codemirror/view@6.39.16: + resolution: {integrity: sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==} + dependencies: + '@codemirror/state': 6.5.4 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + dev: true + /@colors/colors@1.6.0: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -2643,7 +2825,7 @@ packages: '@antfu/install-pkg': 0.1.1 '@antfu/utils': 0.7.10 '@iconify/types': 2.0.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 kolorist: 1.8.0 local-pkg: 0.4.3 transitivePeerDependencies: @@ -2656,7 +2838,7 @@ packages: dependencies: string-width: 5.1.2 string-width-cjs: /string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 strip-ansi-cjs: /strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: /wrap-ansi@7.0.0 @@ -2809,6 +2991,10 @@ packages: resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==} dev: true + /@lezer/common@1.5.1: + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + dev: true + /@lezer/cpp@1.1.2: resolution: {integrity: sha512-macwKtyeUO0EW86r3xWQCzOV9/CF8imJLpJlPv3sDY57cPGeUZ8gXWOWNlJr52TVByMV3PayFQCA5SHEERDmVQ==} dependencies: @@ -2817,6 +3003,14 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@lezer/cpp@1.1.5: + resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@lezer/css@1.1.9: resolution: {integrity: sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==} dependencies: @@ -2825,12 +3019,20 @@ packages: '@lezer/lr': 1.4.2 dev: true - /@lezer/go@1.0.0: - resolution: {integrity: sha512-co9JfT3QqX1YkrMmourYw2Z8meGC50Ko4d54QEcQbEYpvdUvN4yb0NBZdn/9ertgvjsySxHsKzH3lbm3vqJ4Jw==} + /@lezer/css@1.3.1: + resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==} dependencies: - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + + /@lezer/go@1.0.1: + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 dev: true /@lezer/highlight@1.2.1: @@ -2839,6 +3041,12 @@ packages: '@lezer/common': 1.2.3 dev: true + /@lezer/highlight@1.2.3: + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + dependencies: + '@lezer/common': 1.5.1 + dev: true + /@lezer/html@1.3.10: resolution: {integrity: sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==} dependencies: @@ -2847,6 +3055,14 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@lezer/html@1.3.13: + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@lezer/java@1.1.3: resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} dependencies: @@ -2863,6 +3079,14 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@lezer/javascript@1.5.4: + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@lezer/json@1.0.2: resolution: {integrity: sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==} dependencies: @@ -2871,6 +3095,14 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@lezer/json@1.0.3: + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@lezer/lezer@1.1.2: resolution: {integrity: sha512-O8yw3CxPhzYHB1hvwbdozjnAslhhR8A5BH7vfEMof0xk3p+/DFDfZkA9Tde6J+88WgtwaHy4Sy6ThZSkaI0Evw==} dependencies: @@ -2884,6 +3116,12 @@ packages: '@lezer/common': 1.2.3 dev: true + /@lezer/lr@1.4.8: + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + dependencies: + '@lezer/common': 1.5.1 + dev: true + /@lezer/markdown@1.3.2: resolution: {integrity: sha512-Wu7B6VnrKTbBEohqa63h5vxXjiC4pO5ZQJ/TDbhJxPQaaIoRD/6UVDhSDtVsCwVZV12vvN9KxuLL3ATMnlG0oQ==} dependencies: @@ -2891,6 +3129,13 @@ packages: '@lezer/highlight': 1.2.1 dev: true + /@lezer/markdown@1.6.3: + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + dev: true + /@lezer/php@1.0.2: resolution: {integrity: sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==} dependencies: @@ -2899,6 +3144,14 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@lezer/php@1.0.5: + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@lezer/python@1.1.14: resolution: {integrity: sha512-ykDOb2Ti24n76PJsSa4ZoDF0zH12BSw1LGfQXCYJhJyOGiFTfGaX0Du66Ze72R+u/P35U+O6I9m8TFXov1JzsA==} dependencies: @@ -2907,6 +3160,14 @@ packages: '@lezer/lr': 1.4.2 dev: true + /@lezer/python@1.1.18: + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + dev: true + /@lezer/rust@1.0.2: resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} dependencies: @@ -2931,12 +3192,12 @@ packages: '@lezer/lr': 1.4.2 dev: true - /@lezer/yaml@1.0.3: - resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==} + /@lezer/yaml@1.0.4: + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} dependencies: - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 dev: true /@loadable/component@5.15.2(react@18.3.1): @@ -2958,20 +3219,24 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true dependencies: - detect-libc: 2.0.3 + detect-libc: 2.1.2 https-proxy-agent: 5.0.1 make-dir: 3.1.0 node-fetch: 2.7.0 nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.6.3 + semver: 7.7.4 tar: 6.2.1 transitivePeerDependencies: - encoding - supports-color dev: false + /@marijn/find-cluster-break@1.0.2: + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + dev: true + /@monaco-editor/loader@1.4.0(monaco-editor@0.33.0): resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} peerDependencies: @@ -3006,8 +3271,8 @@ packages: state-local: 1.0.7 dev: true - /@napi-rs/nice-android-arm-eabi@1.0.1: - resolution: {integrity: sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==} + /@napi-rs/nice-android-arm-eabi@1.1.1: + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} cpu: [arm] os: [android] @@ -3015,8 +3280,8 @@ packages: dev: true optional: true - /@napi-rs/nice-android-arm64@1.0.1: - resolution: {integrity: sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==} + /@napi-rs/nice-android-arm64@1.1.1: + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} engines: {node: '>= 10'} cpu: [arm64] os: [android] @@ -3024,8 +3289,8 @@ packages: dev: true optional: true - /@napi-rs/nice-darwin-arm64@1.0.1: - resolution: {integrity: sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==} + /@napi-rs/nice-darwin-arm64@1.1.1: + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -3033,8 +3298,8 @@ packages: dev: true optional: true - /@napi-rs/nice-darwin-x64@1.0.1: - resolution: {integrity: sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==} + /@napi-rs/nice-darwin-x64@1.1.1: + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -3042,8 +3307,8 @@ packages: dev: true optional: true - /@napi-rs/nice-freebsd-x64@1.0.1: - resolution: {integrity: sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==} + /@napi-rs/nice-freebsd-x64@1.1.1: + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] @@ -3051,8 +3316,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-arm-gnueabihf@1.0.1: - resolution: {integrity: sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==} + /@napi-rs/nice-linux-arm-gnueabihf@1.1.1: + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} engines: {node: '>= 10'} cpu: [arm] os: [linux] @@ -3060,8 +3325,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-arm64-gnu@1.0.1: - resolution: {integrity: sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==} + /@napi-rs/nice-linux-arm64-gnu@1.1.1: + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -3069,8 +3334,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-arm64-musl@1.0.1: - resolution: {integrity: sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==} + /@napi-rs/nice-linux-arm64-musl@1.1.1: + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -3078,8 +3343,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-ppc64-gnu@1.0.1: - resolution: {integrity: sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==} + /@napi-rs/nice-linux-ppc64-gnu@1.1.1: + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] @@ -3087,8 +3352,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-riscv64-gnu@1.0.1: - resolution: {integrity: sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==} + /@napi-rs/nice-linux-riscv64-gnu@1.1.1: + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] @@ -3096,8 +3361,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-s390x-gnu@1.0.1: - resolution: {integrity: sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==} + /@napi-rs/nice-linux-s390x-gnu@1.1.1: + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] @@ -3105,8 +3370,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-x64-gnu@1.0.1: - resolution: {integrity: sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==} + /@napi-rs/nice-linux-x64-gnu@1.1.1: + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -3114,8 +3379,8 @@ packages: dev: true optional: true - /@napi-rs/nice-linux-x64-musl@1.0.1: - resolution: {integrity: sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==} + /@napi-rs/nice-linux-x64-musl@1.1.1: + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -3123,8 +3388,17 @@ packages: dev: true optional: true - /@napi-rs/nice-win32-arm64-msvc@1.0.1: - resolution: {integrity: sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==} + /@napi-rs/nice-openharmony-arm64@1.1.1: + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@napi-rs/nice-win32-arm64-msvc@1.1.1: + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -3132,8 +3406,8 @@ packages: dev: true optional: true - /@napi-rs/nice-win32-ia32-msvc@1.0.1: - resolution: {integrity: sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==} + /@napi-rs/nice-win32-ia32-msvc@1.1.1: + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -3141,8 +3415,8 @@ packages: dev: true optional: true - /@napi-rs/nice-win32-x64-msvc@1.0.1: - resolution: {integrity: sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==} + /@napi-rs/nice-win32-x64-msvc@1.1.1: + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3150,27 +3424,28 @@ packages: dev: true optional: true - /@napi-rs/nice@1.0.1: - resolution: {integrity: sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==} + /@napi-rs/nice@1.1.1: + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} engines: {node: '>= 10'} requiresBuild: true optionalDependencies: - '@napi-rs/nice-android-arm-eabi': 1.0.1 - '@napi-rs/nice-android-arm64': 1.0.1 - '@napi-rs/nice-darwin-arm64': 1.0.1 - '@napi-rs/nice-darwin-x64': 1.0.1 - '@napi-rs/nice-freebsd-x64': 1.0.1 - '@napi-rs/nice-linux-arm-gnueabihf': 1.0.1 - '@napi-rs/nice-linux-arm64-gnu': 1.0.1 - '@napi-rs/nice-linux-arm64-musl': 1.0.1 - '@napi-rs/nice-linux-ppc64-gnu': 1.0.1 - '@napi-rs/nice-linux-riscv64-gnu': 1.0.1 - '@napi-rs/nice-linux-s390x-gnu': 1.0.1 - '@napi-rs/nice-linux-x64-gnu': 1.0.1 - '@napi-rs/nice-linux-x64-musl': 1.0.1 - '@napi-rs/nice-win32-arm64-msvc': 1.0.1 - '@napi-rs/nice-win32-ia32-msvc': 1.0.1 - '@napi-rs/nice-win32-x64-msvc': 1.0.1 + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 dev: true optional: true @@ -3219,7 +3494,7 @@ packages: requiresBuild: true dependencies: '@gar/promisify': 1.1.3 - semver: 7.6.3 + semver: 7.7.4 dev: false optional: true @@ -3396,7 +3671,7 @@ packages: engines: {node: '>=14.0.0'} dev: true - /@replit/codemirror-lang-csharp@6.2.0(@codemirror/autocomplete@6.18.3)(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2): + /@replit/codemirror-lang-csharp@6.2.0(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8): resolution: {integrity: sha512-6utbaWkoymhoAXj051mkRp+VIJlpwUgCX9Toevz3YatiZsz512fw3OVCedXQx+WcR0wb6zVHjChnuxqfCLtFVQ==} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -3407,16 +3682,16 @@ packages: '@lezer/highlight': ^1.0.0 '@lezer/lr': ^1.0.0 dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) - '@codemirror/language': 6.10.6 + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 dev: true - /@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.18.3)(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2): + /@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8): resolution: {integrity: sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -3427,25 +3702,25 @@ packages: '@lezer/highlight': ^1.0.0 '@lezer/lr': ^1.0.0 dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) - '@codemirror/language': 6.10.6 + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 dev: true - /@replit/codemirror-lang-solidity@6.0.2(@codemirror/language@6.10.6): + /@replit/codemirror-lang-solidity@6.0.2(@codemirror/language@6.12.2): resolution: {integrity: sha512-/dpTVH338KFV6SaDYYSadkB4bI/0B0QRF/bkt1XS3t3QtyR49mn6+2k0OUQhvt2ZSO7kt10J+OPilRAtgbmX0w==} peerDependencies: '@codemirror/language': ^6.0.0 dependencies: - '@codemirror/language': 6.10.6 + '@codemirror/language': 6.12.2 '@lezer/highlight': 1.2.1 dev: true - /@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.18.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.21)(@lezer/lr@1.4.2): + /@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8): resolution: {integrity: sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -3460,17 +3735,17 @@ packages: '@lezer/javascript': ^1.2.0 '@lezer/lr': ^1.0.0 dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.20.1 '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) '@codemirror/lang-html': 6.4.9 '@codemirror/lang-javascript': 6.2.2 - '@codemirror/language': 6.10.6 + '@codemirror/language': 6.12.2 '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/javascript': 1.4.21 - '@lezer/lr': 1.4.2 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/javascript': 1.5.4 + '@lezer/lr': 1.4.8 dev: true /@sideway/address@4.1.5: @@ -4145,12 +4420,12 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.35.0)(typescript@5.2.2) '@typescript-eslint/utils': 5.62.0(eslint@8.35.0)(typescript@5.2.2) - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 eslint: 8.35.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare-lite: 1.4.0 - semver: 7.6.3 + semver: 7.7.4 tsutils: 3.21.0(typescript@5.2.2) typescript: 5.2.2 transitivePeerDependencies: @@ -4172,7 +4447,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 eslint: 8.35.0 typescript: 5.2.2 transitivePeerDependencies: @@ -4201,7 +4476,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) '@typescript-eslint/utils': 5.62.0(eslint@8.35.0)(typescript@5.2.2) - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 eslint: 8.35.0 tsutils: 3.21.0(typescript@5.2.2) typescript: 5.2.2 @@ -4225,10 +4500,10 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.6.3 + semver: 7.7.4 tsutils: 3.21.0(typescript@5.2.2) typescript: 5.2.2 transitivePeerDependencies: @@ -4252,7 +4527,7 @@ packages: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) eslint: 8.35.0 eslint-scope: 5.1.1 - semver: 7.6.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color - typescript @@ -4266,7 +4541,7 @@ packages: eslint-visitor-keys: 3.4.3 dev: true - /@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.18.3)(@codemirror/commands@6.7.1)(@codemirror/language@6.10.6)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0): + /@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.7.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0): resolution: {integrity: sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' @@ -4277,16 +4552,16 @@ packages: '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.20.1 '@codemirror/commands': 6.7.1 - '@codemirror/language': 6.10.6 - '@codemirror/lint': 6.8.4 - '@codemirror/search': 6.5.8 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 dev: true - /@uiw/codemirror-extensions-langs@4.23.6(@codemirror/autocomplete@6.18.3)(@codemirror/language-data@6.5.1)(@codemirror/language@6.10.6)(@codemirror/legacy-modes@6.4.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.21)(@lezer/lr@1.4.2): + /@uiw/codemirror-extensions-langs@4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/language-data@6.5.2)(@codemirror/language@6.12.2)(@codemirror/legacy-modes@6.5.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8): resolution: {integrity: sha512-VKWbEXmVq3EFYrJPWXH4Ei1f92zxuAg6dOlo8suSmwjmEc0qjNEP5Ss2CUi9LlzuWMGMmZgdKw56I3L71wYOog==} peerDependencies: '@codemirror/language-data': '>=6.0.0' @@ -4311,13 +4586,13 @@ packages: '@codemirror/lang-vue': 0.1.3 '@codemirror/lang-wast': 6.0.2 '@codemirror/lang-xml': 6.1.0 - '@codemirror/language-data': 6.5.1(@codemirror/view@6.35.0) - '@codemirror/legacy-modes': 6.4.2 + '@codemirror/language-data': 6.5.2(@codemirror/view@6.35.0) + '@codemirror/legacy-modes': 6.5.2 '@nextjournal/lang-clojure': 1.0.0 - '@replit/codemirror-lang-csharp': 6.2.0(@codemirror/autocomplete@6.18.3)(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2) - '@replit/codemirror-lang-nix': 6.0.1(@codemirror/autocomplete@6.18.3)(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/lr@1.4.2) - '@replit/codemirror-lang-solidity': 6.0.2(@codemirror/language@6.10.6) - '@replit/codemirror-lang-svelte': 6.0.0(@codemirror/autocomplete@6.18.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.21)(@lezer/lr@1.4.2) + '@replit/codemirror-lang-csharp': 6.2.0(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8) + '@replit/codemirror-lang-nix': 6.0.1(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8) + '@replit/codemirror-lang-solidity': 6.0.2(@codemirror/language@6.12.2) + '@replit/codemirror-lang-svelte': 6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) codemirror-lang-mermaid: 0.5.0 transitivePeerDependencies: - '@codemirror/autocomplete' @@ -4330,7 +4605,7 @@ packages: - '@lezer/lr' dev: true - /@uiw/react-codemirror@4.23.6(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.3)(@codemirror/language@6.10.6)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.35.0)(codemirror@6.0.1)(react-dom@18.3.1)(react@18.3.1): + /@uiw/react-codemirror@4.23.6(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.35.0)(codemirror@6.0.2)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==} peerDependencies: '@babel/runtime': '>=7.11.0' @@ -4346,13 +4621,13 @@ packages: react-dom: optional: true dependencies: - '@babel/runtime': 7.26.0 + '@babel/runtime': 7.28.6 '@codemirror/commands': 6.7.1 '@codemirror/state': 6.4.1 - '@codemirror/theme-one-dark': 6.1.2 + '@codemirror/theme-one-dark': 6.1.3 '@codemirror/view': 6.35.0 - '@uiw/codemirror-extensions-basic-setup': 4.23.6(@codemirror/autocomplete@6.18.3)(@codemirror/commands@6.7.1)(@codemirror/language@6.10.6)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0) - codemirror: 6.0.1(@lezer/common@1.2.3) + '@uiw/codemirror-extensions-basic-setup': 4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.7.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0) + codemirror: 6.0.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -4726,7 +5001,7 @@ packages: react-error-overlay: 6.0.9 react-refresh: 0.14.2 resolve: 1.22.8 - semver: 7.6.3 + semver: 7.7.4 yargs-parser: 21.1.1 optionalDependencies: '@umijs/mako-darwin-arm64': 0.11.1 @@ -5110,7 +5385,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color dev: false @@ -5120,6 +5395,15 @@ packages: engines: {node: '>= 8.0.0'} dependencies: humanize-ms: 1.2.1 + dev: true + + /agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + dependencies: + humanize-ms: 1.2.1 + dev: false + optional: true /aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} @@ -5224,8 +5508,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - /ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + /ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} dev: true @@ -5367,8 +5651,8 @@ packages: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} dev: false - /aproba@2.0.0: - resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + /aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} dev: false /are-we-there-yet@2.0.0: @@ -5814,9 +6098,16 @@ packages: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 + dev: true - /brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + /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 @@ -6198,18 +6489,16 @@ packages: '@lezer/lr': 1.4.2 dev: true - /codemirror@6.0.1(@lezer/common@1.2.3): - resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} + /codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) - '@codemirror/commands': 6.7.1 - '@codemirror/language': 6.10.6 - '@codemirror/lint': 6.8.4 - '@codemirror/search': 6.5.8 + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.2 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.6.0 '@codemirror/state': 6.4.1 '@codemirror/view': 6.35.0 - transitivePeerDependencies: - - '@lezer/common' dev: true /color-convert@1.9.3: @@ -6333,13 +6622,13 @@ packages: /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - /concat-stream@1.6.2: - resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} - engines: {'0': node >= 0.8} + /concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} dependencies: buffer-from: 1.1.2 inherits: 2.0.4 - readable-stream: 2.3.8 + readable-stream: 3.6.2 typedarray: 0.0.6 dev: false @@ -6436,6 +6725,7 @@ packages: /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: true /cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} @@ -6616,7 +6906,7 @@ packages: postcss-modules-scope: 3.2.1(postcss@8.4.49) postcss-modules-values: 4.0.0(postcss@8.4.49) postcss-value-parser: 4.2.0 - semver: 7.6.3 + semver: 7.7.4 dev: true /css-prefers-color-scheme@6.0.3(postcss@8.4.49): @@ -6795,6 +7085,17 @@ packages: ms: 2.1.3 supports-color: 5.5.0 + /debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} engines: {node: '>=0.10.0'} @@ -6914,8 +7215,8 @@ packages: hasBin: true dev: true - /detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} dev: false @@ -8082,10 +8383,10 @@ packages: deepmerge: 4.3.1 fs-extra: 10.1.0 memfs: 3.5.3 - minimatch: 3.1.2 + minimatch: 3.1.5 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.6.3 + semver: 7.7.4 tapable: 2.2.1 typescript: 5.2.2 dev: true @@ -8185,7 +8486,7 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. dependencies: - aproba: 2.0.0 + aproba: 2.1.0 color-support: 1.1.3 console-control-strings: 1.1.0 has-unicode: 2.0.1 @@ -8202,7 +8503,7 @@ packages: deprecated: This package is no longer supported. requiresBuild: true dependencies: - aproba: 2.0.0 + aproba: 2.1.0 color-support: 1.1.3 console-control-strings: 1.1.0 has-unicode: 2.0.1 @@ -8286,26 +8587,27 @@ packages: is-glob: 4.0.3 dev: true - /glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + /glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true dependencies: foreground-child: 3.3.0 jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 dev: true /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 @@ -8575,9 +8877,8 @@ packages: entities: 2.2.0 dev: true - /http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} - requiresBuild: true + /http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} dev: false optional: true @@ -8605,7 +8906,7 @@ packages: dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color dev: false @@ -8645,7 +8946,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color dev: false @@ -8822,13 +9123,9 @@ packages: loose-envify: 1.4.0 dev: true - /ip-address@9.0.5: - resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + /ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - requiresBuild: true - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.3 dev: false optional: true @@ -9171,6 +9468,7 @@ packages: /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + dev: true /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -9325,12 +9623,6 @@ packages: dependencies: argparse: 2.0.1 - /jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - requiresBuild: true - dev: false - optional: true - /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} @@ -9741,7 +10033,7 @@ packages: ansi-escapes: 5.0.0 cli-cursor: 4.0.0 slice-ansi: 5.0.0 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 wrap-ansi: 8.1.0 dev: true @@ -9830,9 +10122,9 @@ packages: engines: {node: '>= 10'} requiresBuild: true dependencies: - agentkeepalive: 4.5.0 + agentkeepalive: 4.6.0 cacache: 15.3.0 - http-cache-semantics: 4.1.1 + http-cache-semantics: 4.2.0 http-proxy-agent: 4.0.1 https-proxy-agent: 5.0.1 is-lambda: 1.0.1 @@ -10018,12 +10310,18 @@ packages: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 + dev: true - /minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + /minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + dependencies: + 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.0.1 + brace-expansion: 2.0.2 dev: true /minimist-options@4.1.0: @@ -10037,6 +10335,7 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} @@ -10099,8 +10398,8 @@ packages: engines: {node: '>=8'} dev: false - /minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + /minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} dev: true @@ -10117,6 +10416,7 @@ packages: hasBin: true dependencies: minimist: 1.2.8 + dev: true /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} @@ -10151,17 +10451,14 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /multer@1.4.5-lts.1: - resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} - engines: {node: '>= 6.0.0'} + /multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} dependencies: append-field: 1.0.0 busboy: 1.6.0 - concat-stream: 1.6.2 - mkdirp: 0.5.6 - object-assign: 4.1.1 + concat-stream: 2.0.0 type-is: 1.6.18 - xtend: 4.0.2 dev: false /mz@2.7.0: @@ -10263,7 +10560,7 @@ packages: nopt: 5.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.6.3 + semver: 7.7.4 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -10395,7 +10692,7 @@ packages: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.15.1 - semver: 7.6.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 dev: true @@ -10781,7 +11078,7 @@ packages: engines: {node: '>=16 || 14 >=14.18'} dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 dev: true /path-to-regexp@0.1.12: @@ -10892,7 +11189,7 @@ packages: /piscina@4.7.0: resolution: {integrity: sha512-b8hvkpp9zS0zsfa939b/jXbe64Z2gZv0Ha7FYPNUiDIB1y2AtxcOZdfP8xN8HFjUaqQiT9gRlfjAsoL8vdJ1Iw==} optionalDependencies: - '@napi-rs/nice': 1.0.1 + '@napi-rs/nice': 1.1.1 dev: true /point-in-polygon@1.1.0: @@ -11530,6 +11827,7 @@ packages: /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: true /process-okam@0.11.10: resolution: {integrity: sha512-p8e5nl6/OCeMalVb9dSojND5B9m/nq64WsyUfRmrTdLMKcNYcDN++/2I8WV1mTQDqrh2PQ6tIIb2A7/A38eSvw==} @@ -12951,6 +13249,7 @@ packages: safe-buffer: 5.1.2 string_decoder: 1.1.1 util-deprecate: 1.0.2 + dev: true /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} @@ -13156,7 +13455,7 @@ packages: engines: {node: '>=14'} hasBin: true dependencies: - glob: 10.4.5 + glob: 10.5.0 dev: true /ripemd160@2.0.2: @@ -13221,6 +13520,7 @@ packages: /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + dev: true /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -13301,6 +13601,11 @@ packages: engines: {node: '>=10'} hasBin: true + /semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + /send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -13555,19 +13860,18 @@ packages: requiresBuild: true dependencies: agent-base: 6.0.2 - debug: 4.3.7(supports-color@5.5.0) - socks: 2.8.3 + debug: 4.4.3 + socks: 2.8.7 transitivePeerDependencies: - supports-color dev: false optional: true - /socks@2.8.3: - resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} + /socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - requiresBuild: true dependencies: - ip-address: 9.0.5 + ip-address: 10.1.0 smart-buffer: 4.2.0 dev: false optional: true @@ -13654,7 +13958,7 @@ packages: /spdy-transport@3.0.0: resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} dependencies: - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 detect-node: 2.1.0 hpack.js: 2.1.6 obuf: 1.1.2 @@ -13668,7 +13972,7 @@ packages: resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} engines: {node: '>=6.0.0'} dependencies: - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 handle-thing: 2.0.1 http-deceiver: 1.2.7 select-hose: 2.0.0 @@ -13702,12 +14006,6 @@ packages: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true - /sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - requiresBuild: true - dev: false - optional: true - /ssri@8.0.1: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} @@ -13810,7 +14108,7 @@ packages: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 dev: true /string.prototype.matchall@4.0.11: @@ -13868,6 +14166,7 @@ packages: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: safe-buffer: 5.1.2 + dev: true /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -13880,11 +14179,11 @@ packages: dependencies: ansi-regex: 5.0.1 - /strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + /strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} dependencies: - ansi-regex: 6.1.0 + ansi-regex: 6.2.2 dev: true /strip-final-newline@2.0.0: @@ -13913,6 +14212,10 @@ packages: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} dev: true + /style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + dev: true + /style-search@0.1.0: resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==} dev: true @@ -14152,6 +14455,7 @@ packages: /tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: chownr: 2.0.0 fs-minipass: 2.1.0 @@ -14178,7 +14482,7 @@ packages: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.5 dev: true /text-hex@1.0.0: @@ -15123,7 +15427,7 @@ packages: dependencies: ansi-styles: 6.2.1 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 dev: true /wrappy@1.0.2: @@ -15140,6 +15444,7 @@ packages: /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + dev: true /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} @@ -15225,3 +15530,7 @@ packages: - encoding - supports-color dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false From fd516977e3bc66ced448be0529828b35e47e056e Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 7 Mar 2026 22:35:18 +0800 Subject: [PATCH 006/123] chore: upgrade nodemailer --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5858eaf2..1494538a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "lodash": "^4.17.21", "multer": "2.1.1", "node-schedule": "^2.1.0", - "nodemailer": "^6.9.16", + "nodemailer": "^8.0.1", "p-queue-cjs": "7.3.4", "@bufbuild/protobuf": "^2.10.0", "ps-tree": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54c1a45e..03414b75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,8 +92,8 @@ dependencies: specifier: ^2.1.0 version: 2.1.1 nodemailer: - specifier: ^6.9.16 - version: 6.9.16 + specifier: ^8.0.1 + version: 8.0.1 p-queue-cjs: specifier: 7.3.4 version: 7.3.4 @@ -10647,8 +10647,8 @@ packages: sorted-array-functions: 1.3.0 dev: false - /nodemailer@6.9.16: - resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==} + /nodemailer@8.0.1: + resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==} engines: {node: '>=6.0.0'} dev: false From 07bf0c705b13deba5b1850c886c72c2868b8ac91 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:43:56 +0800 Subject: [PATCH 007/123] fix: respect QlPort env var in Docker health check (#2963) * Initial plan * fix: use QlPort env variable in health check with fallback to default 5700 Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- docker/310.Dockerfile | 2 +- docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/310.Dockerfile b/docker/310.Dockerfile index ebd0af81..fb690b57 100644 --- a/docker/310.Dockerfile +++ b/docker/310.Dockerfile @@ -84,6 +84,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ - CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1 + CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 ENTRYPOINT ["./docker/docker-entrypoint.sh"] diff --git a/docker/Dockerfile b/docker/Dockerfile index 8ca6c947..5682ae9d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -84,6 +84,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ - CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1 + CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 ENTRYPOINT ["./docker/docker-entrypoint.sh"] From 66700ebe1a02e675542538737f97ccf874a6b4d5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:01:36 +0800 Subject: [PATCH 008/123] Add OpeniLink notification channel (#2988) * Initial plan * Add OpeniLink notification channel support Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/c80b4882-1bd7-4ffe-9180-cd3220da5986 Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> * Add context_token and hub_url to OpeniLink notification channel Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/a5e66f5a-dab8-4a65-96ca-960d35fa9d50 Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/data/notify.ts | 10 +++++++- back/services/notify.ts | 32 ++++++++++++++++++++++++ sample/config.sample.sh | 9 +++++++ sample/notify.js | 54 +++++++++++++++++++++++++++++++++++++++++ sample/notify.py | 43 ++++++++++++++++++++++++++++++++ src/utils/config.ts | 22 +++++++++++++++++ 6 files changed, 169 insertions(+), 1 deletion(-) diff --git a/back/data/notify.ts b/back/data/notify.ts index b0899500..1d048dfe 100644 --- a/back/data/notify.ts +++ b/back/data/notify.ts @@ -20,6 +20,7 @@ export enum NotificationMode { 'chronocat' = 'Chronocat', 'ntfy' = 'ntfy', 'wxPusherBot' = 'wxPusherBot', + 'openiLink' = 'openiLink', } abstract class NotificationBaseInfo { @@ -161,6 +162,12 @@ export class WxPusherBotNotification extends NotificationBaseInfo { public wxPusherBotUids = ''; } +export class OpeniLinkNotification extends NotificationBaseInfo { + public openiLinkAppToken = ''; + public openiLinkHubUrl = ''; + public openiLinkContextToken = ''; +} + export interface NotificationInfo extends GoCqHttpBotNotification, GotifyNotification, @@ -182,4 +189,5 @@ export interface NotificationInfo ChronocatNotification, LarkNotification, NtfyNotification, - WxPusherBotNotification {} + WxPusherBotNotification, + OpeniLinkNotification {} diff --git a/back/services/notify.ts b/back/services/notify.ts index 22609748..a8b2aeea 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -34,6 +34,7 @@ export default class NotificationService { ['chronocat', this.chronocat], ['ntfy', this.ntfy], ['wxPusherBot', this.wxPusherBot], + ['openiLink', this.openiLink], ]); private title = ''; @@ -858,4 +859,35 @@ export default class NotificationService { } return {}; } + + private async openiLink() { + const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } = + this.params; + const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com'; + const url = `${baseUrl}/bot/v1/message/send`; + const body: Record = { + type: 'text', + content: `${this.title}\n\n${this.content}`, + }; + if (openiLinkContextToken) { + body.context_token = openiLinkContextToken; + } + try { + const res = await httpClient.post(url, { + ...this.gotOption, + json: body, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${openiLinkAppToken}`, + }, + }); + if (res.ok) { + return true; + } else { + throw new Error(JSON.stringify(res)); + } + } catch (error: any) { + throw new Error(error.response ? error.response.body : error); + } + } } diff --git a/sample/config.sample.sh b/sample/config.sample.sh index 734f4ae5..9c93e78f 100644 --- a/sample/config.sample.sh +++ b/sample/config.sample.sh @@ -259,4 +259,13 @@ export WEBHOOK_METHOD="" ## 支持 text/plain、application/json、multipart/form-data、application/x-www-form-urlencoded export WEBHOOK_CONTENT_TYPE="" +## 23. OpeniLink +## 官方文档: https://openilink.com/docs/hub/apps +## 在 OpeniLink Hub 后台安装 App 后获取 app_token +export OPENILINK_APP_TOKEN="" +## OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址 +export OPENILINK_HUB_URL="" +## OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取 +export OPENILINK_CONTEXT_TOKEN="" + ## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可 diff --git a/sample/notify.js b/sample/notify.js index f935e8ce..89803264 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -151,6 +151,11 @@ const push_config = { WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 + + // 官方文档: https://openilink.com/docs/hub/apps + OPENILINK_APP_TOKEN: '', // OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取 + OPENILINK_HUB_URL: '', // OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址 + OPENILINK_CONTEXT_TOKEN: '', // OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取 }; for (const key in push_config) { @@ -1408,6 +1413,54 @@ function wxPusherNotify(text, desp) { }); } +function openiLinkNotify(text, desp) { + return new Promise((resolve) => { + const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } = + push_config; + if (OPENILINK_APP_TOKEN) { + const baseUrl = OPENILINK_HUB_URL + ? OPENILINK_HUB_URL.replace(/\/$/, '') + : 'https://hub.openilink.com'; + const body = { + type: 'text', + content: `${text}\n\n${desp}`, + }; + if (OPENILINK_CONTEXT_TOKEN) { + body.context_token = OPENILINK_CONTEXT_TOKEN; + } + const options = { + url: `${baseUrl}/bot/v1/message/send`, + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENILINK_APP_TOKEN}`, + }, + timeout, + }; + + $.post(options, (err, resp, data) => { + try { + if (err) { + console.log('OpeniLink 发送通知消息失败!\n', err); + } else { + if (data.ok) { + console.log('OpeniLink 发送通知消息成功!'); + } else { + console.log(`OpeniLink 发送通知消息异常:${data.error}`); + } + } + } catch (e) { + $.logErr(e, resp); + } finally { + resolve(data); + } + }); + } else { + resolve(); + } + }); +} + function parseString(input, valueFormatFn) { const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g; const matches = {}; @@ -1538,6 +1591,7 @@ async function sendNotify(text, desp, params = {}) { qmsgNotify(text, desp), // 自定义通知 ntfyNotify(text, desp), // Ntfy wxPusherNotify(text, desp), // wxpusher + openiLinkNotify(text, desp), // OpeniLink ]); } diff --git a/sample/notify.py b/sample/notify.py index 8dd885ca..46383498 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -135,6 +135,10 @@ push_config = { 'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/ 'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 'WXPUSHER_UIDS': '', # wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 + + 'OPENILINK_APP_TOKEN': '', # OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取 官方文档: https://openilink.com/docs/hub/apps + 'OPENILINK_HUB_URL': '', # OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址 + 'OPENILINK_CONTEXT_TOKEN': '', # OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取 } # fmt: on @@ -898,6 +902,43 @@ def wxpusher_bot(title: str, content: str) -> None: print(f"wxpusher 推送失败!错误信息:{response.get('msg')}") +def openilink(title: str, content: str) -> None: + """ + 通过 OpeniLink 推送消息。 + 支持的环境变量: + - OPENILINK_APP_TOKEN: 在 OpeniLink Hub 后台安装 App 后获取的 app_token + - OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com + - OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取 + """ + if not push_config.get("OPENILINK_APP_TOKEN"): + return + + print("OpeniLink 服务启动") + + base_url = ( + push_config.get("OPENILINK_HUB_URL", "").rstrip("/") + or "https://hub.openilink.com" + ) + url = f"{base_url}/bot/v1/message/send" + headers = { + "Content-Type": "application/json", + "Authorization": f'Bearer {push_config.get("OPENILINK_APP_TOKEN")}', + } + data = { + "type": "text", + "content": f"{title}\n\n{content}", + } + if push_config.get("OPENILINK_CONTEXT_TOKEN"): + data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN") + + response = requests.post(url=url, json=data, headers=headers).json() + + if response.get("ok"): + print("OpeniLink 推送成功!") + else: + print(f'OpeniLink 推送失败!错误信息:{response.get("error")}') + + def parse_headers(headers): if not headers: return {} @@ -1063,6 +1104,8 @@ def add_notify_function(): push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS") ): notify_function.append(wxpusher_bot) + if push_config.get("OPENILINK_APP_TOKEN"): + notify_function.append(openilink) if not notify_function: print(f"无推送渠道,请检查通知变量是否正确") return notify_function diff --git a/src/utils/config.ts b/src/utils/config.ts index b529a7d0..f15a48b5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -98,6 +98,7 @@ export default { { value: 'pushPlus', label: 'PushPlus' }, { value: 'wePlusBot', label: intl.get('微加机器人') }, { value: 'wxPusherBot', label: 'wxPusher' }, + { value: 'openiLink', label: 'OpeniLink' }, { value: 'chat', label: intl.get('群晖chat') }, { value: 'email', label: intl.get('邮箱') }, { value: 'lark', label: intl.get('飞书机器人') }, @@ -387,6 +388,27 @@ export default { required: false, }, ], + openiLink: [ + { + label: 'openiLinkAppToken', + tip: intl.get( + 'OpeniLink的app_token,在OpeniLink Hub后台安装App后获取,参考 https://openilink.com/docs/hub/apps', + ), + required: true, + }, + { + label: 'openiLinkHubUrl', + tip: intl.get( + 'OpeniLink Hub地址,默认为 https://hub.openilink.com,自建Hub时填写自己的地址', + ), + }, + { + label: 'openiLinkContextToken', + tip: intl.get( + 'OpeniLink的context_token,用于标识消息会话上下文,可从消息事件中获取', + ), + }, + ], lark: [ { label: 'larkKey', From a1ae08da58c047d27e07725219532212638dda1e Mon Sep 17 00:00:00 2001 From: TengDream <42526114+tenoms@users.noreply.github.com> Date: Wed, 6 May 2026 00:32:29 +0800 Subject: [PATCH 009/123] fix: create root .tmp directory on init for data export (#2993) The data export feature (system backup) writes data.tgz to `config.tmpPath` which resolves to `/.tmp/`. However, `initFile.ts` only created `/log/.tmp/` (used for crontab list temp files), never the root-level `.tmp/` directory. In Docker deployments, `shell/share.sh`'s `fix_config()` creates `$dir_root/.tmp` during shell initialization, but local/non-Docker deployments that start the Node service directly skip the shell init, causing a 404 ENOENT error when attempting to export/backup data. Add `rootTmpPath` (`/.tmp/`) to the directories array in `initFile.ts` so it is created during Node service startup regardless of deployment method. --- back/loaders/initFile.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/back/loaders/initFile.ts b/back/loaders/initFile.ts index 2eab9446..ac629d63 100644 --- a/back/loaders/initFile.ts +++ b/back/loaders/initFile.ts @@ -20,6 +20,7 @@ const uploadPath = path.join(dataPath, 'upload/'); const bakPath = path.join(dataPath, 'bak/'); const samplePath = path.join(rootPath, 'sample/'); const tmpPath = path.join(logPath, '.tmp/'); +const rootTmpPath = path.join(rootPath, '.tmp/'); const confFile = path.join(configPath, 'config.sh'); const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh'); @@ -44,6 +45,7 @@ const directories = [ preloadPath, logPath, tmpPath, + rootTmpPath, uploadPath, sshPath, bakPath, From 1315578878b5db655af86a00e06eaf929a61b883 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 6 May 2026 00:34:26 +0800 Subject: [PATCH 010/123] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=8E=A5=E6=94=B6=E9=82=AE=E7=AE=B1=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=20(#2973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 支持自定义接收邮箱地址 * 新增支持多个接收邮箱,同步到node和系统内置版本 --- back/services/notify.ts | 11 ++++++++++- sample/config.sample.sh | 4 +++- sample/notify.js | 26 ++++++++++++++++++++++---- sample/notify.py | 16 ++++++++-------- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/back/services/notify.ts b/back/services/notify.ts index a8b2aeea..39a0e47b 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -91,6 +91,14 @@ export default class NotificationService { return true; } + private parseMailRecipients(value?: string) { + const recipients = (value || '') + .split(/[;;]/) + .map((item) => item.trim()) + .filter(Boolean); + return recipients.length > 0 ? recipients : undefined; + } + private async gotify() { const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params; try { @@ -592,6 +600,7 @@ export default class NotificationService { private async email() { const { emailPass, emailService, emailUser, emailTo } = this.params; + const recipients = this.parseMailRecipients(emailTo) || emailUser; try { const transporter = nodemailer.createTransport({ @@ -604,7 +613,7 @@ export default class NotificationService { const info = await transporter.sendMail({ from: `"青龙快讯" <${emailUser}>`, - to: emailTo ? emailTo.split(';') : emailUser, + to: recipients, subject: `${this.title}`, html: `${this.content.replace(/\n/g, '
')}`, }); diff --git a/sample/config.sample.sh b/sample/config.sample.sh index 9c93e78f..9f653e56 100644 --- a/sample/config.sample.sh +++ b/sample/config.sample.sh @@ -195,12 +195,14 @@ export SMTP_SERVER="" ## SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false export SMTP_SSL="" -## smtp_email 填写 SMTP 收发件邮箱,通知将会由自己发给自己 +## smtp_email 填写 SMTP 发件邮箱 export SMTP_EMAIL="" ## smtp_password 填写 SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 export SMTP_PASSWORD="" ## smtp_name 填写 SMTP 收发件人姓名,可随意填写 export SMTP_NAME="" +## smtp_email_to 填写 SMTP 收件邮箱,多个用英文;分隔,不填默认发给发件邮箱 +export SMTP_EMAIL_TO="" ## 17. PushMe ## 官方说明文档:https://push.i-i.me/ diff --git a/sample/notify.js b/sample/notify.js index 89803264..c0f3db43 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -121,7 +121,8 @@ const push_config = { SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json SMTP_EMAIL: '', // SMTP 发件邮箱 - SMTP_TO: '', // SMTP 收件邮箱,默认通知将会发给发件邮箱 + SMTP_TO: '', // SMTP 收件邮箱,兼容旧参数名,默认通知将会发给发件邮箱 + SMTP_EMAIL_TO: '', // SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱 SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写 @@ -1051,8 +1052,14 @@ function fsBotNotify(text, desp) { } async function smtpNotify(text, desp) { - const { SMTP_EMAIL, SMTP_TO, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } = - push_config; + const { + SMTP_EMAIL, + SMTP_TO, + SMTP_EMAIL_TO, + SMTP_PASSWORD, + SMTP_SERVICE, + SMTP_NAME, + } = push_config; if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) { return; } @@ -1068,9 +1075,20 @@ async function smtpNotify(text, desp) { }); const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL; + const recipients = [SMTP_EMAIL_TO, SMTP_TO].reduce((list, value) => { + if (!value) { + return list; + } + return list.concat( + value + .split(/[;;]/) + .map((item) => item.trim()) + .filter(Boolean), + ); + }, []); const info = await transporter.sendMail({ from: addr, - to: SMTP_TO ? SMTP_TO.split(';') : addr, + to: recipients.length ? recipients : SMTP_EMAIL, subject: text, html: `${desp.replace(/\n/g, '
')}`, }); diff --git a/sample/notify.py b/sample/notify.py index 46383498..da8bfdcf 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -107,7 +107,8 @@ push_config = { 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false - 'SMTP_EMAIL': '', # SMTP 收发件邮箱,通知将会由自己发给自己 + 'SMTP_EMAIL': '', # SMTP 发件邮箱 + 'SMTP_EMAIL_TO': '', # SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写 @@ -694,6 +695,10 @@ def smtp(title: str, content: str) -> None: return print("SMTP 邮件 服务启动") + email_to = push_config.get("SMTP_EMAIL_TO") or push_config.get("SMTP_EMAIL") + email_to_list = [ + item.strip() for item in re.split(r"[;;]", email_to) if item.strip() + ] message = MIMEText(content, "plain", "utf-8") message["From"] = formataddr( ( @@ -701,12 +706,7 @@ def smtp(title: str, content: str) -> None: push_config.get("SMTP_EMAIL"), ) ) - message["To"] = formataddr( - ( - Header(push_config.get("SMTP_NAME"), "utf-8").encode(), - push_config.get("SMTP_EMAIL"), - ) - ) + message["To"] = ",".join(email_to_list) message["Subject"] = Header(title, "utf-8") try: @@ -720,7 +720,7 @@ def smtp(title: str, content: str) -> None: ) smtp_server.sendmail( push_config.get("SMTP_EMAIL"), - push_config.get("SMTP_EMAIL"), + email_to_list, message.as_bytes(), ) smtp_server.close() From 3464c4da61ebe2eba518fa3442aedbfc11d3d36b Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 6 May 2026 01:29:01 +0800 Subject: [PATCH 011/123] fix IPv6 connectivity --- back/config/index.ts | 4 ++++ back/schedule/client.ts | 2 +- back/services/grpc.ts | 39 ++++++++++++++++++++++++------- back/services/http.ts | 52 ++++++++++++++++++++++++++++------------- 4 files changed, 71 insertions(+), 26 deletions(-) diff --git a/back/config/index.ts b/back/config/index.ts index f7c7a54c..fb1c9ca5 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -9,6 +9,8 @@ dotenv.config({ interface Config { port: number; grpcPort: number; + bindHost: string; + bindHostGrpc: string; nodeEnv: string; isDevelopment: boolean; isProduction: boolean; @@ -31,6 +33,8 @@ interface Config { const config: Config = { port: parseInt(process.env.BACK_PORT || '5700', 10), grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10), + bindHost: process.env.BIND_HOST || '::', + bindHostGrpc: process.env.BIND_HOST_GRPC || '::', nodeEnv: process.env.NODE_ENV || 'development', isDevelopment: process.env.NODE_ENV === 'development', isProduction: process.env.NODE_ENV === 'production', diff --git a/back/schedule/client.ts b/back/schedule/client.ts index 99bf364a..6b086452 100644 --- a/back/schedule/client.ts +++ b/back/schedule/client.ts @@ -10,7 +10,7 @@ import config from '../config'; class Client { private client = new CronClient( - `0.0.0.0:${config.grpcPort}`, + `localhost:${config.grpcPort}`, credentials.createInsecure(), { 'grpc.enable_http_proxy': 0 }, ); diff --git a/back/services/grpc.ts b/back/services/grpc.ts index e8ea2282..5edd9d2c 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -16,6 +16,13 @@ import { Service } from 'typedi'; export class GrpcServerService { private server: Server = new Server({ 'grpc.enable_http_proxy': 0 }); + private formatGrpcAddress(host: string, port: number): string { + if (host === '::') { + return `[::]:${port}`; + } + return `${host}:${port}`; + } + async initialize() { try { this.server.addService(HealthService, { check }); @@ -23,18 +30,32 @@ export class GrpcServerService { this.server.addService(ApiService, Api); const grpcPort = config.grpcPort; + const hostsToTry = [ + config.bindHostGrpc, + ...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : []) + ]; const bindAsync = promisify(this.server.bindAsync).bind(this.server); - await bindAsync( - `0.0.0.0:${grpcPort}`, - ServerCredentials.createInsecure(), - ); - Logger.debug(`✌️ gRPC service started successfully`); - metricsService.record('grpc_service_start', 1, { - port: grpcPort.toString(), - }); + let lastError: Error | null = null; - return grpcPort; + for (const host of hostsToTry) { + try { + const address = this.formatGrpcAddress(host, grpcPort); + await bindAsync(address, ServerCredentials.createInsecure()); + Logger.debug(`✌️ gRPC service started successfully on ${address}`); + metricsService.record('grpc_service_start', 1, { + port: grpcPort.toString(), + host + }); + return grpcPort; + } catch (err) { + lastError = err as Error; + Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err); + } + } + + Logger.error('Failed to start gRPC service on all hosts'); + throw lastError || new Error('Failed to start gRPC service'); } catch (err) { Logger.error('Failed to start gRPC service:', err); throw err; diff --git a/back/services/http.ts b/back/services/http.ts index 5391c842..bbce8ff7 100644 --- a/back/services/http.ts +++ b/back/services/http.ts @@ -3,31 +3,51 @@ import Logger from '../loaders/logger'; import { metricsService } from './metrics'; import { Service } from 'typedi'; import { Server } from 'http'; +import config from '../config'; @Service() export class HttpServerService { private server?: Server = undefined; async initialize(expressApp: express.Application, port: number) { - try { - return new Promise((resolve, reject) => { - this.server = expressApp.listen(port, '0.0.0.0', () => { - Logger.debug(`✌️ HTTP service started successfully`); - metricsService.record('http_service_start', 1, { - port: port.toString(), - }); - resolve(this.server); - }); + const hostsToTry = [ + config.bindHost, + ...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : []) + ]; - this.server?.on('error', (err: Error) => { - Logger.error('Failed to start HTTP service:', err); - reject(err); + let lastError: Error | null = null; + + for (const host of hostsToTry) { + try { + const server = await this.tryListen(expressApp, port, host); + Logger.debug(`✌️ HTTP service started successfully on ${host}:${port}`); + metricsService.record('http_service_start', 1, { + port: port.toString(), + host }); - }); - } catch (err) { - Logger.error('Failed to start HTTP service:', err); - throw err; + this.server = server; + return server; + } catch (err) { + lastError = err as Error; + Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err); + } } + + Logger.error('Failed to start HTTP service on all hosts'); + throw lastError || new Error('Failed to start HTTP service'); + } + + private async tryListen(expressApp: express.Application, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const server = expressApp.listen(port, host, () => { + resolve(server); + }); + + server.on('error', (err: Error) => { + server.close(); + reject(err); + }); + }); } async shutdown() { From 40d4de90179a579e15574c4a77fd7b7b66a842fe Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 17 May 2026 23:43:09 +0800 Subject: [PATCH 012/123] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20localhost=20?= =?UTF-8?q?=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +++++ back/schedule/health.ts | 2 +- back/shared/pLimit.ts | 2 +- docker/310.Dockerfile | 2 +- docker/Dockerfile | 2 +- docker/docker-entrypoint.sh | 10 ++++++++++ 6 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 0832167e..f86264af 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ GRPC_PORT=5500 BACK_PORT=5700 +# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6) +# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定 +# BIND_HOST=0.0.0.0 +# BIND_HOST_GRPC=0.0.0.0 + LOG_LEVEL='info' JWT_SECRET= diff --git a/back/schedule/health.ts b/back/schedule/health.ts index f77a914b..dc90f7e6 100644 --- a/back/schedule/health.ts +++ b/back/schedule/health.ts @@ -10,7 +10,7 @@ const check = async ( switch (call.request.service) { case 'cron': const res = await promiseExec( - `curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`, + `curl -s --noproxy '*' http://localhost:${config.port}/api/system`, ); if (res.includes('200')) { diff --git a/back/shared/pLimit.ts b/back/shared/pLimit.ts index f84763ad..2f04d5be 100644 --- a/back/shared/pLimit.ts +++ b/back/shared/pLimit.ts @@ -37,7 +37,7 @@ class TaskLimit { concurrency: Math.max(os.cpus().length, 4), }); private client = new ApiClient( - `0.0.0.0:${config.grpcPort}`, + `localhost:${config.grpcPort}`, credentials.createInsecure(), { 'grpc.enable_http_proxy': 0 }, ); diff --git a/docker/310.Dockerfile b/docker/310.Dockerfile index fb690b57..f9cb29b7 100644 --- a/docker/310.Dockerfile +++ b/docker/310.Dockerfile @@ -84,6 +84,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ - CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 + CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1 ENTRYPOINT ["./docker/docker-entrypoint.sh"] diff --git a/docker/Dockerfile b/docker/Dockerfile index 5682ae9d..6cde05d9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -84,6 +84,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ - CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 + CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1 ENTRYPOINT ["./docker/docker-entrypoint.sh"] diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 5d784cc3..b0693fe3 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -25,6 +25,16 @@ if [ -f /etc/alpine-release ]; then fi fi +# 确保 /etc/hosts 包含 localhost 解析(应对精简镜像或仅 IPv4/IPv6 环境) +if ! grep -qE '^127\.0\.0\.1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then + echo "127.0.0.1 localhost" >> /etc/hosts + log_with_style "INFO" "🔧 0. 已添加 IPv4 localhost 解析" +fi +if ! grep -qE '^::1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then + echo "::1 localhost ip6-localhost ip6-loopback" >> /etc/hosts + log_with_style "INFO" "🔧 0. 已添加 IPv6 localhost 解析" +fi + log_with_style "INFO" "🚀 1. 检测配置文件..." load_ql_envs export_ql_envs From 400e4770dea3999f44d5f987c94e6aac1ebad973 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 17 May 2026 23:53:43 +0800 Subject: [PATCH 013/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=20position=20=E6=95=B0=E6=8D=AE=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=8F=AF=E8=83=BD=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/services/env.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/back/services/env.ts b/back/services/env.ts index cd4d0a84..ed74ab5f 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -27,7 +27,7 @@ export default class EnvService { envs.length > 0 && typeof envs[envs.length - 1].position === 'number' ) { - position = envs[envs.length - 1].position!; + position = this.getPrecisionPosition(envs[envs.length - 1].position!); } const tabs = payloads.map((x) => { position = position - stepPosition; @@ -100,7 +100,7 @@ export default class EnvService { } private async checkPosition(position: number, edge: number = 0) { - const precisionPosition = parseFloat(position.toPrecision(16)); + const precisionPosition = this.getPrecisionPosition(position); if ( precisionPosition < minPosition || precisionPosition > maxPosition || @@ -116,7 +116,7 @@ export default class EnvService { } private getPrecisionPosition(position: number): number { - return parseFloat(position.toPrecision(16)); + return Math.trunc(parseFloat(position.toPrecision(16))); } public async envs(searchText: string = '', query: any = {}): Promise { From 0c5e3b5d04d3d3296048eb50a5098155296308d6 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 18 May 2026 00:12:22 +0800 Subject: [PATCH 014/123] ci: update workflow actions --- .github/workflows/build-docker-image.yml | 41 ++++++++++++------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 63c42609..10a59c5e 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -46,13 +46,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: build front and back run: | @@ -121,12 +122,13 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: Read version from version.yaml id: version @@ -136,18 +138,17 @@ jobs: echo "Version: $VERSION" - name: Setup timezone - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: Asia/Shanghai + run: | + sudo timedatectl set-timezone Asia/Shanghai - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -155,7 +156,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ${{ github.repository }} @@ -169,14 +170,14 @@ jobs: type=semver,pattern={{version}} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push id: docker_build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: build-args: | MAINTAINER=${{ github.repository_owner }} @@ -209,12 +210,13 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: Read version from version.yaml id: version @@ -224,32 +226,31 @@ jobs: echo "Version: $VERSION" - name: Setup timezone - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: Asia/Shanghai + run: | + sudo timedatectl set-timezone Asia/Shanghai - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push python3.10 id: docker_build_310 - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: build-args: | MAINTAINER=${{ github.repository_owner }} From 66f9457be82b7ae8117fd924ff450a27c8fa1629 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 18 May 2026 01:05:46 +0800 Subject: [PATCH 015/123] ci: replace hub-mirror-action with direct git push for Gitee sync Replace Yikun/hub-mirror-action with manual git push for Gitee mirrors. The action HTTPS API call to gitee.com timed out (60s). Use set +e with explicit notice/warning status. Also add .deepseek/ to .gitignore. --- .github/workflows/build-docker-image.yml | 63 +++++++++++++++++------- .gitignore | 2 + 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 10a59c5e..6b399d28 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -33,14 +33,38 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: Yikun/hub-mirror-action@master - with: - src: github/whyour - dst: gitee/whyour - dst_key: ${{ secrets.GITLAB_SSH_PK }} - dst_token: ${{ secrets.GITEE_TOKEN }} - static_list: "qinglong" - force_update: true + - name: Setup SSH and push to Gitee + env: + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + run: | + set +e + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null + git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null + + if git push --force --all gitee 2>&1; then + echo "::notice::Gitee push --all succeeded" + else + echo "::warning::Push --all failed, trying to create repo via API..." + curl -sS --connect-timeout 30 --max-time 60 \ + -X POST "https://gitee.com/api/v5/user/repos" \ + -H "Content-Type: application/json" \ + -d '{"name":"qinglong","private":"false"}' \ + "?access_token=$GITEE_TOKEN" 2>/dev/null + if git push --force --all gitee 2>&1; then + echo "::notice::Gitee push --all succeeded after repo creation" + else + echo "::warning::Gitee push --all failed after retry" + fi + fi + + if git push --force --tags gitee 2>&1; then + echo "::notice::Gitee push --tags succeeded" + else + echo "::warning::Gitee push --tags failed" + fi build-static: runs-on: ubuntu-latest @@ -98,17 +122,18 @@ jobs: needs: build-static runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: Yikun/hub-mirror-action@master - with: - src: github/whyour - dst: gitee/whyour - dst_key: ${{ secrets.GITLAB_SSH_PK }} - dst_token: ${{ secrets.GITEE_TOKEN }} - static_list: "qinglong-static" - force_update: true + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan github.com gitee.com >> ~/.ssh/known_hosts + - name: Mirror qinglong-static to Gitee + run: | + git clone --mirror https://github.com/whyour/qinglong-static.git static-mirror + cd static-mirror + git remote set-url origin git@gitee.com:whyour/qinglong-static.git + git push --force --mirror build: if: ${{ !startsWith(github.ref, 'refs/tags/') }} diff --git a/.gitignore b/.gitignore index 709791ed..c9fc9200 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ __pycache__ /shell/preload/notify.* /shell/preload/*-notify.json /shell/preload/__ql_notify__.* + +.deepseek/ From 7a8917f8e40c4365a2381213fe627124d3cda009 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 20:48:10 +0800 Subject: [PATCH 016/123] Fix CodeMirror "multiple instances of @codemirror/state" crash on page navigation (#2969) * Initial plan * Fix CodeMirror multiple instances error by pinning to single versions Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- package.json | 8 +- pnpm-lock.yaml | 251 ++++++++++++++++++++++--------------------------- 2 files changed, 120 insertions(+), 139 deletions(-) diff --git a/package.json b/package.json index 1494538a..eb0b5af4 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ } }, "overrides": { - "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3" + "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", + "@codemirror/state": "6.5.4", + "@codemirror/view": "6.39.16" } }, "dependencies": { @@ -104,8 +106,8 @@ "moment": "2.30.1", "@ant-design/icons": "^5.0.1", "@ant-design/pro-layout": "6.38.22", - "@codemirror/view": "^6.34.1", - "@codemirror/state": "^6.4.1", + "@codemirror/view": "6.39.16", + "@codemirror/state": "6.5.4", "@monaco-editor/react": "4.2.1", "@react-hook/resize-observer": "^2.0.2", "react-router-dom": "6.26.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03414b75..b26696ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,6 +2,8 @@ lockfileVersion: '6.0' overrides: sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.0.3 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 dependencies: '@bufbuild/protobuf': @@ -145,11 +147,11 @@ devDependencies: specifier: 6.38.22 version: 6.38.22(prop-types@15.8.1)(react-dom@18.3.1)(react@18.3.1) '@codemirror/state': - specifier: ^6.4.1 - version: 6.4.1 + specifier: 6.5.4 + version: 6.5.4 '@codemirror/view': - specifier: ^6.34.1 - version: 6.35.0 + specifier: 6.39.16 + version: 6.39.16 '@monaco-editor/react': specifier: 4.2.1 version: 4.2.1(monaco-editor@0.33.0)(react-dom@18.3.1)(react@18.3.1) @@ -236,10 +238,10 @@ devDependencies: version: 8.3.4 '@uiw/codemirror-extensions-langs': specifier: ^4.21.9 - version: 4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/language-data@6.5.2)(@codemirror/language@6.12.2)(@codemirror/legacy-modes@6.5.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) + version: 4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/language-data@6.5.2)(@codemirror/language@6.12.2)(@codemirror/legacy-modes@6.5.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) '@uiw/react-codemirror': specifier: ^4.21.9 - version: 4.23.6(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.35.0)(codemirror@6.0.2)(react-dom@18.3.1)(react@18.3.1) + version: 4.23.6(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.16)(codemirror@6.0.2)(react-dom@18.3.1)(react@18.3.1) '@umijs/max': specifier: ^4.4.4 version: 4.4.4(@types/node@17.0.45)(@types/react-dom@18.3.1)(@types/react@18.3.12)(prettier@2.8.8)(react-dom@18.3.1)(react@18.3.1)(sockjs-client@1.6.1)(typescript@5.2.2) @@ -1403,7 +1405,7 @@ packages: tinycolor2: 1.6.0 dev: true - /@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3): + /@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3): resolution: {integrity: sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==} peerDependencies: '@codemirror/language': ^6.0.0 @@ -1412,8 +1414,8 @@ packages: '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 dev: true @@ -1421,8 +1423,8 @@ packages: resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} dependencies: '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 dev: true @@ -1430,8 +1432,8 @@ packages: resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} dependencies: '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 dev: true @@ -1439,8 +1441,8 @@ packages: resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} dependencies: '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 dev: true @@ -1480,12 +1482,12 @@ packages: '@lezer/cpp': 1.1.5 dev: true - /@codemirror/lang-css@6.3.1(@codemirror/view@6.35.0): + /@codemirror/lang-css@6.3.1(@codemirror/view@6.39.16): resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.2.3 '@lezer/css': 1.1.9 transitivePeerDependencies: @@ -1497,7 +1499,7 @@ packages: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.5.1 '@lezer/go': 1.0.1 dev: true @@ -1506,11 +1508,11 @@ packages: resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} dependencies: '@codemirror/autocomplete': 6.20.1 - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/css': 1.3.1 '@lezer/html': 1.3.13 @@ -1519,12 +1521,12 @@ packages: /@codemirror/lang-html@6.4.9: resolution: {integrity: sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/lang-javascript': 6.2.2 '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 '@lezer/css': 1.1.9 '@lezer/html': 1.3.10 @@ -1547,11 +1549,11 @@ packages: /@codemirror/lang-javascript@6.2.2: resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/language': 6.10.6 '@codemirror/lint': 6.8.4 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 '@lezer/javascript': 1.4.21 dev: true @@ -1562,8 +1564,8 @@ packages: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 '@codemirror/lint': 6.9.5 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/javascript': 1.5.4 dev: true @@ -1592,10 +1594,10 @@ packages: '@lezer/json': 1.0.3 dev: true - /@codemirror/lang-less@6.0.2(@codemirror/view@6.35.0): + /@codemirror/lang-less@6.0.2(@codemirror/view@6.39.16): resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} dependencies: - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/language': 6.10.6 '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 @@ -1608,7 +1610,7 @@ packages: resolution: {integrity: sha512-WHwjI7OqKFBEfkunohweqA5B/jIlxaZso6Nl3weVckz8EafYbPZldQEKSDb4QQ9H9BUkle4PVELP4sftKoA0uQ==} dependencies: '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.2.3 '@lezer/lezer': 1.1.2 dev: true @@ -1616,11 +1618,11 @@ packages: /@codemirror/lang-liquid@6.2.2: resolution: {integrity: sha512-7Dm841fk37+JQW6j2rI1/uGkJyESrjzyhiIkaLjbbR0U6aFFQvMrJn35WxQreRMADMhzkyVkZM4467OR7GR8nQ==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/lang-html': 6.4.9 '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 '@lezer/lr': 1.4.2 @@ -1632,8 +1634,8 @@ packages: '@codemirror/autocomplete': 6.20.1 '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 @@ -1642,11 +1644,11 @@ packages: /@codemirror/lang-markdown@6.3.1: resolution: {integrity: sha512-y3sSPuQjBKZQbQwe3ZJKrSW6Silyl9PnrU/Mf0m2OQgIlPoSYTtOvEL7xs94SVMkb8f4x+SQFnzXPdX4Wk2lsg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/lang-html': 6.4.9 '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 '@lezer/markdown': 1.3.2 dev: true @@ -1657,8 +1659,8 @@ packages: '@codemirror/autocomplete': 6.20.1 '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/markdown': 1.6.3 dev: true @@ -1668,7 +1670,7 @@ packages: dependencies: '@codemirror/lang-html': 6.4.9 '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.2.3 '@lezer/php': 1.0.2 dev: true @@ -1678,17 +1680,17 @@ packages: dependencies: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.5.1 '@lezer/php': 1.0.5 dev: true - /@codemirror/lang-python@6.1.6(@codemirror/view@6.35.0): + /@codemirror/lang-python@6.1.6(@codemirror/view@6.39.16): resolution: {integrity: sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.2.3 '@lezer/python': 1.1.14 transitivePeerDependencies: @@ -1700,7 +1702,7 @@ packages: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.5.1 '@lezer/python': 1.1.18 dev: true @@ -1719,12 +1721,12 @@ packages: '@lezer/rust': 1.0.2 dev: true - /@codemirror/lang-sass@6.0.2(@codemirror/view@6.35.0): + /@codemirror/lang-sass@6.0.2(@codemirror/view@6.39.16): resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} dependencies: - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.2.3 '@lezer/sass': 1.0.7 transitivePeerDependencies: @@ -1736,18 +1738,18 @@ packages: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 dev: true - /@codemirror/lang-sql@6.8.0(@codemirror/view@6.35.0): + /@codemirror/lang-sql@6.8.0(@codemirror/view@6.39.16): resolution: {integrity: sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 '@lezer/lr': 1.4.2 @@ -1778,10 +1780,10 @@ packages: /@codemirror/lang-xml@6.1.0: resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} dependencies: - '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3) + '@codemirror/autocomplete': 6.18.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.2.3) '@codemirror/language': 6.10.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 '@lezer/xml': 1.0.5 dev: true @@ -1791,32 +1793,32 @@ packages: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 '@lezer/yaml': 1.0.4 dev: true - /@codemirror/language-data@6.5.2(@codemirror/view@6.35.0): + /@codemirror/language-data@6.5.2(@codemirror/view@6.39.16): resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} dependencies: '@codemirror/lang-angular': 0.1.4 '@codemirror/lang-cpp': 6.0.3 - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/lang-go': 6.0.1 '@codemirror/lang-html': 6.4.11 '@codemirror/lang-java': 6.0.2 '@codemirror/lang-javascript': 6.2.5 '@codemirror/lang-jinja': 6.0.0 '@codemirror/lang-json': 6.0.2 - '@codemirror/lang-less': 6.0.2(@codemirror/view@6.35.0) + '@codemirror/lang-less': 6.0.2(@codemirror/view@6.39.16) '@codemirror/lang-liquid': 6.3.2 '@codemirror/lang-markdown': 6.5.0 '@codemirror/lang-php': 6.0.2 '@codemirror/lang-python': 6.2.1 '@codemirror/lang-rust': 6.0.2 - '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.35.0) + '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.39.16) '@codemirror/lang-sql': 6.10.0 '@codemirror/lang-vue': 0.1.3 '@codemirror/lang-wast': 6.0.2 @@ -1831,19 +1833,19 @@ packages: /@codemirror/language@6.10.6: resolution: {integrity: sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==} dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 '@lezer/lr': 1.4.2 - style-mod: 4.1.2 + style-mod: 4.1.3 dev: true /@codemirror/language@6.12.2: resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 @@ -1859,31 +1861,27 @@ packages: /@codemirror/lint@6.8.4: resolution: {integrity: sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==} dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 crelt: 1.0.6 dev: true /@codemirror/lint@6.9.5: resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 crelt: 1.0.6 dev: true /@codemirror/search@6.6.0: resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} dependencies: - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 crelt: 1.0.6 dev: true - /@codemirror/state@6.4.1: - resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} - dev: true - /@codemirror/state@6.5.4: resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} dependencies: @@ -1894,19 +1892,11 @@ packages: resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} dependencies: '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/highlight': 1.2.3 dev: true - /@codemirror/view@6.35.0: - resolution: {integrity: sha512-I0tYy63q5XkaWsJ8QRv5h6ves7kvtrBWjBcnf/bzohFJQc5c14a1AQRdE8QpPF9eMp5Mq2FMm59TCj1gDfE7kw==} - dependencies: - '@codemirror/state': 6.4.1 - style-mod: 4.1.2 - w3c-keyname: 2.2.8 - dev: true - /@codemirror/view@6.39.16: resolution: {integrity: sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==} dependencies: @@ -2668,13 +2658,13 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.0 js-yaml: 4.1.0 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -2799,8 +2789,8 @@ packages: deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7(supports-color@5.5.0) - minimatch: 3.1.2 + debug: 4.4.3 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color dev: true @@ -3671,7 +3661,7 @@ packages: engines: {node: '>=14.0.0'} dev: true - /@replit/codemirror-lang-csharp@6.2.0(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8): + /@replit/codemirror-lang-csharp@6.2.0(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8): resolution: {integrity: sha512-6utbaWkoymhoAXj051mkRp+VIJlpwUgCX9Toevz3YatiZsz512fw3OVCedXQx+WcR0wb6zVHjChnuxqfCLtFVQ==} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -3684,14 +3674,14 @@ packages: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 dev: true - /@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8): + /@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8): resolution: {integrity: sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -3704,8 +3694,8 @@ packages: dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 @@ -3720,7 +3710,7 @@ packages: '@lezer/highlight': 1.2.1 dev: true - /@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8): + /@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8): resolution: {integrity: sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==} peerDependencies: '@codemirror/autocomplete': ^6.0.0 @@ -3736,12 +3726,12 @@ packages: '@lezer/lr': ^1.0.0 dependencies: '@codemirror/autocomplete': 6.20.1 - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/lang-html': 6.4.9 '@codemirror/lang-javascript': 6.2.2 '@codemirror/language': 6.12.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/javascript': 1.5.4 @@ -4541,7 +4531,7 @@ packages: eslint-visitor-keys: 3.4.3 dev: true - /@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.7.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0): + /@uiw/codemirror-extensions-basic-setup@4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.7.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16): resolution: {integrity: sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' @@ -4557,11 +4547,11 @@ packages: '@codemirror/language': 6.12.2 '@codemirror/lint': 6.9.5 '@codemirror/search': 6.6.0 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 dev: true - /@uiw/codemirror-extensions-langs@4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/language-data@6.5.2)(@codemirror/language@6.12.2)(@codemirror/legacy-modes@6.5.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8): + /@uiw/codemirror-extensions-langs@4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/language-data@6.5.2)(@codemirror/language@6.12.2)(@codemirror/legacy-modes@6.5.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8): resolution: {integrity: sha512-VKWbEXmVq3EFYrJPWXH4Ei1f92zxuAg6dOlo8suSmwjmEc0qjNEP5Ss2CUi9LlzuWMGMmZgdKw56I3L71wYOog==} peerDependencies: '@codemirror/language-data': '>=6.0.0' @@ -4569,30 +4559,30 @@ packages: dependencies: '@codemirror/lang-angular': 0.1.3 '@codemirror/lang-cpp': 6.0.2 - '@codemirror/lang-css': 6.3.1(@codemirror/view@6.35.0) + '@codemirror/lang-css': 6.3.1(@codemirror/view@6.39.16) '@codemirror/lang-html': 6.4.9 '@codemirror/lang-java': 6.0.1 '@codemirror/lang-javascript': 6.2.2 '@codemirror/lang-json': 6.0.1 - '@codemirror/lang-less': 6.0.2(@codemirror/view@6.35.0) + '@codemirror/lang-less': 6.0.2(@codemirror/view@6.39.16) '@codemirror/lang-lezer': 6.0.1 '@codemirror/lang-liquid': 6.2.2 '@codemirror/lang-markdown': 6.3.1 '@codemirror/lang-php': 6.0.1 - '@codemirror/lang-python': 6.1.6(@codemirror/view@6.35.0) + '@codemirror/lang-python': 6.1.6(@codemirror/view@6.39.16) '@codemirror/lang-rust': 6.0.1 - '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.35.0) - '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.35.0) + '@codemirror/lang-sass': 6.0.2(@codemirror/view@6.39.16) + '@codemirror/lang-sql': 6.8.0(@codemirror/view@6.39.16) '@codemirror/lang-vue': 0.1.3 '@codemirror/lang-wast': 6.0.2 '@codemirror/lang-xml': 6.1.0 - '@codemirror/language-data': 6.5.2(@codemirror/view@6.35.0) + '@codemirror/language-data': 6.5.2(@codemirror/view@6.39.16) '@codemirror/legacy-modes': 6.5.2 '@nextjournal/lang-clojure': 1.0.0 - '@replit/codemirror-lang-csharp': 6.2.0(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8) - '@replit/codemirror-lang-nix': 6.0.1(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8) + '@replit/codemirror-lang-csharp': 6.2.0(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8) + '@replit/codemirror-lang-nix': 6.0.1(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.8) '@replit/codemirror-lang-solidity': 6.0.2(@codemirror/language@6.12.2) - '@replit/codemirror-lang-svelte': 6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.12.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) + '@replit/codemirror-lang-svelte': 6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) codemirror-lang-mermaid: 0.5.0 transitivePeerDependencies: - '@codemirror/autocomplete' @@ -4605,7 +4595,7 @@ packages: - '@lezer/lr' dev: true - /@uiw/react-codemirror@4.23.6(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.35.0)(codemirror@6.0.2)(react-dom@18.3.1)(react@18.3.1): + /@uiw/react-codemirror@4.23.6(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.16)(codemirror@6.0.2)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==} peerDependencies: '@babel/runtime': '>=7.11.0' @@ -4623,10 +4613,10 @@ packages: dependencies: '@babel/runtime': 7.28.6 '@codemirror/commands': 6.7.1 - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.4 '@codemirror/theme-one-dark': 6.1.3 - '@codemirror/view': 6.35.0 - '@uiw/codemirror-extensions-basic-setup': 4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.7.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0) + '@codemirror/view': 6.39.16 + '@uiw/codemirror-extensions-basic-setup': 4.23.6(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.7.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.16) codemirror: 6.0.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -6093,13 +6083,6 @@ packages: big-integer: 1.6.52 dev: true - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: true - /brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} dependencies: @@ -6497,8 +6480,8 @@ packages: '@codemirror/language': 6.12.2 '@codemirror/lint': 6.9.5 '@codemirror/search': 6.6.0 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 dev: true /color-convert@1.9.3: @@ -7832,7 +7815,7 @@ packages: eslint: 8.35.0 estraverse: 5.3.0 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.8 object.fromentries: 2.0.8 object.hasown: 1.1.4 @@ -10309,7 +10292,7 @@ packages: /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 dev: true /minimatch@3.1.5: @@ -13792,7 +13775,7 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} dependencies: - semver: 7.6.3 + semver: 7.7.4 dev: true /single-spa@5.9.5: @@ -14208,10 +14191,6 @@ packages: engines: {node: '>=8'} dev: true - /style-mod@4.1.2: - resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} - dev: true - /style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} dev: true From 8bc09069499f9b098f4480291fb33b9a343aece0 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 23 May 2026 23:21:38 +0800 Subject: [PATCH 017/123] feat: add environment variable labels --- back/api/env.ts | 45 +++ back/data/env.ts | 3 + back/loaders/db.ts | 1 + back/services/env.ts | 38 +++ docs/PROJECT_ARCHITECTURE.md | 550 +++++++++++++++++++++++++++++++++++ src/components/tag.tsx | 4 +- src/locales/en-US.json | 3 + src/locales/zh-CN.json | 3 + src/pages/env/index.tsx | 53 +++- src/pages/env/modal.tsx | 101 ++++++- 10 files changed, 792 insertions(+), 9 deletions(-) create mode 100644 docs/PROJECT_ARCHITECTURE.md diff --git a/back/api/env.ts b/back/api/env.ts index 834bdd51..e58cd3ae 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -18,6 +18,10 @@ const storage = multer.diskStorage({ }, }); const upload = multer({ storage: storage }); +const labelSchema = Joi.array() + .items(Joi.string().trim().required()) + .min(1) + .required(); export default (app: Router) => { app.use('/envs', route); @@ -44,6 +48,7 @@ export default (app: Router) => { .required() .pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/), remarks: Joi.string().optional().allow(''), + labels: Joi.array().items(Joi.string().trim()).optional(), }), ), }), @@ -70,6 +75,7 @@ export default (app: Router) => { name: Joi.string().required(), remarks: Joi.string().optional().allow('').allow(null), id: Joi.number().required(), + labels: Joi.array().items(Joi.string().trim()).optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -230,6 +236,44 @@ export default (app: Router) => { }, ); + route.post( + '/labels', + celebrate({ + body: Joi.object({ + ids: Joi.array().items(Joi.number().required()).min(1).required(), + labels: labelSchema, + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const envService = Container.get(EnvService); + const data = await envService.addLabels(req.body.ids, req.body.labels); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.delete( + '/labels', + celebrate({ + body: Joi.object({ + ids: Joi.array().items(Joi.number().required()).min(1).required(), + labels: labelSchema, + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const envService = Container.get(EnvService); + const data = await envService.removeLabels(req.body.ids, req.body.labels); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + route.post( '/upload', upload.single('env'), @@ -248,6 +292,7 @@ export default (app: Router) => { name: x.name, value: x.value, remarks: x.remarks, + labels: x.labels, })), ); return res.send({ code: 200, data: result }); diff --git a/back/data/env.ts b/back/data/env.ts index bd24f22a..ea238a95 100644 --- a/back/data/env.ts +++ b/back/data/env.ts @@ -10,6 +10,7 @@ export class Env { name?: string; remarks?: string; isPinned?: 1 | 0; + labels?: string[]; constructor(options: Env) { this.value = options.value; @@ -23,6 +24,7 @@ export class Env { this.name = options.name; this.remarks = options.remarks || ''; this.isPinned = options.isPinned || 0; + this.labels = options.labels || []; } } @@ -45,4 +47,5 @@ export const EnvModel = sequelize.define('Env', { name: { type: DataTypes.STRING, unique: 'compositeIndex' }, remarks: DataTypes.STRING, isPinned: DataTypes.NUMBER, + labels: DataTypes.JSON, }); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index 49fefb6b..987c6cbe 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -40,6 +40,7 @@ export default async () => { type: 'NUMBER', }, { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, + { table: 'Envs', column: 'labels', type: 'JSON' }, ]; for (const migration of migrations) { diff --git a/back/services/env.ts b/back/services/env.ts index ed74ab5f..5efd148b 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -199,6 +199,44 @@ export default class EnvService { await EnvModel.update({ isPinned: 0 }, { where: { id: ids } }); } + public async addLabels(ids: number[], labels: string[]) { + await sequelize.transaction(async (transaction) => { + const docs = await EnvModel.findAll({ + where: { id: ids }, + transaction, + }); + for (const doc of docs) { + const env = doc.get({ plain: true }); + await EnvModel.update( + { labels: Array.from(new Set([...(env.labels || []), ...labels])) }, + { where: { id: env.id }, transaction }, + ); + } + }); + return await this.find({ id: ids }); + } + + public async removeLabels(ids: number[], labels: string[]) { + await sequelize.transaction(async (transaction) => { + const docs = await EnvModel.findAll({ + where: { id: ids }, + transaction, + }); + for (const doc of docs) { + const env = doc.get({ plain: true }); + await EnvModel.update( + { + labels: (env.labels || []).filter( + (label: string) => !labels.includes(label), + ), + }, + { where: { id: env.id }, transaction }, + ); + } + }); + return await this.find({ id: ids }); + } + public async set_envs() { const envs = await this.envs('', { name: { [Op.not]: null }, diff --git a/docs/PROJECT_ARCHITECTURE.md b/docs/PROJECT_ARCHITECTURE.md new file mode 100644 index 00000000..8ea6ffac --- /dev/null +++ b/docs/PROJECT_ARCHITECTURE.md @@ -0,0 +1,550 @@ +# Project Architecture Guide + +This document is written for AI coding agents and maintainers who need to understand and modify this project safely. It focuses on where behavior lives, how the application starts, and which files are usually involved for common changes. + +## Project Summary + +Qinglong is a timed task management platform. It provides a web admin panel for managing cron jobs, scripts, environment variables, subscriptions, dependencies, logs, configuration files, and system settings. + +The repository is organized as a full-stack TypeScript application: + +- `src/`: frontend admin panel, built with Umi Max, React, Ant Design, and Ant Design Pro Layout. +- `back/`: backend application, built with Express, TypeScript, typedi, Sequelize, SQLite, gRPC, and worker processes. +- `shell/`: runtime shell scripts used to execute tasks and preload task environments. +- `data/`: local runtime data, including scripts, logs, configs, SQLite database, uploaded files, and cloned repositories. +- `static/`: built frontend and backend artifacts. +- `docker/`: Docker images, compose file, and entrypoint. +- `sample/`: sample scripts and default config templates. + +## High-Level Runtime Flow + +```text +Browser + -> src/pages/* + -> src/utils/http.tsx + -> /api/* + -> back/api/* + -> back/services/* + -> back/data/* Sequelize models + -> data/db/database.sqlite + +Cron/task execution + -> back/services/cron.ts + -> shell/task.sh or shell/otask.sh + -> data/scripts/* + -> data/log/* + +Frontend assets in production + -> static/dist/* + -> served by back/loaders/express.ts +``` + +## Main Startup Path + +Development starts from `package.json`: + +```bash +pnpm start +``` + +This runs: + +- `start:back`: `nodemon ./back/app.ts` +- `start:front`: `max dev` + +Backend startup begins in `back/app.ts`. + +Important details: + +- The backend uses Node `cluster`. +- The primary process initializes the database first. +- A gRPC worker starts before the HTTP worker. +- The HTTP worker starts Express and serves API routes plus frontend static files. +- If the gRPC worker restarts, the HTTP worker is asked to re-register cron jobs. + +Production-style backend output is generated by: + +```bash +pnpm run build:back +``` + +The compiled backend is placed under `static/build`. + +Frontend output is generated by: + +```bash +pnpm run build:front +``` + +The compiled frontend is placed under `static/dist`. + +## Backend Architecture + +### Entry Point + +- `back/app.ts` + +Responsibilities: + +- Creates the Express application. +- Starts primary/worker process logic. +- Initializes database in the primary process. +- Starts gRPC and HTTP workers. +- Handles graceful shutdown. +- Re-registers cron jobs after gRPC worker recovery. + +### Loaders + +- `back/loaders/app.ts` +- `back/loaders/express.ts` +- `back/loaders/db.ts` +- `back/loaders/depInjector.ts` +- `back/loaders/initData.ts` +- `back/loaders/initFile.ts` +- `back/loaders/initTask.ts` +- `back/loaders/server.ts` +- `back/loaders/sock.ts` + +Loader responsibilities: + +- Register dependency injection bindings. +- Sync Sequelize models. +- Initialize files and default data. +- Initialize scheduled tasks. +- Configure Express middleware. +- Register routes. +- Attach socket/server behavior. + +`back/loaders/express.ts` is the main HTTP middleware and routing setup. It handles: + +- CORS. +- Helmet. +- body parser. +- static frontend serving. +- JWT validation. +- token validation against shared auth state. +- `/open/*` rewrite to `/api/*`. +- route mounting through `back/api/index.ts`. +- frontend fallback to `static/dist/index.html`. +- API error handling. + +### API Routes + +- `back/api/index.ts` + +This file registers all API modules: + +- `user.ts`: login, initialization, authentication-related user endpoints. +- `env.ts`: environment variable endpoints. +- `config.ts`: config file endpoints. +- `log.ts`: log endpoints. +- `cron.ts`: cron/task endpoints. +- `script.ts`: script file endpoints. +- `open.ts`: open API/app token endpoints. +- `dependence.ts`: dependency management endpoints. +- `system.ts`: system information/settings endpoints. +- `subscription.ts`: subscription endpoints. +- `update.ts`: update/check endpoints. +- `health.ts`: health check endpoints. + +Route files should stay thin. They should validate input, get a service from `typedi`'s `Container`, call the service, and return `{ code, data, message }` style responses. + +### Services + +- `back/services/*` + +Services contain most business logic. Common examples: + +- `cron.ts`: create/update/delete/run cron jobs, generate crontab data, manage logs, call scheduler client. +- `env.ts`: manage environment variables. +- `config.ts`: read/write config files. +- `script.ts`: manage script files. +- `subscription.ts`: manage script subscriptions and repository pulls. +- `dependence.ts`: install/manage runtime dependencies. +- `system.ts`: system info and settings. +- `notify.ts`: notification behavior. +- `sock.ts`: socket/log stream behavior. +- `grpc.ts`: gRPC server lifecycle. +- `http.ts`: HTTP server lifecycle. + +When changing backend behavior, first find the API route, then follow it into the matching service. In most cases, the service is the right place for behavioral changes. + +### Data Models + +- `back/data/index.ts` +- `back/data/*.ts` + +The backend uses Sequelize with SQLite. Database storage is configured in `back/data/index.ts`: + +```text +data/db/database.sqlite +``` + +Common model files: + +- `cron.ts`: cron job model. +- `cronView.ts`: saved cron table views. +- `env.ts`: environment variable model. +- `dependence.ts`: dependency model. +- `open.ts`: open API app/token model. +- `subscription.ts`: subscription model. +- `system.ts`: system settings model. +- `notify.ts`: notification-related data. + +Model sync and simple column migrations are currently handled in `back/loaders/db.ts`. + +### Configuration + +- `back/config/index.ts` + +This is the central runtime config file. It reads `.env`, establishes `QL_DIR`, and defines important paths: + +- `dataPath`: runtime data root. +- `configPath`: config files. +- `scriptPath`: user scripts. +- `repoPath`: subscription repositories. +- `logPath`: task logs. +- `dbPath`: SQLite database location. +- `uploadPath`: uploaded files. +- `shellPath`: shell runtime scripts. +- `preloadPath`: JS/Python/Shell preload files. + +Before hardcoding paths, check `back/config/index.ts`. + +### Scheduling And gRPC + +- `back/schedule/*` +- `back/protos/*` +- `back/services/grpc.ts` + +The project has two scheduling paths: + +- Standard crontab-style tasks are persisted and written through backend cron logic. +- Node/gRPC scheduler logic handles cases such as second-level cron expressions or additional schedules. + +`back/services/cron.ts` decides whether a task needs the Node scheduler using schedule shape and `extra_schedules`. + +### Shared Backend Utilities + +- `back/shared/*` +- `back/config/util.ts` +- `back/config/share.ts` +- `back/config/http.ts` + +Use these before adding new global helpers. Existing shared code includes: + +- auth helpers. +- shared store. +- log stream manager. +- task runner helpers. +- concurrency limits. +- file locking utilities. +- HTTP/proxy helpers. + +## Frontend Architecture + +### Umi Config + +- `.umirc.ts` + +Important behavior: + +- Dev server proxies API requests to `http://127.0.0.1:5700/`. +- Frontend build output is `static/dist`. +- Runtime env script is loaded from `./api/env.js`. +- `QlBaseUrl` affects frontend public path and routing base. + +### App Initialization + +- `src/app.ts` + +Responsibilities: + +- Load Chinese and English locale JSON. +- Determine locale from URL/cookie/localStorage. +- Set Umi locale. +- Apply `QlBaseUrl` as public path and router basename. + +### Layout And Routes + +- `src/layouts/defaultProps.tsx` +- `src/layouts/index.tsx` + +`defaultProps.tsx` defines the main route/menu list. If adding a new page visible in the sidebar, update this file. + +Current major pages: + +- `src/pages/crontab`: timed task management. +- `src/pages/subscription`: subscription management. +- `src/pages/env`: environment variables. +- `src/pages/config`: config files. +- `src/pages/script`: script management. +- `src/pages/dependence`: dependency management. +- `src/pages/log`: log management. +- `src/pages/diff`: diff tool. +- `src/pages/setting`: system settings. +- `src/pages/login`: login. +- `src/pages/initialization`: first-run initialization. +- `src/pages/error`: error page. + +### Frontend Utilities + +- `src/utils/http.tsx`: API request helper. +- `src/utils/websocket.ts`: socket connection behavior. +- `src/utils/config.ts`: frontend config helpers. +- `src/utils/const.ts`: constants. +- `src/utils/date.ts`: date formatting helpers. +- `src/utils/init.ts`: initialization helpers. +- `src/utils/codemirror/*`: CodeMirror integration. +- `src/utils/monaco/*`: Monaco integration. + +When changing a page's API behavior, inspect both the page file and `src/utils/http.tsx`. + +### Components And Styling + +- `src/components/*`: reusable UI components. +- `src/pages/**/index.less`: page-level styles. +- `src/pages/script/index.module.less` and `src/pages/log/index.module.less`: CSS module styles. +- `src/assets/fonts/*`: bundled fonts. +- `src/locales/*.json`: i18n text. + +Follow the existing Ant Design and Ant Design Pro patterns when modifying UI. + +## Shell Runtime + +- `shell/task.sh`: task execution path. +- `shell/otask.sh`: alternate/manual task execution path. +- `shell/api.sh`: shell-side API helpers. +- `shell/env.sh`: environment setup. +- `shell/check.sh`: runtime check helpers. +- `shell/update.sh`: update helpers. +- `shell/rmlog.sh`: log cleanup. +- `shell/share.sh`: shared shell helpers. +- `shell/preload/*`: preload files injected into JS/Python/Shell task environments. + +The backend often coordinates task execution, but the actual user script process environment is shaped by files in `shell/`. + +## Runtime Data Directory + +- `data/` + +This directory is runtime state, not just source code. Be careful when modifying or deleting files here. + +Important subdirectories: + +- `data/db`: SQLite database. +- `data/config`: generated and user-edited config files. +- `data/scripts`: user scripts. +- `data/repo`: cloned subscription repositories. +- `data/log`: task logs. +- `data/upload`: uploaded files. +- `data/syslog`: system logs. +- `data/ssh.d`: SSH-related runtime data. +- `data/dep_cache`: dependency cache, when present. + +Many bugs that appear as "backend logic" may involve state stored under `data/`. + +## Docker And Release Files + +- `docker/Dockerfile` +- `docker/310.Dockerfile` +- `docker/docker-compose.yml` +- `docker/docker-entrypoint.sh` +- `ecosystem.config.js` +- `version.yaml` + +Use these when changing deployment, container startup, PM2 behavior, or release metadata. + +## Common Modification Map + +### Add Or Modify A Backend API + +Typical files: + +1. Add or update route in `back/api/.ts`. +2. Add or update service logic in `back/services/.ts`. +3. Add or update model in `back/data/.ts` if persistence changes. +4. Add validation with `celebrate`/`Joi` near the route. +5. Update frontend caller in `src/pages/**` or `src/utils/**`. + +### Add A New Frontend Page + +Typical files: + +1. Create `src/pages//index.tsx`. +2. Add styles in `src/pages//index.less` if needed. +3. Register route/menu in `src/layouts/defaultProps.tsx`. +4. Add locale strings in `src/locales/zh-CN.json` and `src/locales/en-US.json`. +5. Add API calls through the existing request helper. + +### Change Cron/Task Behavior + +Start with: + +- `back/api/cron.ts` +- `back/services/cron.ts` +- `back/schedule/*` +- `shell/task.sh` +- `shell/otask.sh` +- `shell/preload/*` + +Also inspect: + +- `back/data/cron.ts` +- `back/validation/schedule.ts` +- `data/config/crontab.list` +- `data/log/*` + +### Change Environment Variable Behavior + +Start with: + +- `back/api/env.ts` +- `back/services/env.ts` +- `back/data/env.ts` +- `src/pages/env/index.tsx` + +Also inspect: + +- `shell/preload/env.sh` +- `shell/preload/env.js` +- `shell/preload/env.py` + +### Change Script Management + +Start with: + +- `back/api/script.ts` +- `back/services/script.ts` +- `src/pages/script/index.tsx` +- `data/scripts/*` + +### Change Login/Auth/Security + +Start with: + +- `back/api/user.ts` +- `back/services/user.ts` +- `back/shared/auth.ts` +- `back/shared/store.ts` +- `back/loaders/express.ts` +- `back/token.ts` +- `src/pages/login/index.tsx` +- `src/pages/initialization/index.tsx` + +Be careful with: + +- JWT behavior. +- open API token behavior. +- first-run initialization. +- platform-specific session limits. + +### Change Subscription Behavior + +Start with: + +- `back/api/subscription.ts` +- `back/services/subscription.ts` +- `back/data/subscription.ts` +- `src/pages/subscription/index.tsx` +- `data/repo/*` + +### Change Dependency Management + +Start with: + +- `back/api/dependence.ts` +- `back/services/dependence.ts` +- `back/data/dependence.ts` +- `src/pages/dependence/index.tsx` +- `data/deps` +- `data/dep_cache` + +### Change Logs Or Live Log Streaming + +Start with: + +- `back/api/log.ts` +- `back/services/log.ts` +- `back/services/sock.ts` +- `back/shared/logStreamManager.ts` +- `src/pages/log/index.tsx` +- `src/components/terminal.tsx` +- `data/log/*` + +## Coding Conventions + +Backend: + +- Prefer adding business logic to services, not route files. +- Use `typedi` services consistently. +- Use existing config paths from `back/config/index.ts`. +- Return API responses in the existing `{ code, data, message }` shape. +- Use existing utilities before adding new helpers. +- Preserve current SQLite/Sequelize style unless doing a larger data-layer refactor. + +Frontend: + +- Follow existing Umi/React/Ant Design patterns. +- Keep route/menu changes in `src/layouts/defaultProps.tsx`. +- Use existing request/WebSocket helpers. +- Add or update locale strings for visible UI text. +- Keep page-specific styles near the page. + +Shell/runtime: + +- Treat `shell/` as part of production behavior. +- Test task execution changes with realistic scripts when possible. +- Be careful with path quoting and environment variable propagation. + +Data: + +- Treat `data/` as mutable runtime state. +- Do not delete runtime state unless explicitly requested. +- Schema changes should account for existing SQLite databases. + +## Suggested First Steps For AI Agents + +When asked to modify behavior: + +1. Identify whether the change is frontend, backend, shell runtime, data model, or deployment. +2. Search by feature name in `src/pages`, `back/api`, and `back/services`. +3. Read the route file and matching service before editing. +4. If persistence is involved, read the matching `back/data` model and `back/loaders/db.ts`. +5. If task execution is involved, inspect `shell/` and `back/services/cron.ts`. +6. Make the smallest scoped change that matches existing patterns. +7. Run the most relevant check: + - `pnpm run build:back` for backend TypeScript changes. + - `pnpm run build:front` for frontend build changes. + - targeted manual task/API checks for shell and scheduler changes. + +## Quick Directory Reference + +```text +. +├── back/ Backend TypeScript application +│ ├── api/ Express route modules +│ ├── config/ Runtime config, paths, constants, helpers +│ ├── data/ Sequelize models and SQLite connection +│ ├── loaders/ Startup initialization and Express setup +│ ├── middlewares/ Express middlewares +│ ├── protos/ gRPC proto files and generated TS +│ ├── schedule/ Scheduler/gRPC client helpers +│ ├── services/ Business logic services +│ ├── shared/ Shared backend utilities +│ └── validation/ Joi validation schemas +├── src/ Frontend Umi/React application +│ ├── assets/ Fonts and static frontend assets +│ ├── components/ Shared UI components +│ ├── hooks/ Frontend hooks +│ ├── layouts/ Main layout and menu route config +│ ├── locales/ i18n JSON +│ ├── pages/ Feature pages +│ └── utils/ HTTP, WebSocket, editor, date, and config utilities +├── shell/ Task runtime shell scripts and preload files +├── data/ Runtime state: db, logs, scripts, repos, configs +├── docker/ Docker build and compose files +├── sample/ Sample scripts and default config templates +├── static/ Built frontend/backend artifacts +└── docs/ Project documentation +``` diff --git a/src/components/tag.tsx b/src/components/tag.tsx index f9b5db51..2df5cb50 100644 --- a/src/components/tag.tsx +++ b/src/components/tag.tsx @@ -66,9 +66,7 @@ const EditableTagGroup = ({ }, [inputVisible]); useEffect(() => { - if (value) { - setTags(value); - } + setTags(value || []); }, [value]); return ( diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 0a1a2a2a..29e4bdbf 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -437,6 +437,9 @@ "Cron表达式格式有误": "Incorrect Cron Expression Format", "添加Labels成功": "Labels added successfully", "删除Labels成功": "Labels deleted successfully", + "添加标签成功": "Tags added successfully", + "删除标签成功": "Tags deleted successfully", + "请至少输入一个标签": "Please enter at least one tag", "编辑视图": "Edit View", "排序方式": "Sort Order", "开始时间": "Start Time", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 448e140c..01cdf4ab 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -437,6 +437,9 @@ "Cron表达式格式有误": "Cron表达式格式有误", "添加Labels成功": "添加Labels成功", "删除Labels成功": "删除Labels成功", + "添加标签成功": "添加标签成功", + "删除标签成功": "删除标签成功", + "请至少输入一个标签": "请至少输入一个标签", "编辑视图": "编辑视图", "排序方式": "排序方式", "开始时间": "开始时间", diff --git a/src/pages/env/index.tsx b/src/pages/env/index.tsx index fde76fda..0cb2c610 100644 --- a/src/pages/env/index.tsx +++ b/src/pages/env/index.tsx @@ -36,7 +36,7 @@ import { useVT } from 'virtualizedtableforantd4'; import Copy from '../../components/copy'; import EditNameModal from './editNameModal'; import './index.less'; -import EnvModal from './modal'; +import EnvModal, { EnvLabelModal } from './modal'; const { Paragraph } = Typography; const { Search } = Input; @@ -121,6 +121,25 @@ const Env = () => { ); }, }, + { + title: intl.get('标签'), + dataIndex: 'labels', + key: 'labels', + render: (labels: string[] | null) => { + const envLabels = Array.isArray(labels) ? labels : []; + return ( + + {envLabels + .filter((label) => label) + .map((label) => ( + + {label} + + ))} + + ); + }, + }, { title: intl.get('更新时间'), dataIndex: 'timestamp', @@ -238,6 +257,7 @@ const Env = () => { const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [isEditNameModalVisible, setIsEditNameModalVisible] = useState(false); + const [isLabelModalVisible, setIsLabelModalVisible] = useState(false); const [editedEnv, setEditedEnv] = useState(); const [selectedRowIds, setSelectedRowIds] = useState([]); const [searchText, setSearchText] = useState(''); @@ -408,6 +428,13 @@ const Env = () => { getEnvs(); }; + const handleLabelCancel = (needUpdate?: boolean) => { + setIsLabelModalVisible(false); + if (needUpdate) { + getEnvs(); + } + }; + const [vt, setVT] = useVT( () => ({ scroll: { y: tableScrollHeight } }), [tableScrollHeight], @@ -542,7 +569,12 @@ const Env = () => { const exportEnvs = () => { const envs = value .filter((x) => selectedRowIds.includes(x.id)) - .map((x) => ({ value: x.value, name: x.name, remarks: x.remarks })); + .map((x) => ({ + value: x.value, + name: x.name, + remarks: x.remarks, + labels: x.labels, + })); exportJson('env.json', JSON.stringify(envs)); }; @@ -550,6 +582,10 @@ const Env = () => { setIsEditNameModalVisible(true); }; + const modifyLabels = () => { + setIsLabelModalVisible(true); + }; + const onSearch = (value: string) => { setSearchText(value.trim()); }; @@ -622,6 +658,13 @@ const Env = () => { > {intl.get('批量修改变量名称')} + , + , + , + ]} + centered + maskClosable={false} + forceRender + onCancel={() => handleCancel(false)} + > +
+ + + +
+ + ); +}; From bb6d436c19d069271c03ebd4d1f4549e49825d8b Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 24 May 2026 00:15:24 +0800 Subject: [PATCH 018/123] fix: run scripts from their own directory --- shell/otask.sh | 47 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/shell/otask.sh b/shell/otask.sh index 516a9439..f997911d 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -83,6 +83,25 @@ clear_non_sh_env() { fi } +enter_script_workdir() { + local use_dot_prefix="$1" + + 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} + if [[ "${use_dot_prefix}" == "true" ]]; then + file_param="./${script_name}" + else + file_param="${script_name}" + fi + fi + fi +} + ## 正常运行单个脚本,$1:传入参数 run_normal() { local file_param=$1 @@ -90,12 +109,7 @@ run_normal() { random_delay "$file_param" fi - cd $dir_scripts - local relative_path="${file_param%/*}" - if [[ ${file_param} != /* ]] && [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then - cd ${relative_path} - file_param=${file_param/$relative_path\//} - fi + enter_script_workdir if [[ $isJsOrPythonFile == 'false' ]]; then clear_non_sh_env @@ -128,12 +142,7 @@ run_concurrent() { time=$(date "+$mtime_format") single_log_time=$(format_log_time "$mtime_format" "$time") - cd $dir_scripts - local relative_path="${file_param%/*}" - if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then - cd ${relative_path} - file_param=${file_param/$relative_path\//} - fi + enter_script_workdir local j=0 for i in ${array_run[@]}; do @@ -182,12 +191,7 @@ run_designated() { clear_non_sh_env fi - cd $dir_scripts - local relative_path="${file_param%/*}" - if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then - cd ${relative_path} - file_param=${file_param/$relative_path\//} - fi + enter_script_workdir envParam="${env_param}" numParam="${num_param}" $timeoutCmd $which_program $file_param "${script_params[@]}" } @@ -196,12 +200,7 @@ run_designated() { run_else() { local file_param="$1" - cd $dir_scripts - local relative_path="${file_param%/*}" - if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then - cd ${relative_path} - file_param=${file_param/$relative_path\//.\/} - fi + enter_script_workdir true shift From 57d58c871e4f4b51f562c719d8eaa8d64f9990bf Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 24 May 2026 01:40:07 +0800 Subject: [PATCH 019/123] fix: isolate task node dependencies --- docker/310.Dockerfile | 2 +- docker/Dockerfile | 2 +- shell/otask.sh | 15 ++++++++++++++- shell/preload/sitecustomize.js | 31 +++++++++++++++++++++++++++++++ shell/share.sh | 12 +----------- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docker/310.Dockerfile b/docker/310.Dockerfile index f9cb29b7..6cbbc127 100644 --- a/docker/310.Dockerfile +++ b/docker/310.Dockerfile @@ -73,7 +73,7 @@ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ HOME=/root ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ - NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ + NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages diff --git a/docker/Dockerfile b/docker/Dockerfile index 6cde05d9..405260ac 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -73,7 +73,7 @@ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ HOME=/root ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ - NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ + NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages diff --git a/shell/otask.sh b/shell/otask.sh index f997911d..c6612bd1 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -83,6 +83,16 @@ clear_non_sh_env() { fi } +append_node_dependency_path() { + export PREV_NODE_PATH="${NODE_PATH:=}" + + local pnpm_global_path=$(pnpm root -g 2>/dev/null) + if [[ -n "$pnpm_global_path" ]]; then + export QL_NODE_GLOBAL_PATH="$pnpm_global_path" + export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${pnpm_global_path}" + fi +} + enter_script_workdir() { local use_dot_prefix="$1" @@ -241,7 +251,7 @@ check_nounset() { } main() { - if [[ $1 == *.js ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then + if [[ $1 == *.js ]] || [[ $1 == *.mjs ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then if [[ $1 == *.sh ]]; then timeoutCmd="" fi @@ -277,6 +287,7 @@ main() { handle_task_start "${task_shell_params[@]}" check_file "${task_shell_params[@]}" +append_node_dependency_path if [[ $isJsOrPythonFile == 'false' ]]; then run_task_before "${task_shell_params[@]}" fi @@ -286,6 +297,8 @@ main "${task_shell_params[@]}" if [[ "$set_u_on" == 'true' ]]; then set -u fi +export NODE_PATH="${PREV_NODE_PATH}" +unset QL_NODE_GLOBAL_PATH if [[ $isJsOrPythonFile == 'true' ]]; then export NODE_OPTIONS="${PREV_NODE_OPTIONS}" export PYTHONPATH="${PREV_PYTHONPATH}" diff --git a/shell/preload/sitecustomize.js b/shell/preload/sitecustomize.js index dbb9198f..44dc1bea 100644 --- a/shell/preload/sitecustomize.js +++ b/shell/preload/sitecustomize.js @@ -1,7 +1,36 @@ const { execSync } = require('child_process'); +const Module = require('module'); +const path = require('path'); const client = require('./client.js'); require(`./env.js`); +function preferGlobalNodeModules() { + const { QL_NODE_GLOBAL_PATH } = process.env; + if (!QL_NODE_GLOBAL_PATH || Module._qlGlobalPathPatched) { + return; + } + + const originalResolveFilename = Module._resolveFilename; + Module._resolveFilename = function (request, parent, isMain, options) { + if ( + !Module.builtinModules.includes(request) && + !request.startsWith('node:') && + !request.startsWith('.') && + !path.isAbsolute(request) + ) { + try { + return originalResolveFilename.call(this, request, parent, isMain, { + ...options, + paths: [QL_NODE_GLOBAL_PATH], + }); + } catch (error) {} + } + + return originalResolveFilename.call(this, request, parent, isMain, options); + }; + Module._qlGlobalPathPatched = true; +} + function expandRange(rangeStr, max) { const tempRangeStr = rangeStr .trim() @@ -113,6 +142,8 @@ try { return; } + preferGlobalNodeModules(); + process.on('SIGTERM', (code) => { process.exit(15); }); diff --git a/shell/share.sh b/shell/share.sh index cad30659..585d885d 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -65,17 +65,7 @@ link_name=( ) init_env() { - local pnpm_global_path=$(pnpm root -g 2>/dev/null) - export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules${pnpm_global_path:+:${pnpm_global_path}}" - - # 如果存在 pnpm 全局路径,创建软链接 - if [[ -n "$pnpm_global_path" ]]; then - # 确保目标目录存在 - mkdir -p "${dir_root}/node_modules" - # 链接全局模块到项目的 node_modules - ln -sf "${pnpm_global_path}/"* "${dir_root}/node_modules/" 2>/dev/null || true - fi - + export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules" export PYTHONUNBUFFERED=1 } From 84d730d510b8a6343b2f4b2e6c4acf9ae227a31c Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 30 May 2026 18:03:51 +0800 Subject: [PATCH 020/123] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20Alpine/Debian=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=EF=BC=8CQL=5FSCHEDULER=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8C=96=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修改获取示例文件 api path * 增加 debian-slim 基础镜像 * 修复 debian apt 命令,支持 qinglong 命令 * 更新 npm 版本 0.7.7 * 更新 npm v0.8.4 * 修复linux依赖检测 (#2082) * 修复拉取私有仓库 * 修复 shell check_server * 修复 qinglong 命令 * 更新 npm 版本 v0.13.2 * 增加 debian 开发版本 * 修改切换 linux 镜像源 * 修复 qinglong 命令 * 移除 qinglong 命令 npm 默认镜像源 * 修复 workflow * 更新 npm 版本 v0.14.5 * 增加 npx 命令 * 更新 workflow action 版本 * 更新 npm 版本 v0.16.0 * 修复 linux 镜像源 * 更新 npm 版本 v0.17.0 * 更新 npm 版本 v0.18.0 * 修改 npm 安装启动命令 * 更新 npm 版本 v0.19.9 * 修复 debian netcat 包名 * 更新 npm 版本 v0.20.4 * 安装 linux 依赖自动识别 alpine 和 debian * 修改 apt 命令 * 更新 npm 版本 v0.21.2 * 修改 ts 文件执行依赖 * npm 启动增加 reload 逻辑 * 更新 npm 版本 v2.17.8 * 修复 qinglong 命令 * 更新 npm 版本 v2.17.9 * 更新 npm 版本 v2.17.10 * 更新 npm 版本 v2.17.11 * 修改 debian 版本为 12 bookworm * 更新 npm 版本 v2.17.12 * 修改本地服务启动提示 * 更新 npm 版本 v2.17.13 * 写入文件增加文件锁 * 修复系统安装依赖提示 * 更新 npm 版本 v2.18.2-6 * 更新 nodejs 版本 * 更新 npm 版本 v2.18.3-3 * 修复 command 变量 * 移除自动清除 deb * 修复 npm 启动脚本 * 修复发布 npm包依赖文件 * 修改 linux 启动文件逻辑 * 更新 npm 版本 v2.19.0-10 * 修复 apt 命令 * 更新 npm 版本 v2.19.1-0 * 更新 npm 版本 v2.19.2-2 * 增加 packageManager * 增加用户 qinglong * 更新 pipeline * 移除 init_nginx * 更新 npm 版本 v2.20.0 * 更新 npm 版本 2.20.1 * 更新 npm 版本 2.20.2 * fix: 修复非 root 用户启动 * chore: 合并 debian 和 alpine 逻辑 --------- Co-authored-by: dream10201 --- .github/workflows/build-docker-image.yml | 207 +++++++++++++++++++---- .npmignore | 22 +++ back/api/config.ts | 2 +- back/config/const.ts | 35 ++++ back/config/util.ts | 200 +++++++++++++++++++++- back/services/cron.ts | 49 ++++-- back/services/dependence.ts | 78 +++++++-- back/services/system.ts | 34 ++-- docker/310.Dockerfile | 2 +- docker/310.Dockerfile.debian | 112 ++++++++++++ docker/Dockerfile | 2 +- docker/Dockerfile.debian | 112 ++++++++++++ docker/docker-entrypoint.sh | 87 +++++++++- package.json | 18 +- shell/bot.sh | 17 +- shell/pub.sh | 16 +- shell/start.sh | 132 +++++++++++++++ src/pages/diff/index.tsx | 2 +- src/pages/setting/dependence.tsx | 4 +- 19 files changed, 1018 insertions(+), 113 deletions(-) create mode 100644 .npmignore create mode 100644 docker/310.Dockerfile.debian create mode 100644 docker/Dockerfile.debian create mode 100644 shell/start.sh diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 6b399d28..420dc9f4 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -89,9 +89,6 @@ jobs: env: GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_BRANCH: ${{ github.ref_name }} - REPO_GITEE: git@gitee.com:whyour/qinglong-static.git - REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git - PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }} run: | mkdir -p tmp cd ./tmp @@ -135,16 +132,13 @@ jobs: git remote set-url origin git@gitee.com:whyour/qinglong-static.git git push --force --mirror - build: + build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static - runs-on: ubuntu-22.04 - permissions: packages: write contents: read - steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -160,11 +154,9 @@ jobs: run: | VERSION=$(grep '^version:' version.yaml | awk '{print $2}') echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - name: Setup timezone - run: | - sudo timedatectl set-timezone Asia/Shanghai + run: sudo timedatectl set-timezone Asia/Shanghai - name: Login to DockerHub uses: docker/login-action@v4 @@ -190,7 +182,7 @@ jobs: latest=false tags: | type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=semver,pattern={{version}} @@ -200,8 +192,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Build and push - id: docker_build + - name: Build and push (Alpine) uses: docker/build-push-action@v7 with: build-args: | @@ -209,30 +200,22 @@ jobs: QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host - # linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 context: . file: ./docker/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=whyour/qinglong:cache - cache-to: type=registry,ref=whyour/qinglong:cache,mode=max + cache-from: type=registry,ref=whyour/qinglong:cache-alpine + cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max - - name: Image digest - run: | - echo ${{ steps.docker_build.outputs.digest }} - - build310: - if: ${{ github.ref_name == 'master' }} + build-debian: + if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static - runs-on: ubuntu-22.04 - permissions: packages: write contents: read - steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -248,11 +231,85 @@ jobs: run: | VERSION=$(grep '^version:' version.yaml | awk '{print $2}') echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - name: Setup timezone + run: sudo timedatectl set-timezone Asia/Shanghai + + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ github.repository }} + ghcr.io/${{ github.repository }} + flavor: | + latest=false + tags: | + type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} + type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and push (Debian) + uses: docker/build-push-action@v7 + with: + build-args: | + MAINTAINER=${{ github.repository_owner }} + QL_BRANCH=${{ github.ref_name }} + SOURCE_COMMIT=${{ github.sha }} + network: host + platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + context: . + file: ./docker/Dockerfile.debian + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=whyour/qinglong:cache-debian + cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max + + build-alpine310: + if: ${{ github.ref_name == 'master' }} + needs: build-static + runs-on: ubuntu-22.04 + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: "8.3.1" + - uses: actions/setup-node@v6 + with: + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml + + - name: Read version from version.yaml + id: version run: | - sudo timedatectl set-timezone Asia/Shanghai + VERSION=$(grep '^version:' version.yaml | awk '{print $2}') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup timezone + run: sudo timedatectl set-timezone Asia/Shanghai - name: Login to DockerHub uses: docker/login-action@v4 @@ -273,8 +330,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Build and push python3.10 - id: docker_build_310 + - name: Build and push (Alpine Python 3.10) uses: docker/build-push-action@v7 with: build-args: | @@ -282,7 +338,6 @@ jobs: QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host - # linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 context: . file: ./docker/310.Dockerfile @@ -290,9 +345,93 @@ jobs: tags: | whyour/qinglong:python3.10 whyour/qinglong:${{ steps.version.outputs.version }}-python3.10 - cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 - cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max + cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10 + cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max - - name: Image digest + build-debian310: + if: ${{ github.ref_name == 'master' }} + needs: build-static + runs-on: ubuntu-22.04 + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: "8.3.1" + - uses: actions/setup-node@v6 + with: + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml + + - name: Read version from version.yaml + id: version run: | - echo ${{ steps.docker_build_310.outputs.digest }} + VERSION=$(grep '^version:' version.yaml | awk '{print $2}') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup timezone + run: sudo timedatectl set-timezone Asia/Shanghai + + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and push (Debian Python 3.10) + uses: docker/build-push-action@v7 + with: + build-args: | + MAINTAINER=${{ github.repository_owner }} + QL_BRANCH=${{ github.ref_name }} + SOURCE_COMMIT=${{ github.sha }} + network: host + platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + context: . + file: ./docker/310.Dockerfile.debian + push: true + tags: | + whyour/qinglong:debian-python3.10 + whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10 + cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10 + cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max + + publish: + if: ${{ github.ref_name == 'master' }} + needs: [build-alpine, build-debian] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: "8.3.1" + + - uses: actions/setup-node@v3 + with: + cache: "pnpm" + + - name: build front and back + run: | + pnpm install --frozen-lockfile + pnpm build:front + pnpm build:back + + - name: publish npm package + run: | + echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc + npm publish diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..230f2ff4 --- /dev/null +++ b/.npmignore @@ -0,0 +1,22 @@ +/.tmp/ +/.github/ +/.vscode/ +/.history/ +/back/**/*.ts +/back/**/*.json +/cli/ +/data/ +/src/ +/static/**/*.js.map +/static/**/*.gz +/.editorconfig +/.gitignore +/.prettierignore +/.prettierrc +/.umirc.ts +/nodemon.json +/pnpm-lock.yaml +/tsconfig.back.json +/tsconfig.json +/typings.d.ts +/.env \ No newline at end of file diff --git a/back/api/config.ts b/back/api/config.ts index 99649fb3..55c740fd 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -14,7 +14,7 @@ export default (app: Router) => { app.use('/configs', route); route.get( - '/sample', + '/samples', async (req: Request, res: Response, next: NextFunction) => { try { res.send({ diff --git a/back/config/const.ts b/back/config/const.ts index e5083f43..74be9a93 100644 --- a/back/config/const.ts +++ b/back/config/const.ts @@ -49,3 +49,38 @@ export const NotificationModeStringMap = { 19: 'ntfy', 20: 'wxPusherBot', } as const; + +export const LINUX_DEPENDENCE_COMMAND: Record< + 'Debian' | 'Ubuntu' | 'Alpine', + { + install: string; + uninstall: string; + info: string; + check(info: string): boolean; + } +> = { + Debian: { + install: 'apt-get install -y', + uninstall: 'apt-get remove -y', + info: 'dpkg-query -s', + check(info: string) { + return info.includes('install ok installed'); + }, + }, + Ubuntu: { + install: 'apt-get install -y', + uninstall: 'apt-get remove -y', + info: 'dpkg-query -s', + check(info: string) { + return info.includes('install ok installed'); + }, + }, + Alpine: { + install: 'apk add --no-check-certificate', + uninstall: 'apk del', + info: 'apk info -es', + check(info: string) { + return info.includes('installed'); + }, + }, +}; diff --git a/back/config/util.ts b/back/config/util.ts index 7d61f74f..0f050193 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -1,6 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { exec } from 'child_process'; +import { exec, execSync } from 'child_process'; import psTreeFun from 'ps-tree'; import { promisify } from 'util'; import { load } from 'js-yaml'; @@ -10,9 +10,38 @@ import Logger from '../loaders/logger'; import { writeFileWithLock } from '../shared/utils'; import { DependenceTypes } from '../data/dependence'; import { FormData } from 'undici'; +import os from 'os'; export * from './share'; +let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined; + +function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined { + // 1. 环境变量覆盖 + const envOs = process.env.QL_OS_TYPE?.toLowerCase(); + if (envOs === 'alpine') return 'Alpine'; + if (envOs === 'debian') return 'Debian'; + if (envOs === 'ubuntu') return 'Ubuntu'; + + // 2. 模块缓存(由 detectOS 设置) + if (osType) return osType; + + // 3. 能力检测:检查包管理器二进制 + try { + execSync('which apt-get', { stdio: 'ignore' }); + return 'Debian'; + } catch { + try { + execSync('which apk', { stdio: 'ignore' }); + return 'Alpine'; + } catch { + // macOS / 未知系统 + } + } + + return undefined; +} + export async function getFileContentByName(fileName: string) { const _exsit = await fileExist(fileName); if (_exsit) { @@ -550,7 +579,9 @@ except: spec=u.find_spec(name) print(name if spec else '') ''')"`, - [DependenceTypes.linux]: `apk info -es ${name}`, + [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' + ? `apk info -es ${name}` + : `dpkg-query -s ${name}`, }; return baseCommands[type]; @@ -561,7 +592,9 @@ export function getInstallCommand(type: DependenceTypes, name: string): string { [DependenceTypes.nodejs]: 'pnpm add -g', [DependenceTypes.python3]: 'pip3 install --disable-pip-version-check --root-user-action=ignore', - [DependenceTypes.linux]: 'apk add --no-check-certificate', + [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' + ? 'apk add --no-check-certificate' + : 'apt-get install -y', }; let command = baseCommands[type]; @@ -581,7 +614,9 @@ export function getUninstallCommand( [DependenceTypes.nodejs]: 'pnpm remove -g', [DependenceTypes.python3]: 'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', - [DependenceTypes.linux]: 'apk del', + [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' + ? 'apk del' + : 'apt-get remove -y', }; return `${baseCommands[type]} ${name.trim()}`; @@ -590,3 +625,160 @@ export function getUninstallCommand( export function isDemoEnv() { return process.env.DeployEnv === 'demo'; } + +async function getOSReleaseInfo(): Promise { + const osRelease = await fs.readFile('/etc/os-release', 'utf8'); + return osRelease; +} + +function isDebian(osReleaseInfo: string): boolean { + return osReleaseInfo.includes('Debian'); +} + +function isUbuntu(osReleaseInfo: string): boolean { + return osReleaseInfo.includes('Ubuntu'); +} + +function isCentOS(osReleaseInfo: string): boolean { + return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat'); +} + +function isAlpine(osReleaseInfo: string): boolean { + return osReleaseInfo.includes('Alpine'); +} + +export async function detectOS(): Promise< + 'Debian' | 'Ubuntu' | 'Alpine' | undefined +> { + if (osType) return osType; + + const envOs = process.env.QL_OS_TYPE?.toLowerCase(); + if (envOs === 'alpine') { + osType = 'Alpine'; + return osType; + } + if (envOs === 'debian') { + osType = 'Debian'; + return osType; + } + if (envOs === 'ubuntu') { + osType = 'Ubuntu'; + return osType; + } + + const platform = os.platform(); + + if (platform === 'linux') { + const osReleaseInfo = await getOSReleaseInfo(); + if (isDebian(osReleaseInfo)) { + osType = 'Debian'; + } else if (isUbuntu(osReleaseInfo)) { + osType = 'Ubuntu'; + } else if (isAlpine(osReleaseInfo)) { + osType = 'Alpine'; + } else { + Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`); + console.error(`Unknown Linux Distribution: ${osReleaseInfo}`); + } + } else if (platform === 'darwin') { + osType = undefined; + } else { + Logger.error(`Unsupported platform: ${platform}`); + console.error(`Unsupported platform: ${platform}`); + } + + return osType; +} + +async function getCurrentMirrorDomain( + filePath: string, +): Promise { + 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 { + 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, + mirrorDomainWithScheme: string, +): Promise { + let filePath: string, currentDomainWithScheme: string | null; + switch (osType) { + case 'Debian': + filePath = '/etc/apt/sources.list.d/debian.sources'; + currentDomainWithScheme = await getCurrentMirrorDomain(filePath); + if (currentDomainWithScheme) { + await replaceDomainInFile( + filePath, + currentDomainWithScheme, + mirrorDomainWithScheme || 'http://deb.debian.org', + ); + return 'apt-get update'; + } else { + throw Error(`Current mirror domain not found.`); + } + case 'Ubuntu': + filePath = '/etc/apt/sources.list.d/ubuntu.sources'; + currentDomainWithScheme = await getCurrentMirrorDomain(filePath); + if (currentDomainWithScheme) { + await replaceDomainInFile( + filePath, + currentDomainWithScheme, + mirrorDomainWithScheme || 'http://archive.ubuntu.com', + ); + return 'apt-get update'; + } else { + throw Error(`Current mirror domain not found.`); + } + case 'Alpine': + filePath = '/etc/apk/repositories'; + currentDomainWithScheme = await getCurrentMirrorDomain(filePath); + if (currentDomainWithScheme) { + await replaceDomainInFile( + filePath, + currentDomainWithScheme, + mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org', + ); + return 'apk update'; + } else { + throw Error(`Current mirror domain not found.`); + } + default: + throw Error('Unsupported OS type for updating mirrors.'); + } +} + +export async function updateLinuxMirrorFile(mirror: string): Promise { + const detectedOS = await detectOS(); + if (!detectedOS) { + throw Error(`Unknown Linux Distribution`); + } + return await _updateLinuxMirror(detectedOS, mirror); +} diff --git a/back/services/cron.ts b/back/services/cron.ts index 94cdd95a..def418a4 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -39,6 +39,25 @@ export default class CronService { return false; } + private get schedulerMode(): 'system' | 'node' { + const env = process.env.QL_SCHEDULER; + if (env === 'system') return 'system'; + if (env === 'node') return 'node'; + try { + execSync('which crond', { stdio: 'ignore' }); + return 'system'; + } catch { + return 'node'; + } + } + + private shouldUseCronClient(cron: Crontab): boolean { + if (this.schedulerMode === 'node') { + return !this.isSpecialSchedule(cron.schedule); + } + return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule); + } + private isOnceSchedule(schedule?: string) { return schedule?.startsWith(ScheduleType.ONCE); } @@ -80,7 +99,7 @@ export default class CronService { return doc; } - if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) { + if (this.shouldUseCronClient(doc)) { await cronClient.addCron([ { name: doc.name || '', @@ -111,11 +130,9 @@ export default class CronService { return newDoc; } - if (this.isNodeCron(doc)) { - await cronClient.delCron([String(doc.id)]); - } + await cronClient.delCron([String(newDoc.id)]); - if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) { + if (this.shouldUseCronClient(newDoc)) { await cronClient.addCron([ { name: doc.name || '', @@ -577,8 +594,8 @@ export default class CronService { public async enabled(ids: number[]) { await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } }); - const sixCron = docs - .filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule)) + const crons = docs + .filter((x) => this.shouldUseCronClient(x)) .map((doc) => ({ name: doc.name || '', id: String(doc.id), @@ -590,7 +607,8 @@ export default class CronService { if (isDemoEnv()) { return; } - await cronClient.addCron(sixCron); + + await cronClient.addCron(crons); await this.setCrontab(); } @@ -690,11 +708,13 @@ export default class CronService { await writeFileWithLock(config.crontabFile, crontab_string); - try { - execSync(`crontab ${config.crontabFile}`); - } catch (error: any) { - const errorMsg = error.message || String(error); - this.logger.error('[crontab] Failed to update system crontab:', errorMsg); + if (this.schedulerMode === 'system') { + try { + execSync(`crontab ${config.crontabFile}`); + } catch (error: any) { + const errorMsg = error.message || String(error); + this.logger.error('[crontab] Failed to update system crontab:', errorMsg); + } } await CrontabModel.update({ saved: true }, { where: {} }); @@ -745,8 +765,7 @@ export default class CronService { .filter( (x) => x.isDisabled !== 1 && - this.isNodeCron(x) && - !this.isSpecialSchedule(x.schedule), + this.shouldUseCronClient(x), ) .map((doc) => ({ name: doc.name || '', diff --git a/back/services/dependence.ts b/back/services/dependence.ts index eac054be..c00422b3 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -22,6 +22,8 @@ import { } from '../config/util'; import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; +import { detectOS } from '../config/util'; +import { LINUX_DEPENDENCE_COMMAND } from '../config/const'; @Service() export default class DependenceService { @@ -159,8 +161,19 @@ export default class DependenceService { const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { taskLimit.removeQueuedDependency(doc); - const depInstallCommand = getInstallCommand(doc.type, doc.name); - const depUnInstallCommand = getUninstallCommand(doc.type, doc.name); + let depInstallCommand = getInstallCommand(doc.type, doc.name); + let depUnInstallCommand = getUninstallCommand(doc.type, doc.name); + const isLinuxDependence = doc.type === DependenceTypes.linux; + + if (isLinuxDependence) { + const osType = await detectOS(); + if (!osType) { + continue; + } + const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType]; + depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`; + depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`; + } const pids = await Promise.all([ getPid(depInstallCommand), getPid(depUnInstallCommand), @@ -217,23 +230,54 @@ export default class DependenceService { if (taskLimit.firstDependencyId !== dependency.id) { return resolve(null); } - - taskLimit.removeQueuedDependency(dependency); - const depIds = [dependency.id!]; + let depName = dependency.name.trim(); + const actionText = isInstall ? '安装' : '删除'; + const socketMessageType = isInstall + ? 'installDependence' + : 'uninstallDependence'; + const isNodeDependence = dependency.type === DependenceTypes.nodejs; + const isLinuxDependence = dependency.type === DependenceTypes.linux; + const isPythonDependence = dependency.type === DependenceTypes.python3; + const osType = await detectOS(); + let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine; + taskLimit.removeQueuedDependency(dependency); + if (isLinuxDependence) { + if (!osType) { + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { where: { id: depIds } }, + ); + const startTime = dayjs(); + const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( + 'YYYY-MM-DD HH:mm:ss', + )}\n\n当前系统不支持\n\n依赖${actionText}失败,结束时间 ${startTime.format( + 'YYYY-MM-DD HH:mm:ss', + )},耗时 ${startTime.diff(startTime, 'second')} 秒`; + this.sockService.sendMessage({ + type: socketMessageType, + message, + references: depIds, + }); + this.updateLog(depIds, message); + return resolve(null); + } + linuxCommand = LINUX_DEPENDENCE_COMMAND[osType]; + } + const status = isInstall ? DependenceStatus.installing : DependenceStatus.removing; await DependenceModel.update({ status }, { where: { id: depIds } }); - const socketMessageType = isInstall - ? 'installDependence' - : 'uninstallDependence'; - let depName = dependency.name.trim(); - const command = isInstall + let command = isInstall ? getInstallCommand(dependency.type, depName) : getUninstallCommand(dependency.type, depName); - const actionText = isInstall ? '安装' : '删除'; + if (isLinuxDependence) { + command = isInstall + ? `${linuxCommand.install} ${depName.trim()}` + : `${linuxCommand.uninstall} ${depName.trim()}`; + } const startTime = dayjs(); const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( @@ -248,8 +292,12 @@ export default class DependenceService { // 判断是否已经安装过依赖 if (isInstall && !force) { - const getCommand = getGetCommand(dependency.type, depName); + let getCommand = getGetCommand(dependency.type, depName); const depVersionStr = versionDependenceCommandTypes[dependency.type]; + if (isLinuxDependence) { + getCommand = `${linuxCommand.info} ${depName}`; + } + let depVersion = ''; if (depName.includes(depVersionStr)) { const symbolRegx = new RegExp( @@ -261,10 +309,6 @@ export default class DependenceService { depVersion = _depVersion; } } - const isNodeDependence = dependency.type === DependenceTypes.nodejs; - const isLinuxDependence = dependency.type === DependenceTypes.linux; - const isPythonDependence = - dependency.type === DependenceTypes.python3; const depInfo = (await promiseExecSuccess(getCommand)) .replace(/\s{2,}/, ' ') .replace(/\s+$/, ''); @@ -273,7 +317,7 @@ export default class DependenceService { depInfo && ((isNodeDependence && depInfo.split(' ')?.[0] === depName) || (isLinuxDependence && - depInfo.toLocaleLowerCase().includes('installed')) || + linuxCommand.check(depInfo.toLocaleLowerCase())) || isPythonDependence) && (!depVersion || depInfo.includes(depVersion)) ) { diff --git a/back/services/system.ts b/back/services/system.ts index ecc2a732..97b500f1 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -37,6 +37,7 @@ import ScheduleService, { TaskCallbacks } from './schedule'; import SockService from './sock'; import os from 'os'; import dayjs from 'dayjs'; +import { updateLinuxMirrorFile } from '../config/util'; @Service() export default class SystemService { @@ -214,33 +215,11 @@ export default class SystemService { onEnd?: () => void, ) { const oDoc = await this.getSystemConfig(); - await this.updateAuthDb({ - ...oDoc, - info: { ...oDoc.info, ...info }, - }); - let defaultDomain = 'https://dl-cdn.alpinelinux.org'; - let targetDomain = 'https://dl-cdn.alpinelinux.org'; if (os.platform() !== 'linux') { return; } - const content = await fs.promises.readFile('/etc/apk/repositories', { - encoding: 'utf-8', - }); - const domainMatch = content.match(/(http.*)\/alpine\/.*/); - if (domainMatch) { - defaultDomain = domainMatch[1]; - } - if (info.linuxMirror) { - targetDomain = info.linuxMirror; - } - const command = `sed -i 's/${defaultDomain.replace( - /\//g, - '\\/', - )}/${targetDomain.replace( - /\//g, - '\\/', - )}/g' /etc/apk/repositories && apk update -f`; - + const command = await updateLinuxMirrorFile(info.linuxMirror || ''); + let hasError = false; this.scheduleService.runTask( command, { @@ -254,8 +233,15 @@ export default class SystemService { message: 'update linux mirror end', }); onEnd?.(); + if (!hasError) { + await this.updateAuthDb({ + ...oDoc, + info: { ...oDoc.info, ...info }, + }); + } }, onError: async (message: string) => { + hasError = true; this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, onLog: async (message: string) => { diff --git a/docker/310.Dockerfile b/docker/310.Dockerfile index 6cbbc127..6bc02e53 100644 --- a/docker/310.Dockerfile +++ b/docker/310.Dockerfile @@ -22,7 +22,7 @@ ENV QL_DIR=/ql \ PS1="\u@\h:\w \$ " VOLUME /ql/data - + EXPOSE 5700 COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ diff --git a/docker/310.Dockerfile.debian b/docker/310.Dockerfile.debian new file mode 100644 index 00000000..194a6faa --- /dev/null +++ b/docker/310.Dockerfile.debian @@ -0,0 +1,112 @@ +FROM node:22-slim AS nodebuilder + +FROM python:3.10-slim-bookworm AS builder +COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ +COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/ +COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ +RUN set -x && \ + ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + apt-get update && \ + apt-get install --no-install-recommends -y libatomic1 && \ + npm i -g pnpm@8.3.1 && \ + cd /tmp/build && \ + pnpm install --prod + +FROM python:3.10-slim-bookworm + +ARG QL_MAINTAINER="whyour" +LABEL maintainer="${QL_MAINTAINER}" +ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git +ARG QL_BRANCH=develop +ARG PYTHON_SHORT_VERSION=3.10 + +ENV QL_DIR=/ql \ + QL_BRANCH=${QL_BRANCH} \ + LANG=C.UTF-8 \ + SHELL=/bin/bash \ + PS1="\u@\h:\w \$ " + +ARG QL_UID=5432 +ARG QL_GID=5432 +RUN groupadd -g ${QL_GID} qinglong && \ + useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \ + mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \ + chmod 700 /home/qinglong/.ssh && \ + chown -R ${QL_UID}:${QL_GID} /home/qinglong + +ENV QL_USER=qinglong +ENV QL_HOME=/home/$QL_USER + +COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/ +COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ + +RUN set -x && \ + ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + apt-get update && \ + apt-get upgrade -y && \ + apt-get install --no-install-recommends -y git \ + curl \ + wget \ + tzdata \ + perl \ + openssl \ + openssh-client \ + jq \ + procps \ + netcat-openbsd \ + unzip \ + libatomic1 && \ + apt-get clean && \ + ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" >/etc/timezone && \ + git config --global user.email "qinglong@users.noreply.github.com" && \ + git config --global user.name "qinglong" && \ + git config --global http.postBuffer 524288000 && \ + npm install -g pnpm@8.3.1 pm2 ts-node && \ + rm -rf /root/.cache && \ + rm -rf /root/.npm && \ + rm -rf /etc/apt/apt.conf.d/docker-clean && \ + ulimit -c 0 + +RUN mkdir -p ${QL_DIR} ${QL_DIR}/data && \ + chown -R ${QL_UID}:${QL_GID} ${QL_DIR} + +USER qinglong + +ARG SOURCE_COMMIT +RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \ + cd ${QL_DIR} && \ + cp -f .env.example .env && \ + chmod 777 ${QL_DIR}/shell/*.sh && \ + chmod 777 ${QL_DIR}/docker/*.sh && \ + git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \ + mkdir -p ${QL_DIR}/static && \ + cp -rf /tmp/static/* ${QL_DIR}/static && \ + rm -rf /tmp/static + +ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ + PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ + PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ + HOME=/root + +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ + NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \ + PIP_CACHE_DIR=${PYTHON_HOME}/pip \ + PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages + +RUN pip3 install --prefix ${PYTHON_HOME} requests + +COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/ + +USER root + +WORKDIR ${QL_DIR} + +HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ + CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1 + +ENTRYPOINT ["./docker/docker-entrypoint.sh"] + +VOLUME /ql/data + +EXPOSE 5700 diff --git a/docker/Dockerfile b/docker/Dockerfile index 405260ac..352a6cb6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ ENV QL_DIR=/ql \ PS1="\u@\h:\w \$ " VOLUME /ql/data - + EXPOSE 5700 COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian new file mode 100644 index 00000000..45ba5104 --- /dev/null +++ b/docker/Dockerfile.debian @@ -0,0 +1,112 @@ +FROM node:22-slim AS nodebuilder + +FROM python:3.11-slim-bookworm AS builder +COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ +COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/ +COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ +RUN set -x && \ + ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + apt-get update && \ + apt-get install --no-install-recommends -y libatomic1 && \ + npm i -g pnpm@8.3.1 && \ + cd /tmp/build && \ + pnpm install --prod + +FROM python:3.11-slim-bookworm + +ARG QL_MAINTAINER="whyour" +LABEL maintainer="${QL_MAINTAINER}" +ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git +ARG QL_BRANCH=develop +ARG PYTHON_SHORT_VERSION=3.11 + +ENV QL_DIR=/ql \ + QL_BRANCH=${QL_BRANCH} \ + LANG=C.UTF-8 \ + SHELL=/bin/bash \ + PS1="\u@\h:\w \$ " + +ARG QL_UID=5432 +ARG QL_GID=5432 +RUN groupadd -g ${QL_GID} qinglong && \ + useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \ + mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \ + chmod 700 /home/qinglong/.ssh && \ + chown -R ${QL_UID}:${QL_GID} /home/qinglong + +ENV QL_USER=qinglong +ENV QL_HOME=/home/$QL_USER + +COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/ +COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ + +RUN set -x && \ + ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ + apt-get update && \ + apt-get upgrade -y && \ + apt-get install --no-install-recommends -y git \ + curl \ + wget \ + tzdata \ + perl \ + openssl \ + openssh-client \ + jq \ + procps \ + netcat-openbsd \ + unzip \ + libatomic1 && \ + apt-get clean && \ + ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" >/etc/timezone && \ + git config --global user.email "qinglong@users.noreply.github.com" && \ + git config --global user.name "qinglong" && \ + git config --global http.postBuffer 524288000 && \ + npm install -g pnpm@8.3.1 pm2 ts-node && \ + rm -rf /root/.cache && \ + rm -rf /root/.npm && \ + rm -rf /etc/apt/apt.conf.d/docker-clean && \ + ulimit -c 0 + +RUN mkdir -p ${QL_DIR} ${QL_DIR}/data && \ + chown -R ${QL_UID}:${QL_GID} ${QL_DIR} + +USER qinglong +ARG SOURCE_COMMIT +RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \ + cd ${QL_DIR} && \ + cp -f .env.example .env && \ + chmod 777 ${QL_DIR}/shell/*.sh && \ + chmod 777 ${QL_DIR}/docker/*.sh && \ + git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \ + mkdir -p ${QL_DIR}/static && \ + cp -rf /tmp/static/* ${QL_DIR}/static && \ + rm -rf /tmp/static + +ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ + PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ + PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ + HOME=/root + +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ + NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \ + PIP_CACHE_DIR=${PYTHON_HOME}/pip \ + PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages + +RUN pip3 install --prefix ${PYTHON_HOME} requests + +COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/ + +USER root + +WORKDIR ${QL_DIR} + +HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ + CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1 + +ENTRYPOINT ["./docker/docker-entrypoint.sh"] + +VOLUME /ql/data + +EXPOSE 5700 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index b0693fe3..3015073a 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -15,9 +15,68 @@ log_with_style() { printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" } +# ============================================ +# 确保当前用户对 /ql 和 /ql/data 目录有写入权限 +# /ql/data 是 Docker Volume 挂载点,权限可能与 /ql 不同,需单独检测 +# ============================================ +ensure_ql_permissions() { + local current_uid + local current_gid + current_uid=$(id -u) + current_gid=$(id -g) + + if [ "$current_uid" -eq 0 ]; then + return 0 + fi + + # ---- 检查 /ql 目录 ---- + if ! mkdir -p "$QL_DIR/.tmp" 2>/dev/null; then + if chown -R "$current_uid:$current_gid" "$QL_DIR" 2>/dev/null; then + log_with_style "INFO" "已修正 /ql 目录权限: UID=$current_uid GID=$current_gid" + else + local ql_owner + ql_owner=$(stat -c '%u' "$QL_DIR" 2>/dev/null || stat -f '%u' "$QL_DIR" 2>/dev/null) + log_with_style "ERROR" "=============================================" + log_with_style "ERROR" " 权限错误:无法写入 /ql 目录" + log_with_style "ERROR" " 当前用户 UID: $current_uid" + log_with_style "ERROR" " /ql 目录所有者 UID: ${ql_owner:-未知}" + log_with_style "ERROR" "" + log_with_style "ERROR" " 解决方案:" + log_with_style "ERROR" " 1. 使用镜像内置用户: docker run --user ${ql_owner:-5432}:${ql_owner:-5432} ..." + log_with_style "ERROR" " 2. 使用 root 运行: 移除 --user 参数" + log_with_style "ERROR" " 3. 修正宿主机数据目录: chown -R $current_uid:$current_gid /path/to/ql/data" + log_with_style "ERROR" "=============================================" + exit 1 + fi + fi + rmdir "$QL_DIR/.tmp" 2>/dev/null || true + + # ---- 检查 /ql/data 目录(Volume 挂载点,不在用户数据卷内创建临时文件) ---- + if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then + if chown "$current_uid:$current_gid" "$QL_DIR/data" 2>/dev/null; then + log_with_style "INFO" "已修正 /ql/data 目录权限: UID=$current_uid GID=$current_gid" + if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then + log_with_style "ERROR" "修正后仍无法写入 /ql/data,请检查挂载的数据卷权限" + log_with_style "ERROR" "确保宿主机目录: chown -R $current_uid:$current_gid /your/data" + exit 1 + fi + else + local data_owner + data_owner=$(stat -c '%u' "$QL_DIR/data" 2>/dev/null || stat -f '%u' "$QL_DIR/data" 2>/dev/null) + log_with_style "ERROR" "=============================================" + log_with_style "ERROR" " 权限错误:无法写入 /ql/data (Volume 挂载点)" + log_with_style "ERROR" " 当前用户 UID: $current_uid" + log_with_style "ERROR" " /ql/data 所有者 UID: ${data_owner:-未知}" + log_with_style "ERROR" "" + log_with_style "ERROR" " 请修正宿主机数据目录权限:" + log_with_style "ERROR" " chown -R $current_uid:$current_gid /your/ql/data" + log_with_style "ERROR" "=============================================" + exit 1 + fi + fi +} + # Fix DNS resolution issues in Alpine Linux -# Alpine uses musl libc which has known DNS resolver issues with certain domains -# Adding ndots:0 prevents unnecessary search domain appending if [ -f /etc/alpine-release ]; then if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then echo "options ndots:0" >> /etc/resolv.conf @@ -35,6 +94,15 @@ if ! grep -qE '^::1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then log_with_style "INFO" "🔧 0. 已添加 IPv6 localhost 解析" fi +# 在一切操作之前检查目录权限 +ensure_ql_permissions + +# Dockerfile 中 HOME=/root,非 root 用户无法写入 +# 将 HOME 修正为临时目录,PM2/npm/pip 等工具的运行时数据无需持久化 +if [ ! -w "$HOME" ]; then + export HOME="$QL_DIR/.tmp" +fi + log_with_style "INFO" "🚀 1. 检测配置文件..." load_ql_envs export_ql_envs @@ -60,6 +128,19 @@ fi log_with_style "SUCCESS" "🎉 容器启动成功!" -crond -f >/dev/null +# 自动检测调度模式:有 crond 二进制 → system 模式,否则 node 模式 +if [ -z "$QL_SCHEDULER" ]; then + if command -v crond &>/dev/null; then + export QL_SCHEDULER="system" + else + export QL_SCHEDULER="node" + fi +fi + +if [ "$QL_SCHEDULER" = "system" ]; then + crond -f > /dev/null +else + tail -f /dev/null +fi exec "$@" diff --git a/package.json b/package.json index eb0b5af4..3e942705 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,17 @@ { - "private": true, + "name": "@whyour/qinglong", "packageManager": "pnpm@8.3.1", + "version": "2.20.2-3", + "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", + "repository": { + "type": "git", + "url": "https://github.com/whyour/qinglong.git" + }, + "author": "whyour", + "license": "Apache License 2.0", + "bugs": { + "url": "https://github.com/whyour/qinglong/issues" + }, "scripts": { "start": "concurrently -n w: npm:start:*", "start:back": "nodemon ./back/app.ts", @@ -25,6 +36,11 @@ "prettier --parser=typescript --write" ] }, + "bin": { + "ql": "shell/update.sh", + "task": "shell/task.sh", + "qinglong": "shell/start.sh" + }, "pnpm": { "peerDependencyRules": { "ignoreMissing": [ diff --git a/shell/bot.sh b/shell/bot.sh index f30cad26..524a44e9 100755 --- a/shell/bot.sh +++ b/shell/bot.sh @@ -9,7 +9,22 @@ else fi echo -e "\n1、安装bot依赖...\n" -apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev +os_name="${QL_OS_TYPE:-}" +if [ -z "$os_name" ]; then + os_name=$(source /etc/os-release && echo "$ID") +fi +case "$os_name" in + alpine) + apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev + ;; + debian|ubuntu) + apt-get install -y gcc python3-dev musl-dev zlib1g-dev libjpeg-dev libfreetype-dev + ;; + *) + echo -e "暂不支持此系统 $os_name" + exit 1 + ;; +esac echo -e "\nbot依赖安装成功...\n" echo -e "2、下载bot所需文件...\n" diff --git a/shell/pub.sh b/shell/pub.sh index 1d126bc6..1d5794f4 100755 --- a/shell/pub.sh +++ b/shell/pub.sh @@ -1,26 +1,26 @@ #!/usr/bin/env bash echo -e "开始发布" -echo -e "切换master分支" -git branch -D master -git checkout -b master -git push --set-upstream origin master -f +echo -e "切换 debian 分支" +git branch -D debian +git checkout -b debian +git push --set-upstream origin debian -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 "当前版本$version-debian" echo -e "删除已经存在的本地tag" -git tag -d "$version" &>/dev/null +git tag -d "$version-debian" &>/dev/null echo -e "删除已经存在的远程tag" -git push origin :refs/tags/$version &>/dev/null +git push origin :refs/tags/$version-debian &>/dev/null echo -e "创建新tag" -git tag -a "$version" -m "release $version" +git tag -a "$version-debian" -m "release $version-debian" echo -e "提交tag" git push --tags diff --git a/shell/start.sh b/shell/start.sh new file mode 100644 index 00000000..1f015f2b --- /dev/null +++ b/shell/start.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +# 前置依赖 nodejs、npm、python3 +set -e +set -x + +if [[ ! $QL_DIR ]]; 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 [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then + QL_DIR="$pnpm_dir/@whyour/qinglong" + else + echo -e "未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装" + fi + + if [[ $QL_DIR ]]; then + echo -e "请先手动设置 export QL_DIR=$QL_DIR,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务" + fi + + exit 1 +fi + +if [[ ! $QL_DATA_DIR ]]; then + echo -e "请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量" + exit 1 +fi + +if [[ $QL_DATA_DIR != */data ]]; then + echo -e "QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中" + exit 1 +fi + +command="$1" + +if [[ $command != "reload" ]]; then + # 安装依赖 + os_name="${QL_OS_TYPE:-}" + if [ -z "$os_name" ]; then + os_name=$(source /etc/os-release && echo "$ID") + fi + + case "$os_name" in + alpine) + apk update + apk add -f bash \ + coreutils \ + git \ + curl \ + wget \ + tzdata \ + perl \ + openssl \ + jq \ + nginx \ + openssh \ + procps \ + netcat-openbsd + ;; + debian|ubuntu) + apt-get update + apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client + ;; + *) + echo -e "暂不支持此系统部署 $os_name" + exit 1 + ;; + esac + + npm install -g pnpm@8.3.1 pm2 ts-node +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 + +cd ${QL_DIR} +cp -f .env.example .env +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" "🎉 启动成功!" diff --git a/src/pages/diff/index.tsx b/src/pages/diff/index.tsx index 5897cb2e..3b42d91e 100644 --- a/src/pages/diff/index.tsx +++ b/src/pages/diff/index.tsx @@ -64,7 +64,7 @@ const Diff = () => { const getFiles = () => { setLoading(true); request - .get(`${config.apiPrefix}configs/sample`) + .get(`${config.apiPrefix}configs/samples`) .then(({ code, data }) => { if (code === 200) { setFiles(data); diff --git a/src/pages/setting/dependence.tsx b/src/pages/setting/dependence.tsx index 536c7861..9a0974aa 100644 --- a/src/pages/setting/dependence.tsx +++ b/src/pages/setting/dependence.tsx @@ -215,12 +215,12 @@ const Dependence = () => { { setSystemConfig({ From abad29cbf990484ca86e62394a428f2529915b61 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 30 May 2026 23:55:57 +0800 Subject: [PATCH 021/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=9E=20root=20de?= =?UTF-8?q?bian=20dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-docker-image.yml | 7 +- README-en.md | 11 + README.md | 11 + back/config/const.ts | 12 +- back/config/util.ts | 10 +- docker/Dockerfile | 4 + docker/{310.Dockerfile => Dockerfile.310} | 4 + docker/Dockerfile.debian | 15 +- ...Dockerfile.debian => Dockerfile.debian310} | 15 +- docker/docker-entrypoint.sh | 11 +- sample/notify.js | 4 +- sample/notify.py | 4 +- sample/notify.py.save | 1010 ----------------- shell/bot.sh | 11 +- shell/start.sh | 14 +- 15 files changed, 97 insertions(+), 1046 deletions(-) rename docker/{310.Dockerfile => Dockerfile.310} (93%) rename docker/{310.Dockerfile.debian => Dockerfile.debian310} (89%) delete mode 100644 sample/notify.py.save diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 420dc9f4..5630916c 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -11,6 +11,9 @@ on: - "v*" workflow_dispatch: +permissions: + contents: read + jobs: code_gitlab: runs-on: ubuntu-latest @@ -340,7 +343,7 @@ jobs: network: host platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 context: . - file: ./docker/310.Dockerfile + file: ./docker/Dockerfile.310 push: true tags: | whyour/qinglong:python3.10 @@ -403,7 +406,7 @@ jobs: network: host platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x context: . - file: ./docker/310.Dockerfile.debian + file: ./docker/Dockerfile.debian310 push: true tags: | whyour/qinglong:debian-python3.10 diff --git a/README-en.md b/README-en.md index e0e1eb59..4570d6d5 100644 --- a/README-en.md +++ b/README-en.md @@ -48,6 +48,17 @@ docker pull whyour/qinglong:latest docker pull whyour/qinglong:debian ``` +When running the `debian` image as a non-root user, specify `--user qinglong`: + +```bash +docker run -d \ + -v /path/to/ql/data:/ql/data \ + -p 5700:5700 \ + --user qinglong \ + --name qinglong \ + whyour/qinglong:debian +``` + ### npm The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed. diff --git a/README.md b/README.md index fadc841b..9b7cdd55 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,17 @@ docker pull whyour/qinglong:latest docker pull whyour/qinglong:debian ``` +使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`: + +```bash +docker run -d \ + -v /path/to/ql/data:/ql/data \ + -p 5700:5700 \ + --user qinglong \ + --name qinglong \ + whyour/qinglong:debian +``` + ### npm npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm` diff --git a/back/config/const.ts b/back/config/const.ts index 74be9a93..59350a98 100644 --- a/back/config/const.ts +++ b/back/config/const.ts @@ -60,17 +60,17 @@ export const LINUX_DEPENDENCE_COMMAND: Record< } > = { Debian: { - install: 'apt-get install -y', - uninstall: 'apt-get remove -y', - info: 'dpkg-query -s', + install: 'sudo apt-get install -y', + uninstall: 'sudo apt-get remove -y', + info: 'sudo dpkg-query -s', check(info: string) { return info.includes('install ok installed'); }, }, Ubuntu: { - install: 'apt-get install -y', - uninstall: 'apt-get remove -y', - info: 'dpkg-query -s', + install: 'sudo apt-get install -y', + uninstall: 'sudo apt-get remove -y', + info: 'sudo dpkg-query -s', check(info: string) { return info.includes('install ok installed'); }, diff --git a/back/config/util.ts b/back/config/util.ts index 0f050193..1b994e31 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -581,7 +581,7 @@ except: ''')"`, [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? `apk info -es ${name}` - : `dpkg-query -s ${name}`, + : `sudo dpkg-query -s ${name}`, }; return baseCommands[type]; @@ -594,7 +594,7 @@ export function getInstallCommand(type: DependenceTypes, name: string): string { 'pip3 install --disable-pip-version-check --root-user-action=ignore', [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk add --no-check-certificate' - : 'apt-get install -y', + : 'sudo apt-get install -y', }; let command = baseCommands[type]; @@ -616,7 +616,7 @@ export function getUninstallCommand( 'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk del' - : 'apt-get remove -y', + : 'sudo apt-get remove -y', }; return `${baseCommands[type]} ${name.trim()}`; @@ -740,7 +740,7 @@ async function _updateLinuxMirror( currentDomainWithScheme, mirrorDomainWithScheme || 'http://deb.debian.org', ); - return 'apt-get update'; + return 'sudo apt-get update'; } else { throw Error(`Current mirror domain not found.`); } @@ -753,7 +753,7 @@ async function _updateLinuxMirror( currentDomainWithScheme, mirrorDomainWithScheme || 'http://archive.ubuntu.com', ); - return 'apt-get update'; + return 'sudo apt-get update'; } else { throw Error(`Current mirror domain not found.`); } diff --git a/docker/Dockerfile b/docker/Dockerfile index 352a6cb6..b874649f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -81,6 +81,10 @@ RUN pip3 install --prefix ${PYTHON_HOME} requests COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ +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 + WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ diff --git a/docker/310.Dockerfile b/docker/Dockerfile.310 similarity index 93% rename from docker/310.Dockerfile rename to docker/Dockerfile.310 index 6bc02e53..6312448b 100644 --- a/docker/310.Dockerfile +++ b/docker/Dockerfile.310 @@ -81,6 +81,10 @@ RUN pip3 install --prefix ${PYTHON_HOME} requests COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ +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 + WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 45ba5104..e4d791f3 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -32,7 +32,9 @@ RUN groupadd -g ${QL_GID} qinglong && \ useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \ mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \ chmod 700 /home/qinglong/.ssh && \ - chown -R ${QL_UID}:${QL_GID} /home/qinglong + chown -R ${QL_UID}:${QL_GID} /home/qinglong && \ + mkdir -p /etc/sudoers.d && \ + echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong ENV QL_USER=qinglong ENV QL_HOME=/home/$QL_USER @@ -55,6 +57,7 @@ RUN set -x && \ jq \ procps \ netcat-openbsd \ + sudo \ unzip \ libatomic1 && \ apt-get clean && \ @@ -69,7 +72,7 @@ RUN set -x && \ rm -rf /etc/apt/apt.conf.d/docker-clean && \ ulimit -c 0 -RUN mkdir -p ${QL_DIR} ${QL_DIR}/data && \ +RUN mkdir -p ${QL_DIR} && \ chown -R ${QL_UID}:${QL_GID} ${QL_DIR} USER qinglong @@ -87,7 +90,7 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ - HOME=/root + HOME=/home/qinglong ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \ @@ -100,6 +103,10 @@ COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node 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 + WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ @@ -108,5 +115,5 @@ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ ENTRYPOINT ["./docker/docker-entrypoint.sh"] VOLUME /ql/data - + EXPOSE 5700 diff --git a/docker/310.Dockerfile.debian b/docker/Dockerfile.debian310 similarity index 89% rename from docker/310.Dockerfile.debian rename to docker/Dockerfile.debian310 index 194a6faa..bc110ed7 100644 --- a/docker/310.Dockerfile.debian +++ b/docker/Dockerfile.debian310 @@ -32,7 +32,9 @@ RUN groupadd -g ${QL_GID} qinglong && \ useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \ mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \ chmod 700 /home/qinglong/.ssh && \ - chown -R ${QL_UID}:${QL_GID} /home/qinglong + chown -R ${QL_UID}:${QL_GID} /home/qinglong && \ + mkdir -p /etc/sudoers.d && \ + echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong ENV QL_USER=qinglong ENV QL_HOME=/home/$QL_USER @@ -54,6 +56,7 @@ RUN set -x && \ jq \ procps \ netcat-openbsd \ + sudo \ unzip \ libatomic1 && \ apt-get clean && \ @@ -68,7 +71,7 @@ RUN set -x && \ rm -rf /etc/apt/apt.conf.d/docker-clean && \ ulimit -c 0 -RUN mkdir -p ${QL_DIR} ${QL_DIR}/data && \ +RUN mkdir -p ${QL_DIR} && \ chown -R ${QL_UID}:${QL_GID} ${QL_DIR} USER qinglong @@ -87,7 +90,7 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ - HOME=/root + HOME=/home/qinglong ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \ @@ -100,6 +103,10 @@ COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node 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 + WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ @@ -108,5 +115,5 @@ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ ENTRYPOINT ["./docker/docker-entrypoint.sh"] VOLUME /ql/data - + EXPOSE 5700 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 3015073a..978a92cd 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -94,15 +94,16 @@ if ! grep -qE '^::1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then log_with_style "INFO" "🔧 0. 已添加 IPv6 localhost 解析" fi -# 在一切操作之前检查目录权限 -ensure_ql_permissions - -# Dockerfile 中 HOME=/root,非 root 用户无法写入 -# 将 HOME 修正为临时目录,PM2/npm/pip 等工具的运行时数据无需持久化 +# 自定义用户(非 qinglong/root)可能 HOME 为空或不可写 +# 修正 HOME 确保 npm/pip/pm2 等工具有可用的缓存目录 if [ ! -w "$HOME" ]; then + mkdir -p "$QL_DIR/.tmp" export HOME="$QL_DIR/.tmp" fi +# 在一切操作之前检查目录权限 +ensure_ql_permissions + log_with_style "INFO" "🚀 1. 检测配置文件..." load_ql_envs export_ql_envs diff --git a/sample/notify.js b/sample/notify.js index c0f3db43..49d5fdc8 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -108,8 +108,8 @@ const push_config = { QYWX_KEY: '', // 企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa - TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ - TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1434078534 + TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN,例:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz + TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1234567890 TG_API_HOST: 'https://api.telegram.org', // tg 代理 api TG_PROXY_AUTH: '', // tg 代理认证参数 TG_PROXY_HOST: '', // tg 机器人的 TG_PROXY_HOST diff --git a/sample/notify.py b/sample/notify.py index da8bfdcf..74c9bd70 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -94,8 +94,8 @@ push_config = { 'QYWX_KEY': '', # 企业微信机器人 - 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ - 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534 + 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz + 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1234567890 'TG_API_HOST': '', # tg 代理 api 'TG_PROXY_AUTH': '', # tg 代理认证参数 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST diff --git a/sample/notify.py.save b/sample/notify.py.save deleted file mode 100644 index 0bfe668a..00000000 --- a/sample/notify.py.save +++ /dev/null @@ -1,1010 +0,0 @@ -#!/usr/bin/env python3 -# _*_ coding:utf-8 _*_ -import base64 -import hashlib -import hmac -import json -import os -import re -import threading -import time -import urllib.parse -import smtplib -from email.mime.text import MIMEText -from email.header import Header -from email.utils import formataddr - -import requests - -# 原先的 print 函数和主线程的锁 -_print = print -mutex = threading.Lock() - - -# 定义新的 print 函数 -def print(text, *args, **kw): - """ - 使输出有序进行,不出现多线程同一时间输出导致错乱的问题。 - """ - with mutex: - _print(text, *args, **kw) - - -# 通知服务 -# fmt: off -push_config = { - 'HITOKOTO': True, # 启用一言(随机句子) - - 'BARK_PUSH': '', # bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/ - 'BARK_ARCHIVE': '', # bark 推送是否存档 - 'BARK_GROUP': '', # bark 推送分组 - 'BARK_SOUND': '', # bark 推送声音 - 'BARK_ICON': '', # bark 推送图标 - 'BARK_LEVEL': '', # bark 推送时效性 - 'BARK_URL': '', # bark 推送跳转URL - - 'CONSOLE': False, # 控制台输出 - - 'DD_BOT_SECRET': '', # 钉钉机器人的 DD_BOT_SECRET - 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN - - 'FSKEY': '', # 飞书机器人的 FSKEY - - 'GOBOT_URL': '', # go-cqhttp - # 推送到个人QQ:http://127.0.0.1/send_private_msg - # 群:http://127.0.0.1/send_group_msg - 'GOBOT_QQ': '', # go-cqhttp 的推送群或用户 - # GOBOT_URL 设置 /send_private_msg 时填入 user_id=个人QQ - # /send_group_msg 时填入 group_id=QQ群 - 'GOBOT_TOKEN': '', # go-cqhttp 的 access_token - - 'GOTIFY_URL': '', # gotify地址,如https://push.example.de:8080 - 'GOTIFY_TOKEN': '', # gotify的消息应用token - 'GOTIFY_PRIORITY': 0, # 推送消息优先级,默认为0 - - 'IGOT_PUSH_KEY': '', # iGot 聚合推送的 IGOT_PUSH_KEY - - 'PUSH_KEY': '', # server 酱的 PUSH_KEY,兼容旧版与 Turbo 版 - - 'DEER_KEY': '', # PushDeer 的 PUSHDEER_KEY - 'DEER_URL': '', # PushDeer 的 PUSHDEER_URL - - 'CHAT_URL': '', # synology chat url - 'CHAT_TOKEN': '', # synology chat token - - 'PUSH_PLUS_TOKEN': '', # push+ 微信推送的用户令牌 - 'PUSH_PLUS_USER': '', # push+ 微信推送的群组编码 - - 'WE_PLUS_BOT_TOKEN': '', # 微加机器人的用户令牌 - 'WE_PLUS_BOT_RECEIVER': '', # 微加机器人的消息接收者 - 'WE_PLUS_BOT_VERSION': 'pro', # 微加机器人的调用版本 - - 'QMSG_KEY': '', # qmsg 酱的 QMSG_KEY - 'QMSG_TYPE': '', # qmsg 酱的 QMSG_TYPE - - 'QYWX_ORIGIN': '', # 企业微信代理地址 - - 'QYWX_AM': '', # 企业微信应用 - - 'QYWX_KEY': '', # 企业微信机器人 - - 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ - 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534 - 'TG_API_HOST': '', # tg 代理 api - 'TG_PROXY_AUTH': '', # tg 代理认证参数 - 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST - 'TG_PROXY_PORT': '', # tg 机器人的 TG_PROXY_PORT - - 'AIBOTK_KEY': '', # 智能微秘书 个人中心的apikey 文档地址:http://wechat.aibotk.com/docs/about - 'AIBOTK_TYPE': '', # 智能微秘书 发送目标 room 或 contact - 'AIBOTK_NAME': '', # 智能微秘书 发送群名 或者好友昵称和type要对应好 - - 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 - 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false - 'SMTP_EMAIL': '', # SMTP 收发件邮箱,通知将会由自己发给自己 - 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 - 'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写 - - 'PUSHME_KEY': '', # PushMe 的 PUSHME_KEY - 'PUSHME_URL': '', # PushMe 的 PUSHME_URL - - 'CHRONOCAT_QQ': '', # qq号 - 'CHRONOCAT_TOKEN': '', # CHRONOCAT 的token - 'CHRONOCAT_URL': '', # CHRONOCAT的url地址 - - 'WEBHOOK_URL': '', # 自定义通知 请求地址 - 'WEBHOOK_BODY': '', # 自定义通知 请求体 - 'WEBHOOK_HEADERS': '', # 自定义通知 请求头 - 'WEBHOOK_METHOD': '', # 自定义通知 请求方法 - 'WEBHOOK_CONTENT_TYPE': '' # 自定义通知 content-type - - 'NTFY_URL': '', # ntfy地址,如https://ntfy.sh - 'NTFY_TOPIC': '', # ntfy的消息应用topic - 'NTFY_PRIORITY': '3', # 推送消息优先级,默认为3 -} -# fmt: on - -for k in push_config: - if os.getenv(k): - v = os.getenv(k) - push_config[k] = v - - -def bark(title: str, content: str) -> None: - """ - 使用 bark 推送消息。 - """ - if not push_config.get("BARK_PUSH"): - print("bark 服务的 BARK_PUSH 未设置!!\n取消推送") - return - print("bark 服务启动") - - if push_config.get("BARK_PUSH").startswith("http"): - url = f'{push_config.get("BARK_PUSH")}' - else: - url = f'https://api.day.app/{push_config.get("BARK_PUSH")}' - - bark_params = { - "BARK_ARCHIVE": "isArchive", - "BARK_GROUP": "group", - "BARK_SOUND": "sound", - "BARK_ICON": "icon", - "BARK_LEVEL": "level", - "BARK_URL": "url", - } - data = { - "title": title, - "body": content, - } - for pair in filter( - lambda pairs: pairs[0].startswith("BARK_") - and pairs[0] != "BARK_PUSH" - and pairs[1] - and bark_params.get(pairs[0]), - push_config.items(), - ): - data[bark_params.get(pair[0])] = pair[1] - headers = {"Content-Type": "application/json;charset=utf-8"} - response = requests.post( - url=url, data=json.dumps(data), headers=headers, timeout=15 - ).json() - - if response["code"] == 200: - print("bark 推送成功!") - else: - print("bark 推送失败!") - - -def console(title: str, content: str) -> None: - """ - 使用 控制台 推送消息。 - """ - print(f"{title}\n\n{content}") - - -def dingding_bot(title: str, content: str) -> None: - """ - 使用 钉钉机器人 推送消息。 - """ - if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): - print("钉钉机器人 服务的 DD_BOT_SECRET 或者 DD_BOT_TOKEN 未设置!!\n取消推送") - return - print("钉钉机器人 服务启动") - - timestamp = str(round(time.time() * 1000)) - secret_enc = push_config.get("DD_BOT_SECRET").encode("utf-8") - string_to_sign = "{}\n{}".format(timestamp, push_config.get("DD_BOT_SECRET")) - string_to_sign_enc = string_to_sign.encode("utf-8") - hmac_code = hmac.new( - secret_enc, string_to_sign_enc, digestmod=hashlib.sha256 - ).digest() - sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) - url = f'https://oapi.dingtalk.com/robot/send?access_token={push_config.get("DD_BOT_TOKEN")}×tamp={timestamp}&sign={sign}' - headers = {"Content-Type": "application/json;charset=utf-8"} - data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} - response = requests.post( - url=url, data=json.dumps(data), headers=headers, timeout=15 - ).json() - - if not response["errcode"]: - print("钉钉机器人 推送成功!") - else: - print("钉钉机器人 推送失败!") - - -def feishu_bot(title: str, content: str) -> None: - """ - 使用 飞书机器人 推送消息。 - """ - if not push_config.get("FSKEY"): - print("飞书 服务的 FSKEY 未设置!!\n取消推送") - return - print("飞书 服务启动") - - url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' - data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} - response = requests.post(url, data=json.dumps(data)).json() - - if response.get("StatusCode") == 0 or response.get("code") == 0: - print("飞书 推送成功!") - else: - print("飞书 推送失败!错误信息如下:\n", response) - - -def go_cqhttp(title: str, content: str) -> None: - """ - 使用 go_cqhttp 推送消息。 - """ - if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): - print("go-cqhttp 服务的 GOBOT_URL 或 GOBOT_QQ 未设置!!\n取消推送") - return - print("go-cqhttp 服务启动") - - url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}' - response = requests.get(url).json() - - if response["status"] == "ok": - print("go-cqhttp 推送成功!") - else: - print("go-cqhttp 推送失败!") - - -def gotify(title: str, content: str) -> None: - """ - 使用 gotify 推送消息。 - """ - if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): - print("gotify 服务的 GOTIFY_URL 或 GOTIFY_TOKEN 未设置!!\n取消推送") - return - print("gotify 服务启动") - - url = f'{push_config.get("GOTIFY_URL")}/message?token={push_config.get("GOTIFY_TOKEN")}' - data = { - "title": title, - "message": content, - "priority": push_config.get("GOTIFY_PRIORITY"), - } - response = requests.post(url, data=data).json() - - if response.get("id"): - print("gotify 推送成功!") - else: - print("gotify 推送失败!") - - -def iGot(title: str, content: str) -> None: - """ - 使用 iGot 推送消息。 - """ - if not push_config.get("IGOT_PUSH_KEY"): - print("iGot 服务的 IGOT_PUSH_KEY 未设置!!\n取消推送") - return - print("iGot 服务启动") - - url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}' - data = {"title": title, "content": content} - headers = {"Content-Type": "application/x-www-form-urlencoded"} - response = requests.post(url, data=data, headers=headers).json() - - if response["ret"] == 0: - print("iGot 推送成功!") - else: - print(f'iGot 推送失败!{response["errMsg"]}') - - -def serverJ(title: str, content: str) -> None: - """ - 通过 serverJ 推送消息。 - """ - if not push_config.get("PUSH_KEY"): - print("serverJ 服务的 PUSH_KEY 未设置!!\n取消推送") - return - print("serverJ 服务启动") - - data = {"text": title, "desp": content.replace("\n", "\n\n")} - if push_config.get("PUSH_KEY").startswith("sctp"): - url = f'https://{push_config.get("PUSH_KEY")}.push.ft07.com/send' - else: - url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send' - response = requests.post(url, data=data).json() - - if response.get("errno") == 0 or response.get("code") == 0: - print("serverJ 推送成功!") - else: - print(f'serverJ 推送失败!错误码:{response["message"]}') - - -def pushdeer(title: str, content: str) -> None: - """ - 通过PushDeer 推送消息 - """ - if not push_config.get("DEER_KEY"): - print("PushDeer 服务的 DEER_KEY 未设置!!\n取消推送") - return - print("PushDeer 服务启动") - data = { - "text": title, - "desp": content, - "type": "markdown", - "pushkey": push_config.get("DEER_KEY"), - } - url = "https://api2.pushdeer.com/message/push" - if push_config.get("DEER_URL"): - url = push_config.get("DEER_URL") - - response = requests.post(url, data=data).json() - - if len(response.get("content").get("result")) > 0: - print("PushDeer 推送成功!") - else: - print("PushDeer 推送失败!错误信息:", response) - - -def chat(title: str, content: str) -> None: - """ - 通过Chat 推送消息 - """ - if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): - print("chat 服务的 CHAT_URL或CHAT_TOKEN 未设置!!\n取消推送") - return - print("chat 服务启动") - data = "payload=" + json.dumps({"text": title + "\n" + content}) - url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN") - response = requests.post(url, data=data) - - if response.status_code == 200: - print("Chat 推送成功!") - else: - print("Chat 推送失败!错误信息:", response) - - -def pushplus_bot(title: str, content: str) -> None: - """ - 通过 push+ 推送消息。 - """ - if not push_config.get("PUSH_PLUS_TOKEN"): - print("PUSHPLUS 服务的 PUSH_PLUS_TOKEN 未设置!!\n取消推送") - return - print("PUSHPLUS 服务启动") - - url = "http://www.pushplus.plus/send" - data = { - "token": push_config.get("PUSH_PLUS_TOKEN"), - "title": title, - "content": content, - "topic": push_config.get("PUSH_PLUS_USER"), - } - body = json.dumps(data).encode(encoding="utf-8") - headers = {"Content-Type": "application/json"} - response = requests.post(url=url, data=body, headers=headers).json() - - if response["code"] == 200: - print("PUSHPLUS 推送成功!") - - else: - url_old = "http://pushplus.hxtrip.com/send" - headers["Accept"] = "application/json" - response = requests.post(url=url_old, data=body, headers=headers).json() - - if response["code"] == 200: - print("PUSHPLUS(hxtrip) 推送成功!") - - else: - print("PUSHPLUS 推送失败!") - - -def weplus_bot(title: str, content: str) -> None: - """ - 通过 微加机器人 推送消息。 - """ - if not push_config.get("WE_PLUS_BOT_TOKEN"): - print("微加机器人 服务的 WE_PLUS_BOT_TOKEN 未设置!!\n取消推送") - return - print("微加机器人 服务启动") - - template = "txt" - if len(content) > 800: - template = "html" - - url = "https://www.weplusbot.com/send" - data = { - "token": push_config.get("WE_PLUS_BOT_TOKEN"), - "title": title, - "content": content, - "template": template, - "receiver": push_config.get("WE_PLUS_BOT_RECEIVER"), - "version": push_config.get("WE_PLUS_BOT_VERSION"), - } - body = json.dumps(data).encode(encoding="utf-8") - headers = {"Content-Type": "application/json"} - response = requests.post(url=url, data=body, headers=headers).json() - - if response["code"] == 200: - print("微加机器人 推送成功!") - else: - print("微加机器人 推送失败!") - - -def qmsg_bot(title: str, content: str) -> None: - """ - 使用 qmsg 推送消息。 - """ - if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): - print("qmsg 的 QMSG_KEY 或者 QMSG_TYPE 未设置!!\n取消推送") - return - print("qmsg 服务启动") - - url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}' - payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")} - response = requests.post(url=url, params=payload).json() - - if response["code"] == 0: - print("qmsg 推送成功!") - else: - print(f'qmsg 推送失败!{response["reason"]}') - - -def wecom_app(title: str, content: str) -> None: - """ - 通过 企业微信 APP 推送消息。 - """ - if not push_config.get("QYWX_AM"): - print("QYWX_AM 未设置!!\n取消推送") - return - QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) - if 4 < len(QYWX_AM_AY) > 5: - print("QYWX_AM 设置错误!!\n取消推送") - return - print("企业微信 APP 服务启动") - - corpid = QYWX_AM_AY[0] - corpsecret = QYWX_AM_AY[1] - touser = QYWX_AM_AY[2] - agentid = QYWX_AM_AY[3] - try: - media_id = QYWX_AM_AY[4] - except IndexError: - media_id = "" - wx = WeCom(corpid, corpsecret, agentid) - # 如果没有配置 media_id 默认就以 text 方式发送 - if not media_id: - message = title + "\n\n" + content - response = wx.send_text(message, touser) - else: - response = wx.send_mpnews(title, content, media_id, touser) - - if response == "ok": - print("企业微信推送成功!") - else: - print("企业微信推送失败!错误信息如下:\n", response) - - -class WeCom: - def __init__(self, corpid, corpsecret, agentid): - self.CORPID = corpid - self.CORPSECRET = corpsecret - self.AGENTID = agentid - self.ORIGIN = "https://qyapi.weixin.qq.com" - if push_config.get("QYWX_ORIGIN"): - self.ORIGIN = push_config.get("QYWX_ORIGIN") - - def get_access_token(self): - url = f"{self.ORIGIN}/cgi-bin/gettoken" - values = { - "corpid": self.CORPID, - "corpsecret": self.CORPSECRET, - } - req = requests.post(url, params=values) - data = json.loads(req.text) - return data["access_token"] - - def send_text(self, message, touser="@all"): - send_url = ( - f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" - ) - send_values = { - "touser": touser, - "msgtype": "text", - "agentid": self.AGENTID, - "text": {"content": message}, - "safe": "0", - } - send_msges = bytes(json.dumps(send_values), "utf-8") - respone = requests.post(send_url, send_msges) - respone = respone.json() - return respone["errmsg"] - - def send_mpnews(self, title, message, media_id, touser="@all"): - send_url = ( - f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" - ) - send_values = { - "touser": touser, - "msgtype": "mpnews", - "agentid": self.AGENTID, - "mpnews": { - "articles": [ - { - "title": title, - "thumb_media_id": media_id, - "author": "Author", - "content_source_url": "", - "content": message.replace("\n", "
"), - "digest": message, - } - ] - }, - } - send_msges = bytes(json.dumps(send_values), "utf-8") - respone = requests.post(send_url, send_msges) - respone = respone.json() - return respone["errmsg"] - - -def wecom_bot(title: str, content: str) -> None: - """ - 通过 企业微信机器人 推送消息。 - """ - if not push_config.get("QYWX_KEY"): - print("企业微信机器人 服务的 QYWX_KEY 未设置!!\n取消推送") - return - print("企业微信机器人服务启动") - - origin = "https://qyapi.weixin.qq.com" - if push_config.get("QYWX_ORIGIN"): - origin = push_config.get("QYWX_ORIGIN") - - url = f"{origin}/cgi-bin/webhook/send?key={push_config.get('QYWX_KEY')}" - headers = {"Content-Type": "application/json;charset=utf-8"} - data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} - response = requests.post( - url=url, data=json.dumps(data), headers=headers, timeout=15 - ).json() - - if response["errcode"] == 0: - print("企业微信机器人推送成功!") - else: - print("企业微信机器人推送失败!") - - -def telegram_bot(title: str, content: str) -> None: - """ - 使用 telegram 机器人 推送消息。 - """ - if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): - print("tg 服务的 bot_token 或者 user_id 未设置!!\n取消推送") - return - print("tg 服务启动") - - if push_config.get("TG_API_HOST"): - url = f"{push_config.get('TG_API_HOST')}/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" - else: - url = ( - f"https://api.telegram.org/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" - ) - headers = {"Content-Type": "application/x-www-form-urlencoded"} - payload = { - "chat_id": str(push_config.get("TG_USER_ID")), - "text": f"{title}\n\n{content}", - "disable_web_page_preview": "true", - } - proxies = None - if push_config.get("TG_PROXY_HOST") and push_config.get("TG_PROXY_PORT"): - if push_config.get("TG_PROXY_AUTH") is not None and "@" not in push_config.get( - "TG_PROXY_HOST" - ): - push_config["TG_PROXY_HOST"] = ( - push_config.get("TG_PROXY_AUTH") - + "@" - + push_config.get("TG_PROXY_HOST") - ) - proxyStr = "http://{}:{}".format( - push_config.get("TG_PROXY_HOST"), push_config.get("TG_PROXY_PORT") - ) - proxies = {"http": proxyStr, "https": proxyStr} - response = requests.post( - url=url, headers=headers, params=payload, proxies=proxies - ).json() - - if response["ok"]: - print("tg 推送成功!") - else: - print("tg 推送失败!") - - -def aibotk(title: str, content: str) -> None: - """ - 使用 智能微秘书 推送消息。 - """ - if ( - not push_config.get("AIBOTK_KEY") - or not push_config.get("AIBOTK_TYPE") - or not push_config.get("AIBOTK_NAME") - ): - print( - "智能微秘书 的 AIBOTK_KEY 或者 AIBOTK_TYPE 或者 AIBOTK_NAME 未设置!!\n取消推送" - ) - return - print("智能微秘书 服务启动") - - if push_config.get("AIBOTK_TYPE") == "room": - url = "https://api-bot.aibotk.com/openapi/v1/chat/room" - data = { - "apiKey": push_config.get("AIBOTK_KEY"), - "roomName": push_config.get("AIBOTK_NAME"), - "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, - } - else: - url = "https://api-bot.aibotk.com/openapi/v1/chat/contact" - data = { - "apiKey": push_config.get("AIBOTK_KEY"), - "name": push_config.get("AIBOTK_NAME"), - "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, - } - body = json.dumps(data).encode(encoding="utf-8") - headers = {"Content-Type": "application/json"} - response = requests.post(url=url, data=body, headers=headers).json() - print(response) - if response["code"] == 0: - print("智能微秘书 推送成功!") - else: - print(f'智能微秘书 推送失败!{response["error"]}') - - -def smtp(title: str, content: str) -> None: - """ - 使用 SMTP 邮件 推送消息。 - """ - if ( - not push_config.get("SMTP_SERVER") - or not push_config.get("SMTP_SSL") - or not push_config.get("SMTP_EMAIL") - or not push_config.get("SMTP_PASSWORD") - or not push_config.get("SMTP_NAME") - ): - print( - "SMTP 邮件 的 SMTP_SERVER 或者 SMTP_SSL 或者 SMTP_EMAIL 或者 SMTP_PASSWORD 或者 SMTP_NAME 未设置!!\n取消推送" - ) - return - print("SMTP 邮件 服务启动") - - message = MIMEText(content, "plain", "utf-8") - message["From"] = formataddr( - ( - Header(push_config.get("SMTP_NAME"), "utf-8").encode(), - push_config.get("SMTP_EMAIL"), - ) - ) - message["To"] = formataddr( - ( - Header(push_config.get("SMTP_NAME"), "utf-8").encode(), - push_config.get("SMTP_EMAIL"), - ) - ) - message["Subject"] = Header(title, "utf-8") - - try: - smtp_server = ( - smtplib.SMTP_SSL(push_config.get("SMTP_SERVER")) - if push_config.get("SMTP_SSL") == "true" - else smtplib.SMTP(push_config.get("SMTP_SERVER")) - ) - smtp_server.login( - push_config.get("SMTP_EMAIL"), push_config.get("SMTP_PASSWORD") - ) - smtp_server.sendmail( - push_config.get("SMTP_EMAIL"), - push_config.get("SMTP_EMAIL"), - message.as_bytes(), - ) - smtp_server.close() - print("SMTP 邮件 推送成功!") - except Exception as e: - print(f"SMTP 邮件 推送失败!{e}") - - -def pushme(title: str, content: str) -> None: - """ - 使用 PushMe 推送消息。 - """ - if not push_config.get("PUSHME_KEY"): - print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送") - return - print("PushMe 服务启动") - - url = ( - push_config.get("PUSHME_URL") - if push_config.get("PUSHME_URL") - else "https://push.i-i.me/" - ) - data = { - "push_key": push_config.get("PUSHME_KEY"), - "title": title, - "content": content, - "date": push_config.get("date") if push_config.get("date") else "", - "type": push_config.get("type") if push_config.get("type") else "", - } - response = requests.post(url, data=data) - - if response.status_code == 200 and response.text == "success": - print("PushMe 推送成功!") - else: - print(f"PushMe 推送失败!{response.status_code} {response.text}") - - -def chronocat(title: str, content: str) -> None: - """ - 使用 CHRONOCAT 推送消息。 - """ - if ( - not push_config.get("CHRONOCAT_URL") - or not push_config.get("CHRONOCAT_QQ") - or not push_config.get("CHRONOCAT_TOKEN") - ): - print("CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送") - return - - print("CHRONOCAT 服务启动") - - user_ids = re.findall(r"user_id=(\d+)", push_config.get("CHRONOCAT_QQ")) - group_ids = re.findall(r"group_id=(\d+)", push_config.get("CHRONOCAT_QQ")) - - url = f'{push_config.get("CHRONOCAT_URL")}/api/message/send' - headers = { - "Content-Type": "application/json", - "Authorization": f'Bearer {push_config.get("CHRONOCAT_TOKEN")}', - } - - for chat_type, ids in [(1, user_ids), (2, group_ids)]: - if not ids: - continue - for chat_id in ids: - data = { - "peer": {"chatType": chat_type, "peerUin": chat_id}, - "elements": [ - { - "elementType": 1, - "textElement": {"content": f"{title}\n\n{content}"}, - } - ], - } - response = requests.post(url, headers=headers, data=json.dumps(data)) - if response.status_code == 200: - if chat_type == 1: - print(f"QQ个人消息:{ids}推送成功!") - else: - print(f"QQ群消息:{ids}推送成功!") - else: - if chat_type == 1: - print(f"QQ个人消息:{ids}推送失败!") - else: - print(f"QQ群消息:{ids}推送失败!") - -def ntfy(title: str, content: str) -> None: - """ - 使用 Ntfy 推送消息。 - """ - if not push_config.get("Ntfy_T"): - print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送") - return - print("PushMe 服务启动") - - url = push_config.get("PUSHME_URL") if push_config.get("PUSHME_URL") else "https://push.i-i.me/" - data = { - "push_key": push_config.get("PUSHME_KEY"), - "title": title, - "content": content, - "date": push_config.get("date") if push_config.get("date") else "", - "type": push_config.get("type") if push_config.get("type") else "", - } - response = requests.post(url, data=data) - - if response.status_code == 200 and response.text == "success": - print("PushMe 推送成功!") - else: - print(f"PushMe 推送失败!{response.status_code} {response.text}") - - -def parse_headers(headers): - if not headers: - return {} - - parsed = {} - lines = headers.split("\n") - - for line in lines: - i = line.find(":") - if i == -1: - continue - - key = line[:i].strip().lower() - val = line[i + 1 :].strip() - parsed[key] = parsed.get(key, "") + ", " + val if key in parsed else val - - return parsed - - -def parse_string(input_string, value_format_fn=None): - matches = {} - pattern = r"(\w+):\s*((?:(?!\n\w+:).)*)" - regex = re.compile(pattern) - for match in regex.finditer(input_string): - key, value = match.group(1).strip(), match.group(2).strip() - try: - value = value_format_fn(value) if value_format_fn else value - json_value = json.loads(value) - matches[key] = json_value - except: - matches[key] = value - return matches - - -def parse_body(body, content_type, value_format_fn=None): - if not body or content_type == "text/plain": - return value_format_fn(body) if value_format_fn and body else body - - parsed = parse_string(body, value_format_fn) - - if content_type == "application/x-www-form-urlencoded": - data = urllib.parse.urlencode(parsed, doseq=True) - return data - - if content_type == "application/json": - data = json.dumps(parsed) - return data - - return parsed - - -def custom_notify(title: str, content: str) -> None: - """ - 通过 自定义通知 推送消息。 - """ - if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"): - print("自定义通知的 WEBHOOK_URL 或 WEBHOOK_METHOD 未设置!!\n取消推送") - return - - print("自定义通知服务启动") - - WEBHOOK_URL = push_config.get("WEBHOOK_URL") - WEBHOOK_METHOD = push_config.get("WEBHOOK_METHOD") - WEBHOOK_CONTENT_TYPE = push_config.get("WEBHOOK_CONTENT_TYPE") - WEBHOOK_BODY = push_config.get("WEBHOOK_BODY") - WEBHOOK_HEADERS = push_config.get("WEBHOOK_HEADERS") - - if "$title" not in WEBHOOK_URL and "$title" not in WEBHOOK_BODY: - print("请求头或者请求体中必须包含 $title 和 $content") - return - - headers = parse_headers(WEBHOOK_HEADERS) - body = parse_body( - WEBHOOK_BODY, - WEBHOOK_CONTENT_TYPE, - lambda v: v.replace("$title", title.replace("\n", "\\n")).replace( - "$content", content.replace("\n", "\\n") - ), - ) - formatted_url = WEBHOOK_URL.replace( - "$title", urllib.parse.quote_plus(title) - ).replace("$content", urllib.parse.quote_plus(content)) - response = requests.request( - method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body - ) - - if response.status_code == 200: - print("自定义通知推送成功!") - else: - print(f"自定义通知推送失败!{response.status_code} {response.text}") - - -def one() -> str: - """ - 获取一条一言。 - :return: - """ - url = "https://v1.hitokoto.cn/" - res = requests.get(url).json() - return res["hitokoto"] + " ----" + res["from"] - - -def add_notify_function(): - notify_function = [] - if push_config.get("BARK_PUSH"): - notify_function.append(bark) - if push_config.get("CONSOLE"): - notify_function.append(console) - if push_config.get("DD_BOT_TOKEN") and push_config.get("DD_BOT_SECRET"): - notify_function.append(dingding_bot) - if push_config.get("FSKEY"): - notify_function.append(feishu_bot) - if push_config.get("GOBOT_URL") and push_config.get("GOBOT_QQ"): - notify_function.append(go_cqhttp) - if push_config.get("GOTIFY_URL") and push_config.get("GOTIFY_TOKEN"): - notify_function.append(gotify) - if push_config.get("IGOT_PUSH_KEY"): - notify_function.append(iGot) - if push_config.get("PUSH_KEY"): - notify_function.append(serverJ) - if push_config.get("DEER_KEY"): - notify_function.append(pushdeer) - if push_config.get("CHAT_URL") and push_config.get("CHAT_TOKEN"): - notify_function.append(chat) - if push_config.get("PUSH_PLUS_TOKEN"): - notify_function.append(pushplus_bot) - if push_config.get("WE_PLUS_BOT_TOKEN"): - notify_function.append(weplus_bot) - if push_config.get("QMSG_KEY") and push_config.get("QMSG_TYPE"): - notify_function.append(qmsg_bot) - if push_config.get("QYWX_AM"): - notify_function.append(wecom_app) - if push_config.get("QYWX_KEY"): - notify_function.append(wecom_bot) - if push_config.get("TG_BOT_TOKEN") and push_config.get("TG_USER_ID"): - notify_function.append(telegram_bot) - if ( - push_config.get("AIBOTK_KEY") - and push_config.get("AIBOTK_TYPE") - and push_config.get("AIBOTK_NAME") - ): - notify_function.append(aibotk) - if ( - push_config.get("SMTP_SERVER") - and push_config.get("SMTP_SSL") - and push_config.get("SMTP_EMAIL") - and push_config.get("SMTP_PASSWORD") - and push_config.get("SMTP_NAME") - ): - notify_function.append(smtp) - if push_config.get("PUSHME_KEY"): - notify_function.append(pushme) - if ( - push_config.get("CHRONOCAT_URL") - and push_config.get("CHRONOCAT_QQ") - and push_config.get("CHRONOCAT_TOKEN") - ): - notify_function.append(chronocat) - if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"): - notify_function.append(custom_notify) - - if not notify_function: - print(f"无推送渠道,请检查通知变量是否正确") - return notify_function - - -def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): - if kwargs: - global push_config - if ignore_default_config: - push_config = kwargs # 清空从环境变量获取的配置 - else: - push_config.update(kwargs) - - if not content: - print(f"{title} 推送内容为空!") - return - - # 根据标题跳过一些消息推送,环境变量:SKIP_PUSH_TITLE 用回车分隔 - skipTitle = os.getenv("SKIP_PUSH_TITLE") - if skipTitle: - if title in re.split("\n", skipTitle): - print(f"{title} 在SKIP_PUSH_TITLE环境变量内,跳过推送!") - return - - hitokoto = push_config.get("HITOKOTO") - content += "\n\n" + one() if hitokoto != "false" else "" - - notify_function = add_notify_function() - ts = [ - threading.Thread(target=mode, args=(title, content), name=mode.__name__) - for mode in notify_function - ] - [t.start() for t in ts] - [t.join() for t in ts] - - -def main(): - send("title", "content") - - -if __name__ == "__main__": - main() diff --git a/shell/bot.sh b/shell/bot.sh index 524a44e9..3d8e799d 100755 --- a/shell/bot.sh +++ b/shell/bot.sh @@ -13,12 +13,19 @@ os_name="${QL_OS_TYPE:-}" if [ -z "$os_name" ]; then os_name=$(source /etc/os-release && echo "$ID") fi + +# 非 root 用户使用 sudo +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" +fi + case "$os_name" in alpine) - apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev + $SUDO apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev ;; debian|ubuntu) - apt-get install -y gcc python3-dev musl-dev zlib1g-dev libjpeg-dev libfreetype-dev + $SUDO apt-get install -y gcc python3-dev musl-dev zlib1g-dev libjpeg-dev libfreetype-dev ;; *) echo -e "暂不支持此系统 $os_name" diff --git a/shell/start.sh b/shell/start.sh index 1f015f2b..3f4bb9cd 100644 --- a/shell/start.sh +++ b/shell/start.sh @@ -41,10 +41,16 @@ if [[ $command != "reload" ]]; then os_name=$(source /etc/os-release && echo "$ID") fi + # 非 root 用户使用 sudo + SUDO="" + if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" + fi + case "$os_name" in alpine) - apk update - apk add -f bash \ + $SUDO apk update + $SUDO apk add -f bash \ coreutils \ git \ curl \ @@ -59,8 +65,8 @@ if [[ $command != "reload" ]]; then netcat-openbsd ;; debian|ubuntu) - apt-get update - apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client + $SUDO apt-get update + $SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client ;; *) echo -e "暂不支持此系统部署 $os_name" From 2fe9470ff08ae891dd466a4205fe38b1418c52a8 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 31 May 2026 00:14:22 +0800 Subject: [PATCH 022/123] =?UTF-8?q?fix:=20gRPC=20extra=5Fschedules=20?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E6=97=B6=E5=BA=8F=E5=88=97=E5=8C=96=E6=8A=A5?= =?UTF-8?q?=E9=94=99=20not=20iterable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/schedule/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back/schedule/api.ts b/back/schedule/api.ts index 5bcc7b1f..81747958 100644 --- a/back/schedule/api.ts +++ b/back/schedule/api.ts @@ -250,7 +250,7 @@ const normalizeCronData = (data: CronItem | null): CronItem | undefined => { return { ...data, sub_id: data.sub_id ?? undefined, - extra_schedules: data.extra_schedules ?? undefined, + extra_schedules: data.extra_schedules ?? [], pid: data.pid ?? undefined, task_before: data.task_before ?? undefined, task_after: data.task_after ?? undefined, From 3aab1233bbcbaadb248390e5205d7573e2770922 Mon Sep 17 00:00:00 2001 From: jmclulu <2817852344@QQ.com> Date: Sun, 31 May 2026 00:32:04 +0800 Subject: [PATCH 023/123] fix: correct typos in source code and locales (#3003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Countrys -> Countries in comment - Fix Scrolldown -> ScrollDown in variable name - Fix completeTowFactor -> completeTwoFactor (3x) - Fix deactiveTowFactor -> deactivateTwoFactor (3x) - Fix activeOrDeactiveTwoFactor -> activeOrDeactivateTwoFactor - Fix API route /two-factor/deactive -> /two-factor/deactivate - Fix elment -> element in function param - Fix synolog -> synology in comment - Fix Chinese comment 制定 -> 指定 - Fix swapped BARK English translations on lines 360-361 --- back/api/user.ts | 4 ++-- back/services/user.ts | 2 +- sample/notify.js | 2 +- src/components/terminal.tsx | 8 ++++---- src/locales/en-US.json | 4 ++-- src/pages/login/index.tsx | 6 +++--- src/pages/setting/security.tsx | 12 ++++++------ src/utils/config.ts | 2 +- src/utils/index.ts | 6 +++--- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/back/api/user.ts b/back/api/user.ts index bed0fd78..6155f0a4 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -140,12 +140,12 @@ export default (app: Router) => { ); route.put( - '/two-factor/deactive', + '/two-factor/deactivate', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); - const data = await userService.deactiveTwoFactor(); + const data = await userService.deactivateTwoFactor(); res.send({ code: 200, data }); } catch (e) { return next(e); diff --git a/back/services/user.ts b/back/services/user.ts index 068b850c..362cb693 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -330,7 +330,7 @@ export default class UserService { } } - public async deactiveTwoFactor() { + public async deactivateTwoFactor() { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { twoFactorActivated: false, diff --git a/sample/notify.js b/sample/notify.js index 49d5fdc8..2b4d91d3 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -1598,7 +1598,7 @@ async function sendNotify(text, desp, params = {}) { iGotNotify(text, desp, params), // iGot gobotNotify(text, desp), // go-cqhttp gotifyNotify(text, desp), // gotify - chatNotify(text, desp), // synolog chat + chatNotify(text, desp), // synology chat pushDeerNotify(text, desp), // PushDeer aibotkNotify(text, desp), // 智能微秘书 fsBotNotify(text, desp), // 飞书机器人 diff --git a/src/components/terminal.tsx b/src/components/terminal.tsx index 0492595b..8b4d1804 100644 --- a/src/components/terminal.tsx +++ b/src/components/terminal.tsx @@ -29,10 +29,10 @@ const Terminal = ({ const lastLineRef = useRef(null); // An effect that handles scrolling into view the last line of terminal input or output - const performScrolldown = useRef(false); + const performScrollDown = useRef(false); useEffect(() => { - if (performScrolldown.current) { - // skip scrolldown when the component first loads + if (performScrollDown.current) { + // skip scrollDown when the component first loads setTimeout( () => lastLineRef?.current?.scrollIntoView({ @@ -42,7 +42,7 @@ const Terminal = ({ 500, ); } - performScrolldown.current = true; + performScrollDown.current = true; }, [lineData.length]); const renderedLineData = lineData.map((ld, i) => { diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 29e4bdbf..4662e9b7 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -357,8 +357,8 @@ "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK push icon, custom push icon (requires iOS 15 or above to display)", "BARK推送铃声,铃声列表去APP查看复制填写": "BARK push ringtone, check and copy from the APP's ringtone list", "BARK推送消息的分组,默认为qinglong": "BARK push message grouping, default is qinglong", - "BARK推送消息的时效性,默认为active": "BARK push message redirecting URL", - "BARK推送消息的跳转URL": "BARK push message grouping, default is qinglong", + "BARK推送消息的时效性,默认为active": "BARK push message timeliness, default is active", + "BARK推送消息的跳转URL": "BARK push message redirecting URL", "BARK是否保存推送消息": "Does BARK save push messages", "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw": "Telegram Bot token, e.g., 1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw", "telegram用户的id,例如:129xxx206": "Telegram user ID, e.g., 129xxx206", diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index be88e737..60755c0d 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -50,7 +50,7 @@ const Login = () => { }); }; - const completeTowFactor = (values: any) => { + const completeTwoFactor = (values: any) => { setVerifying(true); request .put(`${config.apiPrefix}user/two-factor/login`, { @@ -129,7 +129,7 @@ const Login = () => { const { value } = e.target as any; const regx = /^[0-9]{6}$/; if (regx.test(value)) { - completeTowFactor({ code: value }); + completeTwoFactor({ code: value }); } }; @@ -156,7 +156,7 @@ const Login = () => {
{twoFactor ? ( -
+ { }); }; - const activeOrDeactiveTwoFactor = () => { + const activeOrDeactivateTwoFactor = () => { if (twoFactorActivated) { - deactiveTowFactor(); + deactivateTwoFactor(); } else { getTwoFactorInfo(); setTwoFactoring(true); } }; - const deactiveTowFactor = () => { + const deactivateTwoFactor = () => { request - .put(`${config.apiPrefix}user/two-factor/deactive`) + .put(`${config.apiPrefix}user/two-factor/deactivate`) .then(({ code, data }) => { if (code === 200 && data) { setTwoFactorActivated(false); @@ -60,7 +60,7 @@ const SecuritySettings = ({ user, userChange }: any) => { }); }; - const completeTowFactor = () => { + const completeTwoFactor = () => { setLoading(true); request .put(`${config.apiPrefix}user/two-factor/active`, { code }) @@ -162,7 +162,7 @@ const SecuritySettings = ({ user, userChange }: any) => { onChange={(e) => setCode(e.target.value)} placeholder="123456" /> -
diff --git a/src/utils/config.ts b/src/utils/config.ts index f15a48b5..310fa1cd 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -18,7 +18,7 @@ export default { /* I18n configuration, `languages` and `defaultLanguage` are required currently. */ i18n: { - /* Countrys flags: https://www.flaticon.com/packs/countrys-flags */ + /* Countries flags: https://www.flaticon.com/packs/countries-flags */ languages: [ { key: 'pt-br', diff --git a/src/utils/index.ts b/src/utils/index.ts index 9260a100..d393df5c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -179,7 +179,7 @@ export default function browserType() { /** * 获取第一个表格的可视化高度 * @param {*} extraHeight 额外的高度(表格底部的内容高度 Number类型,默认为74) - * @param {*} id 当前页面中有多个table时需要制定table的id + * @param {*} id 当前页面中有多个table时需要指定table的id */ export function getTableScroll({ extraHeight, @@ -208,7 +208,7 @@ export function getTableScroll({ } // 自动触发点击事件 -function automaticClick(elment: HTMLElement) { +function automaticClick(element: HTMLElement) { const ev = document.createEvent('MouseEvents'); ev.initMouseEvent( 'click', @@ -227,7 +227,7 @@ function automaticClick(elment: HTMLElement) { 0, null, ); - elment.dispatchEvent(ev); + element.dispatchEvent(ev); } // 导出文件 From c2eac8b8fdf2fe90a8044195bb3fe8d26b3a7313 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 31 May 2026 00:36:54 +0800 Subject: [PATCH 024/123] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/setting/security.tsx | 2 +- src/utils/config.ts | 23 ----------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/src/pages/setting/security.tsx b/src/pages/setting/security.tsx index 04707aae..29b92f00 100644 --- a/src/pages/setting/security.tsx +++ b/src/pages/setting/security.tsx @@ -230,7 +230,7 @@ const SecuritySettings = ({ user, userChange }: any) => { diff --git a/src/utils/config.ts b/src/utils/config.ts index 310fa1cd..3249f67c 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -15,29 +15,6 @@ export default { exclude: [/(\/(en|zh))*\/login/], }, ], - - /* I18n configuration, `languages` and `defaultLanguage` are required currently. */ - i18n: { - /* Countries flags: https://www.flaticon.com/packs/countries-flags */ - languages: [ - { - key: 'pt-br', - title: 'Português', - flag: '/portugal.svg', - }, - { - key: 'en', - title: 'English', - flag: '/america.svg', - }, - { - key: 'zh', - title: intl.get('中文'), - flag: '/china.svg', - }, - ], - defaultLanguage: 'en', - }, scopes: [ { name: intl.get('定时任务'), From e06c3571a8eb2780fd149e89c699e3203150cadc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 14:25:51 +0800 Subject: [PATCH 025/123] =?UTF-8?q?Fix=20undefined=20error=20in=20serverNo?= =?UTF-8?q?tify=20when=20Server=E9=85=B1=20returns=20error=20responses=20(?= =?UTF-8?q?#2917)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Fix: Replace data.data.errno with data.code to prevent undefined error Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- sample/notify.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/notify.js b/sample/notify.js index 2b4d91d3..6b6340a4 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -307,7 +307,7 @@ function serverNotify(text, desp) { console.log('Server 酱发送通知调用API失败😞\n', err); } else { // server酱和Server酱·Turbo版的返回json格式不太一样 - if (data.errno === 0 || data.data.errno === 0) { + if (data.errno === 0 || data.code === 0) { console.log('Server 酱发送通知消息成功🎉\n'); } else if (data.errno === 1024) { // 一分钟内发送相同的内容会触发 From 0e5b8bef4b5dd1e7e24b898e9e00f3eb25c2b2e8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 14:26:57 +0800 Subject: [PATCH 026/123] Fix HITOKOTO parameter boolean/string type handling (#2918) * Initial plan * Fix HITOKOTO parameter type mismatch in notify scripts Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> * Refactor JavaScript HITOKOTO check for consistency with Python Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- sample/notify.js | 2 +- sample/notify.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sample/notify.js b/sample/notify.js index 6b6340a4..da985b2a 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -1582,7 +1582,7 @@ async function sendNotify(text, desp, params = {}) { } } - if (push_config.HITOKOTO !== 'false') { + if (![false, 'false'].includes(push_config.HITOKOTO)) { desp += '\n\n' + (await one()); } diff --git a/sample/notify.py b/sample/notify.py index 74c9bd70..0bc81ffd 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -1131,7 +1131,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs return hitokoto = push_config.get("HITOKOTO") - content += "\n\n" + one() if hitokoto != "false" else "" + content += "\n\n" + one() if hitokoto not in [False, "false"] else "" notify_function = add_notify_function() ts = [ From b6537e27ef71854e9a5e2cb1972a2aaf3d7b7247 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 31 May 2026 15:04:10 +0800 Subject: [PATCH 027/123] update mirror action --- .github/workflows/build-docker-image.yml | 95 ++++++++++++++---------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 5630916c..f7964132 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -21,14 +21,16 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: Yikun/hub-mirror-action@master - with: - src: github/whyour - dst: gitlab/whyour - dst_key: ${{ secrets.GITLAB_SSH_PK }} - dst_token: ${{ secrets.GITLAB_TOKEN }} - static_list: "qinglong" - force_update: true + - name: Push to GitLab + run: | + set +e + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null + git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null + git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed" + git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed" code_gitee: runs-on: ubuntu-latest @@ -36,7 +38,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Setup SSH and push to Gitee + - name: Push to Gitee env: GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} run: | @@ -50,24 +52,15 @@ jobs: if git push --force --all gitee 2>&1; then echo "::notice::Gitee push --all succeeded" else - echo "::warning::Push --all failed, trying to create repo via API..." + echo "::warning::Push failed, trying to create repo via API..." curl -sS --connect-timeout 30 --max-time 60 \ -X POST "https://gitee.com/api/v5/user/repos" \ -H "Content-Type: application/json" \ -d '{"name":"qinglong","private":"false"}' \ "?access_token=$GITEE_TOKEN" 2>/dev/null - if git push --force --all gitee 2>&1; then - echo "::notice::Gitee push --all succeeded after repo creation" - else - echo "::warning::Gitee push --all failed after retry" - fi - fi - - if git push --force --tags gitee 2>&1; then - echo "::notice::Gitee push --tags succeeded" - else - echo "::warning::Gitee push --tags failed" + git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry" fi + git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed" build-static: runs-on: ubuntu-latest @@ -106,34 +99,48 @@ jobs: needs: build-static runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: Yikun/hub-mirror-action@master - with: - src: github/whyour - dst: gitlab/whyour - dst_key: ${{ secrets.GITLAB_SSH_PK }} - dst_token: ${{ secrets.GITLAB_TOKEN }} - static_list: "qinglong-static" - force_update: true + - name: Push qinglong-static to GitLab + run: | + set +e + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null + git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror + cd static-mirror + git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null + git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed" + git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed" static_gitee: needs: build-static runs-on: ubuntu-latest steps: - - name: Setup SSH + - name: Push qinglong-static to Gitee + env: + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} run: | + set +e mkdir -p ~/.ssh - echo "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa - ssh-keyscan github.com gitee.com >> ~/.ssh/known_hosts - - name: Mirror qinglong-static to Gitee - run: | - git clone --mirror https://github.com/whyour/qinglong-static.git static-mirror + ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null + git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror cd static-mirror - git remote set-url origin git@gitee.com:whyour/qinglong-static.git - git push --force --mirror + git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null + + if git push --force --all gitee 2>&1; then + echo "::notice::Gitee static push succeeded" + else + echo "::warning::Push failed, trying to create repo via API..." + curl -sS --connect-timeout 30 --max-time 60 \ + -X POST "https://gitee.com/api/v5/user/repos" \ + -H "Content-Type: application/json" \ + -d '{"name":"qinglong-static","private":"false"}' \ + "?access_token=$GITEE_TOKEN" 2>/dev/null + git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry" + fi + git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed" build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} @@ -191,6 +198,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v4 + with: + cache-image: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -267,6 +276,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v4 + with: + cache-image: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -329,6 +340,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v4 + with: + cache-image: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -392,6 +405,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v4 + with: + cache-image: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 From 4bee3fbdf4e1b2daa7826f4b921a034fd406c94f Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 31 May 2026 15:53:24 +0800 Subject: [PATCH 028/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20getCronById=20api?= =?UTF-8?q?=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/data/cron.ts | 4 ++-- src/pages/crontab/type.ts | 4 ++-- src/pages/crontab/viewCreateModal.tsx | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/back/data/cron.ts b/back/data/cron.ts index f2093798..11c15b64 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -54,9 +54,9 @@ export class Crontab { export enum CrontabStatus { 'running' = 0, - 'queued' = 0.5, 'idle' = 1, - 'disabled', + 'disabled' = 2, + 'queued' = 3, } export interface CronInstance extends Model, Crontab {} diff --git a/src/pages/crontab/type.ts b/src/pages/crontab/type.ts index a71eade3..d559b980 100644 --- a/src/pages/crontab/type.ts +++ b/src/pages/crontab/type.ts @@ -1,8 +1,8 @@ export enum CrontabStatus { 'running' = 0, - 'queued' = 0.5, 'idle' = 1, - 'disabled', + 'disabled' = 2, + 'queued' = 3, } export enum OperationName { diff --git a/src/pages/crontab/viewCreateModal.tsx b/src/pages/crontab/viewCreateModal.tsx index e1e17dfc..646130fa 100644 --- a/src/pages/crontab/viewCreateModal.tsx +++ b/src/pages/crontab/viewCreateModal.tsx @@ -76,6 +76,7 @@ const ViewCreateModal = ({ { name: intl.get('运行中'), value: CrontabStatus.running }, { name: intl.get('空闲中'), value: CrontabStatus.idle }, { name: intl.get('已禁用'), value: CrontabStatus.disabled }, + { name: intl.get('排队中'), value: CrontabStatus.queued }, ], sub_id: data?.data.map((x) => ({ name: x.name, value: x.id })), }; From ca347c585472818a29e065f6ed374768c104ff75 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 31 May 2026 16:48:13 +0800 Subject: [PATCH 029/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=9E=20root=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=9B=B4=E6=96=B0=E6=97=B6=E5=8C=BA=E5=92=8C?= =?UTF-8?q?=20linux=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/config/util.ts | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/back/config/util.ts b/back/config/util.ts index 1b994e31..c4842d92 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -554,8 +554,8 @@ export async function setSystemTimezone(timezone: string): Promise { throw new Error('Invalid timezone'); } - await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); - await promiseExec(`echo "${timezone}" > /etc/timezone`); + await promiseExec(`sudo ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); + await promiseExec(`echo "${timezone}" | sudo tee /etc/timezone`); return true; } catch (error) { @@ -735,40 +735,25 @@ async function _updateLinuxMirror( filePath = '/etc/apt/sources.list.d/debian.sources'; currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (currentDomainWithScheme) { - await replaceDomainInFile( - filePath, - currentDomainWithScheme, - mirrorDomainWithScheme || 'http://deb.debian.org', - ); - return 'sudo apt-get update'; + return `sudo sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (sudo 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" | sudo tee ${filePath}) && sudo apt-get update`; } else { - throw Error(`Current mirror domain not found.`); + return `sudo 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" | sudo tee ${filePath} && sudo apt-get update`; } case 'Ubuntu': filePath = '/etc/apt/sources.list.d/ubuntu.sources'; currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (currentDomainWithScheme) { - await replaceDomainInFile( - filePath, - currentDomainWithScheme, - mirrorDomainWithScheme || 'http://archive.ubuntu.com', - ); - return 'sudo apt-get update'; + return `sudo sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (sudo 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" | sudo tee ${filePath}) && sudo apt-get update`; } else { - throw Error(`Current mirror domain not found.`); + return `sudo 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" | sudo tee ${filePath} && sudo apt-get update`; } case 'Alpine': filePath = '/etc/apk/repositories'; currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (currentDomainWithScheme) { - await replaceDomainInFile( - filePath, - currentDomainWithScheme, - mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org', - ); - return 'apk update'; + 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 { - throw Error(`Current mirror domain not found.`); + 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.'); From c0b752714842cf382e3b6f0a9eb90681b473cba1 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 1 Jun 2026 13:49:32 +0800 Subject: [PATCH 030/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=A9=BF=E8=B6=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/config.ts | 6 ++++++ back/config/util.ts | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/back/api/config.ts b/back/api/config.ts index 55c740fd..3fe3f463 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -78,6 +78,12 @@ export default (app: Router) => { if (name.startsWith('data/scripts/')) { path = join(config.rootPath, name); } + if ( + !path.startsWith(config.configPath) && + !path.startsWith(config.scriptPath) + ) { + return res.send({ code: 403, message: '文件路径无效' }); + } await writeFileWithLock(path, content); res.send({ code: 200, message: '保存成功' }); } catch (e) { diff --git a/back/config/util.ts b/back/config/util.ts index c4842d92..ff1aa544 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -262,7 +262,10 @@ export async function readDir( baseDir: string = '', blacklist: string[] = [], ): Promise { - const absoluteDir = path.join(baseDir, dir); + const absoluteDir = path.resolve(baseDir, dir); + if (!absoluteDir.startsWith(path.resolve(baseDir))) { + return []; + } const relativePath = path.relative(baseDir, absoluteDir); try { From e8ac195c96180ea31ac6ba127e782747b15ac2e1 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 1 Jun 2026 18:20:18 +0800 Subject: [PATCH 031/123] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/dashboard.ts | 370 ++++++++++++++++++++++ back/api/index.ts | 2 + back/data/cronStats.ts | 71 +++++ back/data/open.ts | 2 +- back/loaders/db.ts | 2 + back/loaders/initData.ts | 7 +- back/services/cron.ts | 1 - package.json | 41 +-- pnpm-lock.yaml | 580 ++++++++++++++++++++++++++++++++-- shell/api.sh | 14 + shell/otask.sh | 3 +- shell/share.sh | 17 +- src/layouts/defaultProps.tsx | 8 +- src/layouts/index.less | 1 + src/locales/en-US.json | 31 +- src/locales/zh-CN.json | 31 +- src/pages/dashboard/index.tsx | 359 +++++++++++++++++++++ src/pages/login/index.tsx | 4 +- 18 files changed, 1484 insertions(+), 60 deletions(-) create mode 100644 back/api/dashboard.ts create mode 100644 back/data/cronStats.ts create mode 100644 src/pages/dashboard/index.tsx diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts new file mode 100644 index 00000000..2e23daa5 --- /dev/null +++ b/back/api/dashboard.ts @@ -0,0 +1,370 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { Container } from 'typedi'; +import { fn, col, where, Op } from 'sequelize'; +import { CrontabModel } from '../data/cron'; +import { CrontabStatModel } from '../data/cronStats'; +import dayjs from 'dayjs'; +import os from 'os'; + +const route = Router(); + +export default (app: Router) => { + app.use('/dashboard', route); + + route.post('/record', async (req: Request, res: Response) => { + try { + const { ref_id, code, elapsed } = req.body; + if (!ref_id) return res.send({ code: 400, message: 'ref_id required' }); + + const today = dayjs().format('YYYY-MM-DD'); + const isSuccess = code === 0 ? 1 : 0; + const isFail = code !== 0 ? 1 : 0; + const elapsedMs = (Number(elapsed) || 0) * 1000; + + const existing = await CrontabStatModel.findOne({ + where: { ref_id: Number(ref_id), date: today }, + }); + + if (existing) { + await CrontabStatModel.update( + { + run_count: (existing.run_count || 0) + 1, + success_count: (existing.success_count || 0) + isSuccess, + fail_count: (existing.fail_count || 0) + isFail, + total_time: (existing.total_time || 0) + elapsedMs, + max_time: Math.max(existing.max_time || 0, elapsedMs), + }, + { where: { id: existing.id } }, + ); + } else { + await CrontabStatModel.create({ + ref_id: Number(ref_id), + date: today, + run_count: 1, + success_count: isSuccess, + fail_count: isFail, + total_time: elapsedMs, + max_time: elapsedMs, + }); + } + + res.send({ code: 200 }); + } catch (e) { + res.send({ code: 500 }); + } + }); + + route.get( + '/overview', + async (req: Request, res: Response, next: NextFunction) => { + try { + const today = dayjs().format('YYYY-MM-DD'); + + const [total, enabled, disabled, stats] = await Promise.all([ + CrontabModel.count(), + CrontabModel.count({ where: { isDisabled: 0 } }), + CrontabModel.count({ where: { isDisabled: 1 } }), + CrontabStatModel.findOne({ + attributes: [ + [fn('SUM', col('run_count')), 'total_runs'], + [fn('SUM', col('success_count')), 'total_success'], + [fn('SUM', col('fail_count')), 'total_fail'], + [fn('SUM', col('total_time')), 'total_time'], + ], + where: { date: today }, + raw: true, + }), + ]); + + const row = stats as any; + const totalRuns = Number(row?.total_runs) || 0; + const totalSuccess = Number(row?.total_success) || 0; + const totalFail = Number(row?.total_fail) || 0; + const totalTime = Number(row?.total_time) || 0; + + res.send({ + code: 200, + data: { + total, + enabled, + disabled, + todayRuns: totalRuns, + todaySuccess: totalSuccess, + todayFail: totalFail, + successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0', + avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0, + }, + }); + } catch (e) { + next(e); + } + }, + ); + + route.get( + '/trend', + async (req: Request, res: Response, next: NextFunction) => { + try { + const days = parseInt(req.query.days as string) || 7; + const dates: string[] = []; + for (let i = days - 1; i >= 0; i--) { + dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD')); + } + + const rows = (await CrontabStatModel.findAll({ + attributes: [ + 'date', + [fn('SUM', col('run_count')), 'total_runs'], + [fn('SUM', col('success_count')), 'total_success'], + [fn('SUM', col('fail_count')), 'total_fail'], + ], + where: { + date: { [Op.in]: dates }, + }, + group: ['date'], + order: [['date', 'ASC']], + raw: true, + })) as any[]; + + const dataMap: Record = {}; + rows.forEach((r: any) => { + dataMap[r.date] = { + total: Number(r.total_runs) || 0, + success: Number(r.total_success) || 0, + fail: Number(r.total_fail) || 0, + }; + }); + + const data = dates.map((d) => ({ + date: dayjs(d).format('MM-DD'), + ...(dataMap[d] || { total: 0, success: 0, fail: 0 }), + })); + + res.send({ code: 200, data }); + } catch (e) { + next(e); + } + }, + ); + + route.get( + '/top-time', + async (req: Request, res: Response, next: NextFunction) => { + try { + const today = dayjs().format('YYYY-MM-DD'); + + const rows = (await CrontabStatModel.findAll({ + attributes: [ + 'ref_id', + [fn('SUM', col('total_time')), 'total_time'], + [fn('SUM', col('run_count')), 'run_count'], + [fn('MAX', col('max_time')), 'max_time'], + ], + where: { date: today, run_count: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col('total_time')), 'DESC']], + limit: 5, + raw: true, + })) as any[]; + + const ids = rows.map((r) => Number(r.ref_id)); + const crons = await CrontabModel.findAll({ + where: { id: { [Op.in]: ids } }, + raw: true, + }); + const nameMap: Record = {}; + crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; }); + + const data = rows.map((r: any, i) => ({ + rank: i + 1, + name: nameMap[Number(r.ref_id)] || `任务#${r.ref_id}`, + avgTime: Math.round(Number(r.total_time) / Number(r.run_count)), + maxTime: Number(r.max_time), + })); + + res.send({ code: 200, data }); + } catch (e) { + next(e); + } + }, + ); + + route.get( + '/top-count', + async (req: Request, res: Response, next: NextFunction) => { + try { + const today = dayjs().format('YYYY-MM-DD'); + + const rows = (await CrontabStatModel.findAll({ + attributes: [ + 'ref_id', + [fn('SUM', col('run_count')), 'run_count'], + [fn('SUM', col('total_time')), 'total_time'], + [fn('SUM', col('success_count')), 'success_count'], + ], + where: { date: today, run_count: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col('run_count')), 'DESC']], + limit: 5, + raw: true, + })) as any[]; + + const ids = rows.map((r) => Number(r.ref_id)); + const crons = await CrontabModel.findAll({ + where: { id: { [Op.in]: ids } }, + raw: true, + }); + const nameMap: Record = {}; + crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; }); + + const data = rows.map((r: any, i) => ({ + rank: i + 1, + name: nameMap[Number(r.ref_id)] || `任务#${r.ref_id}`, + runCount: Number(r.run_count), + avgTime: Math.round(Number(r.total_time) / Number(r.run_count)), + successRate: + Number(r.run_count) > 0 + ? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1) + : '0', + })); + + res.send({ code: 200, data }); + } catch (e) { + next(e); + } + }, + ); + + route.get( + '/runtime', + async (req: Request, res: Response, next: NextFunction) => { + try { + const runningCrons = await CrontabModel.findAll({ + where: { + status: 0, // running + }, + raw: true, + }); + + const queuedCrons = await CrontabModel.findAll({ + where: { + status: 3, // queued + }, + raw: true, + }); + + const running = runningCrons.map((c: any) => ({ + id: c.id, + name: c.name || c.command || `任务#${c.id}`, + pid: c.pid, + elapsed: c.last_execution_time + ? Math.floor((Date.now() / 1000) - c.last_execution_time) + : 0, + logPath: c.log_path, + })); + + const dayAgo = dayjs().subtract(24, 'hour').unix(); + const idleTasks = await CrontabModel.findAll({ + where: { + isDisabled: 0, + status: 1, + last_execution_time: { [Op.lt]: dayAgo }, + }, + order: [['last_execution_time', 'ASC']], + limit: 5, + raw: true, + }); + + res.send({ + code: 200, + data: { + runningCount: running.length, + queuedCount: queuedCrons.length, + running, + idleTasks: idleTasks.map((c: any) => ({ + id: c.id, + name: c.name || c.command || `任务#${c.id}`, + lastRun: c.last_execution_time + ? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm') + : '-', + })), + }, + }); + } catch (e) { + next(e); + } + }, + ); + + route.get( + '/labels', + async (req: Request, res: Response, next: NextFunction) => { + try { + const today = dayjs().format('YYYY-MM-DD'); + const [crons, stats] = (await Promise.all([ + CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }), + CrontabStatModel.findAll({ where: { date: today }, raw: true }), + ])) as any[]; + + const statMap: Record = {}; + stats.forEach((s: any) => { statMap[s.ref_id] = s; }); + + const labelMap: Record = {}; + crons.forEach((c: any) => { + let rawLabels = c.labels; + if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels); + const labels: string[] = Array.isArray(rawLabels) && rawLabels.length > 0 ? rawLabels : ['未分类']; + const st = statMap[c.id]; + labels.forEach((label: string) => { + if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 }; + labelMap[label].count += 1; + if (st) { + labelMap[label].runs += Number(st.run_count) || 0; + labelMap[label].success += Number(st.success_count) || 0; + labelMap[label].totalTime += Number(st.total_time) || 0; + } + }); + }); + + const data = Object.entries(labelMap) + .map(([label, v]) => ({ + label, + count: v.count, + todayRuns: v.runs, + successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0', + avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0, + })) + .sort((a, b) => b.todayRuns - a.todayRuns); + + res.send({ code: 200, data }); + } catch (e) { + next(e); + } + }, + ); + + route.get( + '/system', + async (req: Request, res: Response, next: NextFunction) => { + try { + const memUsage = process.memoryUsage(); + res.send({ + code: 200, + data: { + platform: os.platform(), + uptime: Math.floor(os.uptime()), + memTotal: os.totalmem(), + memFree: os.freemem(), + memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1), + heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024), + heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024), + loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))), + cpus: os.cpus().length, + }, + }); + } catch (e) { + next(e); + } + }, + ); +}; diff --git a/back/api/index.ts b/back/api/index.ts index 6dcab62f..443c8e5c 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -10,6 +10,7 @@ import dependence from './dependence'; import system from './system'; import subscription from './subscription'; import update from './update'; +import dashboard from './dashboard'; import health from './health'; export default () => { @@ -25,6 +26,7 @@ export default () => { system(app); subscription(app); update(app); + dashboard(app); health(app); return app; diff --git a/back/data/cronStats.ts b/back/data/cronStats.ts new file mode 100644 index 00000000..660b7c8d --- /dev/null +++ b/back/data/cronStats.ts @@ -0,0 +1,71 @@ +import { DataTypes, Model } from 'sequelize'; +import { sequelize } from '.'; + +export class CrontabStat { + id?: number; + ref_id!: number; + date!: string; + run_count?: number; + success_count?: number; + fail_count?: number; + total_time?: number; + max_time?: number; + + constructor(options: CrontabStat) { + this.id = options.id; + this.ref_id = options.ref_id; + this.date = options.date; + this.run_count = options.run_count || 0; + this.success_count = options.success_count || 0; + this.fail_count = options.fail_count || 0; + this.total_time = options.total_time || 0; + this.max_time = options.max_time || 0; + } +} + +export interface CrontabStatInstance extends Model, CrontabStat {} + +export const CrontabStatModel = sequelize.define( + 'CrontabStat', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + ref_id: { + type: DataTypes.NUMBER, + allowNull: false, + }, + date: { + type: DataTypes.STRING, + allowNull: false, + }, + run_count: { + type: DataTypes.NUMBER, + defaultValue: 0, + }, + success_count: { + type: DataTypes.NUMBER, + defaultValue: 0, + }, + fail_count: { + type: DataTypes.NUMBER, + defaultValue: 0, + }, + total_time: { + type: DataTypes.NUMBER, + defaultValue: 0, + }, + max_time: { + type: DataTypes.NUMBER, + defaultValue: 0, + }, + }, + { + indexes: [ + { unique: true, fields: ['ref_id', 'date'] }, + { fields: ['date'] }, + ], + }, +); diff --git a/back/data/open.ts b/back/data/open.ts index 2c0ba9aa..f508c70b 100644 --- a/back/data/open.ts +++ b/back/data/open.ts @@ -24,7 +24,7 @@ export interface AppToken { expiration: number; } -export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system'; +export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard'; export interface AppInstance extends Model, App {} export const AppModel = sequelize.define('App', { diff --git a/back/loaders/db.ts b/back/loaders/db.ts index 987c6cbe..f2990611 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -6,6 +6,7 @@ import { AppModel } from '../data/open'; import { SystemModel } from '../data/system'; import { SubscriptionModel } from '../data/subscription'; import { CrontabViewModel } from '../data/cronView'; +import { CrontabStatModel } from '../data/cronStats'; import { sequelize } from '../data'; export default async () => { @@ -17,6 +18,7 @@ export default async () => { await EnvModel.sync(); await SubscriptionModel.sync(); await CrontabViewModel.sync(); + await CrontabStatModel.sync(); // 初始化新增字段 const migrations = [ diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 37bbdd6b..264d7e05 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -36,10 +36,15 @@ export default async () => { if (!systemApp) { systemApp = await AppModel.create({ name: 'system', - scopes: ['crons', 'system'], + scopes: ['crons', 'system', 'dashboard'], client_id: createRandomString(12, 12), client_secret: createRandomString(24, 24), }); + } else if (!systemApp.scopes.includes('dashboard')) { + await AppModel.update( + { scopes: [...systemApp.scopes, 'dashboard'] }, + { where: { name: 'system' } }, + ); } const [systemConfig] = await SystemModel.findOrCreate({ where: { type: AuthDataType.systemConfig }, diff --git a/back/services/cron.ts b/back/services/cron.ts index def418a4..9bc34a09 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -573,7 +573,6 @@ export default class CronService { JSON.stringify(params), code, ); - // Close the stream after task completion await logStreamManager.closeStream(absolutePath); await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, diff --git a/package.json b/package.json index 3e942705..cda82f41 100644 --- a/package.json +++ b/package.json @@ -73,12 +73,16 @@ } }, "dependencies": { + "@ant-design/plots": "^2.6.8", + "@bufbuild/protobuf": "^2.10.0", "@grpc/grpc-js": "^1.14.0", "@grpc/proto-loader": "^0.8.0", + "@keyv/sqlite": "^4.0.1", "@otplib/preset-default": "^12.0.1", "body-parser": "^1.20.3", "celebrate": "^15.0.3", "chokidar": "^4.0.1", + "compression": "^1.7.4", "cors": "^2.8.5", "cron-parser": "^5.4.0", "cross-spawn": "^7.0.6", @@ -88,51 +92,48 @@ "express-jwt": "^8.4.1", "express-rate-limit": "^7.4.1", "express-urlrewrite": "^2.0.3", - "undici": "^7.9.0", + "helmet": "^8.1.0", "hpagent": "^1.2.0", "http-proxy-middleware": "^3.0.3", "iconv-lite": "^0.6.3", + "ip2region": "2.3.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", + "keyv": "^5.2.3", "lodash": "^4.17.21", "multer": "2.1.1", "node-schedule": "^2.1.0", "nodemailer": "^8.0.1", "p-queue-cjs": "7.3.4", - "@bufbuild/protobuf": "^2.10.0", + "proper-lockfile": "^4.1.2", "ps-tree": "^1.2.0", "reflect-metadata": "^0.2.2", + "request-ip": "3.3.0", "sequelize": "^6.37.5", "sockjs": "^0.3.24", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", + "undici": "^7.9.0", "uuid": "^11.0.3", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0", - "request-ip": "3.3.0", - "ip2region": "2.3.0", - "keyv": "^5.2.3", - "@keyv/sqlite": "^4.0.1", - "proper-lockfile": "^4.1.2", - "compression": "^1.7.4", - "helmet": "^8.1.0" + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { - "moment": "2.30.1", "@ant-design/icons": "^5.0.1", "@ant-design/pro-layout": "6.38.22", - "@codemirror/view": "6.39.16", "@codemirror/state": "6.5.4", + "@codemirror/view": "6.39.16", "@monaco-editor/react": "4.2.1", "@react-hook/resize-observer": "^2.0.2", - "react-router-dom": "6.26.1", "@types/body-parser": "^1.19.2", + "@types/compression": "^1.7.2", "@types/cors": "^2.8.12", "@types/cross-spawn": "^6.0.2", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", "@types/file-saver": "2.0.2", + "@types/helmet": "^4.0.0", "@types/js-yaml": "^4.0.5", "@types/jsonwebtoken": "^8.5.8", "@types/lodash": "^4.14.185", @@ -140,17 +141,17 @@ "@types/node": "^17.0.21", "@types/node-schedule": "^1.3.2", "@types/nodemailer": "^6.4.4", + "@types/proper-lockfile": "^4.1.4", + "@types/ps-tree": "^1.1.6", "@types/qrcode.react": "^1.0.2", "@types/react": "^18.0.20", "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "^18.0.6", + "@types/request-ip": "0.0.41", "@types/serve-handler": "^6.1.1", "@types/sockjs": "^0.3.33", "@types/sockjs-client": "^1.5.1", "@types/uuid": "^8.3.4", - "@types/request-ip": "0.0.41", - "@types/proper-lockfile": "^4.1.4", - "@types/ps-tree": "^1.1.6", "@uiw/codemirror-extensions-langs": "^4.21.9", "@uiw/react-codemirror": "^4.21.9", "@umijs/max": "^4.4.4", @@ -162,9 +163,9 @@ "axios": "^1.4.0", "compression-webpack-plugin": "9.2.0", "concurrently": "^7.0.0", - "react-hotkeys-hook": "^4.6.1", "file-saver": "2.0.2", "lint-staged": "^13.0.3", + "moment": "2.30.1", "monaco-editor": "0.33.0", "nodemon": "^3.0.1", "prettier": "^2.5.1", @@ -180,7 +181,9 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.3.1", + "react-hotkeys-hook": "^4.6.1", "react-intl-universal": "^2.12.0", + "react-router-dom": "6.26.1", "react-split-pane": "^0.1.92", "sockjs-client": "^1.6.0", "ts-node": "^10.9.2", @@ -188,8 +191,6 @@ "tslib": "^2.4.0", "typescript": "5.2.2", "vh-check": "^2.0.5", - "virtualizedtableforantd4": "1.3.0", - "@types/compression": "^1.7.2", - "@types/helmet": "^4.0.0" + "virtualizedtableforantd4": "1.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b26696ac..f15057a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,11 +1,18 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + overrides: sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.0.3 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 dependencies: + '@ant-design/plots': + specifier: ^2.6.8 + version: 2.6.8(react-dom@18.3.1)(react@18.3.1) '@bufbuild/protobuf': specifier: ^2.10.0 version: 2.10.0 @@ -384,6 +391,22 @@ packages: resolution: {integrity: sha512-0vr5GCwM7xlAl6NxG1lPbABO+SYioNJL3HVy2FA8wTlsIMoZvQwcwsxTw6eLQCiN9V2UQ8kBtfz8DW8utVVE5w==} dev: true + /@ant-design/charts-util@0.0.3(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-x1H7UT6t4dXAyGRoHqlOnEsEqBSTANFGTZEAMI0CWYhYUpp13n0o9grl9oPtoL6FEQMjUBTY+zGJKlHkz8smMw==} + peerDependencies: + react: '>=16.8.4 || 18' + react-dom: '>=16.8.4 || 18' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@ant-design/colors@6.0.0: resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==} dependencies: @@ -469,6 +492,27 @@ packages: resolution: {integrity: sha512-MLm1FUpg02fP615ShQnCUN9la2E4RylDxKyolkGqAWTIHO4HyGM0A5x71AMALEyP/bC+UEEWBGSQ+D4/8hQ+ww==} dev: true + /@ant-design/plots@2.6.8(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-QsunUs2d5rbq/1BwVhga/siA5H50OaG23YopMYwPD4sPsza6NQzPQ8FM3elNIsD/BIk298tihqX1cJ/MmvVJbQ==} + peerDependencies: + react: '>=16.8.4 || 18' + react-dom: '>=16.8.4 || 18' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@ant-design/charts-util': 0.0.3(react-dom@18.3.1)(react@18.3.1) + '@antv/event-emitter': 0.1.3 + '@antv/g': 6.3.1 + '@antv/g2': 5.4.8 + '@antv/g2-extension-plot': 0.2.2 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@ant-design/pro-card@2.9.2(antd@4.24.16)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-mKOmNb7jc3Pz41RrPY7EFKRWBjLdN4tp9yzmRkS2g8K7P3pW435f7Ip6rc+58FWDzbZa8lElTGPxAoFB/dq7LA==} peerDependencies: @@ -903,6 +947,184 @@ packages: resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} dev: true + /@antv/component@2.1.11: + resolution: {integrity: sha512-dTdz8VAd3rpjOaGEZTluz82mtzrP4XCtNlNQyrxY7VNRNcjtvpTLDn57bUL2lRu1T+iklKvgbE2llMriWkq9vQ==} + dependencies: + '@antv/g': 6.3.1 + '@antv/scale': 0.4.16 + '@antv/util': 3.3.11 + svg-path-parser: 1.1.0 + dev: false + + /@antv/coord@0.4.7: + resolution: {integrity: sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA==} + dependencies: + '@antv/scale': 0.4.16 + '@antv/util': 2.0.17 + gl-matrix: 3.4.4 + dev: false + + /@antv/event-emitter@0.1.3: + resolution: {integrity: sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==} + dev: false + + /@antv/expr@1.0.2: + resolution: {integrity: sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==} + dev: false + + /@antv/g-canvas@2.2.0: + resolution: {integrity: sha512-h7zVBBo2aO64DuGKvq9sG+yTU3sCUb9DALCVm7nz8qGPs8hhLuFOkKPEzUDNfNYZGJUGzY8UDtJ3QRGRFcvEQg==} + dependencies: + '@antv/g-lite': 2.7.0 + '@antv/g-math': 3.1.0 + '@antv/util': 3.3.11 + '@babel/runtime': 7.28.6 + gl-matrix: 3.4.4 + tslib: 2.8.1 + dev: false + + /@antv/g-lite@2.7.0: + resolution: {integrity: sha512-uSzgHYa5bwR5L2Au7/5tsOhFmXKZKLPBH90+Q9bP9teVs5VT4kOAi0isPSpDI8uhdDC2/VrfTWu5K9HhWI6FWw==} + dependencies: + '@antv/g-math': 3.1.0 + '@antv/util': 3.3.11 + '@antv/vendor': 1.0.11 + '@babel/runtime': 7.28.6 + eventemitter3: 5.0.1 + gl-matrix: 3.4.4 + tslib: 2.8.1 + dev: false + + /@antv/g-math@3.1.0: + resolution: {integrity: sha512-DtN1Gj/yI0UiK18nSBsZX8RK0LszGwqfb+cBYWgE+ddyTm8dZnW4tPUhV7QXePsS6/A5hHC+JFpAAK7OEGo5ZQ==} + dependencies: + '@antv/util': 3.3.11 + '@babel/runtime': 7.28.6 + gl-matrix: 3.4.4 + tslib: 2.8.1 + dev: false + + /@antv/g-plugin-dragndrop@2.1.1: + resolution: {integrity: sha512-+aesDUJVQDs6UJ2bOBbDlaGAPCfHmU0MbrMTlQlfpwNplWueqtgVAZ3L57oZ2ZGHRWUHiRwZGPjXMBM3O2LELw==} + dependencies: + '@antv/g-lite': 2.7.0 + '@antv/util': 3.3.11 + '@babel/runtime': 7.28.6 + tslib: 2.8.1 + dev: false + + /@antv/g2-extension-plot@0.2.2: + resolution: {integrity: sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw==} + dependencies: + '@antv/g2': 5.4.8 + '@antv/util': 3.3.11 + '@antv/vendor': 1.0.11 + dev: false + + /@antv/g2@5.4.8: + resolution: {integrity: sha512-IvgIpwmT4M5/QAd3Mn2WiHIDeBqFJ4WA2gcZhRRSZuZ2KmgCqZWZwwIT0hc+kIGxwYeDoCQqf//t6FMVu3ryBg==} + dependencies: + '@antv/component': 2.1.11 + '@antv/coord': 0.4.7 + '@antv/event-emitter': 0.1.3 + '@antv/expr': 1.0.2 + '@antv/g': 6.3.1 + '@antv/g-canvas': 2.2.0 + '@antv/g-plugin-dragndrop': 2.1.1 + '@antv/scale': 0.5.2 + '@antv/util': 3.3.11 + '@antv/vendor': 1.0.11 + flru: 1.0.2 + pdfast: 0.2.0 + dev: false + + /@antv/g@6.3.1: + resolution: {integrity: sha512-WYEKqy86LHB2PzTmrZXrIsIe+3Epeds2f68zceQ+BJtRoGki7Sy4IhlC8LrUMztgfT1t3d/0L745NWZwITroKA==} + dependencies: + '@antv/g-lite': 2.7.0 + '@antv/util': 3.3.11 + '@babel/runtime': 7.28.6 + gl-matrix: 3.4.4 + html2canvas: 1.4.1 + dev: false + + /@antv/scale@0.4.16: + resolution: {integrity: sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==} + dependencies: + '@antv/util': 3.3.11 + color-string: 1.9.1 + fecha: 4.2.3 + dev: false + + /@antv/scale@0.5.2: + resolution: {integrity: sha512-rTHRAwvpHWC5PGZF/mJ2ZuTDqwwvVBDRph0Uu5PV9BXwzV7K8+9lsqGJ+XHVLxe8c6bKog5nlzvV/dcYb0d5Ow==} + dependencies: + '@antv/util': 3.3.11 + color-string: 1.9.1 + fecha: 4.2.3 + dev: false + + /@antv/util@2.0.17: + resolution: {integrity: sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==} + dependencies: + csstype: 3.2.3 + tslib: 2.8.1 + dev: false + + /@antv/util@3.3.11: + resolution: {integrity: sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==} + dependencies: + fast-deep-equal: 3.1.3 + gl-matrix: 3.4.4 + tslib: 2.8.1 + dev: false + + /@antv/vendor@1.0.11: + resolution: {integrity: sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==} + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-color': 3.1.3 + '@types/d3-dispatch': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-force-3d: 3.0.6 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-geo-projection: 4.0.0 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-regression: 1.3.10 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false + /@babel/code-frame@7.26.2: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -1351,7 +1573,6 @@ packages: /@babel/runtime@7.28.6: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - dev: true /@babel/template@7.25.9: resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} @@ -1409,8 +1630,8 @@ packages: resolution: {integrity: sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==} peerDependencies: '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.10.6 @@ -3666,8 +3887,8 @@ packages: peerDependencies: '@codemirror/autocomplete': ^6.0.0 '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 '@lezer/lr': ^1.0.0 @@ -3686,8 +3907,8 @@ packages: peerDependencies: '@codemirror/autocomplete': ^6.0.0 '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 '@lezer/lr': ^1.0.0 @@ -3718,8 +3939,8 @@ packages: '@codemirror/lang-html': ^6.2.0 '@codemirror/lang-javascript': ^6.1.1 '@codemirror/language': ^6.0.0 - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 '@lezer/javascript': ^1.2.0 @@ -4082,6 +4303,92 @@ packages: '@types/node': 17.0.45 dev: true + /@types/d3-array@3.2.2: + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-dispatch@3.0.7: + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + dev: false + + /@types/d3-dsv@3.0.7: + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-fetch@3.0.7: + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + dependencies: + '@types/d3-dsv': 3.0.7 + dev: false + + /@types/d3-force@3.0.10: + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + dev: false + + /@types/d3-format@3.0.4: + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + dev: false + + /@types/d3-geo@3.1.0: + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + dependencies: + '@types/geojson': 7946.0.16 + dev: false + + /@types/d3-hierarchy@3.1.7: + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.1.1: + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + dev: false + + /@types/d3-quadtree@3.0.6: + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + dev: false + + /@types/d3-random@3.0.3: + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + dev: false + + /@types/d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + dev: false + + /@types/d3-scale@4.0.9: + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + dependencies: + '@types/d3-time': 3.0.4 + dev: false + + /@types/d3-shape@3.1.8: + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + dependencies: + '@types/d3-path': 3.1.1 + dev: false + + /@types/d3-time@3.0.4: + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/debug@4.1.12: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: @@ -4124,6 +4431,10 @@ packages: resolution: {integrity: sha512-xbqnZmGrCEqi/KUzOkeUSe77p7APvLuyellGaAoeww3CHJ1AbjQWjPSCFtKIzZn8L7LpEax4NXnC+gfa6nM7IA==} dev: true + /@types/geojson@7946.0.16: + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + dev: false + /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: @@ -4539,8 +4850,8 @@ packages: '@codemirror/language': '>=6.0.0' '@codemirror/lint': '>=6.0.0' '@codemirror/search': '>=6.0.0' - '@codemirror/state': '>=6.0.0' - '@codemirror/view': '>=6.0.0' + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.16 dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/commands': 6.7.1 @@ -4599,9 +4910,9 @@ packages: resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==} peerDependencies: '@babel/runtime': '>=7.11.0' - '@codemirror/state': '>=6.0.0' + '@codemirror/state': 6.5.4 '@codemirror/theme-one-dark': '>=6.0.0' - '@codemirror/view': '>=6.0.0' + '@codemirror/view': 6.39.16 codemirror: '>=6.0.0' react: '>=16.8.0 || 18' react-dom: '>=16.8.0 || 18' @@ -5390,6 +5701,7 @@ packages: /agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + requiresBuild: true dependencies: humanize-ms: 1.2.1 dev: false @@ -6018,6 +6330,11 @@ packages: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} dev: true + /base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + dev: false + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -6554,7 +6871,6 @@ packages: /commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - dev: true /commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} @@ -6873,6 +7189,12 @@ packages: postcss-selector-parser: 6.1.2 dev: true + /css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + dependencies: + utrie: 1.0.2 + dev: false + /css-loader@6.7.1: resolution: {integrity: sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==} engines: {node: '>= 12.13.0'} @@ -6961,6 +7283,10 @@ packages: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} dev: true + /csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dev: false + /current-script-polyfill@1.0.0: resolution: {integrity: sha512-qv8s+G47V6Hq+g2kRE5th+ASzzrL7b6l+tap1DHKK25ZQJv3yIFhH96XaQ7NGL+zRW3t/RDbweJf/dJDe5Z5KA==} dev: true @@ -6969,10 +7295,175 @@ packages: resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} dev: true + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-binarytree@1.0.2: + resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==} + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + dev: false + + /d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + dependencies: + d3-dsv: 3.0.1 + dev: false + + /d3-force-3d@3.0.6: + resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==} + engines: {node: '>=12'} + dependencies: + d3-binarytree: 1.0.2 + d3-dispatch: 3.0.1 + d3-octree: 1.1.0 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + dev: false + + /d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + dev: false + + /d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + dev: false + + /d3-geo-projection@4.0.0: + resolution: {integrity: sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==} + engines: {node: '>=12'} + hasBin: true + dependencies: + commander: 7.2.0 + d3-array: 3.2.4 + d3-geo: 3.1.1 + dev: false + + /d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-octree@1.1.0: + resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==} + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + /d3-polygon@1.0.6: resolution: {integrity: sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==} dev: true + /d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + dev: false + + /d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + dev: false + + /d3-regression@1.3.10: + resolution: {integrity: sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==} + dev: false + + /d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + /d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -7998,7 +8489,6 @@ packages: /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - dev: true /events-okam@3.3.0: resolution: {integrity: sha512-6iR7z9hAJEwrT+D2Ywg6Fx62HSmN86OlcvPdrnq1JBeFr30dMF6l+j7M3VabjHfIi2KMtF8rO0J1rIZEfwMAwg==} @@ -8153,7 +8643,6 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true /fast-glob@3.2.12: resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} @@ -8309,6 +8798,11 @@ packages: deprecated: flatten is deprecated in favor of utility frameworks such as lodash. dev: true + /flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + dev: false + /flubber@0.4.2: resolution: {integrity: sha512-79RkJe3rA4nvRCVc2uXjj7U/BAUq84TS3KHn6c0Hr9K64vhj83ZNLUziNx4pJoBumSPhOl5VjH+Z0uhi+eE8Uw==} dependencies: @@ -8556,6 +9050,10 @@ packages: resolution: {integrity: sha512-LF8VeHeR7v+wAbXqfgRlTSX/1BJR9Q1vEMR8JAz1cEg6GX07+zyj3sAdDvYjj/xnlIfVuGgj4qBei1K3hKH+PA==} dev: true + /gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -8851,6 +9349,14 @@ packages: tapable: 2.2.1 dev: true + /html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + dev: false + /htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} dependencies: @@ -8862,6 +9368,7 @@ packages: /http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + requiresBuild: true dev: false optional: true @@ -9074,6 +9581,11 @@ packages: side-channel: 1.0.6 dev: true + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /intersection-observer@0.12.2: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} dev: true @@ -9109,6 +9621,7 @@ packages: /ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + requiresBuild: true dev: false optional: true @@ -9590,7 +10103,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} @@ -10045,7 +10557,6 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 - dev: true /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -11107,6 +11618,10 @@ packages: sha.js: 2.4.11 dev: true + /pdfast@0.2.0: + resolution: {integrity: sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==} + dev: false + /performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} dev: true @@ -12918,7 +13433,6 @@ packages: loose-envify: 1.4.0 react: 18.3.1 scheduler: 0.23.2 - dev: true /react-easy-crop@5.2.0(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-gjb7jN+WnwfgpbNUI2jSwyoIxF1sJ0PVSNVgEysAgF1rj8AqR75fqmdvqZ6PFVgEX3rT1G4HJELesiQXr2ZvAg==} @@ -13189,7 +13703,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - dev: true /reactcss@1.2.3(react@18.3.1): resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} @@ -13485,6 +13998,10 @@ packages: queue-microtask: 1.2.3 dev: true + /rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + dev: false + /rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: @@ -13534,7 +14051,6 @@ packages: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} dependencies: loose-envify: 1.4.0 - dev: true /schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} @@ -13853,6 +14369,7 @@ packages: /socks@2.8.7: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + requiresBuild: true dependencies: ip-address: 10.1.0 smart-buffer: 4.2.0 @@ -14349,6 +14866,10 @@ packages: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} dev: true + /svg-path-parser@1.1.0: + resolution: {integrity: sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==} + dev: false + /svg-path-properties@0.2.2: resolution: {integrity: sha512-GmrB+b6woz6CCdQe6w1GHs/1lt25l7SR5hmhF8jRdarpv/OgjLyuQygLu1makJapixeb1aQhP/Oa1iKi93o/aQ==} dev: true @@ -14468,6 +14989,12 @@ packages: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} dev: false + /text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + dependencies: + utrie: 1.0.2 + dev: false + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true @@ -14647,7 +15174,6 @@ packages: /tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - dev: true /tsutils@3.21.0(typescript@5.2.2): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} @@ -15084,6 +15610,12 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + /utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + dependencies: + base64-arraybuffer: 1.0.2 + dev: false + /uuid@11.0.3: resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} hasBin: true @@ -15509,7 +16041,3 @@ packages: - encoding - supports-color dev: false - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/shell/api.sh b/shell/api.sh index 771a565e..38c3c243 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -220,4 +220,18 @@ update_auth_config() { fi } +record_cron_stat() { + local ref_id="$1" + local exit_code="${2:-0}" + local elapsed="${3:-0}" + [[ $ref_id ]] && [[ $ref_id -gt 0 ]] 2>/dev/null || return + + curl -s --noproxy "*" "http://0.0.0.0:${ql_port:-5700}/open/dashboard/record" \ + -X POST \ + -H "Authorization: Bearer ${__ql_token__}" \ + -H "Content-Type: application/json;charset=UTF-8" \ + --data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}" \ + --compressed +} + get_token diff --git a/shell/otask.sh b/shell/otask.sh index c6612bd1..45a25dc4 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -294,6 +294,7 @@ fi set_u_on="false" check_nounset main "${task_shell_params[@]}" +_task_exit_code=$? if [[ "$set_u_on" == 'true' ]]; then set -u fi @@ -305,4 +306,4 @@ if [[ $isJsOrPythonFile == 'true' ]]; then fi run_task_after "${task_shell_params[@]}" clear_env -handle_task_end "${task_shell_params[@]}" +handle_task_end "${task_shell_params[@]}" "$_task_exit_code" diff --git a/shell/share.sh b/shell/share.sh index 585d885d..1959b7a3 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -388,18 +388,25 @@ handle_task_end() { local end_time=$(format_time "$time_format" "$etime") local end_timestamp=$(format_timestamp "$time_format" "$etime") local diff_time=$(($end_timestamp - $begin_timestamp)) - local suffix="" - [[ "${MANUAL:=}" == "true" ]] && suffix="(手动停止)" - + local exit_code="${@: -1}" [[ "$diff_time" == 0 ]] && diff_time=1 if [[ $ID ]]; then local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time") if [[ $error ]]; then - error_message=", 任务状态更新失败(${error})" + error_message=", 状态更新失败(${error})" fi fi - echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time 秒${error_message:=}     " + + record_cron_stat "$ID" "${exit_code:-0}" "$diff_time" + + if [[ "${MANUAL:=}" == "true" ]]; then + echo -e "\n## 已停止 ⏹... $end_time 耗时 $diff_time 秒${error_message:=}     " + elif [[ $exit_code -eq 0 ]]; then + echo -e "\n## 完成 ✅... $end_time 耗时 $diff_time 秒${error_message:=}     " + else + echo -e "\n## 失败 ❌ (退出码 ${exit_code})... $end_time 耗时 $diff_time 秒${error_message:=}     " + fi } init_env diff --git a/src/layouts/defaultProps.tsx b/src/layouts/defaultProps.tsx index c747b11e..67a70c58 100644 --- a/src/layouts/defaultProps.tsx +++ b/src/layouts/defaultProps.tsx @@ -1,5 +1,5 @@ import intl from 'react-intl-universal'; -import { SettingOutlined } from '@ant-design/icons'; +import { SettingOutlined, DashboardOutlined } from '@ant-design/icons'; import IconFont from '@/components/iconfont'; import { BasicLayoutProps } from '@ant-design/pro-layout'; @@ -24,6 +24,12 @@ export default { hideInMenu: true, component: '@/pages/error/index', }, + { + path: '/dashboard', + name: intl.get('统计面板'), + icon: , + component: '@/pages/dashboard/index', + }, { path: '/crontab', name: intl.get('定时任务'), diff --git a/src/layouts/index.less b/src/layouts/index.less index 66a08da5..5103878f 100644 --- a/src/layouts/index.less +++ b/src/layouts/index.less @@ -212,6 +212,7 @@ body { &.crontab-wrapper, &.log-wrapper, &.env-wrapper, + &.dashboard-wrapper, &.config-wrapper { .CodeMirror { width: calc(100vw - 24px); diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 4662e9b7..27a3306a 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -556,5 +556,34 @@ "批量": "Batch", "全局SSH私钥": "Global SSH Private Key", "用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories", - "请输入完整的SSH私钥内容": "Please enter the complete SSH private key content" + "请输入完整的SSH私钥内容": "Please enter the complete SSH private key content", + "统计面板": "Dashboard", + "总任务": "Total Tasks", + "今日执行": "Today Runs", + "今日成功": "Success", + "今日失败": "Failed", + "成功率": "Success Rate", + "平均耗时": "Avg Time", + "近 7 日趋势": "7-Day Trend", + "今日耗时 Top 5": "Top 5 by Time", + "今日执行次数 Top 5": "Top 5 by Runs", + "实时运行态": "Runtime", + "系统资源": "System", + "排队中": "Queued", + "暂无运行中任务": "No running tasks", + "暂无数据": "No data", + "总执行": "Total", + "次数": "Runs", + "最长单次": "Max Time", + "已运行": "Elapsed", + "系统运行": "System Uptime", + "内存使用": "Memory", + "堆内存": "Heap", + "CPU 核心": "CPU Cores", + "负载 1m": "Load 1m", + "平台": "Platform", + "24小时未运行": "Idle 24h+", + "标签统计": "Label Stats", + "任务数": "Tasks", + "内存": "Memory" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 01cdf4ab..2462c147 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -556,5 +556,34 @@ "批量": "批量", "全局SSH私钥": "全局SSH私钥", "用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥", - "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容" + "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容", + "统计面板": "统计面板", + "总任务": "总任务", + "今日执行": "今日执行", + "今日成功": "今日成功", + "今日失败": "今日失败", + "成功率": "成功率", + "平均耗时": "平均耗时", + "近 7 日趋势": "近 7 日趋势", + "今日耗时 Top 5": "今日耗时 Top 5", + "今日执行次数 Top 5": "今日执行次数 Top 5", + "实时运行态": "实时运行态", + "系统资源": "系统资源", + "排队中": "排队中", + "暂无运行中任务": "暂无运行中任务", + "暂无数据": "暂无数据", + "总执行": "总执行", + "次数": "次数", + "最长单次": "最长单次", + "已运行": "已运行", + "系统运行": "系统运行", + "内存使用": "内存使用", + "堆内存": "堆内存", + "CPU 核心": "CPU 核心", + "负载 1m": "负载 1m", + "平台": "平台", + "24小时未运行": "24小时未运行", + "标签统计": "标签统计", + "任务数": "任务数", + "内存": "内存" } diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..3ad966a6 --- /dev/null +++ b/src/pages/dashboard/index.tsx @@ -0,0 +1,359 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Card, Col, Row, Statistic, Table, Tag, Spin, Empty } from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ClockCircleOutlined, + ThunderboltOutlined, + StopOutlined, + BarChartOutlined, +} from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-layout'; +import { useOutletContext } from '@umijs/max'; +import { Area, Gauge } from '@ant-design/plots'; +import intl from 'react-intl-universal'; +import { SharedContext } from '@/layouts'; +import { request } from '@/utils/http'; +import CronLogModal from '../crontab/logModal'; + +interface Overview { + total: number; + enabled: number; + disabled: number; + todayRuns: number; + todaySuccess: number; + todayFail: number; + successRate: string; + avgTime: number; +} + +interface TrendItem { + date: string; + total: number; + success: number; + fail: number; +} + +interface TopItem { + rank: number; + name: string; + avgTime?: number; + maxTime?: number; + runCount?: number; + successRate?: string; +} + +interface Runtime { + runningCount: number; + queuedCount: number; + running: Array<{ id: number; name: string; pid: number; elapsed: number; logPath: string }>; + idleTasks: Array<{ id: number; name: string; lastRun: string }>; +} + +interface SystemInfo { + platform: string; + uptime: number; + memTotal: number; + memFree: number; + memUsagePercent: string; + heapUsed: number; + heapTotal: number; + loadAvg: number[]; + cpus: number; +} + +const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +}; + +const formatSeconds = (s: number) => { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return `${h}h ${m}m`; +}; + +const REFRESH_INTERVAL = 30000; + +const Dashboard = () => { + const { headerStyle, theme } = useOutletContext(); + const isDark = theme === 'vs-dark'; + const [overview, setOverview] = useState(null); + const [trend, setTrend] = useState([]); + const [topTime, setTopTime] = useState([]); + const [topCount, setTopCount] = useState([]); + const [runtime, setRuntime] = useState(null); + const [system, setSystem] = useState(null); + const [labels, setLabels] = useState([]); + const [logCron, setLogCron] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + try { + const [overviewRes, trendRes, topTimeRes, topCountRes, runtimeRes, systemRes, labelsRes] = + await Promise.allSettled([ + request.get('/api/dashboard/overview'), + request.get('/api/dashboard/trend'), + request.get('/api/dashboard/top-time'), + request.get('/api/dashboard/top-count'), + request.get('/api/dashboard/runtime'), + request.get('/api/dashboard/system'), + request.get('/api/dashboard/labels'), + ]); + + if (overviewRes.status === 'fulfilled' && overviewRes.value.code === 200) + setOverview(overviewRes.value.data); + if (trendRes.status === 'fulfilled' && trendRes.value.code === 200) + setTrend(trendRes.value.data); + if (topTimeRes.status === 'fulfilled' && topTimeRes.value.code === 200) + setTopTime(topTimeRes.value.data); + if (topCountRes.status === 'fulfilled' && topCountRes.value.code === 200) + setTopCount(topCountRes.value.data); + if (runtimeRes.status === 'fulfilled' && runtimeRes.value.code === 200) + setRuntime(runtimeRes.value.data); + if (systemRes.status === 'fulfilled' && systemRes.value.code === 200) + setSystem(systemRes.value.data); + if (labelsRes.status === 'fulfilled' && labelsRes.value.code === 200) + setLabels(labelsRes.value.data); + } catch (e) { + console.error('[dashboard] fetch error', e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let timer: ReturnType; + const poll = async () => { + await fetchData(); + timer = setTimeout(poll, REFRESH_INTERVAL); + }; + fetchData(); + timer = setTimeout(poll, REFRESH_INTERVAL); + return () => clearTimeout(timer); + }, [fetchData]); + + const trendConfig = { + data: trend.flatMap((d) => [ + { date: d.date, value: d.total, type: intl.get('总执行') }, + { date: d.date, value: d.success, type: intl.get('成功') }, + { date: d.date, value: d.fail, type: intl.get('失败') }, + ]), + xField: 'date', + yField: 'value', + seriesField: 'type', + smooth: true, + height: 260, + color: ['#1677ff', '#52c41a', '#ff4d4f'], + legend: { position: 'top' as const }, + theme: isDark ? 'dark' : 'light', + xAxis: { + label: { style: { fill: isDark ? '#aaa' : '#333' } }, + }, + yAxis: { + label: { style: { fill: isDark ? '#aaa' : '#333' } }, + grid: { line: { style: { stroke: isDark ? '#333' : '#eee' } } }, + }, + }; + + const runtimePagination = { + pageSize: 5, + showSizeChanger: false, + showTotal: (total: number) => `共 ${total} 个`, + }; + + if (loading) return ; + + return ( + + + + } /> + + + } /> + + + } /> + + + + + + } /> + + + } /> + + + } /> + + + } /> + + + + + + + {trend.length > 0 ? : } + + + + + + + + v ? `${(v / 1000).toFixed(1)}s` : '-' }, + { title: intl.get('最长单次'), dataIndex: 'maxTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' }, + ]} + /> + + + + +
v ? `${(v / 1000).toFixed(1)}s` : '-' }, + { title: intl.get('成功率'), dataIndex: 'successRate', width: 80, render: (v: string) => `${v}%` }, + ]} + /> + + + + + {labels.length > 0 && ( + + + +
{v} }, + { title: intl.get('任务数'), dataIndex: 'count', width: 80 }, + { title: intl.get('今日执行'), dataIndex: 'todayRuns', width: 100 }, + { title: intl.get('成功率'), dataIndex: 'successRate', width: 100, render: (v: string) => `${v}%` }, + { title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 120, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' }, + ]} + /> + + + + )} + + + + + {intl.get('运行中')} {runtime?.runningCount || 0} + {intl.get('排队中')} {runtime?.queuedCount || 0} + + } + > +
5 ? runtimePagination : false} + size="small" + locale={{ emptyText: }} + columns={[ + { title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true }, + { title: 'PID', dataIndex: 'pid', width: 80 }, + { title: intl.get('已运行'), dataIndex: 'elapsed', width: 100, render: (v: number) => v ? formatSeconds(v) : '-' }, + { title: intl.get('日志'), dataIndex: 'id', width: 60, render: (id, record) => { localStorage.setItem('logCron', String(id)); setLogCron({ id, name: record.name }); }}>{intl.get('查看')} }, + ]} + /> + {runtime?.idleTasks && runtime.idleTasks.length > 0 && ( + <> +
+ {intl.get('24小时未运行')} ({runtime.idleTasks.length}) +
+
{v} }, + ]} + /> + + )} + + + + + + {system && ( + + + { + return `${intl.get('内存')}:${target}%`; + }, + }} + /> + + + + +
+ {intl.get('负载 1m')}: {system.loadAvg?.[0] || '-'} | CPU: {system.cpus} {intl.get('核心')} | {system.platform} +
+ + + )} + + + + {logCron && ( + setLogCron(null)} + /> + )} + + ); +}; + +export default Dashboard; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 60755c0d..8b0a0d50 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -109,7 +109,7 @@ const Login = () => { ), }); reloadUser(true); - history.push('/crontab'); + history.push('/dashboard'); } else if (code === 410) { setWaitTime(data); } else if (code === 420) { @@ -136,7 +136,7 @@ const Login = () => { useEffect(() => { const isAuth = localStorage.getItem(config.authKey); if (isAuth) { - history.push('/crontab'); + history.push('/dashboard'); } }, []); From 4e803df51eda4bbb0bf4b5f4289d0615f0a4a26b Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 1 Jun 2026 18:28:54 +0800 Subject: [PATCH 032/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=20ip=20=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/api.sh | 18 +++++++++--------- shell/check.sh | 6 +++--- shell/preload/client.js | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/shell/api.sh b/shell/api.sh index 38c3c243..b2a82419 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -41,7 +41,7 @@ add_cron_api() { fi local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ @@ -71,7 +71,7 @@ update_cron_api() { fi local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ @@ -98,7 +98,7 @@ update_cron_command_api() { fi local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ @@ -118,7 +118,7 @@ del_cron_api() { local ids="$1" local currentTimeStamp=$(date +%s) local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ -X 'DELETE' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ @@ -143,7 +143,7 @@ update_cron() { local runningTime="${6:-0}" local currentTimeStamp=$(date +%s) local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ @@ -165,7 +165,7 @@ notify_api() { local content="$2" local currentTimeStamp=$(date +%s) local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/notify?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ @@ -185,7 +185,7 @@ find_cron_api() { local params="$1" local currentTimeStamp=$(date +%s) local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --compressed @@ -204,7 +204,7 @@ update_auth_config() { local tip="$2" local currentTimeStamp=$(date +%s) local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ @@ -226,7 +226,7 @@ record_cron_stat() { local elapsed="${3:-0}" [[ $ref_id ]] && [[ $ref_id -gt 0 ]] 2>/dev/null || return - curl -s --noproxy "*" "http://0.0.0.0:${ql_port:-5700}/open/dashboard/record" \ + curl -s --noproxy "*" "http://localhost:${ql_port:-5700}/open/dashboard/record" \ -X POST \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ diff --git a/shell/check.sh b/shell/check.sh index 3f47889a..bc6c7b7e 100755 --- a/shell/check.sh +++ b/shell/check.sh @@ -31,7 +31,7 @@ pm2_log() { } check_ql() { - local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}") + local api=$(curl -s --noproxy "*" "http://localhost:${ql_port}") echo -e "\n=====> 检测面板\n\n$api\n" if [[ $api =~ "
" ]]; then echo -e "=====> 面板服务启动正常\n" @@ -42,10 +42,10 @@ check_pm2() { pm2_log local currentTimeStamp=$(date +%s) local api=$( - curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \ + curl -s --noproxy "*" "http://localhost:${ql_port}/api/system?t=$currentTimeStamp" \ -H 'Accept: */*' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ - -H "Referer: http://0.0.0.0:${ql_port}/crontab" \ + -H "Referer: http://localhost:${ql_port}/crontab" \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ --compressed ) diff --git a/shell/preload/client.js b/shell/preload/client.js index 3d9c4fb2..3a94c0c2 100644 --- a/shell/preload/client.js +++ b/shell/preload/client.js @@ -5,7 +5,7 @@ const { join } = require('path'); class GrpcClient { static #config = { protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), - serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`, + serverAddress: `localhost:${process.env.GRPC_PORT || '5500'}`, protoOptions: { keepCase: true, longs: String, From d8d30c3619e5dcc40b759763e6bd814b8958a22f Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 1 Jun 2026 19:07:15 +0800 Subject: [PATCH 033/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20initFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/loaders/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back/loaders/app.ts b/back/loaders/app.ts index 157c3c1d..c9f8e2dc 100644 --- a/back/loaders/app.ts +++ b/back/loaders/app.ts @@ -14,7 +14,7 @@ export default async ({ app }: { app: Application }) => { await linkDeps(); Logger.info('✌️ Link deps loaded'); - initFile(); + await initFile(); Logger.info('✌️ Init file loaded'); await initData(); From 865e3035b9aced51cba3cee9fd06424516413264 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 1 Jun 2026 23:24:10 +0800 Subject: [PATCH 034/123] =?UTF-8?q?=E5=BC=80=E6=9C=BA=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=90=8C=E6=97=B6=E5=BC=80=E5=A7=8B=E8=BF=90?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/services/cron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back/services/cron.ts b/back/services/cron.ts index 9bc34a09..866ffb21 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -793,7 +793,7 @@ export default class CronService { { where: { id: bootTasks.map((t) => t.id!) } }, ); for (const task of bootTasks) { - await this.runSingle(task.id!); + this.runSingle(task.id!); } } } From fd6b4e4cde666b27e6c32b13b3de911da94e268e Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 7 Jun 2026 13:19:01 +0800 Subject: [PATCH 035/123] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20work=5Fdir=20=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/data/cron.ts | 3 ++ back/loaders/db.ts | 1 + back/services/cron.ts | 3 ++ shell/otask.sh | 68 +++++++++++++++++++++++++++++++++++-- src/pages/crontab/modal.tsx | 9 +++++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/back/data/cron.ts b/back/data/cron.ts index 11c15b64..32b0b554 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -23,6 +23,7 @@ export class Crontab { task_after?: string; log_name?: string; allow_multiple_instances?: 1 | 0; + work_dir?: string; constructor(options: Crontab) { this.name = options.name; @@ -49,6 +50,7 @@ export class Crontab { this.task_after = options.task_after; this.log_name = options.log_name; this.allow_multiple_instances = options.allow_multiple_instances || 0; + this.work_dir = options.work_dir; } } @@ -90,4 +92,5 @@ export const CrontabModel = sequelize.define('Crontab', { task_after: DataTypes.STRING, log_name: DataTypes.STRING, allow_multiple_instances: DataTypes.NUMBER, + work_dir: DataTypes.STRING, }); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index f2990611..389c2c1e 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -41,6 +41,7 @@ export default async () => { column: 'allow_multiple_instances', type: 'NUMBER', }, + { table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' }, { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, { table: 'Envs', column: 'labels', type: 'JSON' }, ]; diff --git a/back/services/cron.ts b/back/services/cron.ts index 866ffb21..798ef37a 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -678,6 +678,9 @@ export default class CronService { .replace(/;? *\n/g, ';') .trim()}' `; } + if (tab.work_dir) { + commandVariable += `work_dir='${tab.work_dir.replace(/'/g, "'\\''")}' `; + } const crontab_job_string = `${commandVariable}${command}`; return crontab_job_string; diff --git a/shell/otask.sh b/shell/otask.sh index 45a25dc4..9783c29c 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -96,6 +96,26 @@ append_node_dependency_path() { enter_script_workdir() { local use_dot_prefix="$1" + # 如果定时任务显式指定了工作目录,优先使用 + if [[ -n "${work_dir:=}" ]]; then + if [[ "${work_dir}" == /* ]]; then + cd "${work_dir}" + else + cd "${dir_scripts}/${work_dir}" + fi + # 仍然处理 file_param 中的路径前缀,确保命令路径正确 + if [[ ${file_param} =~ "/" ]]; then + local script_name="${file_param##*/}" + if [[ "${use_dot_prefix}" == "true" ]]; then + file_param="./${script_name}" + else + file_param="${script_name}" + fi + fi + return + fi + + # 自动检测:从 file_param 中提取脚本所在目录 cd $dir_scripts if [[ ${file_param} =~ "/" ]]; then local script_dir="${file_param%/*}" @@ -210,9 +230,53 @@ run_designated() { run_else() { local file_param="$1" - enter_script_workdir true + # 判断 file_param 本身是否是脚本文件 + local is_file_script="false" + if [[ "$file_param" == *.js || "$file_param" == *.mjs || + "$file_param" == *.py || "$file_param" == *.pyc || + "$file_param" == *.sh || "$file_param" == *.ts ]]; then + is_file_script="true" + fi - shift + if [[ "$is_file_script" != "true" ]]; then + # file_param 不是脚本,从后续参数中查找脚本路径来确定工作目录 + local script_for_dir="" + for arg in "$@"; do + if [[ "$arg" == *.js || "$arg" == *.mjs || + "$arg" == *.py || "$arg" == *.pyc || + "$arg" == *.sh || "$arg" == *.ts ]]; then + script_for_dir="$arg" + break + fi + done + + if [[ -n "$script_for_dir" ]]; then + local saved_file_param="$file_param" + file_param="$script_for_dir" + enter_script_workdir true + local adjusted_script="$file_param" + file_param="$saved_file_param" + + shift + local new_args=() + for arg in "$@"; do + if [[ "$arg" == "$script_for_dir" ]]; then + new_args+=("$adjusted_script") + else + new_args+=("$arg") + fi + done + set -- "${new_args[@]}" + else + # 没有找到脚本参数,只 cd 到 scripts 目录 + enter_script_workdir true + shift + fi + else + # file_param 本身就是脚本,直接用 enter_script_workdir 处理 + enter_script_workdir true + shift + fi clear_non_sh_env $timeoutCmd $which_program $file_param "$@" diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index f4848ff8..dfe9fa21 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -229,6 +229,15 @@ const CronModal = ({ maxLength={200} /> + + + Date: Sun, 7 Jun 2026 13:56:06 +0800 Subject: [PATCH 036/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20work=5Fdir=20?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/validation/schedule.ts | 1 + src/locales/en-US.json | 3 +++ src/locales/zh-CN.json | 3 +++ 3 files changed, 7 insertions(+) diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index cc605152..d5ba6a54 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -82,4 +82,5 @@ export const commonCronSchema = { 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', }), allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null), + work_dir: Joi.string().optional().allow('').allow(null), }; diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 27a3306a..b570244f 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -491,6 +491,9 @@ "请输入运行任务前要执行的命令,不能包含 task 命令": "Please enter the command to run before executing the task, cannot contain task commands", "请输入运行任务后要执行的命令,不能包含 task 命令": "Please enter the command to run after executing the task, cannot contain task commands", "不能包含 task 命令": "Cannot contain task commands", + "工作目录": "Working Directory", + "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.", + "留空自动检测,或输入相对/绝对路径": "Leave blank to auto-detect, or enter a relative/absolute path", "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Connection address of the Chronocat Red service https://chronocat.vercel.app/install/docker/official/", "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "Individuals: user_id=individual QQ Groups fill in group_id=QQ Groups more than one with English; separated by the same time to support individuals and groups such as: user_id=xxx;group_id=xxxx;group_id=xxxxx", "docker安装在持久化config目录下的chronocat.yml文件可找到": "The docker installation can be found in the persistence config directory in the chronocat.yml file", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 2462c147..7ac4daa8 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -491,6 +491,9 @@ "请输入运行任务前要执行的命令,不能包含 task 命令": "请输入运行任务前要执行的命令,不能包含 task 命令", "请输入运行任务后要执行的命令,不能包含 task 命令": "请输入运行任务后要执行的命令,不能包含 task 命令", "不能包含 task 命令": "不能包含 task 命令", + "工作目录": "工作目录", + "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径", + "留空自动检测,或输入相对/绝对路径": "留空自动检测,或输入相对/绝对路径", "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/", "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx", "docker安装在持久化config目录下的chronocat.yml文件可找到": "docker安装在持久化config目录下的chronocat.yml文件可找到", From ad92e0c2c0c5ba36c4d51f73765c56a13ce64ada Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 7 Jun 2026 15:15:12 +0800 Subject: [PATCH 037/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/api.sh | 12 +++++++++++- shell/otask.sh | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/shell/api.sh b/shell/api.sh index b2a82419..56fcbd50 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -226,12 +226,22 @@ record_cron_stat() { local elapsed="${3:-0}" [[ $ref_id ]] && [[ $ref_id -gt 0 ]] 2>/dev/null || return - curl -s --noproxy "*" "http://localhost:${ql_port:-5700}/open/dashboard/record" \ + local api=$( + curl -s --noproxy "*" "http://localhost:${ql_port:-5700}/open/dashboard/record" \ -X POST \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}" \ --compressed + ) + code=$(echo "$api" | jq -r .code) + message=$(echo "$api" | jq -r .message) + if [[ $code != 200 ]]; then + if [[ ! $message ]]; then + message="$api" + fi + echo -e "${message}" + fi } get_token diff --git a/shell/otask.sh b/shell/otask.sh index 9783c29c..5712e0a5 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -98,11 +98,20 @@ enter_script_workdir() { # 如果定时任务显式指定了工作目录,优先使用 if [[ -n "${work_dir:=}" ]]; then + local _target_dir if [[ "${work_dir}" == /* ]]; then - cd "${work_dir}" + _target_dir="${work_dir}" else - cd "${dir_scripts}/${work_dir}" + _target_dir="${dir_scripts}/${work_dir}" fi + if [[ ! -d "${_target_dir}" ]]; then + echo -e "错误:工作目录不存在 ${_target_dir}" + exit 1 + fi + cd "${_target_dir}" || { + echo -e "错误:无法进入工作目录 ${_target_dir}" + exit 1 + } # 仍然处理 file_param 中的路径前缀,确保命令路径正确 if [[ ${file_param} =~ "/" ]]; then local script_name="${file_param##*/}" @@ -116,7 +125,10 @@ enter_script_workdir() { fi # 自动检测:从 file_param 中提取脚本所在目录 - cd $dir_scripts + cd $dir_scripts || { + echo -e "错误:无法进入 scripts 目录 ${dir_scripts}" + exit 1 + } if [[ ${file_param} =~ "/" ]]; then local script_dir="${file_param%/*}" local script_name="${file_param##*/}" From 34d4526413682238cce903e9cfc184112ab85ec7 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 7 Jun 2026 22:03:48 +0800 Subject: [PATCH 038/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20work=5Fdir=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/otask.sh | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/shell/otask.sh b/shell/otask.sh index 5712e0a5..071e6b61 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -104,31 +104,22 @@ enter_script_workdir() { else _target_dir="${dir_scripts}/${work_dir}" fi - if [[ ! -d "${_target_dir}" ]]; then - echo -e "错误:工作目录不存在 ${_target_dir}" - exit 1 - fi - cd "${_target_dir}" || { - echo -e "错误:无法进入工作目录 ${_target_dir}" - exit 1 - } - # 仍然处理 file_param 中的路径前缀,确保命令路径正确 - if [[ ${file_param} =~ "/" ]]; then - local script_name="${file_param##*/}" - if [[ "${use_dot_prefix}" == "true" ]]; then - file_param="./${script_name}" - else - file_param="${script_name}" + if [[ -d "${_target_dir}" ]]; then + cd "${_target_dir}" + if [[ ${file_param} =~ "/" ]]; then + local script_name="${file_param##*/}" + if [[ "${use_dot_prefix}" == "true" ]]; then + file_param="./${script_name}" + else + file_param="${script_name}" + fi fi + return fi - return + echo -e "警告:工作目录不存在 ${_target_dir}" fi - # 自动检测:从 file_param 中提取脚本所在目录 - cd $dir_scripts || { - echo -e "错误:无法进入 scripts 目录 ${dir_scripts}" - exit 1 - } + cd $dir_scripts if [[ ${file_param} =~ "/" ]]; then local script_dir="${file_param%/*}" local script_name="${file_param##*/}" From 23b41893a3df6719eb75a4ee8bc54cd956fa8dd8 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 7 Jun 2026 23:28:21 +0800 Subject: [PATCH 039/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=80=80=E5=87=BA=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/otask.sh | 2 +- shell/share.sh | 2 +- shell/task.sh | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/shell/otask.sh b/shell/otask.sh index 071e6b61..f17d8100 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -373,4 +373,4 @@ if [[ $isJsOrPythonFile == 'true' ]]; then fi run_task_after "${task_shell_params[@]}" clear_env -handle_task_end "${task_shell_params[@]}" "$_task_exit_code" +handle_task_end "${task_shell_params[@]}" diff --git a/shell/share.sh b/shell/share.sh index 1959b7a3..881548fb 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -388,7 +388,7 @@ handle_task_end() { local end_time=$(format_time "$time_format" "$etime") local end_timestamp=$(format_timestamp "$time_format" "$etime") local diff_time=$(($end_timestamp - $begin_timestamp)) - local exit_code="${@: -1}" + local exit_code="${_task_exit_code:-0}" [[ "$diff_time" == 0 ]] && diff_time=1 if [[ $ID ]]; then diff --git a/shell/task.sh b/shell/task.sh index 07c23c5a..ec18ce1a 100755 --- a/shell/task.sh +++ b/shell/task.sh @@ -6,7 +6,9 @@ dir_shell=$QL_DIR/shell trap "single_hanle" 2 3 20 15 14 19 1 single_hanle() { - eval MANUAL=true handle_task_end "$@" "$cmd" + _task_exit_code="${_task_exit_code:-$?}" + [[ "$_task_exit_code" == "0" ]] && _task_exit_code="1" + eval MANUAL=true handle_task_end "$@" exit 1 } From 662adc0f9cd2e9af616929ecd76258b211e929b5 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 7 Jun 2026 23:45:43 +0800 Subject: [PATCH 040/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20dashboard=20=20tit?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layouts/defaultProps.tsx | 2 +- src/locales/en-US.json | 2 +- src/locales/zh-CN.json | 2 +- src/pages/dashboard/index.tsx | 2 +- src/utils/config.ts | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/layouts/defaultProps.tsx b/src/layouts/defaultProps.tsx index 67a70c58..4636a5bb 100644 --- a/src/layouts/defaultProps.tsx +++ b/src/layouts/defaultProps.tsx @@ -26,7 +26,7 @@ export default { }, { path: '/dashboard', - name: intl.get('统计面板'), + name: intl.get('仪表盘'), icon: , component: '@/pages/dashboard/index', }, diff --git a/src/locales/en-US.json b/src/locales/en-US.json index b570244f..d727cab8 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -560,7 +560,7 @@ "全局SSH私钥": "Global SSH Private Key", "用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories", "请输入完整的SSH私钥内容": "Please enter the complete SSH private key content", - "统计面板": "Dashboard", + "仪表盘": "Dashboard", "总任务": "Total Tasks", "今日执行": "Today Runs", "今日成功": "Success", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 7ac4daa8..5ac2a89c 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -560,7 +560,7 @@ "全局SSH私钥": "全局SSH私钥", "用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥", "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容", - "统计面板": "统计面板", + "仪表盘": "仪表盘", "总任务": "总任务", "今日执行": "今日执行", "今日成功": "今日成功", diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 3ad966a6..f2aa9afe 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -171,7 +171,7 @@ const Dashboard = () => { return ( diff --git a/src/utils/config.ts b/src/utils/config.ts index 3249f67c..89c3fcab 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -512,6 +512,7 @@ export default { '/setting': intl.get('系统设置'), '/error': intl.get('错误日志'), '/dependence': intl.get('依赖管理'), + '/dashboard': intl.get('仪表盘'), }, dependenceTypes: ['nodejs', 'python3', 'linux'], }; From 617cf7e5b4946e346a78a47d8d86782c86c6b9d0 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 9 Jun 2026 00:48:03 +0800 Subject: [PATCH 041/123] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20stop=20emoji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/share.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shell/share.sh b/shell/share.sh index 881548fb..42f1254e 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -401,11 +401,11 @@ handle_task_end() { record_cron_stat "$ID" "${exit_code:-0}" "$diff_time" if [[ "${MANUAL:=}" == "true" ]]; then - echo -e "\n## 已停止 ⏹... $end_time 耗时 $diff_time 秒${error_message:=}     " + echo -e "\n## 已停止 🛑... $end_time 耗时 $diff_time 秒${error_message:=}     " elif [[ $exit_code -eq 0 ]]; then echo -e "\n## 完成 ✅... $end_time 耗时 $diff_time 秒${error_message:=}     " else - echo -e "\n## 失败 ❌ (退出码 ${exit_code})... $end_time 耗时 $diff_time 秒${error_message:=}     " + echo -e "\n## 失败 ❌(退出码 ${exit_code})... $end_time 耗时 $diff_time 秒${error_message:=}     " fi } From 946731ac8d4fe5bdbfb2f4df030288cf852ea02d Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 10 Jun 2026 01:53:10 +0800 Subject: [PATCH 042/123] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/cron.ts | 47 ++ back/api/dashboard.ts | 42 +- back/data/runningInstance.ts | 81 +++ back/loaders/db.ts | 2 + back/loaders/initData.ts | 7 + back/services/cron.ts | 105 +++- back/shared/runCron.ts | 11 + src/locales/en-US.json | 1114 +++++++++++++++++---------------- src/locales/zh-CN.json | 1114 +++++++++++++++++---------------- src/pages/crontab/detail.tsx | 200 ++++-- src/pages/crontab/index.tsx | 30 +- src/pages/dashboard/index.tsx | 9 +- 12 files changed, 1594 insertions(+), 1168 deletions(-) create mode 100644 back/data/runningInstance.ts diff --git a/back/api/cron.ts b/back/api/cron.ts index 1bc99062..2c1b6609 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -5,6 +5,10 @@ import CronService from '../services/cron'; import CronViewService from '../services/cronView'; import { celebrate, Joi } from 'celebrate'; import { commonCronSchema } from '../validation/schedule'; +import { + RunningInstanceModel, + InstanceStatus, +} from '../data/runningInstance'; const route = Router(); @@ -446,6 +450,49 @@ export default (app: Router) => { }, ); + route.get( + '/:id/instances', + celebrate({ + params: Joi.object({ + id: Joi.number().required(), + }), + }), + async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { + try { + const instances = await RunningInstanceModel.findAll({ + where: { + cron_id: req.params.id, + status: InstanceStatus.running, + }, + order: [['started_at', 'DESC']], + raw: true, + }); + return res.send({ code: 200, data: instances }); + } catch (e) { + return next(e); + } + }, + ); + + route.post( + '/:id/instances/:instanceId/stop', + celebrate({ + params: Joi.object({ + id: Joi.number().required(), + instanceId: Joi.number().required(), + }), + }), + async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => { + try { + const cronService = Container.get(CronService); + const data = await cronService.stopInstance(req.params.instanceId); + return res.send(data); + } catch (e) { + return next(e); + } + }, + ); + route.get( '/:id/logs', celebrate({ diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 2e23daa5..799503aa 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -3,6 +3,10 @@ import { Container } from 'typedi'; import { fn, col, where, Op } from 'sequelize'; import { CrontabModel } from '../data/cron'; import { CrontabStatModel } from '../data/cronStats'; +import { + RunningInstanceModel, + InstanceStatus, +} from '../data/runningInstance'; import dayjs from 'dayjs'; import os from 'os'; @@ -239,9 +243,9 @@ export default (app: Router) => { '/runtime', async (req: Request, res: Response, next: NextFunction) => { try { - const runningCrons = await CrontabModel.findAll({ + const runningInstances = await RunningInstanceModel.findAll({ where: { - status: 0, // running + status: InstanceStatus.running, }, raw: true, }); @@ -253,15 +257,31 @@ export default (app: Router) => { raw: true, }); - const running = runningCrons.map((c: any) => ({ - id: c.id, - name: c.name || c.command || `任务#${c.id}`, - pid: c.pid, - elapsed: c.last_execution_time - ? Math.floor((Date.now() / 1000) - c.last_execution_time) - : 0, - logPath: c.log_path, - })); + // Fetch cron names for running instances + const cronIds = [ + ...new Set(runningInstances.map((i: any) => i.cron_id)), + ]; + const crons = + cronIds.length > 0 + ? await CrontabModel.findAll({ + where: { id: cronIds }, + raw: true, + }) + : []; + const cronMap = new Map(crons.map((c: any) => [c.id, c])); + + const now = dayjs().unix(); + const running = runningInstances.map((inst: any) => { + const cron = cronMap.get(inst.cron_id); + return { + instanceId: inst.id, + id: inst.cron_id, + name: cron?.name || cron?.command || `任务#${inst.cron_id}`, + pid: inst.pid, + elapsed: inst.started_at ? now - inst.started_at : 0, + logPath: inst.log_path, + }; + }); const dayAgo = dayjs().subtract(24, 'hour').unix(); const idleTasks = await CrontabModel.findAll({ diff --git a/back/data/runningInstance.ts b/back/data/runningInstance.ts new file mode 100644 index 00000000..5d8b3be4 --- /dev/null +++ b/back/data/runningInstance.ts @@ -0,0 +1,81 @@ +import { sequelize } from '.'; +import { DataTypes, Model } from 'sequelize'; + +export enum InstanceStatus { + 'running' = 0, + 'finished' = 1, + 'stopped' = 2, + 'error' = 3, +} + +export interface RunningInstanceAttributes { + id?: number; + cron_id: number; + pid?: number; + log_path?: string; + started_at: number; + finished_at?: number; + status: InstanceStatus; + exit_code?: number; +} + +export class RunningInstance { + id?: number; + cron_id!: number; + pid?: number; + log_path?: string; + started_at!: number; + finished_at?: number; + status!: InstanceStatus; + exit_code?: number; + + constructor(options: RunningInstanceAttributes) { + this.id = options.id; + this.cron_id = options.cron_id; + this.pid = options.pid; + this.log_path = options.log_path; + this.started_at = options.started_at; + this.finished_at = options.finished_at; + this.status = options.status; + this.exit_code = options.exit_code; + } +} + +export interface RunningInstanceModel + extends Model, + RunningInstanceAttributes {} + +export const RunningInstanceModel = sequelize.define( + 'RunningInstance', + { + cron_id: { + type: DataTypes.NUMBER, + allowNull: false, + }, + pid: { + type: DataTypes.NUMBER, + allowNull: true, + }, + log_path: { + type: DataTypes.STRING, + allowNull: true, + }, + started_at: { + type: DataTypes.NUMBER, + allowNull: false, + }, + finished_at: { + type: DataTypes.NUMBER, + allowNull: true, + }, + status: { + type: DataTypes.NUMBER, + allowNull: false, + defaultValue: InstanceStatus.running, + }, + exit_code: { + type: DataTypes.NUMBER, + allowNull: true, + }, + }, +); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index 389c2c1e..a8769d38 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -7,6 +7,7 @@ import { SystemModel } from '../data/system'; import { SubscriptionModel } from '../data/subscription'; import { CrontabViewModel } from '../data/cronView'; import { CrontabStatModel } from '../data/cronStats'; +import { RunningInstanceModel } from '../data/runningInstance'; import { sequelize } from '../data'; export default async () => { @@ -19,6 +20,7 @@ export default async () => { await SubscriptionModel.sync(); await CrontabViewModel.sync(); await CrontabStatModel.sync(); + await RunningInstanceModel.sync(); // 初始化新增字段 const migrations = [ diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 264d7e05..f8dc7758 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -18,6 +18,7 @@ import OpenService from '../services/open'; import { shareStore } from '../shared/store'; import Logger from './logger'; import { AppModel } from '../data/open'; +import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; export default async () => { const cronService = Container.get(CronService); @@ -139,6 +140,12 @@ export default async () => { // 初始化更新所有任务状态为空闲 await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} }); + // 清空所有运行中的实例记录(服务重启后进程已不存在) + await RunningInstanceModel.update( + { status: InstanceStatus.stopped }, + { where: { status: InstanceStatus.running } }, + ); + // 初始化时执行一次所有的 ql repo 任务 CrontabModel.findAll({ where: { diff --git a/back/services/cron.ts b/back/services/cron.ts index 798ef37a..f2a80276 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -2,6 +2,10 @@ import { Service, Inject } from 'typedi'; import winston from 'winston'; import config from '../config'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; +import { + RunningInstanceModel, + InstanceStatus, +} from '../data/runningInstance'; import { exec, execSync } from 'child_process'; import fs from 'fs/promises'; import CronExpressionParser from 'cron-parser'; @@ -189,6 +193,35 @@ export default class CronService { if (status === CrontabStatus.idle && log_path !== cron.log_path) { options = omit(options, ['status', 'log_path', 'pid']); } + + // Manage RunningInstance records for status transitions from shell scripts + if (status === CrontabStatus.running) { + // Create a new running instance record + await RunningInstanceModel.create({ + cron_id: id, + pid: pid || undefined, + log_path: log_path || undefined, + started_at: last_execution_time || dayjs().unix(), + status: InstanceStatus.running, + }); + } else if (status === CrontabStatus.idle) { + // Mark the matching running instance as finished + const finishedAt = dayjs().unix(); + await RunningInstanceModel.update( + { + finished_at: finishedAt, + status: InstanceStatus.finished, + }, + { + where: { + cron_id: id, + pid: pid || undefined, + status: InstanceStatus.running, + }, + }, + ); + } + await CrontabModel.update( { ...pickBy(options, (v) => v === 0 || !!v) }, { where: { id } }, @@ -499,12 +532,53 @@ export default class CronService { } } + // Mark all running instances as stopped + const finishedAt = dayjs().unix(); + await RunningInstanceModel.update( + { status: InstanceStatus.stopped, finished_at: finishedAt }, + { where: { cron_id: ids, status: InstanceStatus.running } }, + ); + await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, { where: { id: ids } }, ); } + public async stopInstance(instanceId: number) { + const instance = await RunningInstanceModel.findOne({ + where: { id: instanceId, status: InstanceStatus.running }, + }); + if (!instance) { + return { code: 400, message: '实例不存在或已停止' }; + } + if (instance.pid) { + try { + await killTask(instance.pid); + } catch (error) { + this.logger.error( + `[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`, + ); + } + } + await RunningInstanceModel.update( + { status: InstanceStatus.stopped, finished_at: dayjs().unix() }, + { where: { id: instanceId } }, + ); + + // Check if there are still other running instances for this cron + const otherRunning = await RunningInstanceModel.count({ + where: { cron_id: instance.cron_id, status: InstanceStatus.running }, + }); + if (otherRunning === 0) { + await CrontabModel.update( + { status: CrontabStatus.idle, pid: undefined }, + { where: { id: instance.cron_id } }, + ); + } + return { code: 200, message: '实例已停止' }; + } + private async runSingle(cronId: number): Promise { return taskLimit.manualRunWithCronLimit(() => { return new Promise(async (resolve: any) => { @@ -543,6 +617,15 @@ export default class CronService { { shell: '/bin/bash' }, ); + const startedAt = dayjs().unix(); + const instance = await RunningInstanceModel.create({ + cron_id: id!, + pid: cp.pid, + log_path: logPath, + started_at: startedAt, + status: InstanceStatus.running, + }); + await CrontabModel.update( { status: CrontabStatus.running, pid: cp.pid, log_path: logPath }, { where: { id } }, @@ -574,10 +657,26 @@ export default class CronService { code, ); await logStreamManager.closeStream(absolutePath); - await CrontabModel.update( - { status: CrontabStatus.idle, pid: undefined }, - { where: { id } }, + const finishedAt = dayjs().unix(); + await RunningInstanceModel.update( + { + finished_at: finishedAt, + status: code === 0 ? InstanceStatus.finished : InstanceStatus.error, + exit_code: code ?? undefined, + }, + { where: { id: instance.id } }, ); + + // Only set cron to idle if no other running instances exist + const otherRunning = await RunningInstanceModel.count({ + where: { cron_id: id!, status: InstanceStatus.running }, + }); + if (otherRunning === 0) { + await CrontabModel.update( + { status: CrontabStatus.idle, pid: undefined }, + { where: { id } }, + ); + } resolve({ ...params, pid: cp.pid, code }); }); }); diff --git a/back/shared/runCron.ts b/back/shared/runCron.ts index 9ccfdb05..c451bf4f 100644 --- a/back/shared/runCron.ts +++ b/back/shared/runCron.ts @@ -4,6 +4,11 @@ import Logger from '../loaders/logger'; import { ICron } from '../protos/cron'; import { CrontabModel, CrontabStatus } from '../data/cron'; import { killTask } from '../config/util'; +import { + RunningInstanceModel, + InstanceStatus, +} from '../data/runningInstance'; +import dayjs from 'dayjs'; export function runCron(cmd: string, cron: ICron): Promise { return taskLimit.runWithCronLimit(cron, () => { @@ -29,6 +34,12 @@ export function runCron(cmd: string, cron: ICron): Promise { `[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`, ); await killTask(existingCron.pid); + // Mark old running instances as stopped + const stoppedAt = dayjs().unix(); + await RunningInstanceModel.update( + { status: InstanceStatus.stopped, finished_at: stoppedAt }, + { where: { cron_id: Number(cron.id), status: InstanceStatus.running } }, + ); // Update the status to idle after killing await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, diff --git a/src/locales/en-US.json b/src/locales/en-US.json index d727cab8..762342a8 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -1,592 +1,598 @@ { - "复制成功": "Copy successful", - "复制": "Copy", - "新建": "New", - "登录": "Login", - "初始化": "Initialize", - "错误": "Error", - "定时任务": "Scheduled Tasks", - "订阅管理": "Subscription Management", - "环境变量": "Environment Variables", - "配置文件": "Configuration Files", - "脚本管理": "Script Management", - "依赖管理": "Dependency Management", - "日志管理": "Log Management", - "对比工具": "Comparison Tool", - "系统设置": "System Settings", - "退出登录": "Logout", - "青龙": "Qinglong", - "返回首页": "Return to Home", - "保存": "Save", - "日志": "Log", - "脚本": "Script", - "确认保存文件": "Confirm to Save File", - ",保存后不可恢复": ", it can't be recovered after saving.", - "确认运行": "Confirm to Run", - "确认运行定时任务": "Confirm to Run Scheduled Task", - "吗": "?", - "确认停止": "Confirm to Stop", - "确认停止定时任务": "Confirm to Stop Scheduled Task", - "确认": "Confirm", - "任务": "Task", - "状态": "Status", - "空闲中": "Idle", - "运行中": "Running", - "队列中": "In Queue", - "已禁用": "Disabled", - "定时": "Schedule", - "最后运行时间": "Last Run Time", - "最后运行时长": "Last Run Duration", - "下次运行时间": "Next Run Time", - "名称": "Name", - "命令/脚本": "Command/Script", - "定时规则": "Schedule Rule", - "操作": "Action", - "确认删除": "Confirm to Delete", - "确认删除定时任务": "Confirm to Delete Scheduled Task", - "编辑": "Edit", - "删除": "Delete", - "确认删除选中的定时任务吗": "Confirm to delete the selected scheduled tasks?", - "选中的定时任务吗": "selected scheduled tasks?", - "创建视图": "Create View", - "视图管理": "View Management", - "请输入名称或者关键词": "Please enter a name or keyword", - "创建任务": "Create Task", - "更多": "More", - "批量删除": "Batch Delete", - "批量启用": "Batch Enable", - "批量禁用": "Batch Disable", - "批量运行": "Batch Run", - "批量停止": "Batch Stop", - "批量置顶": "Batch Top", - "批量取消置顶": "Batch Un-top", - "批量修改标签": "Batch Modify Tags", - "已选择": "Selected", - "项": "items", - "知道了": "Got it", - "请输入任务名称": "Please enter the task name", - "支持输入脚本路径/任意系统可执行命令/task 脚本路径": "Supports input of script paths / any system executable commands / task script paths", - "秒(可选) 分 时 天 月 周": "Seconds (optional) Minutes Hours Day Month Week", - "标签": "Tags", - "取消": "Cancel", - "添加": "Add", - "启用": "Enable", - "禁用": "Disable", - "运行": "Run", - "停止": "Stop", - "置顶": "Top", - "取消置顶": "Un-top", - "命令": "Command", - "包含": "Include", - "不包含": "Exclude", - "属于": "Belong to", - "不属于": "Not Belong to", - "顺序": "Order", - "倒序": "Reverse", - "且": "And", - "或": "Or", - "输入后回车增加自定义选项": "Press Enter to add custom options", - "视图名称": "View Name", - "请输入视图名称": "Please enter the view name", - "请输入内容": "Please enter the content", - "新增筛选条件": "Add Filter", - "新增排序方式": "Add Sort", - "类型": "Type", - "显示": "Display", - "确认删除视图": "Confirm to delete the view", - "安装中": "Installing", - "已安装": "Installed", - "安装失败": "Installation Failed", - "删除中": "Deleting", - "已删除": "Deleted", - "删除失败": "Deletion Failed", - "已取消": "Cancelled", - "序号": "Number", - "备注": "Remarks", - "更新时间": "Update Time", - "创建时间": "Created Time", - "确认删除依赖": "Confirm to delete the dependency", - "确认重新安装": "Confirm to reinstall", - "确认取消安装": "Confirm to cancel install", - "确认删除选中的依赖吗": "Confirm to delete the selected dependencies?", - "确认重新安装选中的依赖吗": "Confirm to reinstall the selected dependencies?", - "请输入名称": "Please enter a name", - "创建依赖": "Create Dependency", - "批量安装": "Batch Install", - "批量强制删除": "Batch Force Delete", - "日志 -": "Log -", - "依赖类型": "Dependency Type", - "自动拆分": "Auto Split", - "多个依赖是否换行分割": "Whether to separate multiple dependencies with new lines", - "是": "Yes", - "否": "No", - "请输入依赖名称,支持指定版本": "Please enter the dependency name, version specification is supported", - "请输入依赖名称": "Please enter the dependency name", - "请输入备注": "Please enter remarks", - "源文件": "Source File", - "当前文件": "Current File", - "修改环境变量名称": "Modify Environment Variable Name", - "请输入新的环境变量名称": "Please enter the new environment variable name", - "已启用": "Enabled", - "值": "Value", - "确认删除变量": "Confirm to delete the variable", - "确认删除选中的变量吗": "Confirm to delete the selected variables?", - "选中的变量吗": "selected variables?", - "请输入名称/值/备注": "Please enter name/value/remarks", - "导入": "Import", - "创建变量": "Create Variable", - "批量修改变量名称": "Batch Modify Variable Names", - "批量导出": "Batch Export", - "请输入环境变量名称": "Please enter the environment variable name", - "只能输入字母数字下划线,且不能以数字开头": "Only letters, numbers, and underscores are allowed, and cannot start with a number", - "请输入环境变量值": "Please enter the environment variable value", - "服务启动超时": "Service startup timeout", - "请先按如下方式修复:": "Please fix it as follows:", "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 ' on the host machine", "2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container", + "24小时未运行": "Idle 24h+", "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. If the problem persists, execute 'pm2 logs' inside the container and copy the results", - "提交 issue": "Submit an issue", - "启动中,请稍后...": "Starting, please wait...", - "欢迎使用": "Welcome to use", - "欢迎使用青龙": "Welcome to use Qinglong", - "支持python3、javascript、shell、typescript 的定时任务管理面板": "A scheduling task management panel that supports python3, javascript, shell, and typescript", - "开始安装": "Start Installation", - "账户设置": "Account Settings", - "用户名": "Username", - "密码": "Password", - "密码不能为admin": "The password cannot be 'admin'", - "确认密码": "Confirm Password", - "您输入的两个密码不匹配!": "The two passwords you entered do not match!", - "提交": "Submit", - "通知设置": "Notification Settings", - "通知方式": "Notification Method", - "请选择通知方式": "Please select a notification method", - "跳过": "Skip", - "完成安装": "Installation Completed", - "恭喜安装完成!": "Congratulations, the installation is completed!", - "Telegram频道": "Telegram Channel", - "去登录": "Go to Login", - "初始化配置": "Initialize Configuration", - "文件": "File", - ",删除后不可恢复": ", it can't be recovered after deletion", - "请选择日志": "Please select a log", - "请输入日志名": "Please enter the log name", - "暂无日志": "No logs available", - "登录成功!": "Login successful!", - "上次登录时间:": "Last login time: ", - "上次登录地点:": "Last login location: ", - "上次登录IP:": "Last login IP: ", - "上次登录设备:": "Last login device: ", - "上次登录状态:": "Last login status: ", - "验证码": "Verification Code", - "验证码为6位数字": "Verification code is a 6-digit number", "6位数字": "6-digit number", - "验证": "Verify", - "请": "Please", - "秒后重试": "Retry after seconds", - "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "Open the two-factor authentication application on your device to view your authentication code and verify your identity.", - "请选择脚本文件": "Please select a script file", - "当前文件不支持预览": "Current file type is not supported for preview", - "清空日志": "Clear Logs", - "设置": "Settings", - "退出": "Exit", - "空文件": "Empty File", - "本地文件": "Local File", - "文件夹": "Folder", - "文件名": "File Name", - "请输入文件名": "Please enter the file name", - "文件名不能包含斜杠": "File names cannot contain slashes", - "文件夹名": "Folder Name", - "请输入文件夹名": "Please enter the folder name", - "父目录": "Parent Directory", - "请选择父目录": "Please select a parent directory", - "点击或者拖拽文件到此区域上传": "Click or drag files here to upload", - "当前修改未保存,确定离开吗": "The current changes are not saved. Are you sure you want to leave?", - "退出编辑": "Exit Editing", - "重命名": "Rename", - "请选择脚本": "Please select a script", - "调试": "Debug", - "请输入脚本名": "Please enter the script name", - "暂无脚本": "No scripts available", - "请输入新名称": "Please enter a new name", - "保存文件": "Save File", - "保存目录": "Save Directory", - "请输入保存目录,默认scripts目录": "Please enter the save directory, default is 'scripts'", - "运行设置": "Run Settings", - "待开发": "To Be Developed", - "开发版": "Developer Edition", - "正式版": "Official Edition", - "版本": "Version", - "更新日志": "Changelog", - "查看": "View", - "提交BUG": "Submit Bug", - "名称不能为保留关键字": "The name cannot be a reserved keyword", - "请输入应用名称": "Please enter the application name", - "权限": "Permission", - "请选择模块权限": "Please select module permissions", - "更新": "Update", - "已经是最新版了!": "It is already the latest version!", - "是目前检测到的最新可用版本了。": "It is the latest available version currently detected.", - "重新下载": "Redownload", - "更新可用": "Update Available", - "新版本": "Create Version", - "可用,你使用的版本为": "available, the version you are using is", - "下载更新": "Download Update", - "以后再说": "Later", - "下载更新中...": "Downloading Update...", - "确认重启": "Confirm to Restart", - "系统安装包下载成功,确认重启": "System installation package downloaded successfully, confirm to restart", - "重启": "Restart", - "系统将在": "The system will restart in", - "秒后自动刷新": "seconds and automatically refresh", - "检查更新": "Check for Updates", - "重新启动": "Reboot", - "确认删除应用": "Confirm to delete the application", - "确认重置": "Confirm to reset", - "确认重置应用": "Confirm to reset the application", - "的Secret吗": "'s Secret?", - "重置Secret会让当前应用所有token失效": "Resetting the Secret will invalidate all tokens for the current application", - "创建应用": "Create Application", - "安全设置": "Security Settings", - "应用设置": "Application Settings", - "登录日志": "Login Logs", - "其他设置": "Other Settings", - "关于": "About", - "成功": "Successfully", - "失败": "Failure", - "登录时间": "Login Time", - "登录地址": "Login Address", - "登录IP": "Login IP", - "登录设备": "Login Device", - "登录状态": "Login Status", - "亮色": "Light", - "暗色": "Dark", - "跟随系统": "Follow System", - "备份数据上传成功,确认覆盖数据": "Data backup uploaded successfully, confirm data overwrite", - "主题设置": "Theme Settings", - "日志删除频率": "Log Deletion Frequency", - "每x天自动删除x天以前的日志": "Automatically delete logs older than x days every x days", - "每": "Every", - "天": "day(s)", - "定时任务并发数": "Concurrent Scheduled Tasks", - "数据备份还原": "Data Backup & Restore", - "还原数据": "Restore Data", - "第一步": "Step 1", - "下载两步验证手机应用,比如 Google Authenticator 、": "Download a two-factor authentication mobile app, like Google Authenticator,", - "第二步": "Step 2", - "使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key", - "第三步": "Step 3", - "输入手机应用上的6位数字": "Enter the 6-digit code from the mobile app", - "完成设置": "Finish Setup", - "修改用户名密码": "Change Username and Password", - "两步验证": "Two-Factor Authentication", - "头像": "Profile Picture", - "更换头像": "Change Profile Picture", - "时": "hour(s)", - "分": "minute(s)", - "秒": "second(s)", - "私有仓库": "Private Repository", - "公开仓库": "Public Repository", - "单文件": "Single File", - "链接": "Link", - "分支": "Branch", - "确认删除定时订阅": "Confirm Deletion of Scheduled Subscription", - "定时订阅": "Scheduled Subscription", - "创建订阅": "Create Subscription", - "私钥": "Private Key", - "请输入私钥": "Please enter the private key", - "请输入认证用户名": "Please enter the authentication username", - "Github已不支持密码认证,请使用Token方式": "Github no longer supports password authentication. Please use the Token method.", - "密码/Token": "Password/Token", - "请输入密码或者Token": "Please enter the password or Token", - "支持拷贝 ql repo/raw 命令,粘贴导入": "Supports copying ql repo/raw command for import", - "请输入订阅链接": "Please enter the subscription link", - "请输入分支": "Please enter the branch", - "唯一值": "Unique Value", - "唯一值用于日志目录和私钥别名": "Unique value used for log directory and private key alias", - "自动生成": "Auto-generated", - "拉取方式": "Pull Method", - "用户名密码/Token": "Username/Password or Token", - "定时类型": "Schedule Type", - "白名单": "Whitelist", - "多个关键词竖线分割,支持正则表达式": "Multiple keywords separated by vertical lines (|), supports regular expressions", - "请输入脚本筛选白名单关键词,多个关键词竖线分割": "Please enter script filtering whitelist keywords, multiple keywords separated by vertical lines (|)", - "黑名单": "Blacklist", - "请输入脚本筛选黑名单关键词,多个关键词竖线分割": "Please enter script filtering blacklist keywords, multiple keywords separated by vertical lines (|)", - "依赖文件": "Dependency Files", - "请输入脚本依赖文件关键词,多个关键词竖线分割": "Please enter script dependency file keywords, multiple keywords separated by vertical lines (|)", - "文件后缀": "File Extension", - "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions": "Repository requires pulling specific file extensions, multiple extensions separated by spaces, uses RepoFileExtensions from the configuration file by default", - "请输入文件后缀": "Please enter the file extension", - "执行前": "Before Execution", - "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the subscription, e.g., cp/mv/python3 xxx.py/node xxx.js", - "请输入运行订阅前要执行的命令": "Please enter the command to run before executing the subscription", - "执行后": "After Execution", - "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the subscription, e.g., cp/mv/python3 xxx.py/node xxx.js", - "请输入运行订阅后要执行的命令": "Please enter the command to run after executing the subscription", - "代理": "Proxy", - "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理": "Public repositories support HTTP/SOCK5 proxies, private repositories support SOCK5 proxies", - "自动添加任务": "Automatically Add Tasks", - "自动删除任务": "Automatically Delete Tasks", - "中文": "Chinese", - "系统信息": "System Information", - "Server酱": "ServerChan", - "Telegram机器人": "Telegram Bot", - "钉钉机器人": "DingTalk Bot", - "企业微信机器人": "WeChat Work Bot", - "企业微信应用": "WeChat Work App", - "智能微秘书": "Smart WeChat Assistant", - "群晖chat": "Synology Chat", - "微加机器人": "WePlusBot", - "邮箱": "Email", - "飞书机器人": "Feishu Bot", - "自定义通知": "Custom Notification", - "已关闭": "Disabled", - "gotify的url地址,例如 https://push.example.de:8080": "gotify URL address, e.g., https://push.example.de:8080", - "gotify的消息应用token码": "gotify message application token code", - "推送消息的优先级": "Priority of Push Messages", - "synologyChat的url地址": "Synology Chat Webhook URL address", - "chat的token码": "Chat token code", - "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "Push to personal QQ: http://127.0.0.1/send_private_msg, group: http://127.0.0.1/send_group_msg", - "访问密钥": "Access Key", - "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群": "If GOBOT_URL is set to /send_private_msg, enter user_id=personal QQ; if set to /send_group_msg, enter group_id=QQ group", - "Server酱SENDKEY": "ServerChan SENDKEY, https://sct.ftqq.com/r/13363", - "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer Key, https://github.com/easychen/pushdeer", - "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer's self-hosted API endpoint, default is https://api2.pushdeer.com/message/push", - "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX": "Bark information IP/device code, e.g., https://api.day.app/XXXXXXXX", "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK push icon, custom push icon (requires iOS 15 or above to display)", - "BARK推送铃声,铃声列表去APP查看复制填写": "BARK push ringtone, check and copy from the APP's ringtone list", "BARK推送消息的分组,默认为qinglong": "BARK push message grouping, default is qinglong", "BARK推送消息的时效性,默认为active": "BARK push message timeliness, default is active", "BARK推送消息的跳转URL": "BARK push message redirecting URL", + "BARK推送铃声,铃声列表去APP查看复制填写": "BARK push ringtone, check and copy from the APP's ringtone list", "BARK是否保存推送消息": "Does BARK save push messages", + "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX": "Bark information IP/device code, e.g., https://api.day.app/XXXXXXXX", + "CPU 核心": "CPU Cores", + "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Connection address of the Chronocat Red service https://chronocat.vercel.app/install/docker/official/", + "Cron表达式格式有误": "Incorrect Cron Expression Format", + "Github已不支持密码认证,请使用Token方式": "Github no longer supports password authentication. Please use the Token method.", + "Linux 软件包镜像源": "Linux Software Package Mirror Source", + "Minimum is 4": "Minimum is 4", + "NPM 镜像源": "NPM Mirror Source", + "Node 软件包镜像源": "Node Software Package Mirror Source", + "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer Key, https://github.com/easychen/pushdeer", + "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer's self-hosted API endpoint, default is https://api2.pushdeer.com/message/push", + "PushMe的Key,https://push.i-i.me/": "PushMe key, https://push.i-i.me/", + "PyPI 镜像源": "PyPI Mirror Source", + "Python 软件包镜像源": "Python Software Package Mirror Source", + "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions", + "SSH 文件缓存": "SSH file cache", + "SSH密钥": "SSH Keys", + "Server酱": "ServerChan", + "Server酱SENDKEY": "ServerChan SENDKEY, https://sct.ftqq.com/r/13363", + "Subscription表达式格式有误": "Incorrect Subscription Expression Format", + "Telegram机器人": "Telegram Bot", + "Telegram频道": "Telegram Channel", + "alpine linux 镜像源": "Alpine Linux Mirror Source", + "chat的token码": "Chat token code", + "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid, corpsecret, touser (note: separate multiple member IDs with |), agentid, message type (optional, defaults to text message type) – separated by commas (`,`), e.g., wwcfrs, B-76WERQ, qinglong, 1000001, 2COat", + "docker安装在持久化config目录下的chronocat.yml文件可找到": "The docker installation can be found in the persistence config directory in the chronocat.yml file", + "gotify的url地址,例如 https://push.example.de:8080": "gotify URL address, e.g., https://push.example.de:8080", + "gotify的消息应用token码": "gotify message application token code", + "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX": "iGot information push key, e.g., https://push.hellyw.com/XXXXXXXX", + "ntfy应用token": "The token for ntfy's application, see https://docs.ntfy.sh/config/#access-tokens", + "ntfy应用topic": "The topic for ntfy's application.", + "ntfy应用密码": "The password for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles", + "ntfy应用用户名": "The username for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles", + "ntfy用户动作": "The user actions for ntfy's application, up to three actions, see https://docs.ntfy.sh/publish/?h=actions#action-buttons", + "ntfy的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.", + "synologyChat的url地址": "Synology Chat Webhook URL address", + "telegram api自建的反向代理地址,默认tg官方api": "Telegram API's self-built reverse proxy address, default is official tg API", + "telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password": "Telegram proxy configuration authentication parameters, connect username and password with a colon, e.g., user:password", "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw": "Telegram Bot token, e.g., 1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw", "telegram用户的id,例如:129xxx206": "Telegram user ID, e.g., 129xxx206", - "代理IP": "Proxy IP", - "代理端口": "Proxy Port", - "telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password": "Telegram proxy configuration authentication parameters, connect username and password with a colon, e.g., user:password", - "telegram api自建的反向代理地址,默认tg官方api": "Telegram API's self-built reverse proxy address, default is official tg API", - "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd": "DingTalk Bot webhook token, e.g., 5a544165465465645d0f31dca676e7bd07415asdasd", - "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串": "Secret key, shown below the signing section on the robot's security settings page, starts with SEC", - "企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa": "WeChat Work Bot webhook (see documentation at https://work.weixin.qq.com/api/doc/90000/90136/91770), e.g., 693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa", - "企业微信代理地址": "WeChat Work Proxy Address", - "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid, corpsecret, touser (note: separate multiple member IDs with |), agentid, message type (optional, defaults to text message type) – separated by commas (`,`), e.g., wwcfrs, B-76WERQ, qinglong, 1000001, 2COat", - "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql": "Key, obtain the API key from the Smart WeChat Assistant's personal center, apply at: https://wechat.aibotk.com/signup?from=ql", - "发送的目标,群组或者好友": "Recipient, group, or friend", - "请输入要发送的目标": "Please enter the recipient's name (group or friend)", - "群聊": "Group Chat", - "好友": "Friend", - "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称": "Enter the recipient's nickname if it's a friend or the group name if it's a group", - "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX": "iGot information push key, e.g., https://push.hellyw.com/XXXXXXXX", - "微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/": "After WeChat scan login, one-to-one or one-to-many push using the provided token (your Token). If PUSH_PLUS_USER is not provided, it defaults to one-to-one push. See reference at https://www.pushplus.plus/", - "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "The 'group code' for one-to-many push (one-to-many push -> your group (if none, create one) -> group code). If you are the creator of the group, you need to click 'View QR code' to scan and bind, otherwise, you won't receive group messages.", - "发送模板": "send template, can use type: 'html,txt,json,markdown,cloudMonitor,jenkins,route,pay'", - "发送渠道": "send channel, can use type: 'wechat,webhook,cp,mail,sms'", "webhook编码": "webhook code", - "发送结果回调地址": "send result callback url", - "好友令牌": "friend token", - "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 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/", - "消息接收人": "message recipient", - "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.", - "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973", - "飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings", - "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", - "邮箱地址": "Email Address", - "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions", - "PushMe的Key,https://push.i-i.me/": "PushMe key, https://push.i-i.me/", - "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "The self built PushMeServer message interface address, for example: http://127.0.0.1:3010 If left blank, use the official message interface", - "ntfy的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.", - "ntfy应用topic": "The topic for ntfy's application.", - "ntfy应用token": "The token for ntfy's application, see https://docs.ntfy.sh/config/#access-tokens", - "ntfy应用用户名": "The username for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles", - "ntfy应用密码": "The password for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles", - "ntfy用户动作": "The user actions for ntfy's application, up to three actions, see https://docs.ntfy.sh/publish/?h=actions#action-buttons", "wxPusherBot的appToken": "wxPusherBot's appToken, obtain according to docs https://wxpusher.zjiecode.com/docs/", "wxPusherBot的topicIds": "wxPusherBot's topicIds, at least one of topicIds or uids must be configured", "wxPusherBot的uids": "wxPusherBot's uids, at least one of topicIds or uids must be configured", - "请求方法": "Request Method", - "请求头Content-Type": "Request Header Content-Type", - "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request URL should start with http or https. URL or body must contain $title, $content is optional and corresponds to the API content position.", - "请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)", - "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.", - "错误日志": "Error Log", - "执行结束": "Execution Finished", - "备份": "Backup", - "生成数据中...": "Generating data...", - "请选择日志文件": "Please select a log file", - "筛选条件": "Filter Conditions", - "系统": "System", - "个人": "Personal", - "重新安装": "Reinstall", - "取消安装": "Cancel Install", - "强制删除": "Force Delete", - "全部任务": "All Tasks", - "关联订阅": "Associate Subscription", - "订阅": "Subscription", - "创建": "Create", - "同时删除关联任务和脚本": "Delete associated tasks and scripts as well", - "未找到": "Not Found", - "保存成功": "Saved successfully", - "删除成功": "Deleted successfully", - "批量删除成功": "Batch deleted successfully", - "启动中...": "Starting...", - "任务未运行": "Task not running", - "更新任务成功": "Task updated successfully", - "创建任务成功": "Task created successfully", - "编辑任务": "Edit Task", - "Cron表达式格式有误": "Incorrect Cron Expression Format", - "添加Labels成功": "Labels added successfully", - "删除Labels成功": "Labels deleted successfully", - "添加标签成功": "Tags added successfully", - "删除标签成功": "Tags deleted successfully", - "请至少输入一个标签": "Please enter at least one tag", - "编辑视图": "Edit View", - "排序方式": "Sort Order", - "开始时间": "Start Time", - "安装": "Install", - "结束时间": "End Time", - "编辑依赖": "Edit Dependency", - "更新环境变量名称成功": "Environment Variable Name updated successfully", - "更新变量成功": "Variable updated successfully", - "创建变量成功": "Variable created successfully", - "编辑变量": "Edit Variable", - "加载中...": "Loading...", - "夹下所有日志": "Folder and All Subfiles", - "创建文件夹成功": "Folder created successfully", - "创建文件成功": "File created successfully", - "夹及其子文件": "Folder and Its Subfiles", - "更新名称成功": "Name updated successfully", - "保存文件成功": "File saved successfully", - "更新应用成功": "Application updated successfully", - "创建应用成功": "Application created successfully", - "编辑应用": "Edit Application", - "检查更新中...": "Checking for updates...", - "失败,请检查": "Failed, please check", - "更新失败,请检查网络及日志或稍后再试": "Update failed, please check network and logs or try again later", - "更新包下载成功": "Update package download successful", - "重置secret": "Reset secret", - "重置成功": "Reset successful", - "通知发送成功": "Notification sent successfully", - "通知关闭成功": "Notification closed successfully", - "测试中...": "Testing...", - "上传": "Upload", - "下载": "Download", - "更新成功": "Update successful", - "激活成功": "Activation successful", - "验证失败": "Validation failed", - "更新订阅成功": "Subscription updated successfully", - "创建订阅成功": "Subscription created successfully", - "编辑订阅": "Edit Subscription", - "Subscription表达式格式有误": "Incorrect Subscription Expression Format", + "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "The 'group code' for one-to-many push (one-to-many push -> your group (if none, create one) -> group code). If you are the creator of the group, you need to click 'View QR code' to scan and bind, otherwise, you won't receive group messages.", "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则新建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "The 'Group Code' for One-to-Many Push (Below One-to-Many Push->Your Group (if not, create one)->Group Code, if you are the creator of the group, you also need to click 'View QR Code' to scan and bind, otherwise you cannot receive group messages)", - "登录已过期,请重新登录": "Login session has expired, please log in again", - "系统日志": "System Logs", - "主题": "Theme", - "语言": "Language", - "中...": "ing...", - "请选择操作符": "Please select operator", - "新增定时规则": "Add Timing Rules", - "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js", - "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js", - "请输入运行任务前要执行的命令,不能包含 task 命令": "Please enter the command to run before executing the task, cannot contain task commands", - "请输入运行任务后要执行的命令,不能包含 task 命令": "Please enter the command to run after executing the task, cannot contain task commands", + "上传": "Upload", + "上次登录IP:": "Last login IP: ", + "上次登录地点:": "Last login location: ", + "上次登录时间:": "Last login time: ", + "上次登录状态:": "Last login status: ", + "上次登录设备:": "Last login device: ", + "下次运行时间": "Next Run Time", + "下载": "Download", + "下载两步验证手机应用,比如 Google Authenticator 、": "Download a two-factor authentication mobile app, like Google Authenticator,", + "下载更新": "Download Update", + "下载更新中...": "Downloading Update...", + "不包含": "Exclude", + "不属于": "Not Belong to", "不能包含 task 命令": "Cannot contain task commands", - "工作目录": "Working Directory", - "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.", - "留空自动检测,或输入相对/绝对路径": "Leave blank to auto-detect, or enter a relative/absolute path", - "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Connection address of the Chronocat Red service https://chronocat.vercel.app/install/docker/official/", + "且": "And", + "两步验证": "Two-Factor Authentication", + "个人": "Personal", "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "Individuals: user_id=individual QQ Groups fill in group_id=QQ Groups more than one with English; separated by the same time to support individuals and groups such as: user_id=xxx;group_id=xxxx;group_id=xxxxx", - "docker安装在持久化config目录下的chronocat.yml文件可找到": "The docker installation can be found in the persistence config directory in the chronocat.yml file", - "请选择": "Please select", - "请输入": "Please input", - "依赖设置": "Dependence Settings", - "Node 软件包镜像源": "Node Software Package Mirror Source", - "Python 软件包镜像源": "Python Software Package Mirror Source", - "Linux 软件包镜像源": "Linux Software Package Mirror Source", + "中...": "ing...", + "中文": "Chinese", + "主题": "Theme", + "主题设置": "Theme Settings", + "亮色": "Light", + "今日失败": "Failed", + "今日成功": "Success", + "今日执行": "Today Runs", + "今日执行次数 Top 5": "Top 5 by Runs", + "今日耗时 Top 5": "Top 5 by Time", + "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions": "Repository requires pulling specific file extensions, multiple extensions separated by spaces, uses RepoFileExtensions from the configuration file by default", + "代理": "Proxy", + "代理IP": "Proxy IP", "代理与镜像源二选一即可": "Either Proxy or Mirror Source can be chosen", "代理地址, 支持HTTP(S)/SOCK5": "Proxy Address, supports HTTP(S)/SOCK5", - "NPM 镜像源": "NPM Mirror Source", - "PyPI 镜像源": "PyPI Mirror Source", - "alpine linux 镜像源": "Alpine Linux Mirror Source", + "代理端口": "Proxy Port", + "以后再说": "Later", + "仪表盘": "Dashboard", + "任务": "Task", + "任务数": "Tasks", + "任务未运行": "Task not running", + "企业微信代理地址": "WeChat Work Proxy Address", + "企业微信应用": "WeChat Work App", + "企业微信机器人": "WeChat Work Bot", + "企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa": "WeChat Work Bot webhook (see documentation at https://work.weixin.qq.com/api/doc/90000/90136/91770), e.g., 693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa", + "使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key", + "依赖文件": "Dependency Files", + "依赖管理": "Dependency Management", + "依赖类型": "Dependency Type", + "依赖缓存": "Dependency cache", + "依赖设置": "Dependence Settings", + "保存": "Save", + "保存成功": "Saved successfully", + "保存文件": "Save File", + "保存文件成功": "File saved successfully", + "保存目录": "Save Directory", + "修改环境变量名称": "Modify Environment Variable Name", + "修改用户名密码": "Change Username and Password", + "倒序": "Reverse", + "值": "Value", + "停止": "Stop", + "全局SSH私钥": "Global SSH Private Key", + "全部任务": "All Tasks", + "公开仓库": "Public Repository", + "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理": "Public repositories support HTTP/SOCK5 proxies, private repositories support SOCK5 proxies", + "关于": "About", + "关联订阅": "Associate Subscription", + "其他设置": "Other Settings", + "内存": "Memory", + "内存使用": "Memory", + "分": "minute(s)", + "分支": "Branch", + "创建": "Create", + "创建SSH密钥": "Create SSH Key", + "创建SSH密钥成功": "SSH key created successfully", + "创建任务": "Create Task", + "创建任务成功": "Task created successfully", + "创建依赖": "Create Dependency", + "创建变量": "Create Variable", + "创建变量成功": "Variable created successfully", + "创建应用": "Create Application", + "创建应用成功": "Application created successfully", + "创建文件夹成功": "Folder created successfully", + "创建文件成功": "File created successfully", + "创建时间": "Created Time", + "创建视图": "Create View", + "创建订阅": "Create Subscription", + "创建订阅成功": "Subscription created successfully", + "初始化": "Initialize", + "初始化配置": "Initialize Configuration", + "删除": "Delete", + "删除Labels成功": "Labels deleted successfully", + "删除中": "Deleting", + "删除失败": "Deletion Failed", + "删除成功": "Deleted successfully", + "删除标签成功": "Tags deleted successfully", + "别名": "Alias", + "加载中...": "Loading...", + "包含": "Include", + "单实例": "Single Instance", + "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously", + "单文件": "Single File", + "去登录": "Go to Login", + "发送模板": "send template, can use type: 'html,txt,json,markdown,cloudMonitor,jenkins,route,pay'", + "发送渠道": "send channel, can use type: 'wechat,webhook,cp,mail,sms'", + "发送的目标,群组或者好友": "Recipient, group, or friend", + "发送结果回调地址": "send result callback url", + "取消": "Cancel", + "取消安装": "Cancel Install", + "取消置顶": "Un-top", + "只能输入字母数字下划线,且不能以数字开头": "Only letters, numbers, and underscores are allowed, and cannot start with a number", + "可用,你使用的版本为": "available, the version you are using is", + "同时删除关联任务和脚本": "Delete associated tasks and scripts as well", + "名称": "Name", + "名称不能为保留关键字": "The name cannot be a reserved keyword", + "吗": "?", + "否": "No", + "启动": "Start", + "启动中...": "Starting...", + "启动中,请稍后...": "Starting, please wait...", + "启用": "Enable", + "命令": "Command", + "命令/脚本": "Command/Script", + "唯一值": "Unique Value", + "唯一值用于日志目录和私钥别名": "Unique value used for log directory and private key alias", + "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "Open the two-factor authentication application on your device to view your authentication code and verify your identity.", + "基础数据": "Basic data", + "堆内存": "Heap", + "备份": "Backup", + "备份数据上传成功,确认覆盖数据": "Data backup uploaded successfully, confirm data overwrite", + "备注": "Remarks", + "复制": "Copy", + "复制成功": "Copy successful", + "多个依赖是否换行分割": "Whether to separate multiple dependencies with new lines", + "多个关键词竖线分割,支持正则表达式": "Multiple keywords separated by vertical lines (|), supports regular expressions", + "多实例": "Multi-Instance", + "天": "day(s)", + "失败": "Failure", + "失败,请检查": "Failed, please check", + "头像": "Profile Picture", + "夹下所有日志": "Folder and All Subfiles", + "夹及其子文件": "Folder and Its Subfiles", + "好友": "Friend", + "好友令牌": "friend token", + "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群": "If GOBOT_URL is set to /send_private_msg, enter user_id=personal QQ; if set to /send_group_msg, enter group_id=QQ group", "如果恢复失败,可进入容器执行": "If recovery fails, you can enter the container and execute", + "安全设置": "Security Settings", + "安装": "Install", + "安装中": "Installing", + "安装失败": "Installation Failed", + "完成安装": "Installation Completed", + "完成设置": "Finish Setup", + "定时": "Schedule", + "定时任务": "Scheduled Tasks", + "定时任务并发数": "Concurrent Scheduled Tasks", + "定时类型": "Schedule Type", + "定时规则": "Schedule Rule", + "定时订阅": "Scheduled Subscription", + "实例模式": "Instance Mode", + "实时运行态": "Runtime", + "密码": "Password", + "密码/Token": "Password/Token", + "密码不能为admin": "The password cannot be 'admin'", + "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql": "Key, obtain the API key from the Smart WeChat Assistant's personal center, apply at: https://wechat.aibotk.com/signup?from=ql", + "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串": "Secret key, shown below the signing section on the robot's security settings page, starts with SEC", + "对比工具": "Comparison Tool", + "导入": "Import", + "属于": "Belong to", + "工作目录": "Working Directory", + "已关闭": "Disabled", + "已删除": "Deleted", + "已取消": "Cancelled", + "已启用": "Enabled", + "已安装": "Installed", + "已禁用": "Disabled", + "实例已停止": "Instance stopped", + "已经是最新版了!": "It is already the latest version!", + "已运行": "Elapsed", + "已选择": "Selected", "常规定时": "Normal Timing", - "手动运行": "Manual Run", + "平台": "Platform", + "平均耗时": "Avg Time", + "序号": "Number", + "应用设置": "Application Settings", + "开发版": "Developer Edition", + "开始备份": "Start backup", + "开始安装": "Start Installation", + "开始时间": "Start Time", "开机运行": "Boot Run", - "时区": "Timezone", + "强制删除": "Force Delete", "强制打开": "Force Open", "强制打开可能会导致编辑器显示异常": "Force opening may cause display issues in the editor", - "确认离开": "Confirm Leave", + "当前修改未保存,确定离开吗": "The current changes are not saved. Are you sure you want to leave?", + "当前文件": "Current File", + "当前文件不支持预览": "Current file type is not supported for preview", "当前文件未保存,确认离开吗": "Current file is not saved, are you sure to leave?", + "待开发": "To Be Developed", + "微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/": "After WeChat scan login, one-to-one or one-to-many push using the provided token (your Token). If PUSH_PLUS_USER is not provided, it defaults to one-to-one push. See reference at https://www.pushplus.plus/", + "微加机器人": "WePlusBot", + "总任务": "Total Tasks", + "总执行": "Total", + "恭喜安装完成!": "Congratulations, the installation is completed!", + "您输入的两个密码不匹配!": "The two passwords you entered do not match!", + "成功": "Successfully", + "成功率": "Success Rate", + "或": "Or", + "手动运行": "Manual Run", + "执行前": "Before Execution", + "执行后": "After Execution", + "执行结束": "Execution Finished", + "批量": "Batch", + "批量修改变量名称": "Batch Modify Variable Names", + "批量修改标签": "Batch Modify Tags", + "批量停止": "Batch Stop", + "批量删除": "Batch Delete", + "批量删除成功": "Batch deleted successfully", + "批量取消置顶": "Batch Un-top", + "批量启用": "Batch Enable", + "批量安装": "Batch Install", + "批量导出": "Batch Export", + "批量强制删除": "Batch Force Delete", + "批量禁用": "Batch Disable", + "批量置顶": "Batch Top", + "批量运行": "Batch Run", + "拉取方式": "Pull Method", + "排序方式": "Sort Order", + "排队中": "Queued", + "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "Push to personal QQ: http://127.0.0.1/send_private_msg, group: http://127.0.0.1/send_group_msg", + "推送消息的优先级": "Priority of Push Messages", + "提交": "Submit", + "提交 issue": "Submit an issue", + "提交BUG": "Submit Bug", + "操作": "Action", + "支持python3、javascript、shell、typescript 的定时任务管理面板": "A scheduling task management panel that supports python3, javascript, shell, and typescript", + "支持拷贝 ql repo/raw 命令,粘贴导入": "Supports copying ql repo/raw command for import", + "支持输入脚本路径/任意系统可执行命令/task 脚本路径": "Supports input of script paths / any system executable commands / task script paths", "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "Receiving email address, multiple semicolon separated, sent to the sending email address by default", - "选择备份模块": "Select backup module", - "开始备份": "Start backup", - "基础数据": "Basic data", - "脚本文件": "Script files", + "数据备份还原": "Data Backup & Restore", + "文件": "File", + "文件名": "File Name", + "文件名不能包含斜杠": "File names cannot contain slashes", + "文件后缀": "File Extension", + "文件夹": "Folder", + "文件夹名": "Folder Name", + "新增定时规则": "Add Timing Rules", + "新增排序方式": "Add Sort", + "新增筛选条件": "Add Filter", + "新建": "New", + "新版本": "Create Version", + "日志": "Log", + "日志 -": "Log -", + "日志删除频率": "Log Deletion Frequency", + "日志名称": "Log Name", + "日志名称不能超过100个字符": "Log name cannot exceed 100 characters", + "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", "日志文件": "Log files", - "依赖缓存": "Dependency cache", - "远程脚本缓存": "Remote script cache", - "远程仓库缓存": "Remote repository cache", - "SSH 文件缓存": "SSH file cache", + "日志管理": "Log Management", + "时": "hour(s)", + "时区": "Timezone", + "是": "Yes", + "是目前检测到的最新可用版本了。": "It is the latest available version currently detected.", + "显示": "Display", + "智能微秘书": "Smart WeChat Assistant", + "暂无数据": "No data", + "暂无日志": "No logs available", + "暂无脚本": "No scripts available", + "暂无运行中任务": "No running tasks", + "暗色": "Dark", + "更多": "More", + "更换头像": "Change Profile Picture", + "更新": "Update", + "更新SSH密钥成功": "SSH key updated successfully", + "更新任务成功": "Task updated successfully", + "更新包下载成功": "Update package download successful", + "更新变量成功": "Variable updated successfully", + "更新可用": "Update Available", + "更新名称成功": "Name updated successfully", + "更新失败,请检查网络及日志或稍后再试": "Update failed, please check network and logs or try again later", + "更新应用成功": "Application updated successfully", + "更新成功": "Update successful", + "更新日志": "Changelog", + "更新时间": "Update Time", + "更新环境变量名称成功": "Environment Variable Name updated successfully", + "更新订阅成功": "Subscription updated successfully", + "最后运行时长": "Last Run Duration", + "最后运行时间": "Last Run Time", + "最长单次": "Max Time", + "服务启动超时": "Service startup timeout", + "未启用": "Not enabled", + "未找到": "Not Found", + "本地文件": "Local File", + "权限": "Permission", + "查看": "View", + "查看日志": "View Log", + "标签": "Tags", + "标签统计": "Label Stats", + "检查更新": "Check for Updates", + "检查更新中...": "Checking for updates...", + "次数": "Runs", + "欢迎使用": "Welcome to use", + "欢迎使用青龙": "Welcome to use Qinglong", + "正式版": "Official Edition", + "每": "Every", + "每x天自动删除x天以前的日志": "Automatically delete logs older than x days every x days", + "测试中...": "Testing...", + "消息接收人": "message recipient", + "添加": "Add", + "添加Labels成功": "Labels added successfully", + "添加标签成功": "Tags added successfully", + "清空日志": "Clear Logs", "清除依赖缓存": "Clean dependency cache", "清除成功": "Clean successful", - "日志名称": "Log Name", - "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate", - "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null", - "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory", - "请输入自定义日志文件夹名称": "Please enter a custom log folder name", - "请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path", - "请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null", - "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", - "日志名称不能超过100个字符": "Log name cannot exceed 100 characters", - "未启用": "Not enabled", - "默认为 CPU 个数": "Default is the number of CPUs", - "Minimum is 4": "Minimum is 4", - "实例模式": "Instance Mode", - "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously", - "请选择实例模式": "Please select instance mode", - "单实例": "Single Instance", - "多实例": "Multi-Instance", - "SSH密钥": "SSH Keys", - "别名": "Alias", + "源文件": "Source File", + "激活成功": "Activation successful", + "点击或者拖拽文件到此区域上传": "Click or drag files here to upload", + "父目录": "Parent Directory", + "版本": "Version", + "状态": "Status", + "环境变量": "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/", + "用户名": "Username", + "用户名密码/Token": "Username/Password or Token", + "留空自动检测,或输入相对/绝对路径": "Leave blank to auto-detect, or enter a relative/absolute path", + "登录": "Login", + "登录IP": "Login IP", + "登录地址": "Login Address", + "登录已过期,请重新登录": "Login session has expired, please log in again", + "登录成功!": "Login successful!", + "登录日志": "Login Logs", + "登录时间": "Login Time", + "登录状态": "Login Status", + "登录设备": "Login Device", + "白名单": "Whitelist", + "的Secret吗": "'s Secret?", + "知道了": "Got it", + "确认": "Confirm", + "确认保存文件": "Confirm to Save File", + "确认停止": "Confirm to Stop", + "确认停止定时任务": "Confirm to Stop Scheduled Task", + "确认停止实例": "Confirm to Stop Instance", + "确认停止运行实例": "Confirm to stop the running instance", + "确认删除": "Confirm to Delete", + "确认删除SSH密钥": "Confirm to delete SSH key", + "确认删除依赖": "Confirm to delete the dependency", + "确认删除变量": "Confirm to delete the variable", + "确认删除定时任务": "Confirm to Delete Scheduled Task", + "确认删除定时订阅": "Confirm Deletion of Scheduled Subscription", + "确认删除应用": "Confirm to delete the application", + "确认删除视图": "Confirm to delete the view", + "确认删除选中的依赖吗": "Confirm to delete the selected dependencies?", + "确认删除选中的变量吗": "Confirm to delete the selected variables?", + "确认删除选中的定时任务吗": "Confirm to delete the selected scheduled tasks?", + "确认取消安装": "Confirm to cancel install", + "确认密码": "Confirm Password", + "确认离开": "Confirm Leave", + "确认运行": "Confirm to Run", + "确认运行定时任务": "Confirm to Run Scheduled Task", + "确认重启": "Confirm to Restart", + "确认重新安装": "Confirm to reinstall", + "确认重新安装选中的依赖吗": "Confirm to reinstall the selected dependencies?", + "确认重置": "Confirm to reset", + "确认重置应用": "Confirm to reset the application", + "禁用": "Disable", + "私有仓库": "Private Repository", + "私钥": "Private Key", + "秒": "second(s)", + "秒(可选) 分 时 天 月 周": "Seconds (optional) Minutes Hours Day Month Week", + "秒后自动刷新": "seconds and automatically refresh", + "秒后重试": "Retry after seconds", + "空文件": "Empty File", + "空闲中": "Idle", + "第一步": "Step 1", + "第三步": "Step 3", + "第二步": "Step 2", + "筛选条件": "Filter Conditions", + "类型": "Type", + "系统": "System", + "系统信息": "System Information", + "系统安装包下载成功,确认重启": "System installation package downloaded successfully, confirm to restart", + "系统将在": "The system will restart in", + "系统日志": "System Logs", + "系统设置": "System Settings", + "系统资源": "System", + "系统运行": "System Uptime", + "结束时间": "End Time", + "编辑": "Edit", "编辑SSH密钥": "Edit SSH Key", - "创建SSH密钥": "Create SSH Key", - "更新SSH密钥成功": "SSH key updated successfully", - "创建SSH密钥成功": "SSH key created successfully", + "编辑任务": "Edit Task", + "编辑依赖": "Edit Dependency", + "编辑变量": "Edit Variable", + "编辑应用": "Edit Application", + "编辑视图": "Edit View", + "编辑订阅": "Edit Subscription", + "置顶": "Top", + "群晖chat": "Synology Chat", + "群聊": "Group Chat", + "脚本": "Script", + "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.", + "脚本文件": "Script files", + "脚本管理": "Script Management", + "自动删除任务": "Automatically Delete Tasks", + "自动拆分": "Auto Split", + "自动添加任务": "Automatically Add Tasks", + "自动生成": "Auto-generated", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null", + "自定义通知": "Custom Notification", + "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "The self built PushMeServer message interface address, for example: http://127.0.0.1:3010 If left blank, use the official message interface", + "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称": "Enter the recipient's nickname if it's a friend or the group name if it's a group", + "视图名称": "View Name", + "视图管理": "View Management", + "订阅": "Subscription", + "订阅管理": "Subscription Management", + "设置": "Settings", + "访问密钥": "Access Key", + "语言": "Language", + "请": "Please", + "请先按如下方式修复:": "Please fix it as follows:", + "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.", + "请求头Content-Type": "Request Header Content-Type", + "请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)", + "请求方法": "Request Method", + "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request URL should start with http or https. URL or body must contain $title, $content is optional and corresponds to the API content position.", + "请至少输入一个标签": "Please enter at least one tag", + "请输入": "Please input", "请输入SSH密钥别名": "Please enter SSH key alias", "请输入SSH私钥": "Please enter SSH private key", "请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)", - "确认删除SSH密钥": "Confirm to delete SSH key", - "批量": "Batch", - "全局SSH私钥": "Global SSH Private Key", - "用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories", + "请输入任务名称": "Please enter the task name", + "请输入依赖名称": "Please enter the dependency name", + "请输入依赖名称,支持指定版本": "Please enter the dependency name, version specification is supported", + "请输入保存目录,默认scripts目录": "Please enter the save directory, default is 'scripts'", + "请输入内容": "Please enter the content", + "请输入分支": "Please enter the branch", + "请输入名称": "Please enter a name", + "请输入名称/值/备注": "Please enter name/value/remarks", + "请输入名称或者关键词": "Please enter a name or keyword", + "请输入备注": "Please enter remarks", "请输入完整的SSH私钥内容": "Please enter the complete SSH private key content", - "仪表盘": "Dashboard", - "总任务": "Total Tasks", - "今日执行": "Today Runs", - "今日成功": "Success", - "今日失败": "Failed", - "成功率": "Success Rate", - "平均耗时": "Avg Time", - "近 7 日趋势": "7-Day Trend", - "今日耗时 Top 5": "Top 5 by Time", - "今日执行次数 Top 5": "Top 5 by Runs", - "实时运行态": "Runtime", - "系统资源": "System", - "排队中": "Queued", - "暂无运行中任务": "No running tasks", - "暂无数据": "No data", - "总执行": "Total", - "次数": "Runs", - "最长单次": "Max Time", - "已运行": "Elapsed", - "系统运行": "System Uptime", - "内存使用": "Memory", - "堆内存": "Heap", - "CPU 核心": "CPU Cores", + "请输入密码或者Token": "Please enter the password or Token", + "请输入应用名称": "Please enter the application name", + "请输入文件名": "Please enter the file name", + "请输入文件后缀": "Please enter the file extension", + "请输入文件夹名": "Please enter the folder name", + "请输入新名称": "Please enter a new name", + "请输入新的环境变量名称": "Please enter the new environment variable name", + "请输入日志名": "Please enter the log name", + "请输入环境变量值": "Please enter the environment variable value", + "请输入环境变量名称": "Please enter the environment variable name", + "请输入私钥": "Please enter the private key", + "请输入脚本依赖文件关键词,多个关键词竖线分割": "Please enter script dependency file keywords, multiple keywords separated by vertical lines (|)", + "请输入脚本名": "Please enter the script name", + "请输入脚本筛选白名单关键词,多个关键词竖线分割": "Please enter script filtering whitelist keywords, multiple keywords separated by vertical lines (|)", + "请输入脚本筛选黑名单关键词,多个关键词竖线分割": "Please enter script filtering blacklist keywords, multiple keywords separated by vertical lines (|)", + "请输入自定义日志文件夹名称": "Please enter a custom log folder name", + "请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null", + "请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path", + "请输入要发送的目标": "Please enter the recipient's name (group or friend)", + "请输入视图名称": "Please enter the view name", + "请输入订阅链接": "Please enter the subscription link", + "请输入认证用户名": "Please enter the authentication username", + "请输入运行任务前要执行的命令,不能包含 task 命令": "Please enter the command to run before executing the task, cannot contain task commands", + "请输入运行任务后要执行的命令,不能包含 task 命令": "Please enter the command to run after executing the task, cannot contain task commands", + "请输入运行订阅前要执行的命令": "Please enter the command to run before executing the subscription", + "请输入运行订阅后要执行的命令": "Please enter the command to run after executing the subscription", + "请选择": "Please select", + "请选择实例模式": "Please select instance mode", + "请选择操作符": "Please select operator", + "请选择日志": "Please select a log", + "请选择日志文件": "Please select a log file", + "请选择模块权限": "Please select module permissions", + "请选择父目录": "Please select a parent directory", + "请选择脚本": "Please select a script", + "请选择脚本文件": "Please select a script file", + "请选择通知方式": "Please select a notification method", + "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.", + "调试": "Debug", "负载 1m": "Load 1m", - "平台": "Platform", - "24小时未运行": "Idle 24h+", - "标签统计": "Label Stats", - "任务数": "Tasks", - "内存": "Memory" + "账户设置": "Account Settings", + "跟随系统": "Follow System", + "跳过": "Skip", + "输入后回车增加自定义选项": "Press Enter to add custom options", + "输入手机应用上的6位数字": "Enter the 6-digit code from the mobile app", + "运行": "Run", + "运行中": "Running", + "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js", + "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js", + "运行实例": "Running Instances", + "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the subscription, e.g., cp/mv/python3 xxx.py/node xxx.js", + "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the subscription, e.g., cp/mv/python3 xxx.py/node xxx.js", + "运行设置": "Run Settings", + "近 7 日趋势": "7-Day Trend", + "返回首页": "Return to Home", + "还原数据": "Restore Data", + "远程仓库缓存": "Remote repository cache", + "远程脚本缓存": "Remote script cache", + "退出": "Exit", + "退出登录": "Logout", + "退出编辑": "Exit Editing", + "选中的变量吗": "selected variables?", + "选中的定时任务吗": "selected scheduled tasks?", + "选择备份模块": "Select backup module", + "通知关闭成功": "Notification closed successfully", + "通知发送成功": "Notification sent successfully", + "通知方式": "Notification Method", + "通知设置": "Notification Settings", + "邮箱": "Email", + "邮箱地址": "Email Address", + "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", + "配置文件": "Configuration Files", + "重启": "Restart", + "重命名": "Rename", + "重新下载": "Redownload", + "重新启动": "Reboot", + "重新安装": "Reinstall", + "重置Secret会让当前应用所有token失效": "Resetting the Secret will invalidate all tokens for the current application", + "重置secret": "Reset secret", + "重置成功": "Reset successful", + "钉钉机器人": "DingTalk Bot", + "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd": "DingTalk Bot webhook token, e.g., 5a544165465465645d0f31dca676e7bd07415asdasd", + "链接": "Link", + "错误": "Error", + "错误日志": "Error Log", + "队列中": "In Queue", + "青龙": "Qinglong", + "项": "items", + "顺序": "Order", + "飞书机器人": "Feishu Bot", + "飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings", + "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973", + "验证": "Verify", + "验证失败": "Validation failed", + "验证码": "Verification Code", + "验证码为6位数字": "Verification code is a 6-digit number", + "黑名单": "Blacklist", + "默认为 CPU 个数": "Default is the number of CPUs", + ",保存后不可恢复": ", it can't be recovered after saving.", + ",删除后不可恢复": ", it can't be recovered after deletion" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 5ac2a89c..14b03eee 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -1,592 +1,598 @@ { - "复制成功": "复制成功", - "复制": "复制", - "新建": "新建", - "登录": "登录", - "初始化": "初始化", - "错误": "错误", - "定时任务": "定时任务", - "订阅管理": "订阅管理", - "环境变量": "环境变量", - "配置文件": "配置文件", - "脚本管理": "脚本管理", - "依赖管理": "依赖管理", - "日志管理": "日志管理", - "对比工具": "对比工具", - "系统设置": "系统设置", - "退出登录": "退出登录", - "青龙": "青龙", - "返回首页": "返回首页", - "保存": "保存", - "日志": "日志", - "脚本": "脚本", - "确认保存文件": "确认保存文件", - ",保存后不可恢复": ",保存后不可恢复", - "确认运行": "确认运行", - "确认运行定时任务": "确认运行定时任务", - "吗": "吗", - "确认停止": "确认停止", - "确认停止定时任务": "确认停止定时任务", - "确认": "确认", - "任务": "任务", - "状态": "状态", - "空闲中": "空闲中", - "运行中": "运行中", - "队列中": "队列中", - "已禁用": "已禁用", - "定时": "定时", - "最后运行时间": "最后运行时间", - "最后运行时长": "最后运行时长", - "下次运行时间": "下次运行时间", - "名称": "名称", - "命令/脚本": "命令/脚本", - "定时规则": "定时规则", - "操作": "操作", - "确认删除": "确认删除", - "确认删除定时任务": "确认删除定时任务", - "编辑": "编辑", - "删除": "删除", - "确认删除选中的定时任务吗": "确认删除选中的定时任务吗", - "选中的定时任务吗": "选中的定时任务吗", - "创建视图": "创建视图", - "视图管理": "视图管理", - "请输入名称或者关键词": "请输入名称或者关键词", - "创建任务": "创建任务", - "更多": "更多", - "批量删除": "批量删除", - "批量启用": "批量启用", - "批量禁用": "批量禁用", - "批量运行": "批量运行", - "批量停止": "批量停止", - "批量置顶": "批量置顶", - "批量取消置顶": "批量取消置顶", - "批量修改标签": "批量修改标签", - "已选择": "已选择", - "项": "项", - "知道了": "知道了", - "请输入任务名称": "请输入任务名称", - "支持输入脚本路径/任意系统可执行命令/task 脚本路径": "支持输入脚本路径/任意系统可执行命令/task 脚本路径", - "秒(可选) 分 时 天 月 周": "秒(可选) 分 时 天 月 周", - "标签": "标签", - "取消": "取消", - "添加": "添加", - "启用": "启用", - "禁用": "禁用", - "运行": "运行", - "停止": "停止", - "置顶": "置顶", - "取消置顶": "取消置顶", - "命令": "命令", - "包含": "包含", - "不包含": "不包含", - "属于": "属于", - "不属于": "不属于", - "顺序": "顺序", - "倒序": "倒序", - "且": "且", - "或": "或", - "输入后回车增加自定义选项": "输入后回车增加自定义选项", - "视图名称": "视图名称", - "请输入视图名称": "请输入视图名称", - "请输入内容": "请输入内容", - "新增筛选条件": "新增筛选条件", - "新增排序方式": "新增排序方式", - "类型": "类型", - "显示": "显示", - "确认删除视图": "确认删除视图", - "安装中": "安装中", - "已安装": "已安装", - "安装失败": "安装失败", - "删除中": "删除中", - "已删除": "已删除", - "删除失败": "删除失败", - "已取消": "已取消", - "序号": "序号", - "备注": "备注", - "更新时间": "更新时间", - "创建时间": "创建时间", - "确认删除依赖": "确认删除依赖", - "确认重新安装": "确认重新安装", - "确认取消安装": "确认取消安装", - "确认删除选中的依赖吗": "确认删除选中的依赖吗", - "确认重新安装选中的依赖吗": "确认重新安装选中的依赖吗", - "请输入名称": "请输入名称", - "创建依赖": "创建依赖", - "批量安装": "批量安装", - "批量强制删除": "批量强制删除", - "日志 -": "日志 -", - "依赖类型": "依赖类型", - "自动拆分": "自动拆分", - "多个依赖是否换行分割": "多个依赖是否换行分割", - "是": "是", - "否": "否", - "请输入依赖名称,支持指定版本": "请输入依赖名称,支持指定版本", - "请输入依赖名称": "请输入依赖名称", - "请输入备注": "请输入备注", - "源文件": "源文件", - "当前文件": "当前文件", - "修改环境变量名称": "修改环境变量名称", - "请输入新的环境变量名称": "请输入新的环境变量名称", - "已启用": "已启用", - "值": "值", - "确认删除变量": "确认删除变量", - "确认删除选中的变量吗": "确认删除选中的变量吗", - "选中的变量吗": "选中的变量吗", - "请输入名称/值/备注": "请输入名称/值/备注", - "导入": "导入", - "创建变量": "创建变量", - "批量修改变量名称": "批量修改变量名称", - "批量导出": "批量导出", - "请输入环境变量名称": "请输入环境变量名称", - "只能输入字母数字下划线,且不能以数字开头": "只能输入字母数字下划线,且不能以数字开头", - "请输入环境变量值": "请输入环境变量值", - "服务启动超时": "服务启动超时", - "请先按如下方式修复:": "请先按如下方式修复:", "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小时未运行", "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果", - "提交 issue": "提交 issue", - "启动中,请稍后...": "启动中,请稍后...", - "欢迎使用": "欢迎使用", - "欢迎使用青龙": "欢迎使用青龙", - "支持python3、javascript、shell、typescript 的定时任务管理面板": "支持python3、javascript、shell、typescript 的定时任务管理面板", - "开始安装": "开始安装", - "账户设置": "账户设置", - "用户名": "用户名", - "密码": "密码", - "密码不能为admin": "密码不能为admin", - "确认密码": "确认密码", - "您输入的两个密码不匹配!": "您输入的两个密码不匹配!", - "提交": "提交", - "通知设置": "通知设置", - "通知方式": "通知方式", - "请选择通知方式": "请选择通知方式", - "跳过": "跳过", - "完成安装": "完成安装", - "恭喜安装完成!": "恭喜安装完成!", - "Telegram频道": "Telegram频道", - "去登录": "去登录", - "初始化配置": "初始化配置", - "文件": "文件", - ",删除后不可恢复": ",删除后不可恢复", - "请选择日志": "请选择日志", - "请输入日志名": "请输入日志名", - "暂无日志": "暂无日志", - "登录成功!": "登录成功!", - "上次登录时间:": "上次登录时间:", - "上次登录地点:": "上次登录地点:", - "上次登录IP:": "上次登录IP:", - "上次登录设备:": "上次登录设备:", - "上次登录状态:": "上次登录状态:", - "验证码": "验证码", - "验证码为6位数字": "验证码为6位数字", "6位数字": "6位数字", - "验证": "验证", - "请": "请", - "秒后重试": "秒后重试", - "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。", - "请选择脚本文件": "请选择脚本文件", - "当前文件不支持预览": "当前文件不支持预览", - "清空日志": "清空日志", - "设置": "设置", - "退出": "退出", - "空文件": "空文件", - "本地文件": "本地文件", - "文件夹": "文件夹", - "文件名": "文件名", - "请输入文件名": "请输入文件名", - "文件名不能包含斜杠": "文件名不能包含斜杠", - "文件夹名": "文件夹名", - "请输入文件夹名": "请输入文件夹名", - "父目录": "父目录", - "请选择父目录": "请选择父目录", - "点击或者拖拽文件到此区域上传": "点击或者拖拽文件到此区域上传", - "当前修改未保存,确定离开吗": "当前修改未保存,确定离开吗", - "退出编辑": "退出编辑", - "重命名": "重命名", - "请选择脚本": "请选择脚本", - "调试": "调试", - "请输入脚本名": "请输入脚本名", - "暂无脚本": "暂无脚本", - "请输入新名称": "请输入新名称", - "保存文件": "保存文件", - "保存目录": "保存目录", - "请输入保存目录,默认scripts目录": "请输入保存目录,默认scripts目录", - "运行设置": "运行设置", - "待开发": "待开发", - "开发版": "开发版", - "正式版": "正式版", - "版本": "版本", - "更新日志": "更新日志", - "查看": "查看", - "提交BUG": "提交BUG", - "名称不能为保留关键字": "名称不能为保留关键字", - "请输入应用名称": "请输入应用名称", - "权限": "权限", - "请选择模块权限": "请选择模块权限", - "更新": "更新", - "已经是最新版了!": "已经是最新版了!", - "是目前检测到的最新可用版本了。": "是目前检测到的最新可用版本了。", - "重新下载": "重新下载", - "更新可用": "更新可用", - "新版本": "新版本", - "可用,你使用的版本为": "可用,你使用的版本为", - "下载更新": "下载更新", - "以后再说": "以后再说", - "下载更新中...": "下载更新中...", - "确认重启": "确认重启", - "系统安装包下载成功,确认重启": "系统安装包下载成功,确认重启", - "重启": "重启", - "系统将在": "系统将在", - "秒后自动刷新": "秒后自动刷新", - "检查更新": "检查更新", - "重新启动": "重新启动", - "确认删除应用": "确认删除应用", - "确认重置": "确认重置", - "确认重置应用": "确认重置应用", - "的Secret吗": "的Secret吗", - "重置Secret会让当前应用所有token失效": "重置Secret会让当前应用所有token失效", - "创建应用": "创建应用", - "安全设置": "安全设置", - "应用设置": "应用设置", - "登录日志": "登录日志", - "其他设置": "其他设置", - "关于": "关于", - "成功": "成功", - "失败": "失败", - "登录时间": "登录时间", - "登录地址": "登录地址", - "登录IP": "登录IP", - "登录设备": "登录设备", - "登录状态": "登录状态", - "亮色": "亮色", - "暗色": "暗色", - "跟随系统": "跟随系统", - "备份数据上传成功,确认覆盖数据": "备份数据上传成功,确认覆盖数据", - "主题设置": "主题设置", - "日志删除频率": "日志删除频率", - "每x天自动删除x天以前的日志": "每x天自动删除x天以前的日志", - "每": "每", - "天": "天", - "定时任务并发数": "定时任务并发数", - "数据备份还原": "数据备份还原", - "还原数据": "还原数据", - "第一步": "第一步", - "下载两步验证手机应用,比如 Google Authenticator 、": "下载两步验证手机应用,比如 Google Authenticator 、", - "第二步": "第二步", - "使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥", - "第三步": "第三步", - "输入手机应用上的6位数字": "输入手机应用上的6位数字", - "完成设置": "完成设置", - "修改用户名密码": "修改用户名密码", - "两步验证": "两步验证", - "头像": "头像", - "更换头像": "更换头像", - "时": "时", - "分": "分", - "秒": "秒", - "私有仓库": "私有仓库", - "公开仓库": "公开仓库", - "单文件": "单文件", - "链接": "链接", - "分支": "分支", - "确认删除定时订阅": "确认删除定时订阅", - "定时订阅": "定时订阅", - "创建订阅": "创建订阅", - "私钥": "私钥", - "请输入私钥": "请输入私钥", - "请输入认证用户名": "请输入认证用户名", - "Github已不支持密码认证,请使用Token方式": "Github已不支持密码认证,请使用Token方式", - "密码/Token": "密码/Token", - "请输入密码或者Token": "请输入密码或者Token", - "支持拷贝 ql repo/raw 命令,粘贴导入": "支持拷贝 ql repo/raw 命令,粘贴导入", - "请输入订阅链接": "请输入订阅链接", - "请输入分支": "请输入分支", - "唯一值": "唯一值", - "唯一值用于日志目录和私钥别名": "唯一值用于日志目录和私钥别名", - "自动生成": "自动生成", - "拉取方式": "拉取方式", - "用户名密码/Token": "用户名密码/Token", - "定时类型": "定时类型", - "白名单": "白名单", - "多个关键词竖线分割,支持正则表达式": "多个关键词竖线分割,支持正则表达式", - "请输入脚本筛选白名单关键词,多个关键词竖线分割": "请输入脚本筛选白名单关键词,多个关键词竖线分割", - "黑名单": "黑名单", - "请输入脚本筛选黑名单关键词,多个关键词竖线分割": "请输入脚本筛选黑名单关键词,多个关键词竖线分割", - "依赖文件": "依赖文件", - "请输入脚本依赖文件关键词,多个关键词竖线分割": "请输入脚本依赖文件关键词,多个关键词竖线分割", - "文件后缀": "文件后缀", - "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions": "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions", - "请输入文件后缀": "请输入文件后缀", - "执行前": "执行前", - "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", - "请输入运行订阅前要执行的命令": "请输入运行订阅前要执行的命令", - "执行后": "执行后", - "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", - "请输入运行订阅后要执行的命令": "请输入运行订阅后要执行的命令", - "代理": "代理", - "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理": "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理", - "自动添加任务": "自动添加任务", - "自动删除任务": "自动删除任务", - "中文": "中文", - "系统信息": "系统信息", - "Server酱": "Server酱", - "Telegram机器人": "Telegram机器人", - "钉钉机器人": "钉钉机器人", - "企业微信机器人": "企业微信机器人", - "企业微信应用": "企业微信应用", - "智能微秘书": "智能微秘书", - "群晖chat": "群晖chat", - "微加机器人": "微加机器人", - "邮箱": "邮箱", - "飞书机器人": "飞书机器人", - "自定义通知": "自定义通知", - "已关闭": "已关闭", - "gotify的url地址,例如 https://push.example.de:8080": "gotify的url地址,例如 https://push.example.de:8080", - "gotify的消息应用token码": "gotify的消息应用token码", - "推送消息的优先级": "推送消息的优先级", - "synologyChat的url地址": "synologyChat的webhook url地址", - "chat的token码": "chat的token码", - "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg", - "访问密钥": "访问密钥", - "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群": "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群", - "Server酱SENDKEY": "Server 酱 SENDKEY,https://sct.ftqq.com/r/13363", - "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer的Key,https://github.com/easychen/pushdeer", - "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push", - "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX": "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX", "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)", - "BARK推送铃声,铃声列表去APP查看复制填写": "BARK推送铃声,铃声列表去APP查看复制填写", "BARK推送消息的分组,默认为qinglong": "BARK推送消息的分组,默认为qinglong", "BARK推送消息的时效性,默认为active": "BARK推送消息的时效性,默认为active", "BARK推送消息的跳转URL": "BARK推送消息的跳转URL", + "BARK推送铃声,铃声列表去APP查看复制填写": "BARK推送铃声,铃声列表去APP查看复制填写", "BARK是否保存推送消息": "BARK是否保存推送消息", + "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX": "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX", + "CPU 核心": "CPU 核心", + "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/", + "Cron表达式格式有误": "Cron表达式格式有误", + "Github已不支持密码认证,请使用Token方式": "Github已不支持密码认证,请使用Token方式", + "Linux 软件包镜像源": "Linux 软件包镜像源", + "NPM 镜像源": "NPM 镜像源", + "Node 软件包镜像源": "Node 软件包镜像源", + "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer的Key,https://github.com/easychen/pushdeer", + "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push", + "PushMe的Key,https://push.i-i.me/": "PushMe的Key,https://push.i-i.me/", + "PyPI 镜像源": "PyPI 镜像源", + "Python 软件包镜像源": "Python 软件包镜像源", + "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定", + "SSH 文件缓存": "SSH 文件缓存", + "SSH密钥": "SSH密钥", + "Server酱": "Server酱", + "Server酱SENDKEY": "Server 酱 SENDKEY,https://sct.ftqq.com/r/13363", + "Subscription表达式格式有误": "Subscription表达式格式有误", + "Telegram机器人": "Telegram机器人", + "Telegram频道": "Telegram频道", + "alpine linux 镜像源": "alpine linux 镜像源", + "chat的token码": "chat的token码", + "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid,corpsecret,touser(注:多个成员ID使用|隔开),agentid,消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat", + "docker安装在持久化config目录下的chronocat.yml文件可找到": "docker安装在持久化config目录下的chronocat.yml文件可找到", + "gotify的url地址,例如 https://push.example.de:8080": "gotify的url地址,例如 https://push.example.de:8080", + "gotify的消息应用token码": "gotify的消息应用token码", + "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX": "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX", + "ntfy应用token": "ntfy应用token,参考 https://docs.ntfy.sh/config/#access-tokens", + "ntfy应用topic": "ntfy应用topic", + "ntfy应用密码": "ntfy应用密码,参考 https://docs.ntfy.sh/config/#users-and-roles", + "ntfy应用用户名": "ntfy应用用户名,参考 https://docs.ntfy.sh/config/#users-and-roles", + "ntfy用户动作": "ntfy用户动作,最多三个动作,参考 https://docs.ntfy.sh/publish/?h=actions#action-buttons", + "ntfy的url地址,例如 https://ntfy.sh": "ntfy的url地址,例如 https://ntfy.sh", + "synologyChat的url地址": "synologyChat的webhook url地址", + "telegram api自建的反向代理地址,默认tg官方api": "telegram api自建的反向代理地址,默认tg官方api", + "telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password": "telegram代理配置认证参数, 用户名与密码用英文冒号连接 user:password", "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw": "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw", "telegram用户的id,例如:129xxx206": "telegram用户的id,例如:129xxx206", - "代理IP": "代理IP", - "代理端口": "代理端口", - "telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password": "telegram代理配置认证参数, 用户名与密码用英文冒号连接 user:password", - "telegram api自建的反向代理地址,默认tg官方api": "telegram api自建的反向代理地址,默认tg官方api", - "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd": "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd", - "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串": "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串", - "企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa": "企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa", - "企业微信代理地址": "企业微信代理地址", - "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid,corpsecret,touser(注:多个成员ID使用|隔开),agentid,消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat", - "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql": "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql", - "发送的目标,群组或者好友": "发送的目标,群组或者好友", - "请输入要发送的目标": "请输入要发送的目标", - "群聊": "群聊", - "好友": "好友", - "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称": "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称", - "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX": "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX", - "微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/": "微信扫码登录后一对一推送或一对多推送下面的token(你的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/", - "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)", - "发送模板": "发送模板,支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay", - "发送渠道": "发送渠道,支持wechat,webhook,cp,mail,sms", "webhook编码": "webhook编码,可在pushplus公众号上扩展配置出更多渠道", - "发送结果回调地址": "发送结果回调地址,会把推送最终结果通知到这个地址上", - "好友令牌": "好友令牌,微信公众号渠道填写好友令牌,企业微信渠道填写企业微信用户id", - "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/", - "消息接收人": "消息接收人", - "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版", - "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973", - "飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得", - "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", - "邮箱地址": "邮箱地址", - "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定", - "PushMe的Key,https://push.i-i.me/": "PushMe的Key,https://push.i-i.me/", - "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口", - "ntfy的url地址,例如 https://ntfy.sh": "ntfy的url地址,例如 https://ntfy.sh", - "ntfy应用topic": "ntfy应用topic", - "ntfy应用token": "ntfy应用token,参考 https://docs.ntfy.sh/config/#access-tokens", - "ntfy应用用户名": "ntfy应用用户名,参考 https://docs.ntfy.sh/config/#users-and-roles", - "ntfy应用密码": "ntfy应用密码,参考 https://docs.ntfy.sh/config/#users-and-roles", - "ntfy用户动作": "ntfy用户动作,最多三个动作,参考 https://docs.ntfy.sh/publish/?h=actions#action-buttons", "wxPusherBot的appToken": "wxPusherBot的appToken, 按照文档获取 https://wxpusher.zjiecode.com/docs/", "wxPusherBot的topicIds": "wxPusherBot的topicIds, topicIds 和 uids 至少配置一个才行", "wxPusherBot的uids": "wxPusherBot的uids, topicIds 和 uids 至少配置一个才行", - "请求方法": "请求方法", - "请求头Content-Type": "请求头Content-Type", - "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置", - "请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割", - "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置", - "错误日志": "错误日志", - "执行结束": "执行结束", - "备份": "备份", - "生成数据中...": "生成数据中...", - "请选择日志文件": "请选择日志文件", - "筛选条件": "筛选条件", - "系统": "系统", - "个人": "个人", - "重新安装": "重新安装", - "取消安装": "取消安装", - "强制删除": "强制删除", - "全部任务": "全部任务", - "关联订阅": "关联订阅", - "订阅": "订阅", - "创建": "创建", - "同时删除关联任务和脚本": "同时删除关联任务和脚本", - "未找到": "未找到", - "保存成功": "保存成功", - "删除成功": "删除成功", - "批量删除成功": "批量删除成功", - "启动中...": "启动中...", - "任务未运行": "任务未运行", - "更新任务成功": "更新任务成功", - "创建任务成功": "创建任务成功", - "编辑任务": "编辑任务", - "Cron表达式格式有误": "Cron表达式格式有误", - "添加Labels成功": "添加Labels成功", - "删除Labels成功": "删除Labels成功", - "添加标签成功": "添加标签成功", - "删除标签成功": "删除标签成功", - "请至少输入一个标签": "请至少输入一个标签", - "编辑视图": "编辑视图", - "排序方式": "排序方式", - "开始时间": "开始时间", - "安装": "安装", - "结束时间": "结束时间", - "编辑依赖": "编辑依赖", - "更新环境变量名称成功": "更新环境变量名称成功", - "更新变量成功": "更新变量成功", - "创建变量成功": "创建变量成功", - "编辑变量": "编辑变量", - "加载中...": "加载中...", - "夹下所有日志": "夹下所有日志", - "创建文件夹成功": "创建文件夹成功", - "创建文件成功": "创建文件成功", - "夹及其子文件": "夹及其子文件", - "更新名称成功": "更新名称成功", - "保存文件成功": "保存文件成功", - "更新应用成功": "更新应用成功", - "创建应用成功": "创建应用成功", - "编辑应用": "编辑应用", - "检查更新中...": "检查更新中...", - "失败,请检查": "失败,请检查", - "更新失败,请检查网络及日志或稍后再试": "更新失败,请检查网络及日志或稍后再试", - "更新包下载成功": "更新包下载成功", - "重置secret": "重置secret", - "重置成功": "重置成功", - "通知发送成功": "通知发送成功", - "通知关闭成功": "通知关闭成功", - "测试中...": "测试中...", - "上传": "上传", - "下载": "下载", - "更新成功": "更新成功", - "激活成功": "激活成功", - "验证失败": "验证失败", - "更新订阅成功": "更新订阅成功", - "创建订阅成功": "创建订阅成功", - "编辑订阅": "编辑订阅", - "Subscription表达式格式有误": "Subscription表达式格式有误", + "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)", "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则新建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则新建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)", - "登录已过期,请重新登录": "登录已过期,请重新登录", - "系统日志": "系统日志", - "主题": "主题", - "语言": "语言", - "中...": "中...", - "请选择操作符": "请选择操作符", - "新增定时规则": "新增定时规则", - "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", - "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", - "请输入运行任务前要执行的命令,不能包含 task 命令": "请输入运行任务前要执行的命令,不能包含 task 命令", - "请输入运行任务后要执行的命令,不能包含 task 命令": "请输入运行任务后要执行的命令,不能包含 task 命令", + "上传": "上传", + "上次登录IP:": "上次登录IP:", + "上次登录地点:": "上次登录地点:", + "上次登录时间:": "上次登录时间:", + "上次登录状态:": "上次登录状态:", + "上次登录设备:": "上次登录设备:", + "下次运行时间": "下次运行时间", + "下载": "下载", + "下载两步验证手机应用,比如 Google Authenticator 、": "下载两步验证手机应用,比如 Google Authenticator 、", + "下载更新": "下载更新", + "下载更新中...": "下载更新中...", + "不包含": "不包含", + "不属于": "不属于", "不能包含 task 命令": "不能包含 task 命令", - "工作目录": "工作目录", - "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径", - "留空自动检测,或输入相对/绝对路径": "留空自动检测,或输入相对/绝对路径", - "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/", + "且": "且", + "两步验证": "两步验证", + "个人": "个人", "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx", - "docker安装在持久化config目录下的chronocat.yml文件可找到": "docker安装在持久化config目录下的chronocat.yml文件可找到", - "请选择": "请选择", - "请输入": "请输入", - "依赖设置": "依赖设置", - "Node 软件包镜像源": "Node 软件包镜像源", - "Python 软件包镜像源": "Python 软件包镜像源", - "Linux 软件包镜像源": "Linux 软件包镜像源", + "中...": "中...", + "中文": "中文", + "主题": "主题", + "主题设置": "主题设置", + "亮色": "亮色", + "今日失败": "今日失败", + "今日成功": "今日成功", + "今日执行": "今日执行", + "今日执行次数 Top 5": "今日执行次数 Top 5", + "今日耗时 Top 5": "今日耗时 Top 5", + "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions": "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions", + "代理": "代理", + "代理IP": "代理IP", "代理与镜像源二选一即可": "代理与镜像源二选一即可", "代理地址, 支持HTTP(S)/SOCK5": "代理地址, 支持HTTP(S)/SOCK5", - "NPM 镜像源": "NPM 镜像源", - "PyPI 镜像源": "PyPI 镜像源", - "alpine linux 镜像源": "alpine linux 镜像源", + "代理端口": "代理端口", + "以后再说": "以后再说", + "仪表盘": "仪表盘", + "任务": "任务", + "任务数": "任务数", + "任务未运行": "任务未运行", + "企业微信代理地址": "企业微信代理地址", + "企业微信应用": "企业微信应用", + "企业微信机器人": "企业微信机器人", + "企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa": "企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa", + "使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥", + "依赖文件": "依赖文件", + "依赖管理": "依赖管理", + "依赖类型": "依赖类型", + "依赖缓存": "依赖缓存", + "依赖设置": "依赖设置", + "保存": "保存", + "保存成功": "保存成功", + "保存文件": "保存文件", + "保存文件成功": "保存文件成功", + "保存目录": "保存目录", + "修改环境变量名称": "修改环境变量名称", + "修改用户名密码": "修改用户名密码", + "倒序": "倒序", + "值": "值", + "停止": "停止", + "全局SSH私钥": "全局SSH私钥", + "全部任务": "全部任务", + "公开仓库": "公开仓库", + "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理": "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理", + "关于": "关于", + "关联订阅": "关联订阅", + "其他设置": "其他设置", + "内存": "内存", + "内存使用": "内存使用", + "分": "分", + "分支": "分支", + "创建": "创建", + "创建SSH密钥": "创建SSH密钥", + "创建SSH密钥成功": "创建SSH密钥成功", + "创建任务": "创建任务", + "创建任务成功": "创建任务成功", + "创建依赖": "创建依赖", + "创建变量": "创建变量", + "创建变量成功": "创建变量成功", + "创建应用": "创建应用", + "创建应用成功": "创建应用成功", + "创建文件夹成功": "创建文件夹成功", + "创建文件成功": "创建文件成功", + "创建时间": "创建时间", + "创建视图": "创建视图", + "创建订阅": "创建订阅", + "创建订阅成功": "创建订阅成功", + "初始化": "初始化", + "初始化配置": "初始化配置", + "删除": "删除", + "删除Labels成功": "删除Labels成功", + "删除中": "删除中", + "删除失败": "删除失败", + "删除成功": "删除成功", + "删除标签成功": "删除标签成功", + "别名": "别名", + "加载中...": "加载中...", + "包含": "包含", + "单实例": "单实例", + "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例", + "单文件": "单文件", + "去登录": "去登录", + "发送模板": "发送模板,支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay", + "发送渠道": "发送渠道,支持wechat,webhook,cp,mail,sms", + "发送的目标,群组或者好友": "发送的目标,群组或者好友", + "发送结果回调地址": "发送结果回调地址,会把推送最终结果通知到这个地址上", + "取消": "取消", + "取消安装": "取消安装", + "取消置顶": "取消置顶", + "只能输入字母数字下划线,且不能以数字开头": "只能输入字母数字下划线,且不能以数字开头", + "可用,你使用的版本为": "可用,你使用的版本为", + "同时删除关联任务和脚本": "同时删除关联任务和脚本", + "名称": "名称", + "名称不能为保留关键字": "名称不能为保留关键字", + "吗": "吗", + "否": "否", + "启动": "启动", + "启动中...": "启动中...", + "启动中,请稍后...": "启动中,请稍后...", + "启用": "启用", + "命令": "命令", + "命令/脚本": "命令/脚本", + "唯一值": "唯一值", + "唯一值用于日志目录和私钥别名": "唯一值用于日志目录和私钥别名", + "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。", + "基础数据": "基础数据", + "堆内存": "堆内存", + "备份": "备份", + "备份数据上传成功,确认覆盖数据": "备份数据上传成功,确认覆盖数据", + "备注": "备注", + "复制": "复制", + "复制成功": "复制成功", + "多个依赖是否换行分割": "多个依赖是否换行分割", + "多个关键词竖线分割,支持正则表达式": "多个关键词竖线分割,支持正则表达式", + "多实例": "多实例", + "天": "天", + "失败": "失败", + "失败,请检查": "失败,请检查", + "头像": "头像", + "夹下所有日志": "夹下所有日志", + "夹及其子文件": "夹及其子文件", + "好友": "好友", + "好友令牌": "好友令牌,微信公众号渠道填写好友令牌,企业微信渠道填写企业微信用户id", + "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群": "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群", "如果恢复失败,可进入容器执行": "如果恢复失败,可进入容器执行", + "安全设置": "安全设置", + "安装": "安装", + "安装中": "安装中", + "安装失败": "安装失败", + "完成安装": "完成安装", + "完成设置": "完成设置", + "定时": "定时", + "定时任务": "定时任务", + "定时任务并发数": "定时任务并发数", + "定时类型": "定时类型", + "定时规则": "定时规则", + "定时订阅": "定时订阅", + "实例模式": "实例模式", + "实时运行态": "实时运行态", + "密码": "密码", + "密码/Token": "密码/Token", + "密码不能为admin": "密码不能为admin", + "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql": "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql", + "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串": "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串", + "对比工具": "对比工具", + "导入": "导入", + "属于": "属于", + "工作目录": "工作目录", + "已关闭": "已关闭", + "已删除": "已删除", + "已取消": "已取消", + "已启用": "已启用", + "已安装": "已安装", + "已禁用": "已禁用", + "实例已停止": "实例已停止", + "已经是最新版了!": "已经是最新版了!", + "已运行": "已运行", + "已选择": "已选择", "常规定时": "常规定时", - "手动运行": "手动运行", + "平台": "平台", + "平均耗时": "平均耗时", + "序号": "序号", + "应用设置": "应用设置", + "开发版": "开发版", + "开始备份": "开始备份", + "开始安装": "开始安装", + "开始时间": "开始时间", "开机运行": "开机运行", - "时区": "时区", + "强制删除": "强制删除", "强制打开": "强制打开", "强制打开可能会导致编辑器显示异常": "强制打开可能会导致编辑器显示异常", - "确认离开": "确认离开", + "当前修改未保存,确定离开吗": "当前修改未保存,确定离开吗", + "当前文件": "当前文件", + "当前文件不支持预览": "当前文件不支持预览", "当前文件未保存,确认离开吗": "当前文件未保存,确认离开吗", + "待开发": "待开发", + "微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/": "微信扫码登录后一对一推送或一对多推送下面的token(你的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/", + "微加机器人": "微加机器人", + "总任务": "总任务", + "总执行": "总执行", + "恭喜安装完成!": "恭喜安装完成!", + "您输入的两个密码不匹配!": "您输入的两个密码不匹配!", + "成功": "成功", + "成功率": "成功率", + "或": "或", + "手动运行": "手动运行", + "执行前": "执行前", + "执行后": "执行后", + "执行结束": "执行结束", + "批量": "批量", + "批量修改变量名称": "批量修改变量名称", + "批量修改标签": "批量修改标签", + "批量停止": "批量停止", + "批量删除": "批量删除", + "批量删除成功": "批量删除成功", + "批量取消置顶": "批量取消置顶", + "批量启用": "批量启用", + "批量安装": "批量安装", + "批量导出": "批量导出", + "批量强制删除": "批量强制删除", + "批量禁用": "批量禁用", + "批量置顶": "批量置顶", + "批量运行": "批量运行", + "拉取方式": "拉取方式", + "排序方式": "排序方式", + "排队中": "排队中", + "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg", + "推送消息的优先级": "推送消息的优先级", + "提交": "提交", + "提交 issue": "提交 issue", + "提交BUG": "提交BUG", + "操作": "操作", + "支持python3、javascript、shell、typescript 的定时任务管理面板": "支持python3、javascript、shell、typescript 的定时任务管理面板", + "支持拷贝 ql repo/raw 命令,粘贴导入": "支持拷贝 ql repo/raw 命令,粘贴导入", + "支持输入脚本路径/任意系统可执行命令/task 脚本路径": "支持输入脚本路径/任意系统可执行命令/task 脚本路径", "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址", - "选择备份模块": "选择备份模块", - "开始备份": "开始备份", - "基础数据": "基础数据", - "脚本文件": "脚本文件", + "数据备份还原": "数据备份还原", + "文件": "文件", + "文件名": "文件名", + "文件名不能包含斜杠": "文件名不能包含斜杠", + "文件后缀": "文件后缀", + "文件夹": "文件夹", + "文件夹名": "文件夹名", + "新增定时规则": "新增定时规则", + "新增排序方式": "新增排序方式", + "新增筛选条件": "新增筛选条件", + "新建": "新建", + "新版本": "新版本", + "日志": "日志", + "日志 -": "日志 -", + "日志删除频率": "日志删除频率", + "日志名称": "日志名称", + "日志名称不能超过100个字符": "日志名称不能超过100个字符", + "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", "日志文件": "日志文件", - "依赖缓存": "依赖缓存", - "远程脚本缓存": "远程脚本缓存", - "远程仓库缓存": "远程仓库缓存", - "SSH 文件缓存": "SSH 文件缓存", + "日志管理": "日志管理", + "时": "时", + "时区": "时区", + "是": "是", + "是目前检测到的最新可用版本了。": "是目前检测到的最新可用版本了。", + "显示": "显示", + "智能微秘书": "智能微秘书", + "暂无数据": "暂无数据", + "暂无日志": "暂无日志", + "暂无脚本": "暂无脚本", + "暂无运行中任务": "暂无运行中任务", + "暗色": "暗色", + "更多": "更多", + "更换头像": "更换头像", + "更新": "更新", + "更新SSH密钥成功": "更新SSH密钥成功", + "更新任务成功": "更新任务成功", + "更新包下载成功": "更新包下载成功", + "更新变量成功": "更新变量成功", + "更新可用": "更新可用", + "更新名称成功": "更新名称成功", + "更新失败,请检查网络及日志或稍后再试": "更新失败,请检查网络及日志或稍后再试", + "更新应用成功": "更新应用成功", + "更新成功": "更新成功", + "更新日志": "更新日志", + "更新时间": "更新时间", + "更新环境变量名称成功": "更新环境变量名称成功", + "更新订阅成功": "更新订阅成功", + "最后运行时长": "最后运行时长", + "最后运行时间": "最后运行时间", + "最小是 4": "最小是 4", + "最长单次": "最长单次", + "服务启动超时": "服务启动超时", + "未启用": "未启用", + "未找到": "未找到", + "本地文件": "本地文件", + "权限": "权限", + "查看": "查看", + "查看日志": "查看日志", + "标签": "标签", + "标签统计": "标签统计", + "检查更新": "检查更新", + "检查更新中...": "检查更新中...", + "次数": "次数", + "欢迎使用": "欢迎使用", + "欢迎使用青龙": "欢迎使用青龙", + "正式版": "正式版", + "每": "每", + "每x天自动删除x天以前的日志": "每x天自动删除x天以前的日志", + "测试中...": "测试中...", + "消息接收人": "消息接收人", + "添加": "添加", + "添加Labels成功": "添加Labels成功", + "添加标签成功": "添加标签成功", + "清空日志": "清空日志", "清除依赖缓存": "清除依赖缓存", "清除成功": "清除成功", - "日志名称": "日志名称", - "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", - "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null", - "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内", - "请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称", - "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", - "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", - "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", - "日志名称不能超过100个字符": "日志名称不能超过100个字符", - "未启用": "未启用", - "默认为 CPU 个数": "默认为 CPU 个数", - "最小是 4": "最小是 4", - "实例模式": "实例模式", - "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例", - "请选择实例模式": "请选择实例模式", - "单实例": "单实例", - "多实例": "多实例", - "SSH密钥": "SSH密钥", - "别名": "别名", + "源文件": "源文件", + "激活成功": "激活成功", + "点击或者拖拽文件到此区域上传": "点击或者拖拽文件到此区域上传", + "父目录": "父目录", + "版本": "版本", + "状态": "状态", + "环境变量": "环境变量", + "生成数据中...": "生成数据中...", + "用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥", + "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/", + "用户名": "用户名", + "用户名密码/Token": "用户名密码/Token", + "留空自动检测,或输入相对/绝对路径": "留空自动检测,或输入相对/绝对路径", + "登录": "登录", + "登录IP": "登录IP", + "登录地址": "登录地址", + "登录已过期,请重新登录": "登录已过期,请重新登录", + "登录成功!": "登录成功!", + "登录日志": "登录日志", + "登录时间": "登录时间", + "登录状态": "登录状态", + "登录设备": "登录设备", + "白名单": "白名单", + "的Secret吗": "的Secret吗", + "知道了": "知道了", + "确认": "确认", + "确认保存文件": "确认保存文件", + "确认停止": "确认停止", + "确认停止定时任务": "确认停止定时任务", + "确认停止实例": "确认停止实例", + "确认停止运行实例": "确认停止运行实例", + "确认删除": "确认删除", + "确认删除SSH密钥": "确认删除SSH密钥", + "确认删除依赖": "确认删除依赖", + "确认删除变量": "确认删除变量", + "确认删除定时任务": "确认删除定时任务", + "确认删除定时订阅": "确认删除定时订阅", + "确认删除应用": "确认删除应用", + "确认删除视图": "确认删除视图", + "确认删除选中的依赖吗": "确认删除选中的依赖吗", + "确认删除选中的变量吗": "确认删除选中的变量吗", + "确认删除选中的定时任务吗": "确认删除选中的定时任务吗", + "确认取消安装": "确认取消安装", + "确认密码": "确认密码", + "确认离开": "确认离开", + "确认运行": "确认运行", + "确认运行定时任务": "确认运行定时任务", + "确认重启": "确认重启", + "确认重新安装": "确认重新安装", + "确认重新安装选中的依赖吗": "确认重新安装选中的依赖吗", + "确认重置": "确认重置", + "确认重置应用": "确认重置应用", + "禁用": "禁用", + "私有仓库": "私有仓库", + "私钥": "私钥", + "秒": "秒", + "秒(可选) 分 时 天 月 周": "秒(可选) 分 时 天 月 周", + "秒后自动刷新": "秒后自动刷新", + "秒后重试": "秒后重试", + "空文件": "空文件", + "空闲中": "空闲中", + "第一步": "第一步", + "第三步": "第三步", + "第二步": "第二步", + "筛选条件": "筛选条件", + "类型": "类型", + "系统": "系统", + "系统信息": "系统信息", + "系统安装包下载成功,确认重启": "系统安装包下载成功,确认重启", + "系统将在": "系统将在", + "系统日志": "系统日志", + "系统设置": "系统设置", + "系统资源": "系统资源", + "系统运行": "系统运行", + "结束时间": "结束时间", + "编辑": "编辑", "编辑SSH密钥": "编辑SSH密钥", - "创建SSH密钥": "创建SSH密钥", - "更新SSH密钥成功": "更新SSH密钥成功", - "创建SSH密钥成功": "创建SSH密钥成功", + "编辑任务": "编辑任务", + "编辑依赖": "编辑依赖", + "编辑变量": "编辑变量", + "编辑应用": "编辑应用", + "编辑视图": "编辑视图", + "编辑订阅": "编辑订阅", + "置顶": "置顶", + "群晖chat": "群晖chat", + "群聊": "群聊", + "脚本": "脚本", + "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径", + "脚本文件": "脚本文件", + "脚本管理": "脚本管理", + "自动删除任务": "自动删除任务", + "自动拆分": "自动拆分", + "自动添加任务": "自动添加任务", + "自动生成": "自动生成", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null", + "自定义通知": "自定义通知", + "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口", + "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称": "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称", + "视图名称": "视图名称", + "视图管理": "视图管理", + "订阅": "订阅", + "订阅管理": "订阅管理", + "设置": "设置", + "访问密钥": "访问密钥", + "语言": "语言", + "请": "请", + "请先按如下方式修复:": "请先按如下方式修复:", + "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置", + "请求头Content-Type": "请求头Content-Type", + "请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割", + "请求方法": "请求方法", + "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置", + "请至少输入一个标签": "请至少输入一个标签", + "请输入": "请输入", "请输入SSH密钥别名": "请输入SSH密钥别名", "请输入SSH私钥": "请输入SSH私钥", "请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)", - "确认删除SSH密钥": "确认删除SSH密钥", - "批量": "批量", - "全局SSH私钥": "全局SSH私钥", - "用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥", + "请输入任务名称": "请输入任务名称", + "请输入依赖名称": "请输入依赖名称", + "请输入依赖名称,支持指定版本": "请输入依赖名称,支持指定版本", + "请输入保存目录,默认scripts目录": "请输入保存目录,默认scripts目录", + "请输入内容": "请输入内容", + "请输入分支": "请输入分支", + "请输入名称": "请输入名称", + "请输入名称/值/备注": "请输入名称/值/备注", + "请输入名称或者关键词": "请输入名称或者关键词", + "请输入备注": "请输入备注", "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容", - "仪表盘": "仪表盘", - "总任务": "总任务", - "今日执行": "今日执行", - "今日成功": "今日成功", - "今日失败": "今日失败", - "成功率": "成功率", - "平均耗时": "平均耗时", - "近 7 日趋势": "近 7 日趋势", - "今日耗时 Top 5": "今日耗时 Top 5", - "今日执行次数 Top 5": "今日执行次数 Top 5", - "实时运行态": "实时运行态", - "系统资源": "系统资源", - "排队中": "排队中", - "暂无运行中任务": "暂无运行中任务", - "暂无数据": "暂无数据", - "总执行": "总执行", - "次数": "次数", - "最长单次": "最长单次", - "已运行": "已运行", - "系统运行": "系统运行", - "内存使用": "内存使用", - "堆内存": "堆内存", - "CPU 核心": "CPU 核心", + "请输入密码或者Token": "请输入密码或者Token", + "请输入应用名称": "请输入应用名称", + "请输入文件名": "请输入文件名", + "请输入文件后缀": "请输入文件后缀", + "请输入文件夹名": "请输入文件夹名", + "请输入新名称": "请输入新名称", + "请输入新的环境变量名称": "请输入新的环境变量名称", + "请输入日志名": "请输入日志名", + "请输入环境变量值": "请输入环境变量值", + "请输入环境变量名称": "请输入环境变量名称", + "请输入私钥": "请输入私钥", + "请输入脚本依赖文件关键词,多个关键词竖线分割": "请输入脚本依赖文件关键词,多个关键词竖线分割", + "请输入脚本名": "请输入脚本名", + "请输入脚本筛选白名单关键词,多个关键词竖线分割": "请输入脚本筛选白名单关键词,多个关键词竖线分割", + "请输入脚本筛选黑名单关键词,多个关键词竖线分割": "请输入脚本筛选黑名单关键词,多个关键词竖线分割", + "请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称", + "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", + "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", + "请输入要发送的目标": "请输入要发送的目标", + "请输入视图名称": "请输入视图名称", + "请输入订阅链接": "请输入订阅链接", + "请输入认证用户名": "请输入认证用户名", + "请输入运行任务前要执行的命令,不能包含 task 命令": "请输入运行任务前要执行的命令,不能包含 task 命令", + "请输入运行任务后要执行的命令,不能包含 task 命令": "请输入运行任务后要执行的命令,不能包含 task 命令", + "请输入运行订阅前要执行的命令": "请输入运行订阅前要执行的命令", + "请输入运行订阅后要执行的命令": "请输入运行订阅后要执行的命令", + "请选择": "请选择", + "请选择实例模式": "请选择实例模式", + "请选择操作符": "请选择操作符", + "请选择日志": "请选择日志", + "请选择日志文件": "请选择日志文件", + "请选择模块权限": "请选择模块权限", + "请选择父目录": "请选择父目录", + "请选择脚本": "请选择脚本", + "请选择脚本文件": "请选择脚本文件", + "请选择通知方式": "请选择通知方式", + "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版", + "调试": "调试", "负载 1m": "负载 1m", - "平台": "平台", - "24小时未运行": "24小时未运行", - "标签统计": "标签统计", - "任务数": "任务数", - "内存": "内存" + "账户设置": "账户设置", + "跟随系统": "跟随系统", + "跳过": "跳过", + "输入后回车增加自定义选项": "输入后回车增加自定义选项", + "输入手机应用上的6位数字": "输入手机应用上的6位数字", + "运行": "运行", + "运行中": "运行中", + "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", + "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", + "运行实例": "运行实例", + "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", + "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", + "运行设置": "运行设置", + "近 7 日趋势": "近 7 日趋势", + "返回首页": "返回首页", + "还原数据": "还原数据", + "远程仓库缓存": "远程仓库缓存", + "远程脚本缓存": "远程脚本缓存", + "退出": "退出", + "退出登录": "退出登录", + "退出编辑": "退出编辑", + "选中的变量吗": "选中的变量吗", + "选中的定时任务吗": "选中的定时任务吗", + "选择备份模块": "选择备份模块", + "通知关闭成功": "通知关闭成功", + "通知发送成功": "通知发送成功", + "通知方式": "通知方式", + "通知设置": "通知设置", + "邮箱": "邮箱", + "邮箱地址": "邮箱地址", + "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", + "配置文件": "配置文件", + "重启": "重启", + "重命名": "重命名", + "重新下载": "重新下载", + "重新启动": "重新启动", + "重新安装": "重新安装", + "重置Secret会让当前应用所有token失效": "重置Secret会让当前应用所有token失效", + "重置secret": "重置secret", + "重置成功": "重置成功", + "钉钉机器人": "钉钉机器人", + "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd": "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd", + "链接": "链接", + "错误": "错误", + "错误日志": "错误日志", + "队列中": "队列中", + "青龙": "青龙", + "项": "项", + "顺序": "顺序", + "飞书机器人": "飞书机器人", + "飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得", + "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973", + "验证": "验证", + "验证失败": "验证失败", + "验证码": "验证码", + "验证码为6位数字": "验证码为6位数字", + "黑名单": "黑名单", + "默认为 CPU 个数": "默认为 CPU 个数", + ",保存后不可恢复": ",保存后不可恢复", + ",删除后不可恢复": ",删除后不可恢复" } diff --git a/src/pages/crontab/detail.tsx b/src/pages/crontab/detail.tsx index 42dd4d8f..880180da 100644 --- a/src/pages/crontab/detail.tsx +++ b/src/pages/crontab/detail.tsx @@ -22,6 +22,7 @@ import { PlayCircleOutlined, PauseCircleOutlined, FullscreenOutlined, + StopOutlined, } from '@ant-design/icons'; import { CrontabStatus } from './type'; import { diffTime } from '@/utils/date'; @@ -46,6 +47,10 @@ const tabList = [ key: 'script', tab: intl.get('脚本'), }, + { + key: 'runningInstance', + tab: intl.get('运行实例'), + }, ]; interface LogItem { @@ -77,6 +82,36 @@ const CronDetailModal = ({ const [currentCron, setCurrentCron] = useState({}); const listRef = useRef(null); const tableScrollHeight = useScrollHeight(listRef); + const [runningInstances, setRunningInstances] = useState([]); + const needRefreshRef = useRef(false); + + const fetchRunningInstances = async () => { + if (!cron.id) return Promise.resolve(); + return request + .get(`${config.apiPrefix}crons/${cron.id}/instances`) + .then(({ code, data }) => { + if (code === 200 && data) { + setRunningInstances(data); + } + }) + .catch(() => { }); + }; + + useEffect(() => { + fetchRunningInstances(); + let timer: ReturnType; + let cancelled = false; + const poll = async () => { + await fetchRunningInstances(); + if (cancelled) return; + timer = setTimeout(poll, 10000); + }; + poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [cron.id]); const contentList: any = { log: ( @@ -115,12 +150,107 @@ const CronDetailModal = ({ }} /> ), + runningInstance: ( +
+ + + {(item) => ( + +
+ ), + }; + + const stopRunningInstance = (instance: any) => { + Modal.confirm({ + title: intl.get('确认停止实例'), + content: ( + <> + {intl.get('确认停止运行实例')} PID: {instance.pid}{' '} + {intl.get('吗')} + + ), + onOk() { + request + .post(`${config.apiPrefix}crons/${cron.id}/instances/${instance.id}/stop`) + .then(({ code }) => { + if (code === 200) { + message.success(intl.get('实例已停止')); + needRefreshRef.current = true; + fetchRunningInstances(); + setTimeout(() => getLogs(), 1000); + } + }); + }, + }); + }; + + const viewInstanceLog = (instance: any) => { + if (!instance.log_path) return; + const parts = instance.log_path.split('/'); + const filename = parts.pop() || ''; + const directory = parts.join('/'); + const url = `${config.apiPrefix}logs/detail?file=${filename}&path=${directory}`; + localStorage.setItem('logCron', url); + setLogUrl(url); + request.get(url).then(({ code, data }) => { + if (code === 200) { + setLog(data); + setIsLogModalVisible(true); + } + }); }; const onClickItem = (item: LogItem) => { - const url = `${config.apiPrefix}logs/detail?file=${item.filename}&path=${ - item.directory || '' - }`; + const url = `${config.apiPrefix}logs/detail?file=${item.filename}&path=${item.directory || '' + }`; localStorage.setItem('logCron', url); setLogUrl(url); request.get(url).then(({ code, data }) => { @@ -256,9 +386,8 @@ const CronDetailModal = ({ const enabledOrDisabledCron = () => { Modal.confirm({ - title: `确认${ - currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') - }`, + title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') + }`, content: ( <> {intl.get('确认')} @@ -273,8 +402,7 @@ const CronDetailModal = ({ onOk() { request .put( - `${config.apiPrefix}crons/${ - currentCron.isDisabled === 1 ? 'enable' : 'disable' + `${config.apiPrefix}crons/${currentCron.isDisabled === 1 ? 'enable' : 'disable' }`, [currentCron.id], ) @@ -292,9 +420,8 @@ const CronDetailModal = ({ const pinOrUnPinCron = () => { Modal.confirm({ - title: `确认${ - currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') - }`, + title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') + }`, content: ( <> {intl.get('确认')} @@ -309,8 +436,7 @@ const CronDetailModal = ({ onOk() { request .put( - `${config.apiPrefix}crons/${ - currentCron.isPinned === 1 ? 'unpin' : 'pin' + `${config.apiPrefix}crons/${currentCron.isPinned === 1 ? 'unpin' : 'pin' }`, [currentCron.id], ) @@ -441,7 +567,7 @@ const CronDetailModal = ({ open={true} forceRender footer={false} - onCancel={() => handleCancel()} + onCancel={() => handleCancel(needRefreshRef.current)} wrapClassName="crontab-detail" width={!isPhone ? '80vw' : ''} > @@ -458,27 +584,27 @@ const CronDetailModal = ({
{(!currentCron.isDisabled || currentCron.status !== CrontabStatus.idle) && ( - <> - {currentCron.status === CrontabStatus.idle && ( - } color="default"> - {intl.get('空闲中')} - - )} - {currentCron.status === CrontabStatus.running && ( - } - color="processing" - > - {intl.get('运行中')} - - )} - {currentCron.status === CrontabStatus.queued && ( - } color="default"> - {intl.get('队列中')} - - )} - - )} + <> + {currentCron.status === CrontabStatus.idle && ( + } color="default"> + {intl.get('空闲中')} + + )} + {currentCron.status === CrontabStatus.running && ( + } + color="processing" + > + {intl.get('运行中')} + + )} + {currentCron.status === CrontabStatus.queued && ( + } color="default"> + {intl.get('队列中')} + + )} + + )} {currentCron.isDisabled === 1 && currentCron.status === CrontabStatus.idle && ( } color="error"> @@ -503,8 +629,8 @@ const CronDetailModal = ({
{currentCron.last_execution_time ? dayjs(currentCron.last_execution_time * 1000).format( - 'YYYY-MM-DD HH:mm:ss', - ) + 'YYYY-MM-DD HH:mm:ss', + ) : '-'}
diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index 68aa7280..3269ecd4 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -365,8 +365,8 @@ const Crontab = () => { } }; - const getCrons = () => { - setLoading(true); + const getCrons = async (silent?: boolean) => { + if (!silent) setLoading(true); const { page, size, sorter, filters } = pageConf; let url = `${config.apiPrefix }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( @@ -385,7 +385,7 @@ const Crontab = () => { filterRelation: viewConf.filterRelation || 'and', })}`; } - request + return request .get(url) .then(async ({ code, data: _data }) => { if (code === 200) { @@ -419,9 +419,24 @@ const Crontab = () => { setTotal(total); } }) - .finally(() => setLoading(false)); + .finally(() => !silent && setLoading(false)); }; + useEffect(() => { + let timer: ReturnType; + let cancelled = false; + const poll = async () => { + await getCrons(true); + if (cancelled) return; + timer = setTimeout(poll, 10000); + }; + poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [pageConf, viewConf, searchText]); + const addCron = () => { setEditedCron(null as any); setIsModalVisible(true); @@ -809,7 +824,7 @@ const Crontab = () => { setAllSubscriptions(data || []); } }) - .catch(() => {}); + .catch(() => { }); }; useEffect(() => { @@ -1077,8 +1092,11 @@ const Crontab = () => { )} {isDetailModalVisible && ( { + handleCancel={(needUpdate?: boolean) => { setIsDetailModalVisible(false); + if (needUpdate) { + getCrons(); + } }} cron={detailCron} theme={theme} diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index f2aa9afe..6c16d84c 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -46,7 +46,7 @@ interface TopItem { interface Runtime { runningCount: number; queuedCount: number; - running: Array<{ id: number; name: string; pid: number; elapsed: number; logPath: string }>; + running: Array<{ instanceId: number; id: number; name: string; pid: number; elapsed: number; logPath: string }>; idleTasks: Array<{ id: number; name: string; lastRun: string }>; } @@ -282,12 +282,15 @@ const Dashboard = () => { >
5 ? runtimePagination : false} size="small" locale={{ emptyText: }} columns={[ - { title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true }, + { title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true, render: (name: string, record) => { + const sameTaskCount = (runtime?.running || []).filter(r => r.id === record.id).length; + return sameTaskCount > 1 ? {name} ×{sameTaskCount} : name; + } }, { title: 'PID', dataIndex: 'pid', width: 80 }, { title: intl.get('已运行'), dataIndex: 'elapsed', width: 100, render: (v: number) => v ? formatSeconds(v) : '-' }, { title: intl.get('日志'), dataIndex: 'id', width: 60, render: (id, record) => { localStorage.setItem('logCron', String(id)); setLogCron({ id, name: record.name }); }}>{intl.get('查看')} }, From 05f8fd3805a8a8b4d20b8f77d47856628e52d14c Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 11 Jun 2026 02:19:04 +0800 Subject: [PATCH 043/123] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + back/api/config.ts | 3 +- back/api/cron.ts | 3 +- back/api/env.ts | 5 +- back/api/log.ts | 9 ++-- back/api/script.ts | 9 ++-- back/api/system.ts | 18 +++++++ back/config/index.ts | 2 + back/data/system.ts | 1 + back/loaders/initData.ts | 12 +++++ back/services/cron.ts | 3 +- back/services/system.ts | 35 +++++++++++--- back/services/user.ts | 13 ++--- back/shared/i18n.ts | 96 +++++++++++++++++++++++++++++++++++++ shell/share.sh | 1 + src/layouts/index.tsx | 17 +++++++ src/pages/setting/other.tsx | 4 ++ 17 files changed, 206 insertions(+), 26 deletions(-) create mode 100644 back/shared/i18n.ts diff --git a/.gitignore b/.gitignore index c9fc9200..766153eb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,6 @@ __pycache__ /shell/preload/notify.* /shell/preload/*-notify.json /shell/preload/__ql_notify__.* +/shell/preload/lang_env.sh .deepseek/ diff --git a/back/api/config.ts b/back/api/config.ts index 3fe3f463..7d9552a3 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -6,6 +6,7 @@ import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import { join } from 'path'; import { SAMPLE_FILES } from '../config/const'; +import { t } from '../shared/i18n'; import ConfigService from '../services/config'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); @@ -72,7 +73,7 @@ export default (app: Router) => { try { const { name, content } = req.body; if (config.blackFileList.includes(name)) { - res.send({ code: 403, message: '文件无法访问' }); + res.send({ code: 403, message: t('文件无法访问') }); } let path = join(config.configPath, name); if (name.startsWith('data/scripts/')) { diff --git a/back/api/cron.ts b/back/api/cron.ts index 2c1b6609..2ebe802b 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -9,6 +9,7 @@ import { RunningInstanceModel, InstanceStatus, } from '../data/runningInstance'; +import { t } from '../shared/i18n'; const route = Router(); @@ -64,7 +65,7 @@ export default (app: Router) => { try { const cronViewService = Container.get(CronViewService); if (req.body.type === 1) { - return res.send({ code: 400, message: '参数错误' }); + return res.send({ code: 400, message: t('参数错误') }); } else { const data = await cronViewService.update(req.body); return res.send({ code: 200, data }); diff --git a/back/api/env.ts b/back/api/env.ts index e58cd3ae..24ee76b5 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -6,6 +6,7 @@ import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; import { safeJSONParse } from '../config/util'; +import { t } from '../shared/i18n'; import EnvService from '../services/env'; const route = Router(); @@ -57,7 +58,7 @@ export default (app: Router) => { try { const envService = Container.get(EnvService); if (!req.body?.length) { - return res.send({ code: 400, message: '参数不正确' }); + return res.send({ code: 400, message: t('参数不正确') }); } const data = await envService.create(req.body); return res.send({ code: 200, data }); @@ -299,7 +300,7 @@ export default (app: Router) => { } else { return res.send({ code: 400, - message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', + message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'), }); } } catch (e) { diff --git a/back/api/log.ts b/back/api/log.ts index 020d572e..0c1d03e8 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -3,6 +3,7 @@ import { NextFunction, Request, Response, Router } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; +import { t } from '../shared/i18n'; import { getFileContentByName, readDirs, @@ -42,7 +43,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } const content = await getFileContentByName(finalPath); @@ -65,7 +66,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } const content = await getFileContentByName(finalPath); @@ -96,7 +97,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(path)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await rmPath(finalPath); @@ -126,7 +127,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } return res.download(filePath, filename, (err) => { diff --git a/back/api/script.ts b/back/api/script.ts index 1ecce902..8b1faaad 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -7,6 +7,7 @@ import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import path, { join, parse } from 'path'; import ScriptService from '../services/script'; +import { t } from '../shared/i18n'; import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); @@ -155,7 +156,7 @@ export default (app: Router) => { if (config.writePathList.every((x) => !path.startsWith(x))) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } @@ -217,7 +218,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await writeFileWithLock(filePath, content); @@ -251,7 +252,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await rmPath(filePath); @@ -284,7 +285,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } return res.download(filePath, filename, (err) => { diff --git a/back/api/system.ts b/back/api/system.ts index f51c6575..86e9f3d9 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -426,6 +426,24 @@ export default (app: Router) => { }, ); + route.put( + '/config/lang', + celebrate({ + body: Joi.object({ + lang: Joi.string().allow('').allow(null), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const systemService = Container.get(SystemService); + const result = await systemService.updateLanguage(req.body); + res.send(result); + } catch (e) { + return next(e); + } + }, + ); + route.put( '/config/global-ssh-key', celebrate({ diff --git a/back/config/index.ts b/back/config/index.ts index fb1c9ca5..72596f1f 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -110,6 +110,7 @@ const jsEnvFile = path.join(preloadPath, 'env.js'); const pyEnvFile = path.join(preloadPath, 'env.py'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); +const langEnvFile = path.join(preloadPath, 'lang_env.sh'); const confFile = path.join(configPath, 'config.sh'); const crontabFile = path.join(configPath, 'crontab.list'); const authConfigFile = path.join(configPath, 'auth.json'); @@ -155,6 +156,7 @@ export default { pyEnvFile, jsNotifyFile, pyNotifyFile, + langEnvFile, dbPath, uploadPath, configPath, diff --git a/back/data/system.ts b/back/data/system.ts index 2dc14f50..6343c5e1 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -31,6 +31,7 @@ export enum AuthDataType { } export interface SystemConfigInfo { + lang?: string; logRemoveFrequency?: number; cronConcurrency?: number; dependenceProxy?: string; diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index f8dc7758..83ef8cf1 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -19,6 +19,7 @@ import { shareStore } from '../shared/store'; import Logger from './logger'; import { AppModel } from '../data/open'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; +import { setLang } from '../shared/i18n'; export default async () => { const cronService = Container.get(CronService); @@ -224,6 +225,17 @@ export default async () => { // 初始化保存一次ck和定时任务数据 await cronService.autosave_crontab(); + + // 确保 lang_env.sh 存在,提供默认 QL_LANG + try { + const langEnvExist = await fileExist(config.langEnvFile); + if (!langEnvExist) { + const lang = systemConfig.info?.lang || 'zh'; + await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`); + } + } catch {} + setLang(systemConfig.info?.lang || 'zh'); + await envService.set_envs(); const authInfo = await userService.getAuthInfo(); diff --git a/back/services/cron.ts b/back/services/cron.ts index f2a80276..ee14a669 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -28,6 +28,7 @@ import dayjs from 'dayjs'; import pickBy from 'lodash/pickBy'; import omit from 'lodash/omit'; import { writeFileWithLock } from '../shared/utils'; +import { t } from '../shared/i18n'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; @@ -550,7 +551,7 @@ export default class CronService { where: { id: instanceId, status: InstanceStatus.running }, }); if (!instance) { - return { code: 400, message: '实例不存在或已停止' }; + return { code: 400, message: t('实例不存在或已停止') }; } if (instance.pid) { try { diff --git a/back/services/system.ts b/back/services/system.ts index 97b500f1..4f658622 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -37,6 +37,7 @@ import ScheduleService, { TaskCallbacks } from './schedule'; import SockService from './sock'; import os from 'os'; import dayjs from 'dayjs'; +import { t, setLang } from '../shared/i18n'; import { updateLinuxMirrorFile } from '../config/util'; @Service() @@ -87,7 +88,7 @@ export default class SystemService { }); return { code: 200, data: { ...result, code } }; } else { - return { code: 400, message: '通知发送失败,请检查参数' }; + return { code: 400, message: t('通知发送失败,请检查参数') }; } } @@ -381,9 +382,9 @@ export default class SystemService { notificationInfo, ); if (isSuccess) { - return { code: 200, message: '通知发送成功' }; + return { code: 200, message: t('通知发送成功') }; } else { - return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; + return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') }; } } @@ -401,7 +402,7 @@ export default class SystemService { public async stop({ command, pid }: { command: string; pid: number }) { if (!pid && !command) { - return { code: 400, message: '参数错误' }; + return { code: 400, message: t('参数错误') }; } if (pid) { @@ -417,7 +418,7 @@ export default class SystemService { await killTask(_pid); return { code: 200 }; } else { - return { code: 400, message: '任务未找到' }; + return { code: 400, message: t('任务未找到') }; } } @@ -512,10 +513,30 @@ export default class SystemService { if (success) { return { code: 200, data: info }; } else { - return { code: 400, message: '设置时区失败' }; + return { code: 400, message: t('设置时区失败') }; } } + public async updateLanguage(info: SystemModelInfo) { + const oDoc = await this.getSystemConfig(); + const lang = info.lang || 'zh'; + await this.updateAuthDb({ + ...oDoc, + info: { ...oDoc.info, lang }, + }); + // Write to standalone lang_env.sh, sourced by shell scripts + try { + await fs.promises.writeFile( + config.langEnvFile, + `export QL_LANG='${lang}'\n`, + ); + } catch (error) { + this.logger.error(`Failed to write lang_env.sh: ${error}`); + } + setLang(lang); + return { code: 200, data: { lang } }; + } + public async updateGlobalSshKey(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ @@ -539,7 +560,7 @@ export default class SystemService { public async cleanDependence(type: 'node' | 'python3') { if (!type || !['node', 'python3'].includes(type)) { - return { code: 400, message: '参数错误' }; + return { code: 400, message: t('参数错误') }; } try { const finalPath = path.join(config.dependenceCachePath, type); diff --git a/back/services/user.ts b/back/services/user.ts index 362cb693..31d66864 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -25,6 +25,7 @@ import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; +import { t } from '../shared/i18n'; @Service() export default class UserService { @@ -258,17 +259,17 @@ export default class UserService { password: string; }) { if (password === 'admin') { - return { code: 400, message: '密码不能设置为admin' }; + return { code: 400, message: t('密码不能设置为admin') }; } const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { username, password }); - return { code: 200, message: '更新成功' }; + return { code: 200, message: t('更新成功') }; } public async updateAvatar(avatar: string) { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { avatar }); - return { code: 200, data: avatar, message: '更新成功' }; + return { code: 200, data: avatar, message: t('更新成功') }; } public async initTwoFactor() { @@ -302,7 +303,7 @@ export default class UserService { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; if (!isTwoFactorChecking) { - return { code: 450, message: '未知错误' }; + return { code: 450, message: t('未知错误') }; } const isValid = authenticator.verify({ token: code, @@ -326,7 +327,7 @@ export default class UserService { lastaddr: address, platform: req.platform, }); - return { code: 430, message: '验证失败' }; + return { code: 430, message: t('验证失败') }; } } @@ -398,7 +399,7 @@ export default class UserService { }); return { code: 200, data: { ...result, code } }; } else { - return { code: 400, message: '通知发送失败,请检查参数' }; + return { code: 400, message: t('通知发送失败,请检查参数') }; } } diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts new file mode 100644 index 00000000..e260633f --- /dev/null +++ b/back/shared/i18n.ts @@ -0,0 +1,96 @@ +const messages: Record> = { + zh: {}, + en: { + '暂无权限': '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', + '备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite', + '如果恢复失败,可进入容器执行': 'If restore fails, run in container:', + '系统将在': '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', + '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', + }, +}; + +let currentLang: string = 'zh'; + +export function setLang(lang: string) { + currentLang = lang || 'zh'; +} + +export function getLang(): string { + return currentLang; +} + +export function t(key: string, lang?: string): string { + const effectiveLang = lang || currentLang; + if (effectiveLang === 'en' && messages.en[key]) { + return messages.en[key]; + } + return key; +} diff --git a/shell/share.sh b/shell/share.sh index 42f1254e..1fb3e93c 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -78,6 +78,7 @@ load_ql_envs() { import_config() { [[ -f $file_config_user ]] && . $file_config_user + [[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh load_ql_envs command_timeout_time=${CommandTimeoutTime:-""} diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 9e917002..abc5c759 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -134,6 +134,23 @@ export default function () { getHealthStatus(); }, []); + useEffect(() => { + request + .get(`${config.apiPrefix}system/config`) + .then(({ data }: any) => { + if (!data?.info?.lang) { + const browserLang = + localStorage.getItem('lang') || + navigator.language?.slice(0, 2) || + 'zh'; + request + .put(`${config.apiPrefix}system/config/lang`, { lang: browserLang }) + .catch(() => {}); + } + }) + .catch(() => {}); + }, []); + useEffect(() => { if (theme === 'vs-dark') { document.body.setAttribute('data-dark', 'true'); diff --git a/src/pages/setting/other.tsx b/src/pages/setting/other.tsx index e2ef1973..8ff0674f 100644 --- a/src/pages/setting/other.tsx +++ b/src/pages/setting/other.tsx @@ -89,6 +89,10 @@ const Other = ({ const handleLangChange = (v: string) => { localStorage.setItem('lang', v); + const backendLang = v || navigator.language?.slice(0, 2) || 'zh'; + request + .put(`${config.apiPrefix}system/config/lang`, { lang: backendLang }) + .catch(() => {}); setTimeout(() => { window.location.reload(); }, 500); From 67960685230839df62bf2a9f7f867e096cd43b87 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 11 Jun 2026 23:18:45 +0800 Subject: [PATCH 044/123] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9B=BD=E9=99=85?= =?UTF-8?q?=E5=8C=96=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/config.ts | 4 ++-- back/api/system.ts | 3 ++- back/api/user.ts | 7 ++++--- back/loaders/express.ts | 3 ++- back/services/config.ts | 3 ++- back/services/cron.ts | 2 +- back/services/notify.ts | 5 +++-- back/services/open.ts | 3 ++- back/services/subscription.ts | 3 ++- back/shared/i18n.ts | 6 ++++++ 10 files changed, 26 insertions(+), 13 deletions(-) diff --git a/back/api/config.ts b/back/api/config.ts index 7d9552a3..f40a8cab 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -83,10 +83,10 @@ export default (app: Router) => { !path.startsWith(config.configPath) && !path.startsWith(config.scriptPath) ) { - return res.send({ code: 403, message: '文件路径无效' }); + return res.send({ code: 403, message: t('文件路径无效') }); } await writeFileWithLock(path, content); - res.send({ code: 200, message: '保存成功' }); + res.send({ code: 200, message: t('保存成功') }); } catch (e) { return next(e); } diff --git a/back/api/system.ts b/back/api/system.ts index 86e9f3d9..cf662c30 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -6,6 +6,7 @@ import config from '../config'; import SystemService from '../services/system'; import { celebrate, Joi } from 'celebrate'; import UserService from '../services/user'; +import { t } from '../shared/i18n'; import { getUniqPath, handleLogPath, @@ -401,7 +402,7 @@ export default (app: Router) => { try { const userService = Container.get(UserService); await userService.resetAuthInfo(req.body); - res.send({ code: 200, message: '更新成功' }); + res.send({ code: 200, message: t('更新成功') }); } catch (e) { return next(e); } diff --git a/back/api/user.ts b/back/api/user.ts index 6155f0a4..9e284dba 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -8,6 +8,7 @@ import path from 'path'; import { v4 as uuidV4 } from 'uuid'; import rateLimit from 'express-rate-limit'; import config from '../config'; +import { t } from '../shared/i18n'; import { isDemoEnv, getToken } from '../config/util'; const route = Router(); @@ -76,11 +77,11 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { if (isDemoEnv()) { - return res.send({ code: 450, message: '未知错误' }); + return res.send({ code: 450, message: t('未知错误') }); } const userService = Container.get(UserService); await userService.updateUsernameAndPassword(req.body); - res.send({ code: 200, message: '更新成功' }); + res.send({ code: 200, message: t('更新成功') }); } catch (e) { return next(e); } @@ -229,7 +230,7 @@ export default (app: Router) => { try { const userService = Container.get(UserService); await userService.updateUsernameAndPassword(req.body); - res.send({ code: 200, message: '更新成功' }); + res.send({ code: 200, message: t('更新成功') }); } catch (e) { return next(e); } diff --git a/back/loaders/express.ts b/back/loaders/express.ts index b5bf221b..6b322e93 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -11,6 +11,7 @@ import { serveEnv } from '../config/serverEnv'; import { IKeyvStore, shareStore } from '../shared/store'; import { isValidToken } from '../shared/auth'; import path from 'path'; +import { t } from '../shared/i18n'; export default ({ app }: { app: Application }) => { // Security: Enable strict routing to prevent case-insensitive path bypass @@ -143,7 +144,7 @@ export default ({ app }: { app: Application }) => { } if (isInitialized) { - return res.send({ code: 450, message: '未知错误' }); + return res.send({ code: 450, message: t('未知错误') }); } else { return next(); } diff --git a/back/services/config.ts b/back/services/config.ts index a12a9419..7e7e5985 100644 --- a/back/services/config.ts +++ b/back/services/config.ts @@ -2,6 +2,7 @@ import { Service, Inject } from 'typedi'; import path, { join } from 'path'; import config from '../config'; import { getFileContentByName } from '../config/util'; +import { t } from '../shared/i18n'; import { Response } from 'express'; import { request } from 'undici'; @@ -23,7 +24,7 @@ export default class ConfigService { ) || !filePath ) { - return res.send({ code: 403, message: '文件无法访问' }); + return res.send({ code: 403, message: t('文件无法访问') }); } if (filePath.startsWith('sample/')) { diff --git a/back/services/cron.ts b/back/services/cron.ts index ee14a669..5a4bd774 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -577,7 +577,7 @@ export default class CronService { { where: { id: instance.cron_id } }, ); } - return { code: 200, message: '实例已停止' }; + return { code: 200, message: t('实例已停止') }; } private async runSingle(cronId: number): Promise { diff --git a/back/services/notify.ts b/back/services/notify.ts index 39a0e47b..abf1f96c 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -3,6 +3,7 @@ import nodemailer from 'nodemailer'; import { Inject, Service } from 'typedi'; import { parseBody, parseHeaders } from '../config/util'; import { NotificationInfo } from '../data/notify'; +import { t } from '../shared/i18n'; import UserService from './user'; import { httpClient } from '../config/http'; import { ProxyAgent } from 'undici'; @@ -727,7 +728,7 @@ export default class NotificationService { // topic_ids 和 uids 至少要有一个 if (!topicIds.length && !uids.length) { - throw new Error('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'); + throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行')); } const url = `https://wxpusher.zjiecode.com/api/send/message`; @@ -823,7 +824,7 @@ export default class NotificationService { } = this.params; if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) { - throw new Error('Url 或者 Body 中必须包含 $title'); + throw new Error(t('Url 或者 Body 中必须包含 $title')); } const headers = parseHeaders(webhookHeaders); diff --git a/back/services/open.ts b/back/services/open.ts index 59dcc5bc..19d846c0 100644 --- a/back/services/open.ts +++ b/back/services/open.ts @@ -5,6 +5,7 @@ import { App, AppModel } from '../data/open'; import { v4 as uuidV4 } from 'uuid'; import sequelize, { Op } from 'sequelize'; import { shareStore } from '../shared/store'; +import { t } from '../shared/i18n'; @Service() export default class OpenService { @@ -151,7 +152,7 @@ export default class OpenService { }, }; } else { - return { code: 400, message: 'client_id 或 client_seret 有误' }; + return { code: 400, message: t('client_id 或 client_seret 有误') }; } } diff --git a/back/services/subscription.ts b/back/services/subscription.ts index 1a8638f8..ed7f5b40 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -24,6 +24,7 @@ import path, { join } from 'path'; import ScheduleService, { TaskCallbacks } from './schedule'; import { SimpleIntervalSchedule } from 'toad-scheduler'; import SockService from './sock'; +import { t } from '../shared/i18n'; import SshKeyService from './sshKey'; import dayjs from 'dayjs'; import { LOG_END_SYMBOL } from '../config/const'; @@ -192,7 +193,7 @@ export default class SubscriptionService { this.sockService.sendMessage({ type: 'runSubscriptionEnd', - message: '订阅执行完成', + message: t('订阅执行完成'), references: [doc.id as number], }); }, diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index e260633f..04eaa769 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -74,6 +74,12 @@ const messages: Record> = { '用户名不能为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', + 'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title', }, }; From d1dfde3ca9481ab22c4eeb206222eeb2caa7db8a Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 12 Jun 2026 23:45:40 +0800 Subject: [PATCH 045/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E5=8F=AF=E8=83=BD=E8=B6=8A?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/config.ts | 25 ++++++++++++++----------- back/services/config.ts | 30 ++++++++++++++++++------------ 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/back/api/config.ts b/back/api/config.ts index f40a8cab..23084cfe 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -4,7 +4,7 @@ import { Logger } from 'winston'; import config from '../config'; import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; -import { join } from 'path'; +import { join, basename } from 'path'; import { SAMPLE_FILES } from '../config/const'; import { t } from '../shared/i18n'; import ConfigService from '../services/config'; @@ -72,20 +72,23 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const { name, content } = req.body; - if (config.blackFileList.includes(name)) { - res.send({ code: 403, message: t('文件无法访问') }); - } - let path = join(config.configPath, name); + // Resolve path first to prevent traversal attacks + let basePath = config.configPath; if (name.startsWith('data/scripts/')) { - path = join(config.rootPath, name); + basePath = join(config.rootPath, 'data/scripts'); } - if ( - !path.startsWith(config.configPath) && - !path.startsWith(config.scriptPath) - ) { + const cleanName = name.replace(/^data\/scripts\//, ''); + const resolvedPath = join(basePath, cleanName); + const normalized = join(resolvedPath); + // Verify the resolved path stays within allowed directory + if (!normalized.startsWith(basePath)) { return res.send({ code: 403, message: t('文件路径无效') }); } - await writeFileWithLock(path, content); + // Check blacklist on actual filename (not user input) + if (config.blackFileList.includes(basename(normalized))) { + return res.send({ code: 403, message: t('文件无法访问') }); + } + await writeFileWithLock(normalized, content); res.send({ code: 200, message: t('保存成功') }); } catch (e) { return next(e); diff --git a/back/services/config.ts b/back/services/config.ts index 7e7e5985..0c5ea30a 100644 --- a/back/services/config.ts +++ b/back/services/config.ts @@ -12,18 +12,24 @@ export default class ConfigService { public async getFile(filePath: string, res: Response) { let content = ''; - const avaliablePath = [config.rootPath, config.configPath].map((x) => - path.resolve(x, filePath), - ); - - if ( - config.blackFileList.includes(filePath) || - avaliablePath.every( - (x) => - !x.startsWith(config.scriptPath) && !x.startsWith(config.configPath), - ) || - !filePath - ) { + if (!filePath) { + return res.send({ code: 403, message: t('文件无法访问') }); + } + const normalized = path.normalize(filePath); + if (normalized.startsWith('..') || path.isAbsolute(normalized)) { + return res.send({ code: 403, message: t('文件无法访问') }); + } + const resolvedRoot = path.resolve(config.rootPath, normalized); + const resolvedConfig = path.resolve(config.configPath, normalized); + const isValidPath = + resolvedRoot.startsWith(config.scriptPath) || + resolvedRoot.startsWith(config.configPath) || + resolvedConfig.startsWith(config.scriptPath) || + resolvedConfig.startsWith(config.configPath); + if (!isValidPath) { + return res.send({ code: 403, message: t('文件无法访问') }); + } + if (config.blackFileList.includes(path.basename(normalized))) { return res.send({ code: 403, message: t('文件无法访问') }); } From 7d8feadc78a06effba7e9b063560b5c0c8f27993 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 13 Jun 2026 00:09:31 +0800 Subject: [PATCH 046/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/config/util.ts | 2 +- back/loaders/initData.ts | 2 +- back/services/sshKey.ts | 12 +++++------- src/pages/crontab/index.tsx | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/back/config/util.ts b/back/config/util.ts index ff1aa544..5f24dced 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -437,7 +437,7 @@ export async function killTask(pid: number) { [pid, ...pids].reverse().forEach((x) => { process.kill(x, 15); }); - } catch (error) {} + } catch (error) { } } else { process.kill(pid, 2); } diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 83ef8cf1..96b7033e 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -233,7 +233,7 @@ export default async () => { const lang = systemConfig.info?.lang || 'zh'; await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`); } - } catch {} + } catch { } setLang(systemConfig.info?.lang || 'zh'); await envService.set_envs(); diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index 99465109..2683e7ed 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -42,13 +42,11 @@ export default class SshKeyService { key: string, ): Promise { try { - await writeFileWithLock( - path.join(this.sshPath, alias), - `${key}${os.EOL}`, - { - mode: '400', - }, - ); + const filePath = path.join(this.sshPath, alias); + try { + await rmPath(filePath); + } catch { } + await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' }); } catch (error) { this.logger.error('生成私钥文件失败', error); } diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index 3269ecd4..830e370a 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -367,7 +367,7 @@ const Crontab = () => { const getCrons = async (silent?: boolean) => { if (!silent) setLoading(true); - const { page, size, sorter, filters } = pageConf; + const { page = 1, size = 10, sorter, filters = '{}' } = pageConf; let url = `${config.apiPrefix }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( filters, From 96b4c9039820b7ed4287188ae4289a3f6924720b Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 13 Jun 2026 00:29:32 +0800 Subject: [PATCH 047/123] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20sudo=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/script.ts | 36 +++++++++++++++++++++++++++++------- back/config/const.ts | 14 ++++++++------ back/config/container.ts | 7 +++++++ back/config/util.ts | 20 +++++++++++--------- back/services/cron.ts | 3 ++- docker/Dockerfile | 1 + docker/Dockerfile.310 | 1 + docker/Dockerfile.debian | 1 + docker/Dockerfile.debian310 | 1 + src/pages/crontab/index.tsx | 2 +- 10 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 back/config/container.ts diff --git a/back/api/script.ts b/back/api/script.ts index 8b1faaad..9be941d6 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -12,6 +12,11 @@ import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); +function isPathAllowed(targetPath: string): boolean { + const resolved = path.resolve(targetPath); + return config.writePathList.some((x) => resolved.startsWith(x)); +} + const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, config.scriptPath); @@ -161,24 +166,32 @@ export default (app: Router) => { } if (req.file) { - await fs.rename(req.file.path, join(path, filename)); + const uploadPath = join(path, filename); + if (!isPathAllowed(uploadPath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } + await fs.rename(req.file.path, uploadPath); return res.send({ code: 200 }); } if (directory) { - await fs.mkdir(join(path, directory), { recursive: true }); + const dirPath = join(path, directory); + if (!isPathAllowed(dirPath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } + await fs.mkdir(dirPath, { recursive: true }); return res.send({ code: 200 }); } if (!originFilename) { originFilename = filename; } - const originFilePath = join( - path, - `${originFilename.replace(/\//g, '')}`, - ); + const originFilePath = join(path, originFilename); + const filePath = join(path, filename); + if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } await fs.mkdir(path, { recursive: true }); - const filePath = join(path, `${filename.replace(/\//g, '')}`); const fileExists = await fileExist(filePath); if (fileExists) { await fs.copyFile( @@ -317,6 +330,9 @@ export default (app: Router) => { } const { name, ext } = parse(filename); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); + if (!isPathAllowed(filePath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } await writeFileWithLock(filePath, content || ''); const scriptService = Container.get(ScriptService); @@ -345,6 +361,9 @@ export default (app: Router) => { } const { name, ext } = parse(filename); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); + if (!isPathAllowed(filePath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } const logPath = join(config.logPath, path, `${name}.swap`); const scriptService = Container.get(ScriptService); @@ -380,6 +399,9 @@ export default (app: Router) => { } const filePath = join(config.scriptPath, path, filename); const newPath = join(config.scriptPath, path, newFilename); + if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } await fs.rename(filePath, newPath); res.send({ code: 200 }); } catch (e) { diff --git a/back/config/const.ts b/back/config/const.ts index 59350a98..8bfeff96 100644 --- a/back/config/const.ts +++ b/back/config/const.ts @@ -1,3 +1,5 @@ +import { maybeSudo } from './container'; + export const LOG_END_SYMBOL = '     '; export const TASK_COMMAND = 'task'; @@ -60,17 +62,17 @@ export const LINUX_DEPENDENCE_COMMAND: Record< } > = { Debian: { - install: 'sudo apt-get install -y', - uninstall: 'sudo apt-get remove -y', - info: 'sudo dpkg-query -s', + install: maybeSudo('apt-get install -y'), + uninstall: maybeSudo('apt-get remove -y'), + info: maybeSudo('dpkg-query -s'), check(info: string) { return info.includes('install ok installed'); }, }, Ubuntu: { - install: 'sudo apt-get install -y', - uninstall: 'sudo apt-get remove -y', - info: 'sudo dpkg-query -s', + install: maybeSudo('apt-get install -y'), + uninstall: maybeSudo('apt-get remove -y'), + info: maybeSudo('dpkg-query -s'), check(info: string) { return info.includes('install ok installed'); }, diff --git a/back/config/container.ts b/back/config/container.ts new file mode 100644 index 00000000..57539684 --- /dev/null +++ b/back/config/container.ts @@ -0,0 +1,7 @@ +export function isInContainer(): boolean { + return process.env.QL_CONTAINER === 'true'; +} + +export function maybeSudo(cmd: string): string { + return isInContainer() ? `sudo ${cmd}` : cmd; +} diff --git a/back/config/util.ts b/back/config/util.ts index 5f24dced..4a654bb7 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -11,6 +11,7 @@ import { writeFileWithLock } from '../shared/utils'; import { DependenceTypes } from '../data/dependence'; import { FormData } from 'undici'; import os from 'os'; +import { maybeSudo, isInContainer } from './container'; export * from './share'; @@ -557,8 +558,8 @@ export async function setSystemTimezone(timezone: string): Promise { throw new Error('Invalid timezone'); } - await promiseExec(`sudo ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); - await promiseExec(`echo "${timezone}" | sudo tee /etc/timezone`); + await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`)); + await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`); return true; } catch (error) { @@ -584,7 +585,7 @@ except: ''')"`, [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? `apk info -es ${name}` - : `sudo dpkg-query -s ${name}`, + : maybeSudo(`dpkg-query -s ${name}`), }; return baseCommands[type]; @@ -597,7 +598,7 @@ export function getInstallCommand(type: DependenceTypes, name: string): string { 'pip3 install --disable-pip-version-check --root-user-action=ignore', [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk add --no-check-certificate' - : 'sudo apt-get install -y', + : maybeSudo('apt-get install -y'), }; let command = baseCommands[type]; @@ -619,7 +620,7 @@ export function getUninstallCommand( 'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk del' - : 'sudo apt-get remove -y', + : maybeSudo('apt-get remove -y'), }; return `${baseCommands[type]} ${name.trim()}`; @@ -732,23 +733,24 @@ async function _updateLinuxMirror( osType: string, mirrorDomainWithScheme: string, ): Promise { + const S = isInContainer() ? 'sudo ' : ''; 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 `sudo sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (sudo 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" | sudo tee ${filePath}) && sudo apt-get update`; + 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}) && ${S}apt-get update`; } else { - return `sudo 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" | sudo tee ${filePath} && sudo apt-get update`; + 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} && ${S}apt-get update`; } case 'Ubuntu': filePath = '/etc/apt/sources.list.d/ubuntu.sources'; currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (currentDomainWithScheme) { - return `sudo sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (sudo 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" | sudo tee ${filePath}) && sudo apt-get update`; + 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}) && ${S}apt-get update`; } else { - return `sudo 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" | sudo tee ${filePath} && sudo apt-get update`; + 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} && ${S}apt-get update`; } case 'Alpine': filePath = '/etc/apk/repositories'; diff --git a/back/services/cron.ts b/back/services/cron.ts index 5a4bd774..6ecc9088 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -31,6 +31,7 @@ import { writeFileWithLock } from '../shared/utils'; import { t } from '../shared/i18n'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; +import { isEmpty } from 'lodash'; @Service() export default class CronService { @@ -401,7 +402,7 @@ export default class CronService { } private formatFilterQuery(query: any, filterQuery: any) { - if (filterQuery) { + if (!isEmpty(filterQuery)) { if (!query[Op.and]) { query[Op.and] = []; } diff --git a/docker/Dockerfile b/docker/Dockerfile index b874649f..f9de1f79 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,6 +17,7 @@ ARG PYTHON_SHORT_VERSION=3.11 ENV QL_DIR=/ql \ QL_BRANCH=${QL_BRANCH} \ + QL_CONTAINER=true \ LANG=C.UTF-8 \ SHELL=/bin/bash \ PS1="\u@\h:\w \$ " diff --git a/docker/Dockerfile.310 b/docker/Dockerfile.310 index 6312448b..30e6a332 100644 --- a/docker/Dockerfile.310 +++ b/docker/Dockerfile.310 @@ -17,6 +17,7 @@ ARG PYTHON_SHORT_VERSION=3.10 ENV QL_DIR=/ql \ QL_BRANCH=${QL_BRANCH} \ + QL_CONTAINER=true \ LANG=C.UTF-8 \ SHELL=/bin/bash \ PS1="\u@\h:\w \$ " diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index e4d791f3..ba3e455b 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -22,6 +22,7 @@ ARG PYTHON_SHORT_VERSION=3.11 ENV QL_DIR=/ql \ QL_BRANCH=${QL_BRANCH} \ + QL_CONTAINER=true \ LANG=C.UTF-8 \ SHELL=/bin/bash \ PS1="\u@\h:\w \$ " diff --git a/docker/Dockerfile.debian310 b/docker/Dockerfile.debian310 index bc110ed7..afa41e19 100644 --- a/docker/Dockerfile.debian310 +++ b/docker/Dockerfile.debian310 @@ -22,6 +22,7 @@ ARG PYTHON_SHORT_VERSION=3.10 ENV QL_DIR=/ql \ QL_BRANCH=${QL_BRANCH} \ + QL_CONTAINER=true \ LANG=C.UTF-8 \ SHELL=/bin/bash \ PS1="\u@\h:\w \$ " diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index 830e370a..2c933492 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -367,7 +367,7 @@ const Crontab = () => { const getCrons = async (silent?: boolean) => { if (!silent) setLoading(true); - const { page = 1, size = 10, sorter, filters = '{}' } = pageConf; + const { page = 1, size = 20, sorter, filters = {} } = pageConf; let url = `${config.apiPrefix }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( filters, From 949d956aef64e8f16839ebccf463be2ff7b681ac Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 13 Jun 2026 19:35:47 +0800 Subject: [PATCH 048/123] =?UTF-8?q?grpc=20=E6=9C=8D=E5=8A=A1=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=AF=81=E4=B9=A6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/app.ts | 4 ++ back/config/grpcCerts.ts | 150 +++++++++++++++++++++++++++++++++++++++ back/config/index.ts | 2 + back/schedule/client.ts | 23 ++++-- back/services/grpc.ts | 10 ++- back/shared/pLimit.ts | 26 +++++-- shell/preload/client.js | 20 +++++- 7 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 back/config/grpcCerts.ts diff --git a/back/app.ts b/back/app.ts index fc95a72f..7a265da5 100644 --- a/back/app.ts +++ b/back/app.ts @@ -232,6 +232,10 @@ class Application { } private async startHttpService() { + // 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书 + const { initGrpcCerts } = await import('./config/grpcCerts'); + await initGrpcCerts(); + this.setupMiddlewares(); const { HttpServerService } = await import('./services/http'); diff --git a/back/config/grpcCerts.ts b/back/config/grpcCerts.ts new file mode 100644 index 00000000..63af59aa --- /dev/null +++ b/back/config/grpcCerts.ts @@ -0,0 +1,150 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import path from 'path'; +import config from './index'; +import { fileExist } from './util'; +import Logger from '../loaders/logger'; + +export interface GrpcTlsConfig { + caCert: string; + serverCert: string; + serverKey: string; + clientCert: string; + clientKey: string; +} + +const certDir = path.join(config.configPath, 'grpc'); +const caKeyPath = path.join(certDir, 'ca.key'); +const caCertPath = path.join(certDir, 'ca.crt'); +const serverKeyPath = path.join(certDir, 'server.key'); +const serverCertPath = path.join(certDir, 'server.crt'); +const clientKeyPath = path.join(certDir, 'client.key'); +const clientCertPath = path.join(certDir, 'client.crt'); + +let cachedConfig: GrpcTlsConfig | null = null; + +function run(cmd: string, execOpts?: Record): string { + const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any; + return (execSync(cmd, opts) as string).trim(); +} + +async function tmpFile(prefix: string): Promise { + const dir = (await fileExist(certDir)) ? certDir : os.tmpdir(); + await fs.mkdir(dir, { recursive: true }); + return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`); +} + +async function generateAllCerts(): Promise { + Logger.info('Generating gRPC mTLS certificates...'); + + const caKeyTmp = await tmpFile('ca_key'); + const caCertTmp = await tmpFile('ca_cert'); + const serverKeyTmp = await tmpFile('server_key'); + const serverCsrTmp = await tmpFile('server_csr'); + const serverExtTmp = await tmpFile('server_ext'); + const clientKeyTmp = await tmpFile('client_key'); + const clientCsrTmp = await tmpFile('client_csr'); + const clientExtTmp = await tmpFile('client_ext'); + const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl'); + + const cleanup = async () => { + for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp, + clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) { + try { await fs.unlink(f); } catch {} + } + }; + + try { + // 1. CA(私钥直接存盘,证书写入临时文件供签发使用) + run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`); + run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`); + const caKey = await fs.readFile(caKeyTmp, 'utf-8'); + const caCert = await fs.readFile(caCertTmp, 'utf-8'); + await fs.mkdir(certDir, { recursive: true }); + await fs.writeFile(caKeyPath, caKey, { mode: 0o600 }); + + // 2. 服务端 + run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`); + run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`); + await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n'); + const serverCert = run( + `openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`, + ); + const serverKey = await fs.readFile(serverKeyTmp, 'utf-8'); + + // 3. 客户端 + run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`); + run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`); + await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n'); + const clientCert = run( + `openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`, + ); + const clientKey = await fs.readFile(clientKeyTmp, 'utf-8'); + + await cleanup(); + Logger.info('gRPC mTLS certificates generated successfully'); + + return { caCert, serverCert, serverKey, clientCert, clientKey }; + } catch (e) { + await cleanup(); + throw e; + } +} + +async function saveCerts(tlsConfig: GrpcTlsConfig): Promise { + await fs.mkdir(certDir, { recursive: true }); + + await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 }); + await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 }); + await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 }); + await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 }); + await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 }); + + Logger.info(`gRPC mTLS certificates saved to ${certDir}`); +} + +async function loadExistingCerts(): Promise { + const exists = await Promise.all([ + fileExist(caCertPath), + fileExist(serverCertPath), + fileExist(serverKeyPath), + fileExist(clientCertPath), + fileExist(clientKeyPath), + ]); + + if (exists.some((e) => !e)) { + return null; + } + + const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([ + fs.readFile(caCertPath, 'utf-8'), + fs.readFile(serverCertPath, 'utf-8'), + fs.readFile(serverKeyPath, 'utf-8'), + fs.readFile(clientCertPath, 'utf-8'), + fs.readFile(clientKeyPath, 'utf-8'), + ]); + + Logger.info('Loaded existing gRPC mTLS certificates from disk'); + return { caCert, serverCert, serverKey, clientCert, clientKey }; +} + +export async function initGrpcCerts(): Promise { + if (cachedConfig) { + return cachedConfig; + } + + let tlsConfig = await loadExistingCerts(); + + if (!tlsConfig) { + tlsConfig = await generateAllCerts(); + await saveCerts(tlsConfig); + } + + cachedConfig = tlsConfig; + return tlsConfig; +} + +export function getGrpcCerts(): GrpcTlsConfig | null { + return cachedConfig; +} diff --git a/back/config/index.ts b/back/config/index.ts index 72596f1f..869f5d10 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -173,6 +173,8 @@ export default { 'env.js', 'env.py', 'token.json', + 'grpc', + '__pycache__', ], writePathList: [configPath, scriptPath], bakPath, diff --git a/back/schedule/client.ts b/back/schedule/client.ts index 6b086452..30daacf9 100644 --- a/back/schedule/client.ts +++ b/back/schedule/client.ts @@ -7,13 +7,26 @@ import { DeleteCronResponse, } from '../protos/cron'; import config from '../config'; +import { getGrpcCerts } from '../config/grpcCerts'; class Client { - private client = new CronClient( - `localhost:${config.grpcPort}`, - credentials.createInsecure(), - { 'grpc.enable_http_proxy': 0 }, - ); + private _client: CronClient | null = null; + + private get client(): CronClient { + if (!this._client) { + const tlsConfig = getGrpcCerts()!; + this._client = new CronClient( + `localhost:${config.grpcPort}`, + credentials.createSsl( + Buffer.from(tlsConfig.caCert), + Buffer.from(tlsConfig.clientKey), + Buffer.from(tlsConfig.clientCert), + ), + { 'grpc.enable_http_proxy': 0 }, + ); + } + return this._client; + } addCron(request: AddCronRequest['crons']): Promise { return new Promise((resolve, reject) => { diff --git a/back/services/grpc.ts b/back/services/grpc.ts index 5edd9d2c..7432ca70 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -11,6 +11,7 @@ import { promisify } from 'util'; import config from '../config'; import { metricsService } from './metrics'; import { Service } from 'typedi'; +import { initGrpcCerts } from '../config/grpcCerts'; @Service() export class GrpcServerService { @@ -29,6 +30,13 @@ export class GrpcServerService { this.server.addService(CronService, { addCron, delCron }); this.server.addService(ApiService, Api); + const tlsConfig = await initGrpcCerts(); + const credentials = ServerCredentials.createSsl( + Buffer.from(tlsConfig.caCert), + [{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }], + true, + ); + const grpcPort = config.grpcPort; const hostsToTry = [ config.bindHostGrpc, @@ -41,7 +49,7 @@ export class GrpcServerService { for (const host of hostsToTry) { try { const address = this.formatGrpcAddress(host, grpcPort); - await bindAsync(address, ServerCredentials.createInsecure()); + await bindAsync(address, credentials); Logger.debug(`✌️ gRPC service started successfully on ${address}`); metricsService.record('grpc_service_start', 1, { port: grpcPort.toString(), diff --git a/back/shared/pLimit.ts b/back/shared/pLimit.ts index 2f04d5be..7d75678f 100644 --- a/back/shared/pLimit.ts +++ b/back/shared/pLimit.ts @@ -14,6 +14,7 @@ import { import config from '../config'; import { credentials } from '@grpc/grpc-js'; import { ApiClient } from '../protos/api'; +import { getGrpcCerts } from '../config/grpcCerts'; class TaskLimit { private dependenyLimit = new PQueue({ concurrency: 1 }); @@ -36,11 +37,26 @@ class TaskLimit { private systemLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); - private client = new ApiClient( - `localhost:${config.grpcPort}`, - credentials.createInsecure(), - { 'grpc.enable_http_proxy': 0 }, - ); + private _client: ApiClient | null = null; + + private get client(): ApiClient { + if (!this._client) { + const tlsConfig = getGrpcCerts(); + const creds = tlsConfig + ? credentials.createSsl( + Buffer.from(tlsConfig.caCert), + Buffer.from(tlsConfig.clientKey), + Buffer.from(tlsConfig.clientCert), + ) + : credentials.createInsecure(); + this._client = new ApiClient( + `localhost:${config.grpcPort}`, + creds, + { 'grpc.enable_http_proxy': 0 }, + ); + } + return this._client; + } get cronLimitActiveCount() { return this.cronLimit.pending; diff --git a/shell/preload/client.js b/shell/preload/client.js index 3a94c0c2..7b0420f8 100644 --- a/shell/preload/client.js +++ b/shell/preload/client.js @@ -1,8 +1,26 @@ const grpc = require('@grpc/grpc-js'); const protoLoader = require('@grpc/proto-loader'); +const { readFileSync } = require('fs'); const { join } = require('path'); class GrpcClient { + static #certDir = join( + process.env.QL_DATA_DIR || join(process.env.QL_DIR, 'data'), + 'config/grpc', + ); + + static #loadTlsCredentials() { + try { + return grpc.credentials.createSsl( + readFileSync(join(GrpcClient.#certDir, 'ca.crt')), + readFileSync(join(GrpcClient.#certDir, 'client.key')), + readFileSync(join(GrpcClient.#certDir, 'client.crt')), + ); + } catch { + return grpc.credentials.createInsecure(); + } + } + static #config = { protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), serverAddress: `localhost:${process.env.GRPC_PORT || '5500'}`, @@ -58,7 +76,7 @@ class GrpcClient { this.#client = new apiProto.Api( serverAddress, - grpc.credentials.createInsecure(), + GrpcClient.#loadTlsCredentials(), grpcOptions, ); } catch (error) { From 5f2d5bb24adc4cb7c9ba64d4ce664207a8e25ea1 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 13 Jun 2026 21:04:17 +0800 Subject: [PATCH 049/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20esm=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=9F=A5=E8=AF=A2=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/preload/esm-loader.mjs | 41 ++++++++++++++++++++++++++++++++++ shell/preload/sitecustomize.js | 5 +++++ 2 files changed, 46 insertions(+) create mode 100644 shell/preload/esm-loader.mjs diff --git a/shell/preload/esm-loader.mjs b/shell/preload/esm-loader.mjs new file mode 100644 index 00000000..c59bf3b1 --- /dev/null +++ b/shell/preload/esm-loader.mjs @@ -0,0 +1,41 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const builtinModules = [ + 'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', + 'events', 'fs', 'http', 'https', 'net', 'os', 'path', 'process', 'querystring', + 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'tty', 'url', 'util', + 'v8', 'zlib', +]; + +function isBareSpecifier(specifier) { + return !specifier.startsWith('.') && + !specifier.startsWith('/') && + !specifier.startsWith('file:') && + !specifier.startsWith('node:') && + !builtinModules.includes(specifier) && + !builtinModules.includes(specifier.split('/')[0]); +} + +export function resolve(specifier, context, nextResolve) { + if (!isBareSpecifier(specifier)) { + return nextResolve(specifier, context); + } + + // 解析优先级:全局 pnpm > 系统全局 + const bases = [ + process.env.QL_NODE_GLOBAL_PATH, + '/usr/local/lib/node_modules', + ].filter(Boolean); + + for (const base of bases) { + if (existsSync(join(base, specifier))) { + return nextResolve(specifier, { + ...context, + parentURL: new URL(`${join(base, specifier)}/`, 'file://').href, + }); + } + } + + return nextResolve(specifier, context); +} diff --git a/shell/preload/sitecustomize.js b/shell/preload/sitecustomize.js index 44dc1bea..720dc206 100644 --- a/shell/preload/sitecustomize.js +++ b/shell/preload/sitecustomize.js @@ -4,6 +4,11 @@ const path = require('path'); const client = require('./client.js'); require(`./env.js`); +// 注册 ESM loader,使全局安装的包也可通过 import 导入 +try { + Module.register(new URL('esm-loader.mjs', `file://${__dirname}/`).href); +} catch (_) {} + function preferGlobalNodeModules() { const { QL_NODE_GLOBAL_PATH } = process.env; if (!QL_NODE_GLOBAL_PATH || Module._qlGlobalPathPatched) { From 0ee0b832072fe7a94a813fbb489ce9518fc8537e Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 14 Jun 2026 15:16:04 +0800 Subject: [PATCH 050/123] =?UTF-8?q?=E5=BB=B6=E8=BF=9F=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=97=B4=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/otask.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shell/otask.sh b/shell/otask.sh index f17d8100..a6d0a07b 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -24,7 +24,13 @@ random_delay() { done local delay_second=$(($(gen_random_num "$random_delay_max") + 1)) - echo -e "任务随机延迟 $delay_second 秒,配置文件参数 RandomDelay 置空可取消延迟 \n" + local start_time + if [[ $is_macos -eq 1 ]]; then + start_time=$(date -v "+${delay_second}S" "+%Y-%m-%d %H:%M:%S") + else + start_time=$(date -d "+${delay_second} seconds" "+%Y-%m-%d %H:%M:%S") + fi + echo -e "任务随机延迟 $delay_second 秒,将于 $start_time 开始,配置文件参数 RandomDelay 置空可取消延迟 \n" sleep $delay_second fi } From b5a5fb3be68d64903ed31c8f14e83b8618a7800d Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 14 Jun 2026 16:45:53 +0800 Subject: [PATCH 051/123] =?UTF-8?q?shell=20=E5=A2=9E=E5=8A=A0=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 43 ++++++++++++++++ CLAUDE.md | 43 ++++++++++++++++ shell/api.sh | 24 ++++----- shell/bot.sh | 18 +++---- shell/check.sh | 34 ++++++------ shell/lang/en.sh | 105 ++++++++++++++++++++++++++++++++++++++ shell/lang/zh.sh | 105 ++++++++++++++++++++++++++++++++++++++ shell/otask.sh | 8 +-- shell/rmlog.sh | 10 ++-- shell/share.sh | 55 ++++++++++++++------ shell/start.sh | 10 ++-- shell/task.sh | 8 ++- shell/update.sh | 44 ++++++++-------- src/layouts/index.tsx | 4 +- src/pages/error/index.tsx | 2 +- 16 files changed, 420 insertions(+), 94 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 shell/lang/en.sh create mode 100644 shell/lang/zh.sh diff --git a/.gitignore b/.gitignore index 766153eb..e201ecd0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ __pycache__ /shell/preload/lang_env.sh .deepseek/ +.claude/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4d87c2f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **qinglong** (2740 symbols, 6583 relationships, 230 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. +- NEVER commit changes without running `detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness | +| `gitnexus://repo/qinglong/clusters` | All functional areas | +| `gitnexus://repo/qinglong/processes` | All execution flows | +| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4d87c2f3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **qinglong** (2740 symbols, 6583 relationships, 230 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. +- NEVER commit changes without running `detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness | +| `gitnexus://repo/qinglong/clusters` | All functional areas | +| `gitnexus://repo/qinglong/processes` | All execution flows | +| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + \ No newline at end of file diff --git a/shell/api.sh b/shell/api.sh index 56fcbd50..b7366cb8 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -50,9 +50,9 @@ add_cron_api() { code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then - echo -e "$name -> 添加成功" + t '%s -> 添加成功' "$name" else - echo -e "$name -> 添加失败(${message})" + t '%s -> 添加失败(%s)' "$name" "$message" fi } @@ -81,9 +81,9 @@ update_cron_api() { code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then - echo -e "$name -> 更新成功" + t '%s -> 更新成功' "$name" else - echo -e "$name -> 更新失败(${message})" + t '%s -> 更新失败(%s)' "$name" "$message" fi } @@ -108,9 +108,9 @@ update_cron_command_api() { code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then - echo -e "$command -> 更新成功" + t '%s -> 更新成功' "$command" else - echo -e "$command -> 更新失败(${message})" + t '%s -> 更新失败(%s)' "$command" "$message" fi } @@ -128,9 +128,9 @@ del_cron_api() { code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then - echo -e "成功" + t '成功' else - echo -e "失败(${message})" + t '失败(%s)' "$message" fi } @@ -175,9 +175,9 @@ notify_api() { code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then - echo -e "通知发送成功🎉" + t '通知发送成功🎉' else - echo -e "通知失败(${message})" + t '通知失败(%s)' "$message" fi } @@ -214,9 +214,9 @@ update_auth_config() { code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then - echo -e "${tip}成功🎉" + t '%s成功🎉' "$tip" else - echo -e "${tip}失败(${message})" + t '%s失败(%s)' "$tip" "$message" fi } diff --git a/shell/bot.sh b/shell/bot.sh index 3d8e799d..9eb74780 100755 --- a/shell/bot.sh +++ b/shell/bot.sh @@ -8,7 +8,7 @@ else repo_path="${dir_repo}/diybot" fi -echo -e "\n1、安装bot依赖...\n" +t '\n1、安装bot依赖...\n' os_name="${QL_OS_TYPE:-}" if [ -z "$os_name" ]; then os_name=$(source /etc/os-release && echo "$ID") @@ -28,13 +28,13 @@ case "$os_name" in $SUDO apt-get install -y gcc python3-dev musl-dev zlib1g-dev libjpeg-dev libfreetype-dev ;; *) - echo -e "暂不支持此系统 $os_name" + t '暂不支持此系统 %s' "$os_name" exit 1 ;; esac -echo -e "\nbot依赖安装成功...\n" +t '\nbot依赖安装成功...\n' -echo -e "2、下载bot所需文件...\n" +t '2、下载bot所需文件...\n' if [[ ! -d ${repo_path}/.git ]]; then rm -rf ${repo_path} git_clone_scripts ${url} ${repo_path} "main" @@ -44,9 +44,9 @@ cp -rf "$repo_path/jbot" $dir_data if [[ ! -f "$dir_config/bot.json" ]]; then cp -f "$repo_path/config/bot.json" "$dir_config" fi -echo -e "\nbot文件下载成功...\n" +t '\nbot文件下载成功...\n' -echo -e "3、安装python3依赖...\n" +t '3、安装python3依赖...\n' cp -f "$repo_path/jbot/requirements.txt" "$dir_data" cd $dir_data @@ -56,11 +56,11 @@ cat requirements.txt | while read LREAD; do fi done -echo -e "\npython3依赖安装成功...\n" +t '\npython3依赖安装成功...\n' -echo -e "4、启动bot程序...\n" +t '4、启动bot程序...\n' make_dir $dir_log/bot cd $dir_data ps -eo pid,command | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null nohup python3 -m jbot >$dir_log/bot/nohup.log 2>&1 & -echo -e "bot启动成功...\n" +t 'bot启动成功...\n' diff --git a/shell/check.sh b/shell/check.sh index bc6c7b7e..ce28001b 100755 --- a/shell/check.sh +++ b/shell/check.sh @@ -1,29 +1,29 @@ #!/usr/bin/env bash reset_env() { - echo -e "---> 1. 开始检测配置文件\n" + t '---> 1. 开始检测配置文件\n' fix_config - echo -e "---> 配置文件检测完成\n" + t '---> 配置文件检测完成\n' - echo -e "---> 2. 开始安装青龙依赖\n" + t '---> 2. 开始安装青龙依赖\n' npm_install_2 $dir_root - echo -e "---> 青龙依赖安装完成\n" + t '---> 青龙依赖安装完成\n' - echo -e "---> 脚本依赖安装完成\n" + t '---> 脚本依赖安装完成\n' } copy_dep() { - echo -e "---> 1. 复制通知文件\n" - echo -e "---> 复制一份 $file_notify_py_sample 为 $file_notify_py\n" + t '---> 1. 复制通知文件\n' + t '---> 复制一份 %s 为 %s\n' "$file_notify_py_sample" "$file_notify_py" cp -fv $file_notify_py_sample $file_notify_py echo - echo -e "---> 复制一份 $file_notify_js_sample 为 $file_notify_js\n" + t '---> 复制一份 %s 为 %s\n' "$file_notify_js_sample" "$file_notify_js" cp -fv $file_notify_js_sample $file_notify_js - echo -e "---> 通知文件复制完成\n" + t '---> 通知文件复制完成\n' } pm2_log() { - echo -e "---> pm2日志" + t '---> pm2日志' local panelOut="/root/.pm2/logs/qinglong-out.log" local panelError="/root/.pm2/logs/qinglong-error.log" tail -n 300 "$panelOut" @@ -32,9 +32,10 @@ pm2_log() { check_ql() { local api=$(curl -s --noproxy "*" "http://localhost:${ql_port}") - echo -e "\n=====> 检测面板\n\n$api\n" + t '\n=====> 检测面板' + echo -e "\n\n$api\n" if [[ $api =~ "
" ]]; then - echo -e "=====> 面板服务启动正常\n" + t '=====> 面板服务启动正常\n' fi } @@ -49,14 +50,15 @@ check_pm2() { -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ --compressed ) - echo -e "\n=====> 检测后台\n\n$api\n" + t '\n=====> 检测后台' + echo -e "\n\n$api\n" if [[ $api =~ "{\"code\"" ]]; then - echo -e "=====> 后台服务启动正常\n" + t '=====> 后台服务启动正常\n' fi } main() { - echo -e "=====> 开始检测" + t '=====> 开始检测' npm i -g pnpm@8.3.1 pm2 ts-node reset_env @@ -64,7 +66,7 @@ main() { check_ql check_pm2 reload_pm2 - echo -e "\n=====> 检测结束\n" + t '\n=====> 检测结束\n' } main diff --git a/shell/lang/en.sh b/shell/lang/en.sh new file mode 100644 index 00000000..7e823d25 --- /dev/null +++ b/shell/lang/en.sh @@ -0,0 +1,105 @@ +# English language pack +declare -gA LANG_MESSAGES=( + ['任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n']='Task delayed %s seconds, will start at %s. Set RandomDelay to empty to cancel delay\n' + ['开始执行...\n']='Starting execution...\n' + ['已停止']='Stopped' + ['完成']='Completed' + ['失败(退出码 %s)']='Failed (exit code %s)' + ['安装 %s 依赖包...\n']='Installing %s dependencies...\n' + ['开始拉取仓库 %s 到 %s\n']='Cloning repository %s to %s\n' + ['添加成功']='Added successfully' + ['添加失败(%s)']='Add failed (%s)' + ['更新成功']='Updated successfully' + ['更新失败(%s)']='Update failed (%s)' + ['成功']='Success' + ['失败(%s)']='Failed (%s)' + ['通知发送成功🎉']='Notification sent successfully 🎉' + ['通知失败(%s)']='Notification failed (%s)' + ['当前有以下脚本可以运行:']='Available scripts:' + ['暂无脚本可以执行']='No scripts available' + ['警告:工作目录不存在 %s']='Warning: working directory does not exist: %s' + ['\n缺少并发运行的环境变量参数']='\nMissing concurrency environment variable' + ['\n缺少单独运行的参数 task xxx.js desi Test']='\nMissing parameter: task xxx.js desi Test' + ['暂不支持此系统 %s']='Unsupported system: %s' + ['检测到 pm2 服务正在运行']='pm2 service is running' + ['npm 模块位置: %s']='npm module location: %s' + ['导入数据成功 %s']='Data imported successfully: %s' + ['导出数据成功 %s']='Data exported successfully: %s' + ['当前版本: %s / 最新版本: %s']='Current: %s / Latest: %s' + ['已是最新版本']='Already up to date' + ['%s 个定时任务正在运行']='%s scheduled tasks running' + ['执行前置命令\n']='Running pre-command\n' + ['\n执行前置命令结束\n']='\nPre-command finished\n' + ['\n执行后置命令\n']='\nRunning post-command\n' + ['\n执行后置命令结束']='\nPost-command finished' + ['警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容']='Warning: PM2 start failed (exit code: %s), possibly due to hardware incompatibility' + ['正在尝试直接使用 Node.js 启动服务...']='Attempting to start service with Node.js directly...' + ['已使用 Node.js 直接启动服务 (PID: %s)']='Service started with Node.js directly (PID: %s)' + ['注意: 使用此模式时,部分 PM2 管理功能将不可用']='Note: some PM2 management features are unavailable in this mode' + ['## 开始执行... %s\n']='## Starting... %s\n' + ['\n## 已停止 🛑... %s 耗时 %s 秒%s']='\n## Stopped 🛑... %s took %s seconds%s' + ['\n## 完成 ✅... %s 耗时 %s 秒%s']='\n## Completed ✅... %s took %s seconds%s' + ['\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s']='\n## Failed ❌(exit code %s)... %s took %s seconds%s' + ['%s -> 添加成功']='%s -> Added successfully' + ['%s -> 添加失败(%s)']='%s -> Add failed (%s)' + ['%s -> 更新成功']='%s -> Updated successfully' + ['%s -> 更新失败(%s)']='%s -> Update failed (%s)' + ['%s成功🎉']='%s succeeded 🎉' + ['%s失败(%s)']='%s failed (%s)' + ['检测到有%s的定时任务:']='Found %s scheduled tasks:' + ['\n开始尝试自动删除失效的定时任务...']='\nAttempting to remove invalid scheduled tasks...' + ['\n开始尝试自动添加定时任务...']='\nAttempting to add scheduled tasks...' + ['拉取 %s 成功...\n']='Pull %s succeeded...\n' + ['拉取 %s 失败,请检查日志...\n']='Pull %s failed, check logs...\n' + ['开始下载:%s 保存路径:%s\n']='Downloading: %s to: %s\n' + ['下载 %s 成功...\n']='Download %s succeeded...\n' + ['下载 %s 失败,保留之前正常下载的版本...\n']='Download %s failed, keeping previous version...\n' + ['%s文件不存在,跳过执行...\n']='%s does not exist, skipping...\n' + ['使用 %s 源更新...\n']='Updating using %s mirror...\n' + ['更新青龙源文件成功...\n']='Qinglong source updated successfully\n' + ['更新青龙源文件失败,请检查网络...\n']='Qinglong source update failed, check network\n' + ['更新青龙静态资源成功...\n']='Static assets updated successfully\n' + ['更新青龙静态资源失败,请检查网络...\n']='Static assets update failed, check network\n' + ['\n开始检测依赖...\n']='\nChecking dependencies...\n' + ['\n依赖检测安装成功...\n']='\nDependencies installed successfully\n' + ['更新包下载成功...\n']='Package download succeeded\n' + ['\n依赖检测安装失败,请检查网络...\n']='\nDependency installation failed, check network\n' + ['\n1、安装bot依赖...\n']='\n1. Installing bot dependencies...\n' + ['\nbot依赖安装成功...\n']='\nBot dependencies installed\n' + ['2、下载bot所需文件...\n']='2. Downloading bot files...\n' + ['\nbot文件下载成功...\n']='\nBot files downloaded\n' + ['3、安装python3依赖...\n']='3. Installing python3 dependencies...\n' + ['\npython3依赖安装成功...\n']='\nPython3 dependencies installed\n' + ['4、启动bot程序...\n']='4. Starting bot...\n' + ['bot启动成功...\n']='Bot started successfully\n' + # check.sh + ['---> 1. 开始检测配置文件\n']='---> 1. Checking config...\n' + ['---> 配置文件检测完成\n']='---> Config check complete\n' + ['---> 2. 开始安装青龙依赖\n']='---> 2. Installing qinglong dependencies...\n' + ['---> 青龙依赖安装完成\n']='---> Dependencies installed\n' + ['---> 脚本依赖安装完成\n']='---> Script dependencies installed\n' + ['---> 1. 复制通知文件\n']='---> 1. Copying notification files...\n' + ['---> 复制一份 %s 为 %s\n']='---> Copying %s to %s\n' + ['---> 通知文件复制完成\n']='---> Notification files copied\n' + ['---> pm2日志']='---> pm2 log' + ['\n=====> 检测面板']='\n=====> Checking panel' + ['=====> 面板服务启动正常\n']='=====> Panel service running normally\n' + ['\n=====> 检测后台']='\n=====> Checking backend' + ['=====> 后台服务启动正常\n']='=====> Backend service running normally\n' + ['=====> 开始检测']='=====> Starting check' + ['\n=====> 检测结束\n']='\n=====> Check complete\n' + # rmlog.sh + ['查询文件 %s']='Checking file: %s' + ['删除中~']='Deleting...' + ['正在被 %s 使用,跳过~']='In use by %s, skipping...' + ['查找旧日志文件中...\n']='Looking for old log files...\n' + ['删除旧日志执行完毕\n']='Old log cleanup complete\n' + # start.sh + ['未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装']='Module not found. Run: npm i -g @whyour/qinglong' + ['请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务']='Set env: export QL_DIR=%s, then run qinglong to start' + ['请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量']='Set QL_DATA_DIR (absolute path ending with /data, e.g. /ql/data)' + ['QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中']='QL_DATA_DIR must end with /data, e.g. /ql/data' + ['暂不支持此系统部署 %s']='Unsupported system for deployment: %s' + # update.sh + ['命令输入错误...\n']='Invalid command...\n' +) diff --git a/shell/lang/zh.sh b/shell/lang/zh.sh new file mode 100644 index 00000000..0a3c668c --- /dev/null +++ b/shell/lang/zh.sh @@ -0,0 +1,105 @@ +# 中文语言包(zh key → zh value 恒等,en 包做实际翻译) +declare -gA LANG_MESSAGES=( + ['任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n']='任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n' + ['开始执行...\n']='开始执行...\n' + ['已停止']='已停止' + ['完成']='完成' + ['失败(退出码 %s)']='失败(退出码 %s)' + ['安装 %s 依赖包...\n']='安装 %s 依赖包...\n' + ['开始拉取仓库 %s 到 %s\n']='开始拉取仓库 %s 到 %s\n' + ['添加成功']='添加成功' + ['添加失败(%s)']='添加失败(%s)' + ['更新成功']='更新成功' + ['更新失败(%s)']='更新失败(%s)' + ['成功']='成功' + ['失败(%s)']='失败(%s)' + ['通知发送成功🎉']='通知发送成功🎉' + ['通知失败(%s)']='通知失败(%s)' + ['当前有以下脚本可以运行:']='当前有以下脚本可以运行:' + ['暂无脚本可以执行']='暂无脚本可以执行' + ['警告:工作目录不存在 %s']='警告:工作目录不存在 %s' + ['\n缺少并发运行的环境变量参数']='\n缺少并发运行的环境变量参数' + ['\n缺少单独运行的参数 task xxx.js desi Test']='\n缺少单独运行的参数 task xxx.js desi Test' + ['暂不支持此系统 %s']='暂不支持此系统 %s' + ['检测到 pm2 服务正在运行']='检测到 pm2 服务正在运行' + ['npm 模块位置: %s']='npm 模块位置: %s' + ['导入数据成功 %s']='导入数据成功 %s' + ['导出数据成功 %s']='导出数据成功 %s' + ['当前版本: %s / 最新版本: %s']='当前版本: %s / 最新版本: %s' + ['已是最新版本']='已是最新版本' + ['%s 个定时任务正在运行']='%s 个定时任务正在运行' + ['执行前置命令\n']='执行前置命令\n' + ['\n执行前置命令结束\n']='\n执行前置命令结束\n' + ['\n执行后置命令\n']='\n执行后置命令\n' + ['\n执行后置命令结束']='\n执行后置命令结束' + ['警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容']='警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' + ['正在尝试直接使用 Node.js 启动服务...']='正在尝试直接使用 Node.js 启动服务...' + ['已使用 Node.js 直接启动服务 (PID: %s)']='已使用 Node.js 直接启动服务 (PID: %s)' + ['注意: 使用此模式时,部分 PM2 管理功能将不可用']='注意: 使用此模式时,部分 PM2 管理功能将不可用' + ['## 开始执行... %s\n']='## 开始执行... %s\n' + ['\n## 已停止 🛑... %s 耗时 %s 秒%s']='\n## 已停止 🛑... %s 耗时 %s 秒%s' + ['\n## 完成 ✅... %s 耗时 %s 秒%s']='\n## 完成 ✅... %s 耗时 %s 秒%s' + ['\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s']='\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s' + ['%s -> 添加成功']='%s -> 添加成功' + ['%s -> 添加失败(%s)']='%s -> 添加失败(%s)' + ['%s -> 更新成功']='%s -> 更新成功' + ['%s -> 更新失败(%s)']='%s -> 更新失败(%s)' + ['%s成功🎉']='%s成功🎉' + ['%s失败(%s)']='%s失败(%s)' + ['检测到有%s的定时任务:']='检测到有%s的定时任务:' + ['\n开始尝试自动删除失效的定时任务...']='\n开始尝试自动删除失效的定时任务...' + ['\n开始尝试自动添加定时任务...']='\n开始尝试自动添加定时任务...' + ['拉取 %s 成功...\n']='拉取 %s 成功...\n' + ['拉取 %s 失败,请检查日志...\n']='拉取 %s 失败,请检查日志...\n' + ['开始下载:%s 保存路径:%s\n']='开始下载:%s 保存路径:%s\n' + ['下载 %s 成功...\n']='下载 %s 成功...\n' + ['下载 %s 失败,保留之前正常下载的版本...\n']='下载 %s 失败,保留之前正常下载的版本...\n' + ['%s文件不存在,跳过执行...\n']='%s文件不存在,跳过执行...\n' + ['使用 %s 源更新...\n']='使用 %s 源更新...\n' + ['更新青龙源文件成功...\n']='更新青龙源文件成功...\n' + ['更新青龙源文件失败,请检查网络...\n']='更新青龙源文件失败,请检查网络...\n' + ['更新青龙静态资源成功...\n']='更新青龙静态资源成功...\n' + ['更新青龙静态资源失败,请检查网络...\n']='更新青龙静态资源失败,请检查网络...\n' + ['\n开始检测依赖...\n']='\n开始检测依赖...\n' + ['\n依赖检测安装成功...\n']='\n依赖检测安装成功...\n' + ['更新包下载成功...\n']='更新包下载成功...\n' + ['\n依赖检测安装失败,请检查网络...\n']='\n依赖检测安装失败,请检查网络...\n' + ['\n1、安装bot依赖...\n']='\n1、安装bot依赖...\n' + ['\nbot依赖安装成功...\n']='\nbot依赖安装成功...\n' + ['2、下载bot所需文件...\n']='2、下载bot所需文件...\n' + ['\nbot文件下载成功...\n']='\nbot文件下载成功...\n' + ['3、安装python3依赖...\n']='3、安装python3依赖...\n' + ['\npython3依赖安装成功...\n']='\npython3依赖安装成功...\n' + ['4、启动bot程序...\n']='4、启动bot程序...\n' + ['bot启动成功...\n']='bot启动成功...\n' + # check.sh + ['---> 1. 开始检测配置文件\n']='---> 1. 开始检测配置文件\n' + ['---> 配置文件检测完成\n']='---> 配置文件检测完成\n' + ['---> 2. 开始安装青龙依赖\n']='---> 2. 开始安装青龙依赖\n' + ['---> 青龙依赖安装完成\n']='---> 青龙依赖安装完成\n' + ['---> 脚本依赖安装完成\n']='---> 脚本依赖安装完成\n' + ['---> 1. 复制通知文件\n']='---> 1. 复制通知文件\n' + ['---> 复制一份 %s 为 %s\n']='---> 复制一份 %s 为 %s\n' + ['---> 通知文件复制完成\n']='---> 通知文件复制完成\n' + ['---> pm2日志']='---> pm2日志' + ['\n=====> 检测面板']='\n=====> 检测面板' + ['=====> 面板服务启动正常\n']='=====> 面板服务启动正常\n' + ['\n=====> 检测后台']='\n=====> 检测后台' + ['=====> 后台服务启动正常\n']='=====> 后台服务启动正常\n' + ['=====> 开始检测']='=====> 开始检测' + ['\n=====> 检测结束\n']='\n=====> 检测结束\n' + # rmlog.sh + ['查询文件 %s']='查询文件 %s' + ['删除中~']='删除中~' + ['正在被 %s 使用,跳过~']='正在被 %s 使用,跳过~' + ['查找旧日志文件中...\n']='查找旧日志文件中...\n' + ['删除旧日志执行完毕\n']='删除旧日志执行完毕\n' + # start.sh + ['未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装']='未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' + ['请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务']='请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' + ['请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量']='请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' + ['QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中']='QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' + ['暂不支持此系统部署 %s']='暂不支持此系统部署 %s' + # update.sh + ['命令输入错误...\n']='命令输入错误...\n' +) diff --git a/shell/otask.sh b/shell/otask.sh index a6d0a07b..75451e36 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -30,7 +30,7 @@ random_delay() { else start_time=$(date -d "+${delay_second} seconds" "+%Y-%m-%d %H:%M:%S") fi - echo -e "任务随机延迟 $delay_second 秒,将于 $start_time 开始,配置文件参数 RandomDelay 置空可取消延迟 \n" + t '任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n' "$delay_second" "$start_time" sleep $delay_second fi } @@ -122,7 +122,7 @@ enter_script_workdir() { fi return fi - echo -e "警告:工作目录不存在 ${_target_dir}" + t '警告:工作目录不存在 %s' "${_target_dir}" fi cd $dir_scripts @@ -173,7 +173,7 @@ run_concurrent() { local env_param="$2" local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1') if [[ ! $env_param ]]; then - echo -e "\n 缺少并发运行的环境变量参数" + t '\n缺少并发运行的环境变量参数' exit 1 fi @@ -210,7 +210,7 @@ run_designated() { local env_param="$2" local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1') if [[ ! $env_param ]]; then - echo -e "\n 缺少单独运行的参数 task xxx.js desi Test" + t '\n缺少单独运行的参数 task xxx.js desi Test' exit 1 fi diff --git a/shell/rmlog.sh b/shell/rmlog.sh index 9e67f3a2..cc67ff24 100755 --- a/shell/rmlog.sh +++ b/shell/rmlog.sh @@ -22,12 +22,12 @@ remove_js_log() { if [[ $diff_time -gt $((${days} * 86400)) ]]; then local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") local result=$(find_cron_api "log_path=$log_path") - echo -e "查询文件 $log_path" + t '查询文件 %s' "$log_path" if [[ -z $result ]]; then - echo -e "删除中~" + t '删除中~' rm -vf $log else - echo -e "正在被 $result 使用,跳过~" + t '正在被 %s 使用,跳过~' "$result" fi fi done @@ -43,8 +43,8 @@ remove_empty_dir() { } if [[ ${days} ]]; then - echo -e "查找旧日志文件中...\n" + t '查找旧日志文件中...\n' remove_js_log remove_empty_dir - echo -e "删除旧日志执行完毕\n" + t '删除旧日志执行完毕\n' fi diff --git a/shell/share.sh b/shell/share.sh index 1fb3e93c..027f65d8 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -80,6 +80,15 @@ import_config() { [[ -f $file_config_user ]] && . $file_config_user [[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh + # 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key) + local lang=${QL_LANG:-zh} + local lang_file="$dir_shell/lang/${lang}.sh" + 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 command_timeout_time=${CommandTimeoutTime:-""} file_extensions=${RepoFileExtensions:-"js py"} @@ -92,6 +101,18 @@ import_config() { fi } +t() { + local key="$1" + shift + local msg + if declare -p LANG_MESSAGES &>/dev/null; then + msg="${LANG_MESSAGES["$key"]}" + fi + [[ -z $msg ]] && msg="$key" + # shellcheck disable=SC2059 + printf "$msg\n" "$@" +} + set_proxy() { local proxy="$1" if [[ $proxy ]]; then @@ -226,7 +247,7 @@ npm_install_2() { local dir_work=$1 cd $dir_work - echo -e "安装 $dir_work 依赖包...\n" + t '安装 %s 依赖包...\n' "$dir_work" npm_install_sub cd $dir_current } @@ -245,7 +266,7 @@ git_clone_scripts() { local branch="$3" local proxy="$4" [[ $branch ]] && local part_cmd="-b $branch " - echo -e "开始拉取仓库 ${uniq_path} 到 $dir\n" + t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir" set_proxy "$proxy" @@ -278,18 +299,18 @@ reload_pm2() { return 0 else local exit_code=$? - echo "警告: PM2 启动失败 (退出码: $exit_code),可能是由于硬件不兼容" - echo "正在尝试直接使用 Node.js 启动服务..." - + t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code" + t '正在尝试直接使用 Node.js 启动服务...' + # Kill any existing node processes for qinglong pkill -f "node.*static/build/app.js" 2>/dev/null || true - + # Start node directly in the background nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 & local node_pid=$! - - echo "已使用 Node.js 直接启动服务 (PID: $node_pid)" - echo "注意: 使用此模式时,部分 PM2 管理功能将不可用" + + t '已使用 Node.js 直接启动服务 (PID: %s)' "$node_pid" + t '注意: 使用此模式时,部分 PM2 管理功能将不可用' return 0 fi } @@ -361,16 +382,16 @@ handle_task_start() { error_message=", 任务状态更新失败(${error})" fi fi - echo -e "## 开始执行... ${begin_time}${error_message}\n" + t '## 开始执行... %s\n' "${begin_time}${error_message}" } run_task_before() { . $file_task_before "$@" if [[ ${task_before:=} ]]; then - echo -e "执行前置命令\n" + t '执行前置命令\n' eval "${task_before%;}" - echo -e "\n执行前置命令结束\n" + t '\n执行前置命令结束\n' fi } @@ -378,9 +399,9 @@ run_task_after() { . $file_task_after "$@" if [[ ${task_after:=} ]]; then - echo -e "\n执行后置命令\n" + t '\n执行后置命令\n' eval "${task_after%;}" - echo -e "\n执行后置命令结束" + t '\n执行后置命令结束' fi } @@ -402,11 +423,11 @@ handle_task_end() { record_cron_stat "$ID" "${exit_code:-0}" "$diff_time" if [[ "${MANUAL:=}" == "true" ]]; then - echo -e "\n## 已停止 🛑... $end_time 耗时 $diff_time 秒${error_message:=}     " + t '\n## 已停止 🛑... %s 耗时 %s 秒%s' "$end_time" "$diff_time" "${error_message:=}     " elif [[ $exit_code -eq 0 ]]; then - echo -e "\n## 完成 ✅... $end_time 耗时 $diff_time 秒${error_message:=}     " + t '\n## 完成 ✅... %s 耗时 %s 秒%s' "$end_time" "$diff_time" "${error_message:=}     " else - echo -e "\n## 失败 ❌(退出码 ${exit_code})... $end_time 耗时 $diff_time 秒${error_message:=}     " + t '\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s' "$exit_code" "$end_time" "$diff_time" "${error_message:=}     " fi } diff --git a/shell/start.sh b/shell/start.sh index 3f4bb9cd..ab6b7648 100644 --- a/shell/start.sh +++ b/shell/start.sh @@ -12,23 +12,23 @@ if [[ ! $QL_DIR ]]; then elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then QL_DIR="$pnpm_dir/@whyour/qinglong" else - echo -e "未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装" + t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' fi if [[ $QL_DIR ]]; then - echo -e "请先手动设置 export QL_DIR=$QL_DIR,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务" + t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR" fi exit 1 fi if [[ ! $QL_DATA_DIR ]]; then - echo -e "请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量" + t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' exit 1 fi if [[ $QL_DATA_DIR != */data ]]; then - echo -e "QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中" + t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' exit 1 fi @@ -69,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 ;; *) - echo -e "暂不支持此系统部署 $os_name" + t '暂不支持此系统部署 %s' "$os_name" exit 1 ;; esac diff --git a/shell/task.sh b/shell/task.sh index ec18ce1a..e18e18cb 100755 --- a/shell/task.sh +++ b/shell/task.sh @@ -4,7 +4,13 @@ dir_shell=$QL_DIR/shell . $dir_shell/share.sh . $dir_shell/api.sh -trap "single_hanle" 2 3 20 15 14 19 1 +trap 'single_hanle SIGINT' INT +trap 'single_hanle SIGTERM' TERM +trap 'single_hanle SIGHUP' HUP +trap 'single_hanle SIGALRM' ALRM +trap 'single_hanle SIGTSTP' TSTP +trap 'single_hanle SIGQUIT' QUIT + single_hanle() { _task_exit_code="${_task_exit_code:-$?}" [[ "$_task_exit_code" == "0" ]] && _task_exit_code="1" diff --git a/shell/update.sh b/shell/update.sh index 613ebe69..cdd6a6dc 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -33,7 +33,7 @@ output_list_add_drop() { local list=$1 local type=$2 if [[ -s $list ]]; then - echo -e "检测到有${type}的定时任务:" + t '检测到有%s的定时任务:' "$type" cat $list fi } @@ -45,7 +45,7 @@ del_cron() { local path=$2 local detail="" local ids="" - echo -e "\n开始尝试自动删除失效的定时任务..." + t '\n开始尝试自动删除失效的定时任务...' for cron in $(cat $list_drop); do local id=$(cat $list_crontab_user | grep -E "$cmd_task.* $cron" | perl -pe "s|.*ID=(.*) $cmd_task.* $cron\.*|\1|" | head -1 | awk -F " " '{print $1}') if [[ $ids ]]; then @@ -76,7 +76,7 @@ del_cron() { add_cron() { local list_add=$1 local path=$2 - echo -e "\n开始尝试自动添加定时任务..." + t '\n开始尝试自动添加定时任务...' local detail="" cd $dir_scripts for file in $(cat $list_add); do @@ -136,10 +136,10 @@ update_repo() { git_clone_scripts "${formatUrl}" ${repo_path} "${branch}" "${proxy}" if [[ $exit_status -eq 0 ]]; then - echo -e "拉取 ${uniq_path} 成功...\n" + t '拉取 %s 成功...\n' "${uniq_path}" diff_scripts "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions" "$autoAddCron" "$autoDelCron" else - echo -e "拉取 ${uniq_path} 失败,请检查日志...\n" + t '拉取 %s 失败,请检查日志...\n' "${uniq_path}" fi } @@ -160,7 +160,7 @@ update_raw() { local raw_url="$url" local suffix="${raw_url##*.}" local raw_file_name="${uniq_path}.${suffix}" - echo -e "开始下载:${raw_url} \n\n保存路径:$dir_raw/${raw_file_name}\n" + t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}" set_proxy "$proxy" wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url} @@ -169,7 +169,7 @@ update_raw() { if [[ $? -eq 0 ]]; then mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" - echo -e "下载 ${raw_file_name} 成功...\n" + t '下载 %s 成功...\n' "${raw_file_name}" 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}') @@ -196,7 +196,7 @@ update_raw() { # update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id" fi else - echo -e "下载 ${raw_file_name} 失败,保留之前正常下载的版本...\n" + t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}" [[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new" fi @@ -207,13 +207,13 @@ run_extra_shell() { if [[ -f $file_extra_shell ]]; then . $file_extra_shell else - echo -e "$file_extra_shell文件不存在,跳过执行...\n" + t '%s文件不存在,跳过执行...\n' "$file_extra_shell" fi } ## 脚本用法 usage() { - echo -e "$cmd_update 命令使用方法:" + t "$cmd_update 命令使用方法:" echo -e "1. $cmd_update update # 更新并重启青龙" echo -e "2. $cmd_update extra # 运行自定义脚本" echo -e "3. $cmd_update raw # 更新单个脚本文件" @@ -268,7 +268,7 @@ update_qinglong() { downloadQLUrl="https://github.com/whyour/qinglong/archive/refs/heads" downloadStaticUrl="https://github.com/whyour/qinglong-static/archive/refs/heads" fi - echo -e "使用 ${mirror} 源更新...\n" + t '使用 %s 源更新...\n' "${mirror}" local primary_branch="master" if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then @@ -279,13 +279,13 @@ update_qinglong() { exit_status=$? if [[ $exit_status -eq 0 ]]; then - echo -e "更新青龙源文件成功...\n" + t '更新青龙源文件成功...\n' unzip -oq ${dir_tmp}/ql.zip -d ${dir_tmp} update_qinglong_static else - echo -e "更新青龙源文件失败,请检查网络...\n" + t '更新青龙源文件失败,请检查网络...\n' fi } @@ -294,30 +294,30 @@ update_qinglong_static() { exit_status=$? if [[ $exit_status -eq 0 ]]; then - echo -e "更新青龙静态资源成功...\n" + t '更新青龙静态资源成功...\n' unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp} check_update_dep else - echo -e "更新青龙静态资源失败,请检查网络...\n" + t '更新青龙静态资源失败,请检查网络...\n' fi } check_update_dep() { - echo -e "\n开始检测依赖...\n" + t '\n开始检测依赖...\n' if [[ $(diff $dir_root/package.json ${dir_tmp}/qinglong-${primary_branch}/package.json) ]]; then npm_install_2 "${dir_tmp}/qinglong-${primary_branch}" fi if [[ $exit_status -eq 0 ]]; then - echo -e "\n依赖检测安装成功...\n" - echo -e "更新包下载成功..." + t '\n依赖检测安装成功...\n' + t '更新包下载成功...\n' if [[ "$needRestart" == 'true' ]]; then reload_qinglong "system" fi else - echo -e "\n依赖检测安装失败,请检查网络...\n" + t '\n依赖检测安装失败,请检查网络...\n' fi } @@ -514,7 +514,7 @@ main() { if [[ -n $p2 ]]; then update_repo "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" "$p9" "$p10" else - eval echo -e "命令输入错误...\\\n" $cmd + t '命令输入错误...\n' eval usage $cmd fi ;; @@ -523,7 +523,7 @@ main() { if [[ -n $p2 ]]; then update_raw "$p2" "$p3" "$p4" "$p5" else - eval echo -e "命令输入错误...\\\n" $cmd + t '命令输入错误...\n' eval usage $cmd fi ;; @@ -549,7 +549,7 @@ main() { eval update_auth_config "\\\"username\\\":\\\"$p2\\\"" "重置用户名" $cmd ;; *) - eval echo -e "命令输入错误...\\\n" $cmd + t '命令输入错误...\n' eval usage $cmd ;; esac diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index abc5c759..940d5ddb 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -89,7 +89,7 @@ export default function () { if (code === 200 && data.username) { setUser(data); if (location.pathname === '/') { - history.push('/crontab'); + history.push('/dashboard'); } } needLoading && setLoading(false); @@ -218,7 +218,7 @@ export default function () { if (['/login', '/initialization', '/error'].includes(location.pathname)) { if (systemInfo?.isInitialized && location.pathname === '/initialization') { - history.push('/crontab'); + history.push('/dashboard'); } if (systemInfo || location.pathname === '/error') { diff --git a/src/pages/error/index.tsx b/src/pages/error/index.tsx index a51994fe..df8f5a12 100644 --- a/src/pages/error/index.tsx +++ b/src/pages/error/index.tsx @@ -54,7 +54,7 @@ const Error = () => { useEffect(() => { if (user && user.username) { - history.push('/crontab'); + history.push('/dashboard'); } }, [user]); From 369dd132121db73d2222bc8845c6eb3e8af55998 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 21 Jun 2026 12:12:29 +0800 Subject: [PATCH 052/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20deps=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=BE=9D=E8=B5=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-docker-image.yml | 2 +- back/loaders/deps.ts | 26 +----------------------- shell/otask.sh | 3 +++ shell/pub.sh | 16 +++++++-------- 4 files changed, 13 insertions(+), 34 deletions(-) diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index f7964132..125be7d3 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -192,7 +192,7 @@ jobs: latest=false tags: | type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} - type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=semver,pattern={{version}} diff --git a/back/loaders/deps.ts b/back/loaders/deps.ts index cb2ed171..d9413f4b 100644 --- a/back/loaders/deps.ts +++ b/back/loaders/deps.ts @@ -1,22 +1,9 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; -import chokidar from 'chokidar'; import config from '../config/index'; import Logger from './logger'; -async function linkToNodeModule(src: string, dst?: string) { - const target = path.join(config.rootPath, 'node_modules', dst || src); - const source = path.join(config.rootPath, src); - - try { - const stats = await fs.lstat(target); - if (!stats) { - await fs.symlink(source, target, 'dir'); - } - } catch (error) { } -} - async function linkCommand() { const homeDir = os.homedir(); let userBinDir = path.join(homeDir, 'bin'); @@ -59,17 +46,6 @@ async function linkCommandToDir(commandDir: string) { } } -export default async (src: string = 'deps') => { +export default async () => { await linkCommand(); - await linkToNodeModule(src); - - const source = path.join(config.rootPath, src); - const watcher = chokidar.watch(source, { - ignored: /(^|[\/\\])\../, // ignore dotfiles - persistent: true, - }); - - watcher - .on('add', () => linkToNodeModule(src)) - .on('change', () => linkToNodeModule(src)); }; diff --git a/shell/otask.sh b/shell/otask.sh index 75451e36..d311cce7 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -92,6 +92,9 @@ clear_non_sh_env() { append_node_dependency_path() { export PREV_NODE_PATH="${NODE_PATH:=}" + # 用户依赖目录加入 NODE_PATH,替代 symlink 到 node_modules 的方式 + export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${dir_dep}" + local pnpm_global_path=$(pnpm root -g 2>/dev/null) if [[ -n "$pnpm_global_path" ]]; then export QL_NODE_GLOBAL_PATH="$pnpm_global_path" diff --git a/shell/pub.sh b/shell/pub.sh index 1d5794f4..1d126bc6 100755 --- a/shell/pub.sh +++ b/shell/pub.sh @@ -1,26 +1,26 @@ #!/usr/bin/env bash echo -e "开始发布" -echo -e "切换 debian 分支" -git branch -D debian -git checkout -b debian -git push --set-upstream origin debian -f +echo -e "切换master分支" +git branch -D master +git checkout -b master +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-debian" +echo -e "当前版本$version" echo -e "删除已经存在的本地tag" -git tag -d "$version-debian" &>/dev/null +git tag -d "$version" &>/dev/null echo -e "删除已经存在的远程tag" -git push origin :refs/tags/$version-debian &>/dev/null +git push origin :refs/tags/$version &>/dev/null echo -e "创建新tag" -git tag -a "$version-debian" -m "release $version-debian" +git tag -a "$version" -m "release $version" echo -e "提交tag" git push --tags From 3044f63f031b14bdf7c6415967bf07abd155c9c9 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 21 Jun 2026 23:53:32 +0800 Subject: [PATCH 053/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BD=E9=99=85?= =?UTF-8?q?=E5=8C=96=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/cron.ts | 4 +- back/api/dashboard.ts | 31 +++++++------ back/config/index.ts | 5 --- back/config/util.ts | 6 +-- back/data/sock.ts | 2 + back/loaders/initTask.ts | 5 ++- back/services/cron.ts | 16 ++++--- back/services/dependence.ts | 72 +++++++++++++++++++------------ back/services/notify.ts | 10 ++--- back/services/subscription.ts | 18 +++++--- back/services/system.ts | 17 ++++++-- back/services/user.ts | 46 ++++++++++++++------ back/shared/i18n.ts | 56 ++++++++++++++++++++++++ back/shared/pLimit.ts | 11 ++++- src/locales/en-US.json | 8 +++- src/locales/zh-CN.json | 8 +++- src/pages/crontab/detail.tsx | 4 +- src/pages/crontab/logModal.tsx | 10 ++--- src/pages/dependence/index.tsx | 64 +++++++++++---------------- src/pages/dependence/logModal.tsx | 26 ++++++----- src/pages/env/index.tsx | 2 +- src/pages/log/index.tsx | 4 +- src/pages/script/index.tsx | 8 ++-- src/pages/setting/checkUpdate.tsx | 24 ++++++----- src/pages/setting/dependence.tsx | 11 ++--- src/pages/setting/other.tsx | 2 +- src/pages/setting/systemLog.tsx | 2 +- src/pages/subscription/modal.tsx | 50 +++++++++++++++------ 28 files changed, 335 insertions(+), 187 deletions(-) diff --git a/back/api/cron.ts b/back/api/cron.ts index 2ebe802b..6be6f4a3 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -312,8 +312,8 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.log(req.params.id); - return res.send({ code: 200, data }); + const result = await cronService.log(req.params.id); + return res.send({ code: 200, data: result.content, logStatus: result.status }); } catch (e) { return next(e); } diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 799503aa..5e9438a3 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -9,6 +9,8 @@ import { } from '../data/runningInstance'; import dayjs from 'dayjs'; import os from 'os'; +import { isEmpty } from 'lodash'; +import { t, tf } from '../shared/i18n'; const route = Router(); @@ -181,7 +183,7 @@ export default (app: Router) => { const data = rows.map((r: any, i) => ({ rank: i + 1, - name: nameMap[Number(r.ref_id)] || `任务#${r.ref_id}`, + name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id), avgTime: Math.round(Number(r.total_time) / Number(r.run_count)), maxTime: Number(r.max_time), })); @@ -223,7 +225,7 @@ export default (app: Router) => { const data = rows.map((r: any, i) => ({ rank: i + 1, - name: nameMap[Number(r.ref_id)] || `任务#${r.ref_id}`, + name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id), runCount: Number(r.run_count), avgTime: Math.round(Number(r.total_time) / Number(r.run_count)), successRate: @@ -264,9 +266,9 @@ export default (app: Router) => { const crons = cronIds.length > 0 ? await CrontabModel.findAll({ - where: { id: cronIds }, - raw: true, - }) + where: { id: cronIds }, + raw: true, + }) : []; const cronMap = new Map(crons.map((c: any) => [c.id, c])); @@ -276,7 +278,7 @@ export default (app: Router) => { return { instanceId: inst.id, id: inst.cron_id, - name: cron?.name || cron?.command || `任务#${inst.cron_id}`, + name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id), pid: inst.pid, elapsed: inst.started_at ? now - inst.started_at : 0, logPath: inst.log_path, @@ -303,7 +305,7 @@ export default (app: Router) => { running, idleTasks: idleTasks.map((c: any) => ({ id: c.id, - name: c.name || c.command || `任务#${c.id}`, + name: c.name || c.command || tf('任务#%s', c.id), lastRun: c.last_execution_time ? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm') : '-', @@ -324,17 +326,22 @@ export default (app: Router) => { const [crons, stats] = (await Promise.all([ CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }), CrontabStatModel.findAll({ where: { date: today }, raw: true }), - ])) as any[]; + ])); const statMap: Record = {}; stats.forEach((s: any) => { statMap[s.ref_id] = s; }); const labelMap: Record = {}; - crons.forEach((c: any) => { + crons.forEach((c) => { let rawLabels = c.labels; if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels); - const labels: string[] = Array.isArray(rawLabels) && rawLabels.length > 0 ? rawLabels : ['未分类']; - const st = statMap[c.id]; + const labels: string[] = Array.isArray(rawLabels) + ? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))] + : []; + if (labels.length === 0) { + labels.push(t('未分类')); + } + const st = statMap[c.id!]; labels.forEach((label: string) => { if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 }; labelMap[label].count += 1; @@ -372,7 +379,7 @@ export default (app: Router) => { code: 200, data: { platform: os.platform(), - uptime: Math.floor(os.uptime()), + uptime: Math.floor(process.uptime()), memTotal: os.totalmem(), memFree: os.freemem(), memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1), diff --git a/back/config/index.ts b/back/config/index.ts index 869f5d10..00b2a7b8 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -1,6 +1,5 @@ import dotenv from 'dotenv'; import path from 'path'; -import { createRandomString } from './share'; dotenv.config({ path: path.join(__dirname, '../../.env'), @@ -119,8 +118,6 @@ const confBakDir = path.join(dataPath, 'config/bak/'); const sampleFile = path.join(samplePath, 'config.sample.sh'); const sqliteFile = path.join(samplePath, 'database.sqlite'); -const authError = '错误的用户名密码,请重试'; -const loginFaild = '请先登录!'; const configString = 'config sample crontab shareCode diy'; const versionFile = path.join(rootPath, 'version.yaml'); const dataTgzFile = path.join(tmpPath, 'data.tgz'); @@ -142,8 +139,6 @@ export default { shareShellFile, dependenceProxyFile, configString, - loginFaild, - authError, logPath, extraFile, authConfigFile, diff --git a/back/config/util.ts b/back/config/util.ts index 4a654bb7..f7f123a7 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -536,7 +536,7 @@ export function safeJSONParse(value?: string) { try { return JSON.parse(value); } catch (error) { - Logger.error('[safeJSONParse失败]', error); + Logger.error('[safeJSONParse error]', error); return {}; } } @@ -548,7 +548,7 @@ export async function rmPath(path: string) { await fs.rm(path, { force: true, recursive: true, maxRetries: 5 }); } } catch (error) { - Logger.error('[rmPath失败]', error); + Logger.error('[rmPath error]', error); } } @@ -563,7 +563,7 @@ export async function setSystemTimezone(timezone: string): Promise { return true; } catch (error) { - Logger.error('[setSystemTimezone失败]', error); + Logger.error('[setSystemTimezone error]', error); return false; } } diff --git a/back/data/sock.ts b/back/data/sock.ts index 8dae8e13..bb4d7798 100644 --- a/back/data/sock.ts +++ b/back/data/sock.ts @@ -2,11 +2,13 @@ export class SockMessage { message?: string; type?: SockMessageType; references?: number[]; + status?: number | string; constructor(options: SockMessage) { this.type = options.type; this.message = options.message; this.references = options.references; + this.status = options.status; } } diff --git a/back/loaders/initTask.ts b/back/loaders/initTask.ts index bdc57d42..ed98c0a0 100644 --- a/back/loaders/initTask.ts +++ b/back/loaders/initTask.ts @@ -6,6 +6,7 @@ import SshKeyService from '../services/sshKey'; import config from '../config'; import { fileExist } from '../config/util'; import { join } from 'path'; +import { t } from '../shared/i18n'; export default async () => { const systemService = Container.get(SystemService); @@ -25,7 +26,7 @@ export default async () => { } const cron = { id: NaN, - name: '生成token', + name: t('生成token'), command: tokenCommand, runOrigin: 'system', } as ScheduleTaskType; @@ -44,7 +45,7 @@ export default async () => { if (data.info.logRemoveFrequency) { const rmlogCron = { id: data.id as number, - name: '删除日志', + name: t('删除日志'), command: `ql rmlog ${data.info.logRemoveFrequency}`, runOrigin: 'system' as const, }; diff --git a/back/services/cron.ts b/back/services/cron.ts index 6ecc9088..16e24693 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -712,23 +712,27 @@ export default class CronService { await this.setCrontab(); } - public async log(id: number) { + public async log(id: number): Promise<{ content: string; status: string }> { const doc = await this.getDb({ id }); if (!doc) { - return ''; + return { content: '', status: 'empty' }; } if (doc.log_name === '/dev/null') { - return '日志设置为忽略'; + return { content: t('日志设置为忽略'), status: 'ignored' }; } const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const logFileExist = doc.log_path && (await fileExist(absolutePath)); if (logFileExist) { - return await getFileContentByName(`${absolutePath}`); + const content = await getFileContentByName(`${absolutePath}`); + const isRunning = + typeof doc.status === 'number' && + [CrontabStatus.running, CrontabStatus.queued].includes(doc.status); + return { content, status: isRunning ? 'running' : 'completed' }; } else { return typeof doc.status === 'number' && [CrontabStatus.queued, CrontabStatus.running].includes(doc.status) - ? '运行中...' - : '日志不存在...'; + ? { content: t('运行中...'), status: 'running' } + : { content: t('日志不存在...'), status: 'notFound' }; } } diff --git a/back/services/dependence.ts b/back/services/dependence.ts index c00422b3..d384ec76 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -24,6 +24,7 @@ import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; import { detectOS } from '../config/util'; import { LINUX_DEPENDENCE_COMMAND } from '../config/const'; +import { t, tf } from '../shared/i18n'; @Service() export default class DependenceService { @@ -232,7 +233,7 @@ export default class DependenceService { } const depIds = [dependency.id!]; let depName = dependency.name.trim(); - const actionText = isInstall ? '安装' : '删除'; + const actionText = isInstall ? t('安装') : t('删除'); const socketMessageType = isInstall ? 'installDependence' : 'uninstallDependence'; @@ -249,15 +250,20 @@ export default class DependenceService { { where: { id: depIds } }, ); const startTime = dayjs(); - const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( - 'YYYY-MM-DD HH:mm:ss', - )}\n\n当前系统不支持\n\n依赖${actionText}失败,结束时间 ${startTime.format( - 'YYYY-MM-DD HH:mm:ss', - )},耗时 ${startTime.diff(startTime, 'second')} 秒`; + const message = tf( + '开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒', + actionText, + depName, + startTime.format('YYYY-MM-DD HH:mm:ss'), + actionText, + startTime.format('YYYY-MM-DD HH:mm:ss'), + String(startTime.diff(startTime, 'second')), + ); this.sockService.sendMessage({ type: socketMessageType, message, references: depIds, + status: DependenceStatus.installFailed, }); this.updateLog(depIds, message); return resolve(null); @@ -280,13 +286,17 @@ export default class DependenceService { } const startTime = dayjs(); - const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( - 'YYYY-MM-DD HH:mm:ss', - )}\n\n`; + const message = tf( + '开始%s依赖 %s,开始时间 %s\n\n', + actionText, + depName, + startTime.format('YYYY-MM-DD HH:mm:ss'), + ); this.sockService.sendMessage({ type: socketMessageType, message, references: depIds, + status, }); this.updateLog(depIds, message); @@ -322,13 +332,19 @@ export default class DependenceService { (!depVersion || depInfo.includes(depVersion)) ) { const endTime = dayjs(); - const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format( - 'YYYY-MM-DD HH:mm:ss', - )},耗时 ${endTime.diff(startTime, 'second')} 秒`; + const _message = tf( + '检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒', + depName, + depInfo, + actionText, + endTime.format('YYYY-MM-DD HH:mm:ss'), + String(endTime.diff(startTime, 'second')), + ); this.sockService.sendMessage({ type: socketMessageType, message: _message, references: depIds, + status: DependenceStatus.installed, }); this.updateLog(depIds, _message); await DependenceModel.update( @@ -353,6 +369,7 @@ export default class DependenceService { type: socketMessageType, message: data.toString(), references: depIds, + status, }); this.updateLog(depIds, data.toString()); }); @@ -362,6 +379,7 @@ export default class DependenceService { type: socketMessageType, message: data.toString(), references: depIds, + status, }); this.updateLog(depIds, data.toString()); }); @@ -371,6 +389,7 @@ export default class DependenceService { type: socketMessageType, message: JSON.stringify(err), references: depIds, + status, }); this.updateLog(depIds, JSON.stringify(err)); }); @@ -378,28 +397,27 @@ export default class DependenceService { cp.on('exit', async (code) => { const endTime = dayjs(); const isSucceed = code === 0; - const resultText = isSucceed ? '成功' : '失败'; + const resultText = isSucceed ? t('成功') : t('失败'); - const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format( - 'YYYY-MM-DD HH:mm:ss', - )},耗时 ${endTime.diff(startTime, 'second')} 秒`; + const message = + '\n' + + 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); this.sockService.sendMessage({ type: socketMessageType, message, references: depIds, + status: exitStatus, }); this.updateLog(depIds, message); - let status: number; - if (isSucceed) { - status = isInstall - ? DependenceStatus.installed - : DependenceStatus.removed; - } else { - status = isInstall - ? DependenceStatus.installFailed - : DependenceStatus.removeFailed; - } const docs = await DependenceModel.findAll({ where: { id: depIds } }); const _docIds = docs .filter((x) => x.status !== DependenceStatus.cancelled) @@ -407,7 +425,7 @@ export default class DependenceService { if (_docIds.length > 0) { await DependenceModel.update( - { status }, + { status: exitStatus }, { where: { id: _docIds } }, ); } diff --git a/back/services/notify.ts b/back/services/notify.ts index abf1f96c..099aca12 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -365,7 +365,7 @@ export default class NotificationService { { title: `${this.title}`, thumb_media_id, - author: `智能助手`, + author: t('智能助手'), content_source_url: ``, content: `${this.content.replace(/\n/g, '
')}`, digest: `${this.content}`, @@ -381,7 +381,7 @@ export default class NotificationService { title: `${this.title}`, description: `${this.content}`, url: 'https://github.com/whyour/qinglong', - btntxt: '更多', + btntxt: t('更多'), }, }; break; @@ -432,7 +432,7 @@ export default class NotificationService { roomName: `${aibotkName}`, message: { type: 1, - content: `【青龙快讯】\n\n${this.title}\n${this.content}`, + content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`, }, }; break; @@ -443,7 +443,7 @@ export default class NotificationService { name: `${aibotkName}`, message: { type: 1, - content: `【青龙快讯】\n\n${this.title}\n${this.content}`, + content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`, }, }; break; @@ -613,7 +613,7 @@ export default class NotificationService { }); const info = await transporter.sendMail({ - from: `"青龙快讯" <${emailUser}>`, + from: `"${t('青龙快讯')}" <${emailUser}>`, to: recipients, subject: `${this.title}`, html: `${this.content.replace(/\n/g, '
')}`, diff --git a/back/services/subscription.ts b/back/services/subscription.ts index ed7f5b40..a387bb1b 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -24,7 +24,7 @@ import path, { join } from 'path'; import ScheduleService, { TaskCallbacks } from './schedule'; import { SimpleIntervalSchedule } from 'toad-scheduler'; import SockService from './sock'; -import { t } from '../shared/i18n'; +import { t, tf } from '../shared/i18n'; import SshKeyService from './sshKey'; import dayjs from 'dayjs'; import { LOG_END_SYMBOL } from '../config/const'; @@ -131,14 +131,14 @@ export default class SubscriptionService { ); const absolutePath = await handleLogPath( logPath as string, - `## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`, + tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')), ); // 执行sub_before let beforeStr = ''; try { if (doc.sub_before) { - await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`); + await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`); beforeStr = await promiseExec(doc.sub_before); } } catch (error: any) { @@ -165,7 +165,7 @@ export default class SubscriptionService { let afterStr = ''; try { if (sub.sub_after) { - await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`); + await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`); afterStr = await promiseExec(sub.sub_after); } } catch (error: any) { @@ -178,9 +178,13 @@ export default class SubscriptionService { await logStreamManager.write( absolutePath, - `\n## 执行结束... ${endTime.format( - 'YYYY-MM-DD HH:mm:ss', - )} 耗时 ${diff} 秒${LOG_END_SYMBOL}`, + '\n' + + tf( + '## 执行结束... %s 耗时 %s 秒', + endTime.format('YYYY-MM-DD HH:mm:ss'), + String(diff), + ) + + LOG_END_SYMBOL, ); // Close the stream after task completion diff --git a/back/services/system.ts b/back/services/system.ts index 4f658622..0bd6aef8 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -78,8 +78,8 @@ export default class SystemService { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, - '青龙', - `【蛟龙】测试通知 https://t.me/jiao_long`, + t('青龙'), + t('【蛟龙】测试通知 https://t.me/jiao_long'), ); if (isSuccess) { const result = await this.updateAuthDb({ @@ -100,7 +100,7 @@ export default class SystemService { }); const cron = { id: result.id as number, - name: '删除日志', + name: t('删除日志'), command: `ql rmlog ${info.logRemoveFrequency}`, runOrigin: 'system' as const, }; @@ -179,6 +179,7 @@ export default class SystemService { this.sockService.sendMessage({ type: 'updateNodeMirror', message: 'update node mirror end', + status: 'completed', }); }, onError: async (message: string) => { @@ -232,6 +233,7 @@ export default class SystemService { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'update linux mirror end', + status: 'completed', }); onEnd?.(); if (!hasError) { @@ -340,6 +342,15 @@ export default class SystemService { 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', }); }); diff --git a/back/services/user.ts b/back/services/user.ts index 31d66864..88f00a11 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -25,7 +25,7 @@ import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; -import { t } from '../shared/i18n'; +import { t, tf } from '../shared/i18n'; @Service() export default class UserService { @@ -67,7 +67,7 @@ export default class UserService { ); return { code: 410, - message: `失败次数过多,请${waitTime}秒后重试`, + message: tf('失败次数过多,请%s秒后重试', waitTime), data: waitTime, }; } @@ -128,10 +128,19 @@ export default class UserService { isTwoFactorChecking: false, }); this.notificationService.notify( - '登录通知', - `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ - req.platform - }端 登录成功,ip地址 ${ip}`, + t('登录通知'), + t('你于') + + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + + t('在') + + address + + ' ' + + req.platform + + t('端') + + ' ' + + t('登录成功') + + t(',ip地址') + + ' ' + + ip, ); await this.insertDb({ type: AuthDataType.loginLog, @@ -164,10 +173,19 @@ export default class UserService { platform: req.platform, }); this.notificationService.notify( - '登录通知', - `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ - req.platform - }端 登录失败,ip地址 ${ip}`, + t('登录通知'), + t('你于') + + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + + t('在') + + address + + ' ' + + req.platform + + t('端') + + ' ' + + t('登录失败') + + t(',ip地址') + + ' ' + + ip, ); await this.insertDb({ type: AuthDataType.loginLog, @@ -184,11 +202,11 @@ export default class UserService { const waitTime = Math.round(Math.pow(3, retries + 1)); return { code: 410, - message: `失败次数过多,请${waitTime}秒后重试`, + message: tf('失败次数过多,请%s秒后重试', waitTime), data: waitTime, }; } else { - return { code: 400, message: config.authError }; + return { code: 400, message: t('错误的用户名密码,请重试') }; } } } @@ -389,8 +407,8 @@ export default class UserService { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, - '青龙', - `【蛟龙】测试通知 https://t.me/jiao_long`, + t('青龙'), + t('【蛟龙】测试通知 https://t.me/jiao_long'), ); if (isSuccess) { const result = await this.updateAuthDb({ diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 04eaa769..b8d73847 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -80,6 +80,55 @@ const messages: Record> = { '订阅执行完成': 'Subscription completed', 'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids', '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!', + '运行中...': 'Running...', + '日志不存在...': 'Log does not exist...', + '未分类': 'Uncategorized', + '任务重复运行': 'Duplicate task execution', + '日志设置为忽略': 'Log set to ignore', + '定时规则不能为空': 'Schedule rule cannot be empty', + '无效的定时规则': 'Invalid schedule rule', + '日志名称只能包含字母、数字、下划线和连字符': + 'Log name can only contain letters, numbers, underscores, and hyphens', + '日志名称不能超过100个字符': 'Log name cannot exceed 100 characters', + '错误的用户名密码,请重试': 'Incorrect username or password, please try again', + '青龙快讯': 'QingLong', + '登录通知': 'Login Notification', + '你于': 'You at ', + '在': ' in ', + 端: '', + 登录失败: 'login failed', + ',ip地址': ', IP: ', + '任务#%s': 'Task#%s', + 安装: 'Install', + 删除: 'Uninstall', + 成功: 'Succeeded', + 失败: 'Failed', + '失败次数过多,请%s秒后重试': + 'Too many failed attempts, please retry in %s seconds', + 智能助手: 'Smart Assistant', + 更多: 'More', + '开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒': + 'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds', + '检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒': + 'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds', + '开始%s依赖 %s,开始时间 %s\n\n': + 'Start %s dependency %s, start time %s\n\n', + '依赖%s%s,结束时间 %s,耗时 %s 秒': + 'Dependency %s%s, end time %s, elapsed %s seconds', + '任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置': + 'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings', + 青龙: 'QingLong', + '【蛟龙】测试通知 https://t.me/jiao_long': + '[JiaoLong] Test notification https://t.me/jiao_long', + '生成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', }, }; @@ -100,3 +149,10 @@ export function t(key: string, lang?: string): string { } return key; } + +export function tf(key: string, ...args: (string | number)[]): string { + return args.reduce( + (str, arg) => str.replace(/%s|%d/, String(arg)), + t(key), + ); +} diff --git a/back/shared/pLimit.ts b/back/shared/pLimit.ts index 7d75678f..47f8b6c8 100644 --- a/back/shared/pLimit.ts +++ b/back/shared/pLimit.ts @@ -4,6 +4,7 @@ import { AuthDataType, SystemModel } from '../data/system'; import Logger from '../loaders/logger'; import { Dependence } from '../data/dependence'; import NotificationService from '../services/notify'; +import { t, tf } from '../shared/i18n'; import { ICronFn, IDependencyFn, @@ -152,8 +153,14 @@ class TaskLimit { this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1); this.client.systemNotify( { - title: '任务重复运行', - content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`, + title: t('任务重复运行'), + content: tf( + '任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置', + cron.name || '', + cron.command || '', + cron.schedule || '', + 5, + ), }, (err, res) => { if (err) { diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 762342a8..30ec0dd5 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -594,5 +594,11 @@ "黑名单": "Blacklist", "默认为 CPU 个数": "Default is the number of CPUs", ",保存后不可恢复": ", it can't be recovered after saving.", - ",删除后不可恢复": ", it can't be recovered after deletion" + ",删除后不可恢复": ", it can't be recovered after deletion", + "日志不存在": "Log does not exist", + "日志设置为忽略": "Log set to ignore", + "确认保存": "Confirm to save", + "上传失败": "Upload failed", + "成功上传": "Successfully uploaded", + "个环境变量": " environment variables" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 14b03eee..3c6deddb 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -594,5 +594,11 @@ "黑名单": "黑名单", "默认为 CPU 个数": "默认为 CPU 个数", ",保存后不可恢复": ",保存后不可恢复", - ",删除后不可恢复": ",删除后不可恢复" + ",删除后不可恢复": ",删除后不可恢复", + "日志不存在": "日志不存在", + "日志设置为忽略": "日志设置为忽略", + "确认保存": "确认保存", + "上传失败": "上传失败", + "成功上传": "成功上传", + "个环境变量": "个环境变量" } diff --git a/src/pages/crontab/detail.tsx b/src/pages/crontab/detail.tsx index 880180da..031d0ddb 100644 --- a/src/pages/crontab/detail.tsx +++ b/src/pages/crontab/detail.tsx @@ -298,7 +298,7 @@ const CronDetailModal = ({ const saveFile = () => { Modal.confirm({ - title: `确认保存`, + title: intl.get('确认保存'), content: ( <> {intl.get('确认保存文件')} @@ -323,7 +323,7 @@ const CronDetailModal = ({ .then(({ code, data }) => { if (code === 200) { setValue(content); - message.success(`保存成功`); + message.success(intl.get('保存成功')); } resolve(null); }) diff --git a/src/pages/crontab/logModal.tsx b/src/pages/crontab/logModal.tsx index b23af1bf..aa83c23c 100644 --- a/src/pages/crontab/logModal.tsx +++ b/src/pages/crontab/logModal.tsx @@ -46,17 +46,15 @@ const CronLogModal = ({ } request .get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`) - .then(({ code, data }) => { + .then(({ code, data, logStatus }) => { if ( code === 200 && localStorage.getItem("logCron") === uniqPath && data !== value ) { - const log = data as string; - setValue(log || intl.get("暂无日志")); - const hasNext = Boolean( - log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"), - ); + const log = (data as string) || intl.get("暂无日志"); + setValue(log); + const hasNext = logStatus === 'running'; if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) { setTimeout(() => { autoScroll(); diff --git a/src/pages/dependence/index.tsx b/src/pages/dependence/index.tsx index 7aac607c..1538af4d 100644 --- a/src/pages/dependence/index.tsx +++ b/src/pages/dependence/index.tsx @@ -465,49 +465,33 @@ const Dependence = () => { }, [logDependence]); const handleMessage = useCallback((payload: any) => { - const { message, references } = payload; - let status: number | undefined = undefined; - if (message.includes('开始时间') && references.length > 0) { - status = message.includes('安装') ? Status.安装中 : Status.删除中; - } - if (message.includes('结束时间') && references.length > 0) { - if (message.includes('安装')) { - status = message.includes('成功') ? Status.已安装 : Status.安装失败; - } else { - status = message.includes('成功') ? Status.已删除 : Status.删除失败; - } + const { references, status } = payload; + if (typeof status !== 'number' || !references?.length) return; - if (status === Status.已删除) { - setTimeout(() => { - setValue((p) => { - const _result = [...p]; - for (let i = 0; i < references.length; i++) { - const index = p.findIndex((x) => x.id === references[i]); - if (index !== -1) { - _result.splice(index, 1); - } - } - return _result; - }); - }, 300); - return; - } - } - if (typeof status === 'number') { - setValue((p) => { - const result = [...p]; - for (let i = 0; i < references.length; i++) { - const index = p.findIndex((x) => x.id === references[i]); - if (index !== -1) { - result.splice(index, 1, { - ...p[index], - status, - }); + if (status === Status.已删除) { + setTimeout(() => { + setValue((p) => { + const _result = [...p]; + for (const refId of references) { + const index = p.findIndex((x) => x.id === refId); + if (index !== -1) _result.splice(index, 1); } - } - return result; - }); + return _result; + }); + }, 300); + return; } + + setValue((p) => { + const result = [...p]; + for (const refId of references) { + const index = p.findIndex((x) => x.id === refId); + if (index !== -1) { + result.splice(index, 1, { ...p[index], status }); + } + } + return result; + }); }, []); useEffect(() => { diff --git a/src/pages/dependence/logModal.tsx b/src/pages/dependence/logModal.tsx index 69ec8b4e..66aad46e 100644 --- a/src/pages/dependence/logModal.tsx +++ b/src/pages/dependence/logModal.tsx @@ -54,8 +54,11 @@ const DependenceLogModal = ({ ) { const log = (data?.log || []).join('') as string; setValue(log); - setExecuting(!log.includes('结束时间')); - setIsRemoveFailed(log.includes('删除失败')); + const dbStatus = data?.status as number | undefined; + setExecuting( + dbStatus === Status.安装中 || dbStatus === Status.删除中, + ); + setIsRemoveFailed(dbStatus === Status.删除失败); } }) .finally(() => { @@ -94,16 +97,17 @@ const DependenceLogModal = ({ }, [dependence]); const handleMessage = (payload: any) => { - const { message, references } = payload; + const { message, references, status } = payload; if ( - references.length > 0 && - references.includes(dependence.id) && - [Status.删除中, Status.安装中].includes(dependence.status) - ) { - if (message.includes('结束时间')) { - setExecuting(false); - setIsRemoveFailed(message.includes('删除失败')); - } + !references?.length || + !references.includes(dependence.id) + ) return; + + if (typeof status === 'number') { + setExecuting(status === Status.安装中 || status === Status.删除中); + setIsRemoveFailed(status === Status.删除失败); + } + if (message) { setValue((p) => `${p}${message}`); } }; diff --git a/src/pages/env/index.tsx b/src/pages/env/index.tsx index 0cb2c610..7659abc1 100644 --- a/src/pages/env/index.tsx +++ b/src/pages/env/index.tsx @@ -603,7 +603,7 @@ const Env = () => { ); if (code === 200) { - message.success(`成功上传${data.length}个环境变量`); + message.success(`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`); getEnvs(); } setImportLoading(false); diff --git a/src/pages/log/index.tsx b/src/pages/log/index.tsx index 6cdf6f43..83ba585f 100644 --- a/src/pages/log/index.tsx +++ b/src/pages/log/index.tsx @@ -131,7 +131,7 @@ const Log = () => { const deleteFile = () => { Modal.confirm({ - title: `确认删除`, + title: intl.get('确认删除'), content: ( <> {intl.get('确认删除')} @@ -155,7 +155,7 @@ const Log = () => { }) .then(({ code }) => { if (code === 200) { - message.success(`删除成功`); + message.success(intl.get('删除成功')); let newData = [...data]; if (currentNode.parent) { newData = depthFirstSearch( diff --git a/src/pages/script/index.tsx b/src/pages/script/index.tsx index 8f605d58..f341c0f5 100644 --- a/src/pages/script/index.tsx +++ b/src/pages/script/index.tsx @@ -249,7 +249,7 @@ const Script = () => { const saveFile = () => { Modal.confirm({ - title: `确认保存`, + title: intl.get('确认保存'), content: ( <> {intl.get('确认保存文件')} @@ -273,7 +273,7 @@ const Script = () => { }) .then(({ code, data }) => { if (code === 200) { - message.success(`保存成功`); + message.success(intl.get('保存成功')); setValue(content); handleIsEditing(currentNode.title, false); } @@ -287,7 +287,7 @@ const Script = () => { const deleteFile = () => { Modal.confirm({ - title: `确认删除`, + title: intl.get('确认删除'), content: ( <> {intl.get('确认删除')} @@ -311,7 +311,7 @@ const Script = () => { }) .then(({ code }) => { if (code === 200) { - message.success(`删除成功`); + message.success(intl.get('删除成功')); let newData = [...data]; if (currentNode.parent) { newData = depthFirstSearch( diff --git a/src/pages/setting/checkUpdate.tsx b/src/pages/setting/checkUpdate.tsx index 16e320fc..228148e7 100644 --- a/src/pages/setting/checkUpdate.tsx +++ b/src/pages/setting/checkUpdate.tsx @@ -13,6 +13,7 @@ const CheckUpdate = ({ systemInfo }: any) => { const [updateLoading, setUpdateLoading] = useState(false); const [value, setValue] = useState(""); const modalRef = useRef(); + const lastStatusRef = useRef(); const checkUpgrade = () => { if (updateLoading) return; @@ -166,7 +167,7 @@ const CheckUpdate = ({ systemInfo }: any) => { useEffect(() => { if (!value) return; - const updateFailed = value.includes("失败,请检查"); + const updateFailed = lastStatusRef.current === 'failed'; modalRef.current.update({ maskClosable: updateFailed, @@ -184,26 +185,29 @@ const CheckUpdate = ({ systemInfo }: any) => { }, [value]); const handleMessage = useCallback((payload: any) => { - let { message: _message } = payload; - const updateFailed = _message.includes("失败,请检查"); + const { message: _message, status } = payload; - if (updateFailed) { + if (status === 'failed') { + lastStatusRef.current = 'failed'; message.error(intl.get("更新失败,请检查网络及日志或稍后再试")); } + if (status === 'success') { + lastStatusRef.current = 'success'; + setTimeout(() => { + showReloadModal(); + }, 1000); + } + setTimeout(() => { document .querySelector("#log-identifier") ?.scrollIntoView({ behavior: "smooth" }); }, 600); - if (_message.includes("更新包下载成功")) { - setTimeout(() => { - showReloadModal(); - }, 1000); + if (_message) { + setValue((p) => `${p}${_message}`); } - - setValue((p) => `${p}${_message}`); }, []); useEffect(() => { diff --git a/src/pages/setting/dependence.tsx b/src/pages/setting/dependence.tsx index 9a0974aa..684e4449 100644 --- a/src/pages/setting/dependence.tsx +++ b/src/pages/setting/dependence.tsx @@ -75,12 +75,9 @@ const Dependence = () => { }; const handleMessage = (payload: any) => { - const { message } = payload; - setLog((p) => `${p}${message}`); - if ( - message.includes('update node mirror end') || - message.includes('update linux mirror end') - ) { + const { message, status } = payload; + if (message) setLog((p) => `${p}${message}`); + if (status === 'completed') { setLoading(false); } }; @@ -109,7 +106,7 @@ const Dependence = () => { ws.subscribe('updateLinuxMirror', handleMessage); return () => { - ws.subscribe('updateNodeMirror', handleMessage); + ws.unsubscribe('updateNodeMirror', handleMessage); ws.unsubscribe('updateLinuxMirror', handleMessage); }; }, []); diff --git a/src/pages/setting/other.tsx b/src/pages/setting/other.tsx index 8ff0674f..60ed959e 100644 --- a/src/pages/setting/other.tsx +++ b/src/pages/setting/other.tsx @@ -379,7 +379,7 @@ const Other = ({ showReloadModal(); } if (file.status === 'error') { - message.error('上传失败'); + message.error(intl.get('上传失败')); } }} name="data" diff --git a/src/pages/setting/systemLog.tsx b/src/pages/setting/systemLog.tsx index 2e710d83..57235b72 100644 --- a/src/pages/setting/systemLog.tsx +++ b/src/pages/setting/systemLog.tsx @@ -51,7 +51,7 @@ const SystemLog = ({ height, theme }: any) => { const deleteLog = () => { request.delete(`${config.apiPrefix}system/log`).then((x) => { - message.success('删除成功'); + message.success(intl.get('删除成功')); refresh(); }); }; diff --git a/src/pages/subscription/modal.tsx b/src/pages/subscription/modal.tsx index d5b6afb4..d57dffaa 100644 --- a/src/pages/subscription/modal.tsx +++ b/src/pages/subscription/modal.tsx @@ -205,21 +205,47 @@ const SubscriptionModal = ({ ); }; + const parseArgs = (text: string): string[] => { + const args: string[] = []; + let current = ''; + let inDouble = false; + let inSingle = false; + let justClosed = false; + for (const ch of text) { + if (inDouble) { + if (ch === '"') { inDouble = false; justClosed = true; continue; } + current += ch; + } else if (inSingle) { + if (ch === "'") { inSingle = false; justClosed = true; continue; } + current += ch; + } else if (ch === '"') { + inDouble = true; + justClosed = false; + } else if (ch === "'") { + inSingle = true; + justClosed = false; + } else if (ch === ' ' || ch === '\t') { + if (current || justClosed) { args.push(current); current = ''; justClosed = false; } + } else { + current += ch; + justClosed = false; + } + } + if (current || justClosed) args.push(current); + return args; + }; + const onPaste = useCallback((e: any) => { const text = e.clipboardData.getData('text') as string; if (text.startsWith('ql ')) { - const [ - , - type, - url, - whitelist, - blacklist, - dependences, - branch, - extensions, - ] = text - .split(' ') - .map((x) => x.trim().replace(/\"/g, '').replace(/\'/, '')); + const args = parseArgs(text); + const type = args[1]; + const url = args[2] || ''; + const whitelist = args[3] || ''; + const blacklist = args[4] || ''; + const dependences = args[5] || ''; + const branch = args[6] || ''; + const extensions = args[7] || ''; const _type = type === 'raw' ? 'file' From 5fbff0e1c8fd4a67af950c510bb9412ab7a3eded Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 26 Jun 2026 15:31:25 +0800 Subject: [PATCH 054/123] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20openapi=20?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/loaders/express.ts | 44 +++++++++++++++++++++++++---------------- back/shared/i18n.ts | 2 +- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 6b322e93..9213b046 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -12,6 +12,7 @@ import { IKeyvStore, shareStore } from '../shared/store'; import { isValidToken } from '../shared/auth'; import path from 'path'; import { t } from '../shared/i18n'; +import { AppScope } from '../data/open'; export default ({ app }: { app: Application }) => { // Security: Enable strict routing to prevent case-insensitive path bypass @@ -19,30 +20,30 @@ export default ({ app }: { app: Application }) => { app.set('strict routing', true); app.set('trust proxy', 'loopback'); app.use(cors()); - + // Security: Path normalization middleware to prevent case variation attacks app.use((req, res, next) => { const originalPath = req.path; const normalizedPath = originalPath.toLowerCase(); - + // Block requests with case variations on protected paths - if (originalPath !== normalizedPath && - (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { + if (originalPath !== normalizedPath && + (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { return res.status(400).json({ code: 400, message: 'Invalid path format' }); } - + next(); }); - + // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness if (config.baseUrl) { app.use(rewrite(`${config.baseUrl}/*`, '/$1')); } - + app.get(`${config.api.prefix}/env.js`, serveEnv); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); @@ -87,17 +88,26 @@ export default ({ app }: { app: Application }) => { const currentToken = doc.tokens.find((x) => x.value === headerToken); const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/); const key = keyMatch && keyMatch[1]; - if ( - doc.scopes.includes(key as any) && - currentToken && - currentToken.expiration >= Math.round(Date.now() / 1000) - ) { - return next(); + + if (!doc.scopes.includes(key as AppScope)) { + const err = new UnauthorizedError('credentials_bad_scheme', { + message: t('暂无权限'), + }); + return next(err); } + + if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) { + const err = new UnauthorizedError('invalid_token', { + message: t('Token 已失效'), + }); + return next(err); + } + + return next(); } } - const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`; + const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`; if ( !headerToken && originPath && @@ -113,8 +123,8 @@ export default ({ app }: { app: Application }) => { const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorMessage = headerToken - ? 'jwt malformed' - : 'No authorization token was found'; + ? t('Token 已失效') + : t('请先登录'); const err = new UnauthorizedError(errorCode, { message: errorMessage }); next(err); }); @@ -127,7 +137,7 @@ export default ({ app }: { app: Application }) => { '/api/user/notification/init', '/open/user/init', '/open/user/notification/init', - ].includes(req.path) + ].includes(pathLower) ) { return next(); } diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index b8d73847..2784dbbb 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -82,7 +82,7 @@ const messages: Record> = { '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', From d3016431ce376def56bd4c8896635ead4781e0ac Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 28 Jun 2026 15:48:58 +0800 Subject: [PATCH 055/123] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=AE=8C=E6=95=B4=E8=BF=90=E8=A1=8C=E5=8E=86?= =?UTF-8?q?=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/cron.ts | 2 +- back/api/log.ts | 12 ++- back/services/cron.ts | 40 ++------ shell/api.sh | 8 +- shell/share.sh | 2 +- src/locales/en-US.json | 7 +- src/locales/zh-CN.json | 7 +- src/pages/crontab/detail.tsx | 174 +++++++++++++++-------------------- 8 files changed, 116 insertions(+), 136 deletions(-) diff --git a/back/api/cron.ts b/back/api/cron.ts index 6be6f4a3..0067a0c8 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -434,6 +434,7 @@ export default (app: Router) => { log_path: Joi.string().optional().allow(null), last_running_time: Joi.number().optional().allow(null), last_execution_time: Joi.number().optional().allow(null), + exit_code: Joi.number().optional().allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -463,7 +464,6 @@ export default (app: Router) => { const instances = await RunningInstanceModel.findAll({ where: { cron_id: req.params.id, - status: InstanceStatus.running, }, order: [['started_at', 'DESC']], raw: true, diff --git a/back/api/log.ts b/back/api/log.ts index 0c1d03e8..d642d510 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -11,6 +11,7 @@ import { rmPath, } from '../config/util'; import LogService from '../services/log'; +import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; const route = Router(); const blacklist = ['.tmp']; @@ -46,8 +47,17 @@ export default (app: Router) => { message: t('暂无权限'), }); } + const logPath = `${req.query.path as string}/${req.query.file as string}`; + const runningInstance = await RunningInstanceModel.findOne({ + where: { log_path: logPath, status: InstanceStatus.running }, + }); + const content = await getFileContentByName(finalPath); - res.send({ code: 200, data: removeAnsi(content) }); + res.send({ + code: 200, + data: removeAnsi(content), + logStatus: runningInstance ? 'running' : undefined, + }); } catch (e) { return next(e); } diff --git a/back/services/cron.ts b/back/services/cron.ts index 16e24693..2638ac94 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -166,6 +166,7 @@ export default class CronService { log_path, last_running_time = 0, last_execution_time = 0, + exit_code, }: { ids: number[]; status: CrontabStatus; @@ -173,6 +174,7 @@ export default class CronService { log_path: string; last_running_time: number; last_execution_time: number; + exit_code?: number; }) { let options: any = { status, @@ -209,10 +211,15 @@ export default class CronService { } else if (status === CrontabStatus.idle) { // Mark the matching running instance as finished const finishedAt = dayjs().unix(); + const instanceStatus = + exit_code !== undefined && exit_code !== null && exit_code !== 0 + ? InstanceStatus.error + : InstanceStatus.finished; await RunningInstanceModel.update( { finished_at: finishedAt, - status: InstanceStatus.finished, + status: instanceStatus, + exit_code: exit_code ?? undefined, }, { where: { @@ -564,7 +571,7 @@ export default class CronService { } } await RunningInstanceModel.update( - { status: InstanceStatus.stopped, finished_at: dayjs().unix() }, + { status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 }, { where: { id: instanceId } }, ); @@ -619,15 +626,6 @@ export default class CronService { { shell: '/bin/bash' }, ); - const startedAt = dayjs().unix(); - const instance = await RunningInstanceModel.create({ - cron_id: id!, - pid: cp.pid, - log_path: logPath, - started_at: startedAt, - status: InstanceStatus.running, - }); - await CrontabModel.update( { status: CrontabStatus.running, pid: cp.pid, log_path: logPath }, { where: { id } }, @@ -659,26 +657,6 @@ export default class CronService { code, ); await logStreamManager.closeStream(absolutePath); - const finishedAt = dayjs().unix(); - await RunningInstanceModel.update( - { - finished_at: finishedAt, - status: code === 0 ? InstanceStatus.finished : InstanceStatus.error, - exit_code: code ?? undefined, - }, - { where: { id: instance.id } }, - ); - - // Only set cron to idle if no other running instances exist - const otherRunning = await RunningInstanceModel.count({ - where: { cron_id: id!, status: InstanceStatus.running }, - }); - if (otherRunning === 0) { - await CrontabModel.update( - { status: CrontabStatus.idle, pid: undefined }, - { where: { id } }, - ); - } resolve({ ...params, pid: cp.pid, code }); }); }); diff --git a/shell/api.sh b/shell/api.sh index b7366cb8..59f3b5f9 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -141,13 +141,19 @@ update_cron() { local logPath="$4" local lastExecutingTime="${5:-0}" local runningTime="${6:-0}" + local exitCode="${7:-}" local currentTimeStamp=$(date +%s) + local dataRaw="{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime" + if [[ -n $exitCode ]]; then + dataRaw="${dataRaw},\"exit_code\":$exitCode" + fi + dataRaw="${dataRaw}}" local api=$( curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \ + --data-raw "$dataRaw" \ --compressed ) code=$(echo "$api" | jq -r .code) diff --git a/shell/share.sh b/shell/share.sh index 027f65d8..f182e4b7 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -414,7 +414,7 @@ handle_task_end() { [[ "$diff_time" == 0 ]] && diff_time=1 if [[ $ID ]]; then - local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time") + local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code") if [[ $error ]]; then error_message=", 状态更新失败(${error})" fi diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 30ec0dd5..476b433f 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -600,5 +600,10 @@ "确认保存": "Confirm to save", "上传失败": "Upload failed", "成功上传": "Successfully uploaded", - "个环境变量": " environment variables" + "个环境变量": " environment variables", + "运行历史": "Run History", + "已完成": "Completed", + "已停止": "Stopped", + "退出码": "Exit Code", + "结束": "End" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 3c6deddb..9f844f48 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -600,5 +600,10 @@ "确认保存": "确认保存", "上传失败": "上传失败", "成功上传": "成功上传", - "个环境变量": "个环境变量" + "个环境变量": "个环境变量", + "运行历史": "运行历史", + "已完成": "已完成", + "已停止": "已停止", + "退出码": "退出码", + "结束": "结束" } diff --git a/src/pages/crontab/detail.tsx b/src/pages/crontab/detail.tsx index 031d0ddb..172c0872 100644 --- a/src/pages/crontab/detail.tsx +++ b/src/pages/crontab/detail.tsx @@ -3,8 +3,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Modal, message, - Input, - Form, Button, Card, Tag, @@ -40,24 +38,15 @@ const { Text } = Typography; const tabList = [ { - key: 'log', - tab: intl.get('日志'), + key: 'runningHistory', + tab: intl.get('运行历史'), }, { key: 'script', tab: intl.get('脚本'), }, - { - key: 'runningInstance', - tab: intl.get('运行实例'), - }, ]; -interface LogItem { - directory: string; - filename: string; -} - const CronDetailModal = ({ cron = {}, handleCancel, @@ -69,21 +58,19 @@ const CronDetailModal = ({ theme: string; isPhone: boolean; }) => { - const [activeTabKey, setActiveTabKey] = useState('log'); - const [loading, setLoading] = useState(true); - const [logs, setLogs] = useState([]); - const [log, setLog] = useState(''); + const [activeTabKey, setActiveTabKey] = useState('runningHistory'); const [value, setValue] = useState(''); - const [isLogModalVisible, setIsLogModalVisible] = useState(false); const editorRef = useRef(null); const [scriptInfo, setScriptInfo] = useState({}); - const [logUrl, setLogUrl] = useState(''); const [validTabs, setValidTabs] = useState(tabList); const [currentCron, setCurrentCron] = useState({}); const listRef = useRef(null); const tableScrollHeight = useScrollHeight(listRef); const [runningInstances, setRunningInstances] = useState([]); const needRefreshRef = useRef(false); + const [isLogModalVisible, setIsLogModalVisible] = useState(false); + const [logData, setLogData] = useState(''); + const [logUrl, setLogUrl] = useState(''); const fetchRunningInstances = async () => { if (!cron.id) return Promise.resolve(); @@ -114,25 +101,6 @@ const CronDetailModal = ({ }, [cron.id]); const contentList: any = { - log: ( -
- - - {(item) => ( - onClickItem(item)}> - - {item.directory}/{item.filename} - - )} - - -
- ), script: scriptInfo.filename && ( ), - runningInstance: ( + runningHistory: (
- + + Date: Sat, 18 Jul 2026 10:27:23 +0800 Subject: [PATCH 067/123] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20linux/s390x?= =?UTF-8?q?=20=E9=95=9C=E5=83=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-docker-image.yml | 6 +- docker/Dockerfile | 27 +- docker/Dockerfile.310 | 27 +- docker/Dockerfile.debian | 4 +- docker/Dockerfile.debian310 | 4 +- package.json | 4 +- pnpm-lock.yaml | 523 +++++++---------------- 7 files changed, 209 insertions(+), 386 deletions(-) diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 125be7d3..b1a576e8 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -212,7 +212,7 @@ jobs: QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 context: . file: ./docker/Dockerfile push: true @@ -290,6 +290,7 @@ jobs: QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host + # Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973). platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x context: . file: ./docker/Dockerfile.debian @@ -354,7 +355,7 @@ jobs: QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 context: . file: ./docker/Dockerfile.310 push: true @@ -419,6 +420,7 @@ jobs: QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host + # Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973). platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x context: . file: ./docker/Dockerfile.debian310 diff --git a/docker/Dockerfile b/docker/Dockerfile index 292c9617..f61ef404 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,11 +1,26 @@ -FROM python:3.11-alpine3.18 AS builder +# Run Node package installation natively on the builder. Node/npm can spin at +# 100% CPU when Alpine s390x is emulated through QEMU. +FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder + +ARG TARGETARCH +ENV NPM_CONFIG_PREFIX=/opt/node-global +ENV PATH=/opt/node-global/bin:${PATH} + COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ RUN set -x \ - && apk update \ - && apk add nodejs npm git \ + && apk add --no-cache git \ && npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \ && cd /tmp/build \ - && pnpm install --prod + && case "${TARGETARCH}" in \ + amd64) NODE_ARCH=x64 ;; \ + 386) NODE_ARCH=ia32 ;; \ + ppc64le) NODE_ARCH=ppc64 ;; \ + *) NODE_ARCH="${TARGETARCH}" ;; \ + esac \ + && npm_config_target_platform=linux \ + npm_config_target_arch="${NODE_ARCH}" \ + npm_config_target_libc=musl \ + pnpm install --prod FROM python:3.11-alpine @@ -26,8 +41,8 @@ VOLUME /ql/data EXPOSE 5700 -COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ -COPY --from=builder /usr/local/bin/. /usr/local/bin/ +COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ +COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ RUN set -x \ && apk update -f \ diff --git a/docker/Dockerfile.310 b/docker/Dockerfile.310 index e41f26e4..520b1e0e 100644 --- a/docker/Dockerfile.310 +++ b/docker/Dockerfile.310 @@ -1,11 +1,26 @@ -FROM python:3.10-alpine3.18 AS builder +# Run Node package installation natively on the builder. Node/npm can spin at +# 100% CPU when Alpine s390x is emulated through QEMU. +FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder + +ARG TARGETARCH +ENV NPM_CONFIG_PREFIX=/opt/node-global +ENV PATH=/opt/node-global/bin:${PATH} + COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ RUN set -x \ - && apk update \ - && apk add nodejs npm git \ + && apk add --no-cache git \ && npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \ && cd /tmp/build \ - && pnpm install --prod + && case "${TARGETARCH}" in \ + amd64) NODE_ARCH=x64 ;; \ + 386) NODE_ARCH=ia32 ;; \ + ppc64le) NODE_ARCH=ppc64 ;; \ + *) NODE_ARCH="${TARGETARCH}" ;; \ + esac \ + && npm_config_target_platform=linux \ + npm_config_target_arch="${NODE_ARCH}" \ + npm_config_target_libc=musl \ + pnpm install --prod FROM python:3.10-alpine @@ -26,8 +41,8 @@ VOLUME /ql/data EXPOSE 5700 -COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ -COPY --from=builder /usr/local/bin/. /usr/local/bin/ +COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ +COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ RUN set -x \ && apk update -f \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 0fc406ad..444e988b 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -1,4 +1,6 @@ -FROM node:22-slim AS nodebuilder +# Node 20 Bookworm is the latest official Node image variant that covers the +# full Debian build matrix, including arm/v7, ppc64le, and s390x. +FROM node:20-bookworm-slim AS nodebuilder FROM python:3.11-slim-bookworm AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ diff --git a/docker/Dockerfile.debian310 b/docker/Dockerfile.debian310 index e4d892d1..7ef70f1d 100644 --- a/docker/Dockerfile.debian310 +++ b/docker/Dockerfile.debian310 @@ -1,4 +1,6 @@ -FROM node:22-slim AS nodebuilder +# Node 20 Bookworm is the latest official Node image variant that covers the +# full Debian build matrix, including arm/v7, ppc64le, and s390x. +FROM node:20-bookworm-slim AS nodebuilder FROM python:3.10-slim-bookworm AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ diff --git a/package.json b/package.json index cda82f41..610d1c6e 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ } }, "overrides": { - "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", + "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0", "@codemirror/state": "6.5.4", "@codemirror/view": "6.39.16" } @@ -111,7 +111,7 @@ "request-ip": "3.3.0", "sequelize": "^6.37.5", "sockjs": "^0.3.24", - "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", + "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", "undici": "^7.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f15057a2..214cc22c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.0.3 + sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.1.0 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 @@ -120,13 +120,13 @@ dependencies: version: 3.3.0 sequelize: specifier: ^6.37.5 - version: 6.37.5(@whyour/sqlite3@1.0.3) + version: 6.37.5(@whyour/sqlite3@1.1.0) sockjs: specifier: ^0.3.24 version: 0.3.24 sqlite3: - specifier: git+https://github.com/whyour/node-sqlite3.git#v1.0.3 - version: github.com/whyour/node-sqlite3/3a00af0b5d7603b7f1b290032507320b18a6b741 + specifier: git+https://github.com/whyour/node-sqlite3.git#v1.1.0 + version: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce toad-scheduler: specifier: ^3.0.1 version: 3.0.1 @@ -2969,12 +2969,6 @@ packages: deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package dev: true - /@gar/promisify@1.1.3: - resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - requiresBuild: true - dev: false - optional: true - /@grpc/grpc-js@1.14.0: resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} engines: {node: '>=12.10.0'} @@ -3055,6 +3049,15 @@ packages: wrap-ansi-cjs: /wrap-ansi@7.0.0 dev: true + /@isaacs/fs-minipass@4.0.1: + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + requiresBuild: true + dependencies: + minipass: 7.1.3 + dev: false + optional: true + /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -3191,9 +3194,8 @@ packages: resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==} engines: {node: '>= 18'} dependencies: - sqlite3: github.com/whyour/node-sqlite3/3a00af0b5d7603b7f1b290032507320b18a6b741 + sqlite3: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce transitivePeerDependencies: - - bluebird - encoding - supports-color dev: false @@ -3700,26 +3702,6 @@ packages: fastq: 1.17.1 dev: true - /@npmcli/fs@1.1.1: - resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} - requiresBuild: true - dependencies: - '@gar/promisify': 1.1.3 - semver: 7.7.4 - dev: false - optional: true - - /@npmcli/move-file@1.1.2: - resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} - engines: {node: '>=10'} - deprecated: This functionality has been moved to @npmcli/fs - requiresBuild: true - dependencies: - mkdirp: 1.0.4 - rimraf: 3.0.2 - dev: false - optional: true - /@otplib/core@12.0.1: resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} dev: false @@ -4215,13 +4197,6 @@ packages: use-sync-external-store: 1.2.2(react@18.3.1) dev: true - /@tootallnate/once@1.1.2: - resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} - engines: {node: '>= 6'} - requiresBuild: true - dev: false - optional: true - /@trysound/sax@0.2.0: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -5648,6 +5623,13 @@ packages: requiresBuild: true dev: false + /abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + requiresBuild: true + dev: false + optional: true + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -5698,25 +5680,6 @@ packages: humanize-ms: 1.2.1 dev: true - /agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - requiresBuild: true - dependencies: - humanize-ms: 1.2.1 - dev: false - optional: true - - /aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - requiresBuild: true - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - dev: false - optional: true - /ahooks@3.8.1(react@18.3.1): resolution: {integrity: sha512-JoP9+/RWO7MnI/uSKdvQ8WB10Y3oo1PjLv+4Sv4Vpm19Z86VUMdXh+RhWvMGxZZs06sq2p0xVtFk8Oh5ZObsoA==} engines: {node: '>=8.0.0'} @@ -5966,17 +5929,6 @@ packages: readable-stream: 3.6.2 dev: false - /are-we-there-yet@3.0.1: - resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - deprecated: This package is no longer supported. - requiresBuild: true - dependencies: - delegates: 1.0.0 - readable-stream: 3.6.2 - dev: false - optional: true - /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -6554,34 +6506,6 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - /cacache@15.3.0: - resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} - engines: {node: '>= 10'} - requiresBuild: true - dependencies: - '@npmcli/fs': 1.1.1 - '@npmcli/move-file': 1.1.2 - chownr: 2.0.0 - fs-minipass: 2.1.0 - glob: 7.2.3 - infer-owner: 1.0.4 - lru-cache: 6.0.0 - minipass: 3.3.6 - minipass-collect: 1.0.2 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - mkdirp: 1.0.4 - p-map: 4.0.0 - promise-inflight: 1.0.1 - rimraf: 3.0.2 - ssri: 8.0.1 - tar: 6.2.1 - unique-filename: 1.1.1 - transitivePeerDependencies: - - bluebird - dev: false - optional: true - /call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -6699,6 +6623,13 @@ packages: engines: {node: '>=10'} dev: false + /chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + requiresBuild: true + dev: false + optional: true + /ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -6723,13 +6654,6 @@ packages: source-map: 0.6.1 dev: true - /clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - requiresBuild: true - dev: false - optional: true - /cli-cursor@4.0.0: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7936,14 +7860,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - /encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - requiresBuild: true - dependencies: - iconv-lite: 0.6.3 - dev: false - optional: true - /end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: @@ -7982,12 +7898,6 @@ packages: dev: false optional: true - /err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - requiresBuild: true - dev: false - optional: true - /errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -8549,6 +8459,12 @@ packages: clone-regexp: 2.2.0 dev: true + /exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + requiresBuild: true + dev: false + optional: true + /express-http-proxy@2.1.1: resolution: {integrity: sha512-4aRQRqDQU7qNPV5av0/hLcyc0guB9UP71nCYrQEYml7YphTo8tmWf3nDZWdTJMMjFikyz9xKXaURor7Chygdwg==} engines: {node: '>=6.0.0'} @@ -8706,6 +8622,20 @@ packages: bser: 2.1.1 dev: true + /fdir@6.5.0(picomatch@4.0.5): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + requiresBuild: true + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.5 + dev: false + optional: true + /fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} dev: false @@ -8974,23 +8904,6 @@ packages: wide-align: 1.1.5 dev: false - /gauge@4.0.4: - resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - deprecated: This package is no longer supported. - requiresBuild: true - dependencies: - aproba: 2.1.0 - color-support: 1.1.3 - console-control-strings: 1.1.0 - has-unicode: 2.0.1 - signal-exit: 3.0.7 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wide-align: 1.1.5 - dev: false - optional: true - /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -9366,12 +9279,6 @@ packages: entities: 2.2.0 dev: true - /http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - requiresBuild: true - dev: false - optional: true - /http-deceiver@1.2.7: resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} dev: true @@ -9389,19 +9296,6 @@ packages: /http-parser-js@0.5.8: resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} - /http-proxy-agent@4.0.1: - resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} - engines: {node: '>= 6'} - requiresBuild: true - dependencies: - '@tootallnate/once': 1.1.2 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - dev: false - optional: true - /http-proxy-middleware@3.0.3: resolution: {integrity: sha512-usY0HG5nyDUwtqpiZdETNbmKtw3QQ1jwYFZ9wi5iHzX2BcILwQKtYDJPo7XHTsu5Z0B2Hj3W9NNnbd+AjFWjqg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9455,6 +9349,7 @@ packages: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} dependencies: ms: 2.1.3 + dev: true /iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} @@ -9533,16 +9428,12 @@ packages: /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + dev: true /indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - - /infer-owner@1.0.4: - resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} - requiresBuild: true - dev: false - optional: true + dev: true /inflection@1.13.4: resolution: {integrity: sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==} @@ -9618,13 +9509,6 @@ packages: loose-envify: 1.4.0 dev: true - /ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - requiresBuild: true - dev: false - optional: true - /ip2region@2.3.0(@types/node@17.0.45): resolution: {integrity: sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==} peerDependencies: @@ -9809,12 +9693,6 @@ packages: is-docker: 3.0.0 dev: true - /is-lambda@1.0.1: - resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} - requiresBuild: true - dev: false - optional: true - /is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -9973,6 +9851,13 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + requiresBuild: true + dev: false + optional: true + /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} @@ -10579,6 +10464,7 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 + dev: true /luxon@3.5.0: resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} @@ -10611,33 +10497,6 @@ packages: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true - /make-fetch-happen@9.1.0: - resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} - engines: {node: '>= 10'} - requiresBuild: true - dependencies: - agentkeepalive: 4.6.0 - cacache: 15.3.0 - http-cache-semantics: 4.2.0 - http-proxy-agent: 4.0.1 - https-proxy-agent: 5.0.1 - is-lambda: 1.0.1 - lru-cache: 6.0.0 - minipass: 3.3.6 - minipass-collect: 1.0.2 - minipass-fetch: 1.4.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - negotiator: 0.6.4 - promise-retry: 2.0.1 - socks-proxy-agent: 6.2.1 - ssri: 8.0.1 - transitivePeerDependencies: - - bluebird - - supports-color - dev: false - optional: true - /makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} dependencies: @@ -10831,55 +10690,6 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true - /minipass-collect@1.0.2: - resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} - engines: {node: '>= 8'} - requiresBuild: true - dependencies: - minipass: 3.3.6 - dev: false - optional: true - - /minipass-fetch@1.4.1: - resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} - engines: {node: '>=8'} - requiresBuild: true - dependencies: - minipass: 3.3.6 - minipass-sized: 1.0.3 - minizlib: 2.1.2 - optionalDependencies: - encoding: 0.1.13 - dev: false - optional: true - - /minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} - engines: {node: '>= 8'} - requiresBuild: true - dependencies: - minipass: 3.3.6 - dev: false - optional: true - - /minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - requiresBuild: true - dependencies: - minipass: 3.3.6 - dev: false - optional: true - - /minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - requiresBuild: true - dependencies: - minipass: 3.3.6 - dev: false - optional: true - /minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -10895,7 +10705,6 @@ packages: /minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - dev: true /minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} @@ -10905,6 +10714,15 @@ packages: yallist: 4.0.0 dev: false + /minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + requiresBuild: true + dependencies: + minipass: 7.1.3 + dev: false + optional: true + /mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -11041,25 +10859,22 @@ packages: formdata-polyfill: 4.0.10 dev: true - /node-gyp@8.4.1: - resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} - engines: {node: '>= 10.12.0'} + /node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true requiresBuild: true dependencies: env-paths: 2.2.1 - glob: 7.2.3 + exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 9.1.0 - nopt: 5.0.0 - npmlog: 6.0.2 - rimraf: 3.0.2 + nopt: 9.0.0 + proc-log: 6.1.0 semver: 7.7.4 - tar: 6.2.1 - which: 2.0.2 - transitivePeerDependencies: - - bluebird - - supports-color + tar: 7.5.20 + tinyglobby: 0.2.17 + undici: 6.27.0 + which: 6.0.1 dev: false optional: true @@ -11171,6 +10986,16 @@ packages: abbrev: 1.1.1 dev: false + /nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + requiresBuild: true + dependencies: + abbrev: 4.0.0 + dev: false + optional: true + /normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: @@ -11232,19 +11057,6 @@ packages: set-blocking: 2.0.0 dev: false - /npmlog@6.0.2: - resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - deprecated: This package is no longer supported. - requiresBuild: true - dependencies: - are-we-there-yet: 3.0.1 - console-control-strings: 1.1.0 - gauge: 4.0.4 - set-blocking: 2.0.0 - dev: false - optional: true - /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: @@ -11453,15 +11265,6 @@ packages: p-limit: 3.1.0 dev: true - /p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} - requiresBuild: true - dependencies: - aggregate-error: 3.1.0 - dev: false - optional: true - /p-queue-cjs@7.3.4: resolution: {integrity: sha512-vP0BvEAgmUEShxWBCETvxiUDnwSiCLfBRqmhdKlNvcXF/7x2yemtYLcxT1pfYELZLlyGL3grvGnq+KF5OwNZfA==} engines: {node: '>=12'} @@ -11638,6 +11441,13 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + /picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + requiresBuild: true + dev: false + optional: true + /pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -12323,6 +12133,13 @@ packages: react-is: 16.13.1 dev: true + /proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + requiresBuild: true + dev: false + optional: true + /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} dev: true @@ -12341,27 +12158,6 @@ packages: engines: {node: '>= 0.6.0'} dev: true - /promise-inflight@1.0.1: - resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} - requiresBuild: true - peerDependencies: - bluebird: '*' - peerDependenciesMeta: - bluebird: - optional: true - dev: false - optional: true - - /promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} - requiresBuild: true - dependencies: - err-code: 2.0.3 - retry: 0.12.0 - dev: false - optional: true - /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -14130,7 +13926,7 @@ packages: engines: {node: '>= 10.0.0'} dev: false - /sequelize@6.37.5(@whyour/sqlite3@1.0.3): + /sequelize@6.37.5(@whyour/sqlite3@1.1.0): resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==} engines: {node: '>=10.0.0'} peerDependencies: @@ -14175,7 +13971,7 @@ packages: retry-as-promised: 7.0.4 semver: 7.6.3 sequelize-pool: 7.1.0 - sqlite3: github.com/whyour/node-sqlite3/3a00af0b5d7603b7f1b290032507320b18a6b741 + sqlite3: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce toposort-class: 1.0.1 uuid: 8.3.2 validator: 13.12.0 @@ -14325,13 +14121,6 @@ packages: is-fullwidth-code-point: 4.0.0 dev: true - /smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - requiresBuild: true - dev: false - optional: true - /sockjs-client@1.6.1: resolution: {integrity: sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==} engines: {node: '>=12'} @@ -14353,29 +14142,6 @@ packages: websocket-driver: 0.7.4 dev: false - /socks-proxy-agent@6.2.1: - resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} - engines: {node: '>= 10'} - requiresBuild: true - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - dev: false - optional: true - - /socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - requiresBuild: true - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - dev: false - optional: true - /sonic-boom@2.8.0: resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} dependencies: @@ -14506,15 +14272,6 @@ packages: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true - /ssri@8.0.1: - resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} - engines: {node: '>= 8'} - requiresBuild: true - dependencies: - minipass: 3.3.6 - dev: false - optional: true - /stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' @@ -14965,6 +14722,19 @@ packages: yallist: 4.0.0 dev: false + /tar@7.5.20: + resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} + engines: {node: '>=18'} + requiresBuild: true + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + dev: false + optional: true + /terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} engines: {node: '>=10'} @@ -15042,6 +14812,16 @@ packages: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} dev: true + /tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + requiresBuild: true + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + dev: false + optional: true + /titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -15393,6 +15173,13 @@ packages: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} dev: true + /undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + requiresBuild: true + dev: false + optional: true + /undici@7.9.0: resolution: {integrity: sha512-e696y354tf5cFZPXsF26Yg+5M63+5H3oE6Vtkh2oqbvsE2Oe7s2nIbcQh5lmG7Lp/eS29vJtTpw9+p6PX0qNSg==} engines: {node: '>=20.18.1'} @@ -15409,22 +15196,6 @@ packages: resolution: {integrity: sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==} dev: true - /unique-filename@1.1.1: - resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} - requiresBuild: true - dependencies: - unique-slug: 2.0.2 - dev: false - optional: true - - /unique-slug@2.0.2: - resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} - requiresBuild: true - dependencies: - imurmurhash: 0.1.4 - dev: false - optional: true - /universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -15861,6 +15632,16 @@ packages: dependencies: isexe: 2.0.0 + /which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + requiresBuild: true + dependencies: + isexe: 4.0.0 + dev: false + optional: true + /wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} dependencies: @@ -15968,6 +15749,13 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + /yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + requiresBuild: true + dev: false + optional: true + /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -16022,10 +15810,10 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: true - github.com/whyour/node-sqlite3/3a00af0b5d7603b7f1b290032507320b18a6b741: - resolution: {tarball: https://codeload.github.com/whyour/node-sqlite3/tar.gz/3a00af0b5d7603b7f1b290032507320b18a6b741} + github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce: + resolution: {tarball: https://codeload.github.com/whyour/node-sqlite3/tar.gz/4d29681eee3557890502d3859ec0fd6b6d13ccce} name: '@whyour/sqlite3' - version: 1.0.3 + version: 1.1.0 requiresBuild: true peerDependenciesMeta: node-gyp: @@ -16035,9 +15823,8 @@ packages: node-addon-api: 4.3.0 tar: 6.2.1 optionalDependencies: - node-gyp: 8.4.1 + node-gyp: 12.4.0 transitivePeerDependencies: - - bluebird - encoding - supports-color dev: false From 24d539fc05f7cf3106fd4bb003d1848b013fcb7f Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 19 Jul 2026 00:40:15 +0800 Subject: [PATCH 068/123] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=20npm=20?= =?UTF-8?q?=E5=8C=85=E5=AE=89=E8=A3=85=20sqlite3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +-- pnpm-lock.yaml | 76 ++++++++++++++++++++++++-------------------------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 610d1c6e..1874ae5a 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ } }, "overrides": { - "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0", + "sqlite3": "npm:@whyour/sqlite3@1.1.0", "@codemirror/state": "6.5.4", "@codemirror/view": "6.39.16" } @@ -111,7 +111,7 @@ "request-ip": "3.3.0", "sequelize": "^6.37.5", "sockjs": "^0.3.24", - "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0", + "sqlite3": "npm:@whyour/sqlite3@1.1.0", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", "undici": "^7.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 214cc22c..c273f87d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,11 +1,7 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - overrides: - sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.1.0 + sqlite3: npm:@whyour/sqlite3@1.1.0 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 @@ -125,8 +121,8 @@ dependencies: specifier: ^0.3.24 version: 0.3.24 sqlite3: - specifier: git+https://github.com/whyour/node-sqlite3.git#v1.1.0 - version: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce + specifier: npm:@whyour/sqlite3@1.1.0 + version: /@whyour/sqlite3@1.1.0 toad-scheduler: specifier: ^3.0.1 version: 3.0.1 @@ -1630,8 +1626,8 @@ packages: resolution: {integrity: sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==} peerDependencies: '@codemirror/language': ^6.0.0 - '@codemirror/state': 6.5.4 - '@codemirror/view': 6.39.16 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.10.6 @@ -3194,7 +3190,7 @@ packages: resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==} engines: {node: '>= 18'} dependencies: - sqlite3: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce + sqlite3: /@whyour/sqlite3@1.1.0 transitivePeerDependencies: - encoding - supports-color @@ -3869,8 +3865,8 @@ packages: peerDependencies: '@codemirror/autocomplete': ^6.0.0 '@codemirror/language': ^6.0.0 - '@codemirror/state': 6.5.4 - '@codemirror/view': 6.39.16 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 '@lezer/lr': ^1.0.0 @@ -3889,8 +3885,8 @@ packages: peerDependencies: '@codemirror/autocomplete': ^6.0.0 '@codemirror/language': ^6.0.0 - '@codemirror/state': 6.5.4 - '@codemirror/view': 6.39.16 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 '@lezer/lr': ^1.0.0 @@ -3921,8 +3917,8 @@ packages: '@codemirror/lang-html': ^6.2.0 '@codemirror/lang-javascript': ^6.1.1 '@codemirror/language': ^6.0.0 - '@codemirror/state': 6.5.4 - '@codemirror/view': 6.39.16 + '@codemirror/state': ^6.0.0 + '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 '@lezer/javascript': ^1.2.0 @@ -4825,8 +4821,8 @@ packages: '@codemirror/language': '>=6.0.0' '@codemirror/lint': '>=6.0.0' '@codemirror/search': '>=6.0.0' - '@codemirror/state': 6.5.4 - '@codemirror/view': 6.39.16 + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' dependencies: '@codemirror/autocomplete': 6.20.1 '@codemirror/commands': 6.7.1 @@ -4885,9 +4881,9 @@ packages: resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==} peerDependencies: '@babel/runtime': '>=7.11.0' - '@codemirror/state': 6.5.4 + '@codemirror/state': '>=6.0.0' '@codemirror/theme-one-dark': '>=6.0.0' - '@codemirror/view': 6.39.16 + '@codemirror/view': '>=6.0.0' codemirror: '>=6.0.0' react: '>=16.8.0 || 18' react-dom: '>=16.8.0 || 18' @@ -5618,6 +5614,23 @@ packages: - supports-color dev: true + /@whyour/sqlite3@1.1.0: + resolution: {integrity: sha512-5g9xsjkv9sUepCvBBKmev6JSKSKjp0ebpW25uSsuV5kurIYrd7AnkbffNMNIswxwsQJSLPewVEX5ROsQfETfnA==} + requiresBuild: true + peerDependenciesMeta: + node-gyp: + optional: true + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 4.3.0 + tar: 6.2.1 + optionalDependencies: + node-gyp: 12.4.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} requiresBuild: true @@ -13971,7 +13984,7 @@ packages: retry-as-promised: 7.0.4 semver: 7.6.3 sequelize-pool: 7.1.0 - sqlite3: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce + sqlite3: /@whyour/sqlite3@1.1.0 toposort-class: 1.0.1 uuid: 8.3.2 validator: 13.12.0 @@ -15810,21 +15823,6 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: true - github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce: - resolution: {tarball: https://codeload.github.com/whyour/node-sqlite3/tar.gz/4d29681eee3557890502d3859ec0fd6b6d13ccce} - name: '@whyour/sqlite3' - version: 1.1.0 - requiresBuild: true - peerDependenciesMeta: - node-gyp: - optional: true - dependencies: - '@mapbox/node-pre-gyp': 1.0.11 - node-addon-api: 4.3.0 - tar: 6.2.1 - optionalDependencies: - node-gyp: 12.4.0 - transitivePeerDependencies: - - encoding - - supports-color - dev: false +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false From d67f33df3999ca7f2b276dcf0c6b3a70cd0fab0c Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 24 Jul 2026 22:33:48 +0800 Subject: [PATCH 069/123] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=20task=5Fbefo?= =?UTF-8?q?re=20=E6=B1=A1=E6=9F=93=20Node.js=20=E4=BE=9D=E8=B5=96=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell/preload/sitecustomize.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shell/preload/sitecustomize.js b/shell/preload/sitecustomize.js index 720dc206..084c346e 100644 --- a/shell/preload/sitecustomize.js +++ b/shell/preload/sitecustomize.js @@ -98,7 +98,11 @@ function run() { const newEnvObject = JSON.parse(envStr); if (typeof newEnvObject === 'object' && newEnvObject !== null) { for (const key in newEnvObject) { - if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) { + if ( + Object.prototype.hasOwnProperty.call(newEnvObject, key) && + key !== 'NODE_PATH' && + key !== 'QL_NODE_GLOBAL_PATH' + ) { process.env[key] = newEnvObject[key]; } } From 0bb41ebe9612a6eff4427580d2297509b6b09f18 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 18 Jul 2026 10:27:53 +0800 Subject: [PATCH 070/123] =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=20v2.21.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 ++---- version.yaml | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 1874ae5a..a068f533 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@whyour/qinglong", "packageManager": "pnpm@8.3.1", - "version": "2.20.2-3", + "version": "2.21.0-16", "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", "repository": { "type": "git", @@ -21,9 +21,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", - "test": "umi-test", - "test:coverage": "umi-test --coverage" + "postinstall": "max setup 2>/dev/null || true" }, "gitHooks": { "pre-commit": "lint-staged" diff --git a/version.yaml b/version.yaml index 65648012..ce86d2b4 100644 --- a/version.yaml +++ b/version.yaml @@ -1,6 +1,35 @@ -version: 2.20.2 -changeLogLink: https://t.me/jiao_long/434 -publishTime: 2026-03-01 1800 +version: 2.21.0 +changeLogLink: https://t.me/jiao_long/436 +publishTime: 2026-06-18 2300 changeLog: | - 1. 修复 path 安全漏洞(重要) + 1. shell 增加国际化支持 + 2. 接口提示信息国际化,更新国际化文案 + 3. 统一 Alpine/Debian 分支,QL_SCHEDULER 参数化调度 + 4. grpc 服务增加证书校验 + 5. 定时任务增加 work_dir 设置 + 6. 增加任务统计功能 + 7. 新增 OpeniLink 通知渠道 + 8. 支持自定义接收邮箱地址 + 9. 增加运行实例功能 + 10. 增加 sudo 命令判断 + 11. 延迟增加运行时间提示 + 12. 开机运行任务支持同时开始运行 + 13. 增加 localhost 检测 + 14. 增加环境变量标签功能 + 15. 修复路径穿越安全漏洞 + 16. 修复配置文件路径可能越权 + 17. 修复 work_dir 验证和目录判断 + 18. 修复多个 API 问题(getCronById、定时任务参数、任务退出码等) + 19. 修复 ESM 依赖查询路径 + 20. 修复 CodeMirror 页面切换多实例崩溃 + 21. 修复 IPv6 网络连接问题 + 22. 修复内部服务 IP 地址 + 23. 修复 gRPC extra_schedules 为空时序列化报错 + 24. 修复 HITOKOTO 参数布尔/字符串类型处理 + 25. 修复 Server酱 返回错误时 undefined 异常 + 26. 修复环境变量 position 数据类型异常 + 27. 修复 Docker 健康检查 QlPort 环境变量读取 + 28. 修复任务统计日志 + 29. 升级 multer 解决 CVE 漏洞 + 30. 升级 nodemailer \ No newline at end of file From 6618240c61014cc224f7dd29a0381409361617f5 Mon Sep 17 00:00:00 2001 From: Kobi Hikri Date: Sat, 1 Aug 2026 15:11:55 +0300 Subject: [PATCH 071/123] ci: attach provenance and SBOM attestations to the published images (#3048) * ci: attach provenance and SBOM attestations to the published images * ci: restore trailing newline at end of file --- .github/workflows/build-docker-image.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index b1a576e8..e00a3c54 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -216,6 +216,8 @@ jobs: context: . file: ./docker/Dockerfile push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=whyour/qinglong:cache-alpine @@ -295,6 +297,8 @@ jobs: context: . file: ./docker/Dockerfile.debian push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=whyour/qinglong:cache-debian @@ -359,6 +363,8 @@ jobs: context: . file: ./docker/Dockerfile.310 push: true + provenance: mode=max + sbom: true tags: | whyour/qinglong:python3.10 whyour/qinglong:${{ steps.version.outputs.version }}-python3.10 @@ -425,6 +431,8 @@ jobs: context: . file: ./docker/Dockerfile.debian310 push: true + provenance: mode=max + sbom: true tags: | whyour/qinglong:debian-python3.10 whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10 From 2ac4f07fca09535344c588adc47a25bd5c38a037 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 10 Aug 2026 23:38:33 +0800 Subject: [PATCH 072/123] fix: address dashboard scope and preview interactions --- src/pages/crontab/detail.tsx | 2 ++ src/utils/config.ts | 5 +++++ src/utils/monaco/index.ts | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/src/pages/crontab/detail.tsx b/src/pages/crontab/detail.tsx index 172c0872..1baf32ef 100644 --- a/src/pages/crontab/detail.tsx +++ b/src/pages/crontab/detail.tsx @@ -130,6 +130,8 @@ const CronDetailModal = ({ {(item) => ( item.log_path && viewInstanceLog(item)} + style={{ cursor: item.log_path ? 'pointer' : 'default' }} actions={[ item.log_path && ( diff --git a/src/utils/config.ts b/src/utils/config.ts index e5ee7f1b..3140c091 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -48,6 +48,10 @@ export default { name: intl.get('系统信息'), value: 'system', }, + { + name: intl.get('仪表盘'), + value: 'dashboard', + }, ], scopesMap: { crons: intl.get('定时任务'), @@ -58,6 +62,7 @@ export default { logs: intl.get('日志管理'), dependencies: intl.get('依赖管理'), system: intl.get('系统信息'), + dashboard: intl.get('仪表盘'), }, notificationModes: [ { value: 'gotify', label: 'Gotify' }, diff --git a/src/utils/monaco/index.ts b/src/utils/monaco/index.ts index d9a0bc74..6ca2fb39 100644 --- a/src/utils/monaco/index.ts +++ b/src/utils/monaco/index.ts @@ -10,6 +10,12 @@ interface FileTypeConfig { // 文件类型分类配置(只包含特殊文件类型) const fileTypeConfigs: Record = { + config: { + // Keep common text configuration files previewable even when a Monaco + // build has not registered its optional INI language contribution yet. + extensions: ['.ini'], + }, + // 前端特殊文件 frontend: { extensions: [ From 83e4490b579a96feb870a160680575875574cd34 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 11 Aug 2026 00:08:37 +0800 Subject: [PATCH 073/123] fix: pin Python base for multi-arch builds --- docker/Dockerfile.debian | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 444e988b..5abe80ed 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -2,7 +2,7 @@ # full Debian build matrix, including arm/v7, ppc64le, and s390x. FROM node:20-bookworm-slim AS nodebuilder -FROM python:3.11-slim-bookworm AS builder +FROM python:3.11.14-slim-bookworm AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/ COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ @@ -14,7 +14,7 @@ RUN set -x && \ cd /tmp/build && \ pnpm install --prod -FROM python:3.11-slim-bookworm +FROM python:3.11.14-slim-bookworm ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" From 31f78e4d1f954ad533b23188b1ae970aaf7aab71 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 13:57:33 +0800 Subject: [PATCH 074/123] feat: add client IP diagnostics and blocking --- .env.example | 7 + back/api/clientIp.ts | 55 +++++++ back/api/index.ts | 2 + back/api/user.ts | 53 ++++++ back/data/system.ts | 1 + back/loaders/app.ts | 2 + back/loaders/express.ts | 37 +++-- back/services/user.ts | 67 ++++++-- back/shared/clientIp.ts | 29 ++++ back/shared/i18n.ts | 3 + back/shared/trustProxy.ts | 142 ++++++++++++++++ src/locales/en-US.json | 31 ++++ src/locales/zh-CN.json | 31 ++++ src/pages/setting/clientIp.tsx | 292 +++++++++++++++++++++++++++++++++ src/pages/setting/index.tsx | 8 +- src/pages/setting/loginLog.tsx | 147 +++++++++++------ 16 files changed, 832 insertions(+), 75 deletions(-) create mode 100644 back/api/clientIp.ts create mode 100644 back/shared/clientIp.ts create mode 100644 back/shared/trustProxy.ts create mode 100644 src/pages/setting/clientIp.tsx diff --git a/.env.example b/.env.example index f86264af..c0b29702 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ BACK_PORT=5700 # BIND_HOST=0.0.0.0 # BIND_HOST_GRPC=0.0.0.0 +# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。 +# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表: +# QL_TRUST_PROXY=1 +# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32 +# 仅在面板端口绝对无法绕过代理直接访问时使用 true。 +# QL_TRUST_PROXY=loopback + LOG_LEVEL='info' JWT_SECRET= diff --git a/back/api/clientIp.ts b/back/api/clientIp.ts new file mode 100644 index 00000000..172c8e9b --- /dev/null +++ b/back/api/clientIp.ts @@ -0,0 +1,55 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { celebrate, Joi } from 'celebrate'; +import { + diagnoseClientIp, + getTrustProxyConfig, + updateTrustProxy, +} from '../shared/trustProxy'; + +const route = Router(); + +export default (app: Router) => { + app.use('/system/client-ip', route); + + route.get( + '/config', + async (req: Request, res: Response, next: NextFunction) => { + try { + res.send({ code: 200, data: await getTrustProxyConfig() }); + } catch (error) { + next(error); + } + }, + ); + + route.put( + '/config', + celebrate({ + body: Joi.object({ + trustProxy: Joi.string().max(500).required(), + }), + }), + async (req: Request, res: Response) => { + try { + const data = await updateTrustProxy(req.body.trustProxy); + res.send({ code: 200, data }); + } catch (error) { + res.send({ + code: 400, + message: error instanceof Error ? error.message : '配置更新失败', + }); + } + }, + ); + + route.get( + '/diagnose', + async (req: Request, res: Response, next: NextFunction) => { + try { + res.send({ code: 200, data: await diagnoseClientIp(req) }); + } catch (error) { + next(error); + } + }, + ); +}; diff --git a/back/api/index.ts b/back/api/index.ts index 443c8e5c..cc196429 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -12,6 +12,7 @@ import subscription from './subscription'; import update from './update'; import dashboard from './dashboard'; import health from './health'; +import clientIp from './clientIp'; export default () => { const app = Router(); @@ -28,6 +29,7 @@ export default () => { update(app); dashboard(app); health(app); + clientIp(app); return app; }; diff --git a/back/api/user.ts b/back/api/user.ts index 9e284dba..c1a18146 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -189,6 +189,59 @@ export default (app: Router) => { }, ); + route.get( + '/ip-blacklist', + async (req: Request, res: Response, next: NextFunction) => { + try { + const userService = Container.get(UserService); + const data = await userService.getIpBlacklist(); + res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.put( + '/ip-blacklist', + celebrate({ + body: Joi.object({ + ip: Joi.string() + .ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' }) + .required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const userService = Container.get(UserService); + const data = await userService.blockIp(req.body.ip); + res.send({ code: 200, data, message: t('已加入 IP 黑名单') }); + } catch (e) { + return next(e); + } + }, + ); + + route.delete( + '/ip-blacklist', + celebrate({ + body: Joi.object({ + ip: Joi.string() + .ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' }) + .required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const userService = Container.get(UserService); + const data = await userService.unblockIp(req.body.ip); + res.send({ code: 200, data, message: t('已移出 IP 黑名单') }); + } catch (e) { + return next(e); + } + }, + ); + route.get( '/notification', async (req: Request, res: Response, next: NextFunction) => { diff --git a/back/data/system.ts b/back/data/system.ts index 3f5cfce6..8ae1ab60 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -78,6 +78,7 @@ export interface AuthInfo { twoFactorActivated: boolean; twoFactorSecret: string; avatar: string; + blockedIps?: string[]; } export type SystemModelInfo = SystemConfigInfo & diff --git a/back/loaders/app.ts b/back/loaders/app.ts index 1c814504..094aac9b 100644 --- a/back/loaders/app.ts +++ b/back/loaders/app.ts @@ -6,6 +6,7 @@ import { Application } from 'express'; import linkDeps from './deps'; import initTask from './initTask'; import initFile from './initFile'; +import { initializeTrustProxy } from '../shared/trustProxy'; export default async ({ app }: { app: Application }) => { depInjectorLoader(); @@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => { Logger.info('[boot] Init task loaded'); expressLoader({ app }); + await initializeTrustProxy(app); Logger.info('[boot] Express loaded'); }; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 81e4b014..676e59ed 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -15,11 +15,25 @@ import path from 'path'; import { t } from '../shared/i18n'; import { AppScope } from '../data/open'; +function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { + const setting = value?.trim(); + if (!setting) { + return 'loopback'; + } + if (setting === 'true' || setting === 'false') { + return setting === 'true'; + } + if (/^\d+$/.test(setting)) { + return Number(setting); + } + return setting; +} + export default ({ app }: { app: Application }) => { // Security: Enable strict routing to prevent case-insensitive path bypass app.set('case sensitive routing', true); app.set('strict routing', true); - app.set('trust proxy', 'loopback'); + app.set('trust proxy', resolveTrustProxy()); app.use(cors()); // Security: Path normalization middleware to prevent case variation attacks @@ -28,11 +42,14 @@ export default ({ app }: { app: Application }) => { const normalizedPath = originalPath.toLowerCase(); // Block requests with case variations on protected paths - if (originalPath !== normalizedPath && - (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { + if ( + originalPath !== normalizedPath && + (normalizedPath.startsWith('/api/') || + normalizedPath.startsWith('/open/')) + ) { return res.status(400).json({ code: 400, - message: 'Invalid path format' + message: 'Invalid path format', }); } @@ -97,7 +114,10 @@ export default ({ app }: { app: Application }) => { return next(err); } - if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) { + if ( + !currentToken || + currentToken.expiration < Math.round(Date.now() / 1000) + ) { const err = new UnauthorizedError('invalid_token', { message: t('Token 已失效'), }); @@ -123,9 +143,7 @@ export default ({ app }: { app: Application }) => { } const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; - const errorMessage = headerToken - ? t('Token 已失效') - : t('请先登录'); + const errorMessage = headerToken ? t('Token 已失效') : t('请先登录'); const err = new UnauthorizedError(errorCode, { message: errorMessage }); next(err); }); @@ -142,8 +160,7 @@ export default ({ app }: { app: Application }) => { ) { return next(); } - const authInfo = - (await shareStore.getAuthInfo()) || ({} as AuthInfo); + const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo); let isInitialized = !isDefaultAuthInfo(authInfo); diff --git a/back/services/user.ts b/back/services/user.ts index 92a86f7b..73ed8dd7 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -20,12 +20,12 @@ import ScheduleService from './schedule'; import SockService from './sock'; import dayjs from 'dayjs'; import IP2Region from 'ip2region'; -import requestIp from 'request-ip'; import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; import { t, tf } from '../shared/i18n'; +import { getClientIp, normalizeClientIp } from '../shared/clientIp'; @Service() export default class UserService { @@ -49,6 +49,14 @@ export default class UserService { let { username, password } = payloads; const content = await this.getAuthInfo(); const timestamp = Date.now(); + const ip = getClientIp(req); + const query = new IP2Region(); + const ipAddress = query.search(ip); + let address = ''; + if (ipAddress) { + const { country, province, city, isp } = ipAddress; + address = uniq([country, province, city, isp]).filter(Boolean).join(' '); + } let { username: cUsername, password: cPassword, @@ -59,7 +67,27 @@ export default class UserService { twoFactorActivated, tokens = {}, platform, + blockedIps = [], } = content; + + if ( + ip && + blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip) + ) { + await this.insertDb({ + type: AuthDataType.loginLog, + info: { + timestamp, + address, + ip, + platform: req.platform, + status: LoginStatus.fail, + }, + }); + this.getLoginLog(); + return { code: 403, message: t('该 IP 已被列入黑名单') }; + } + const retriesTime = Math.pow(3, retries) * 1000; if (retries > 2 && timestamp - lastlogon < retriesTime) { const waitTime = Math.ceil( @@ -87,14 +115,6 @@ export default class UserService { }; } - const ip = requestIp.getClientIp(req) || ''; - const query = new IP2Region(); - const ipAddress = query.search(ip); - let address = ''; - if (ipAddress) { - const { country, province, city, isp } = ipAddress; - address = uniq([country, province, city, isp]).filter(Boolean).join(' '); - } if (username === cUsername && password === cPassword) { const data = createRandomString(50, 100); const expiration = twoFactorActivated ? '60d' : '20d'; @@ -264,6 +284,33 @@ export default class UserService { return []; } + public async getIpBlacklist(): Promise { + const authInfo = await this.getAuthInfo(); + return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter( + Boolean, + ); + } + + public async blockIp(ip: string): Promise { + const authInfo = await this.getAuthInfo(); + const blockedIps = uniq([ + ...(authInfo.blockedIps || []).map(normalizeClientIp), + normalizeClientIp(ip), + ]).filter(Boolean); + await this.updateAuthInfo(authInfo, { blockedIps }); + return blockedIps; + } + + public async unblockIp(ip: string): Promise { + const authInfo = await this.getAuthInfo(); + const normalizedIp = normalizeClientIp(ip); + const blockedIps = (authInfo.blockedIps || []) + .map(normalizeClientIp) + .filter((blockedIp) => blockedIp && blockedIp !== normalizedIp); + await this.updateAuthInfo(authInfo, { blockedIps }); + return blockedIps; + } + private async insertDb(payload: SystemInfo): Promise { const doc = await SystemModel.create({ ...payload }, { returning: true }); return doc; @@ -330,7 +377,7 @@ export default class UserService { if (isValid) { return this.login({ username, password }, req, false); } else { - const ip = requestIp.getClientIp(req) || ''; + const ip = getClientIp(req); const query = new IP2Region(); const ipAddress = query.search(ip); let address = ''; diff --git a/back/shared/clientIp.ts b/back/shared/clientIp.ts new file mode 100644 index 00000000..328ab0f6 --- /dev/null +++ b/back/shared/clientIp.ts @@ -0,0 +1,29 @@ +import { Request } from 'express'; + +const IPV4_MAPPED_PREFIX = '::ffff:'; + +export function normalizeClientIp(value?: string): string { + let ip = (value || '').trim().toLowerCase(); + if (!ip) { + return ''; + } + + if (ip.startsWith('[') && ip.endsWith(']')) { + ip = ip.slice(1, -1); + } + + const zoneIndex = ip.indexOf('%'); + if (zoneIndex !== -1) { + ip = ip.slice(0, zoneIndex); + } + + if (ip.startsWith(IPV4_MAPPED_PREFIX)) { + return ip.slice(IPV4_MAPPED_PREFIX.length); + } + + return ip; +} + +export function getClientIp(req: Request): string { + return normalizeClientIp(req.ip || req.socket.remoteAddress); +} diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 827fab87..561bc1d9 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -98,6 +98,9 @@ const messages: Record> = { 'Log name can only contain letters, numbers, underscores, and hyphens', '日志名称不能超过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 ', diff --git a/back/shared/trustProxy.ts b/back/shared/trustProxy.ts new file mode 100644 index 00000000..4afc3a8c --- /dev/null +++ b/back/shared/trustProxy.ts @@ -0,0 +1,142 @@ +import express, { Application, Request } from 'express'; +import { AuthDataType, SystemModel } from '../data/system'; +import { normalizeClientIp } from './clientIp'; + +const DEFAULT_TRUST_PROXY = 'loopback'; + +type TrustProxyValue = boolean | number | string; + +let activeApp: Application | undefined; + +function getEnvironmentSetting(): string { + return process.env.QL_TRUST_PROXY?.trim() || ''; +} + +function normalizeSetting(value?: string): string { + return value?.trim() || DEFAULT_TRUST_PROXY; +} + +export function resolveTrustProxy(value?: string): TrustProxyValue { + const setting = normalizeSetting(value); + if (setting === 'true' || setting === 'false') { + return setting === 'true'; + } + if (/^\d+$/.test(setting)) { + return Number(setting); + } + return setting; +} + +function validateTrustProxy(value: string): string { + const setting = normalizeSetting(value); + if (setting.length > 500 || /[\r\n]/.test(setting)) { + throw new Error('trust proxy 配置格式无效'); + } + if (/^\d+$/.test(setting) && Number(setting) > 20) { + throw new Error('代理层数不能超过 20'); + } + + const probe = express(); + probe.set('trust proxy', resolveTrustProxy(setting)); + return setting; +} + +async function getStoredSetting(): Promise { + const doc = await SystemModel.findOne({ + where: { type: AuthDataType.systemConfig }, + }); + const info = (doc?.get('info') || {}) as Record; + return typeof info.trustProxy === 'string' ? info.trustProxy : ''; +} + +export async function getTrustProxyConfig() { + const environmentSetting = getEnvironmentSetting(); + const storedSetting = await getStoredSetting(); + const trustProxy = normalizeSetting(environmentSetting || storedSetting); + + return { + trustProxy, + source: environmentSetting + ? 'environment' + : storedSetting + ? 'system' + : 'default', + editable: !environmentSetting, + }; +} + +export async function initializeTrustProxy(app: Application) { + activeApp = app; + const { trustProxy } = await getTrustProxyConfig(); + app.set('trust proxy', resolveTrustProxy(trustProxy)); +} + +export async function updateTrustProxy(value: string) { + if (getEnvironmentSetting()) { + throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'); + } + + const trustProxy = validateTrustProxy(value); + const doc = await SystemModel.findOne({ + where: { type: AuthDataType.systemConfig }, + }); + if (!doc) { + throw new Error('系统配置不存在'); + } + + const plain = doc.get({ plain: true }); + await SystemModel.update( + { info: { ...(plain.info || {}), trustProxy } as any }, + { where: { id: plain.id } }, + ); + activeApp?.set('trust proxy', resolveTrustProxy(trustProxy)); + + return getTrustProxyConfig(); +} + +function parseForwardedFor(value: string | string[] | undefined): string[] { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values + .flatMap((item) => item.split(',')) + .map((item) => normalizeClientIp(item)) + .filter(Boolean); +} + +export async function diagnoseClientIp(req: Request) { + const remoteAddress = normalizeClientIp(req.socket.remoteAddress); + const forwardedFor = parseForwardedFor(req.headers['x-forwarded-for']); + const hopsFromApp = [remoteAddress, ...forwardedFor.slice().reverse()].filter( + Boolean, + ); + const trust = req.app.get('trust proxy fn') as + | ((ip: string, hop: number) => boolean) + | undefined; + + let selectedIndex = Math.max(hopsFromApp.length - 1, 0); + for (let index = 0; index < hopsFromApp.length - 1; index += 1) { + if (!trust?.(hopsFromApp[index], index)) { + selectedIndex = index; + break; + } + } + + const hops = hopsFromApp.map((ip, index) => ({ + ip, + hop: index, + status: + index < selectedIndex + ? 'trusted' + : index === selectedIndex + ? 'client' + : 'not_checked', + })); + + return { + ...(await getTrustProxyConfig()), + remoteAddress, + forwardedFor, + expressIps: req.ips.map(normalizeClientIp), + clientIp: normalizeClientIp(req.ip || req.socket.remoteAddress), + hops, + }; +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 3d97a587..58c115a8 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -596,6 +596,37 @@ "验证码": "Verification Code", "验证码为6位数字": "Verification code is a 6-digit number", "黑名单": "Blacklist", + "客户端 IP": "Client IP", + "可信代理配置说明": "Trusted proxy configuration", + "系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。": "QingLong checks the proxy chain from the nearest hop to the farthest and uses the first untrusted address as the client IP.", + "代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。": "Hop count is safe only when every request path has the same length. In production, prefer fixed proxy IPs or dedicated network CIDRs. Do not use true directly.", + "当前由环境变量 QL_TRUST_PROXY 管理": "Managed by QL_TRUST_PROXY", + "请修改容器环境变量并重启,系统设置不能覆盖环境变量。": "Change the container environment variable and restart. System settings cannot override it.", + "Trust Proxy 自定义设置": "Custom trust proxy setting", + "直接访问": "Direct access", + "一层代理": "One proxy", + "固定多层": "Fixed hops", + "指定地址或网段": "IP addresses or CIDRs", + "层": "hops", + "保存配置": "Save", + "当前生效值": "Effective value", + "默认配置": "Default", + "环境变量": "Environment variable", + "客户端 IP 诊断": "Client IP diagnostics", + "重新诊断": "Run again", + "Socket 地址": "Socket address", + "最终客户端 IP": "Final client IP", + "距离青龙": "Distance from QingLong", + "跳": "hop(s)", + "判定": "Decision", + "可信代理": "Trusted proxy", + "最终客户端": "Final client", + "未检查": "Not checked", + "请输入可信代理地址或网段": "Enter trusted proxy addresses or CIDRs", + "加入黑名单": "Block IP", + "移出黑名单": "Unblock IP", + "确认加入 IP 黑名单": "Block this IP address?", + "确认移出 IP 黑名单": "Unblock this IP address?", "默认为 CPU 个数": "Default is the number of CPUs", ",保存后不可恢复": ", it can't be recovered after saving.", ",删除后不可恢复": ", it can't be recovered after deletion", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 12ef0f0b..bdc5d522 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -596,6 +596,37 @@ "验证码": "验证码", "验证码为6位数字": "验证码为6位数字", "黑名单": "黑名单", + "客户端 IP": "客户端 IP", + "可信代理配置说明": "可信代理配置说明", + "系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。": "系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。", + "代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。": "代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。", + "当前由环境变量 QL_TRUST_PROXY 管理": "当前由环境变量 QL_TRUST_PROXY 管理", + "请修改容器环境变量并重启,系统设置不能覆盖环境变量。": "请修改容器环境变量并重启,系统设置不能覆盖环境变量。", + "Trust Proxy 自定义设置": "Trust Proxy 自定义设置", + "直接访问": "直接访问", + "一层代理": "一层代理", + "固定多层": "固定多层", + "指定地址或网段": "指定地址或网段", + "层": "层", + "保存配置": "保存配置", + "当前生效值": "当前生效值", + "默认配置": "默认配置", + "环境变量": "环境变量", + "客户端 IP 诊断": "客户端 IP 诊断", + "重新诊断": "重新诊断", + "Socket 地址": "Socket 地址", + "最终客户端 IP": "最终客户端 IP", + "距离青龙": "距离青龙", + "跳": "跳", + "判定": "判定", + "可信代理": "可信代理", + "最终客户端": "最终客户端", + "未检查": "未检查", + "请输入可信代理地址或网段": "请输入可信代理地址或网段", + "加入黑名单": "加入黑名单", + "移出黑名单": "移出黑名单", + "确认加入 IP 黑名单": "确认将该 IP 加入黑名单?", + "确认移出 IP 黑名单": "确认将该 IP 移出黑名单?", "默认为 CPU 个数": "默认为 CPU 个数", ",保存后不可恢复": ",保存后不可恢复", ",删除后不可恢复": ",删除后不可恢复", diff --git a/src/pages/setting/clientIp.tsx b/src/pages/setting/clientIp.tsx new file mode 100644 index 00000000..7db1da3f --- /dev/null +++ b/src/pages/setting/clientIp.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useState } from 'react'; +import intl from 'react-intl-universal'; +import { + Alert, + Button, + Descriptions, + Input, + InputNumber, + Radio, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import config from '@/utils/config'; +import { request } from '@/utils/http'; + +const { Paragraph, Text } = Typography; + +type TrustProxySource = 'default' | 'system' | 'environment'; +type TrustProxyMode = 'direct' | 'single' | 'hops' | 'custom'; + +interface TrustProxyConfig { + trustProxy: string; + source: TrustProxySource; + editable: boolean; +} + +interface ClientIpDiagnostic extends TrustProxyConfig { + remoteAddress: string; + forwardedFor: string[]; + expressIps: string[]; + clientIp: string; + hops: Array<{ + ip: string; + hop: number; + status: 'trusted' | 'client' | 'not_checked'; + }>; +} + +function parseSetting(setting: string) { + if (setting === 'false' || setting === '0') { + return { mode: 'direct' as const, hops: 2, custom: '' }; + } + if (setting === '1') { + return { mode: 'single' as const, hops: 2, custom: '' }; + } + if (/^\d+$/.test(setting)) { + return { mode: 'hops' as const, hops: Number(setting), custom: '' }; + } + return { mode: 'custom' as const, hops: 2, custom: setting }; +} + +const ClientIp = () => { + const [configInfo, setConfigInfo] = useState(); + const [diagnostic, setDiagnostic] = useState(); + const [mode, setMode] = useState('direct'); + const [hops, setHops] = useState(2); + const [custom, setCustom] = useState(''); + const [saving, setSaving] = useState(false); + const [diagnosing, setDiagnosing] = useState(false); + + const applyConfig = (data: TrustProxyConfig) => { + setConfigInfo(data); + const parsed = parseSetting(data.trustProxy); + setMode(parsed.mode); + setHops(parsed.hops); + setCustom(parsed.custom); + }; + + const getConfig = async () => { + const response = await request.get( + `${config.apiPrefix}system/client-ip/config`, + ); + if (response.code === 200) { + applyConfig(response.data); + } + }; + + const diagnose = async () => { + setDiagnosing(true); + try { + const response = await request.get( + `${config.apiPrefix}system/client-ip/diagnose`, + ); + if (response.code === 200) { + setDiagnostic(response.data); + } + } finally { + setDiagnosing(false); + } + }; + + const getSetting = () => { + if (mode === 'direct') return 'false'; + if (mode === 'single') return '1'; + if (mode === 'hops') return String(hops); + return custom.trim(); + }; + + const save = async () => { + const trustProxy = getSetting(); + if (!trustProxy) { + message.error(intl.get('请输入可信代理地址或网段')); + return; + } + setSaving(true); + try { + const response = await request.put( + `${config.apiPrefix}system/client-ip/config`, + { trustProxy }, + ); + if (response.code === 200) { + applyConfig(response.data); + message.success(intl.get('更新成功')); + await diagnose(); + } + } finally { + setSaving(false); + } + }; + + useEffect(() => { + getConfig(); + diagnose(); + }, []); + + const sourceMap: Record = { + default: intl.get('默认配置'), + system: intl.get('系统设置'), + environment: intl.get('环境变量'), + }; + const statusMap = { + trusted: { color: 'green', text: intl.get('可信代理') }, + client: { color: 'blue', text: intl.get('最终客户端') }, + not_checked: { color: 'default', text: intl.get('未检查') }, + }; + + return ( +
+ + + + {intl.get( + '系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。', + )} + + + {intl.get( + '代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。', + )} + +
+ } + /> + + {configInfo?.source === 'environment' && ( + + )} + +
+ {intl.get('Trust Proxy 自定义设置')} + setMode(event.target.value)} + disabled={!configInfo?.editable} + optionType="button" + buttonStyle="solid" + > + {intl.get('直接访问')} + {intl.get('一层代理')} + {intl.get('固定多层')} + + {intl.get('指定地址或网段')} + + + +
+ {mode === 'hops' && ( + setHops(value || 2)} + addonAfter={intl.get('层')} + disabled={!configInfo?.editable} + /> + )} + {mode === 'custom' && ( + setCustom(event.target.value)} + disabled={!configInfo?.editable} + placeholder="loopback,172.18.0.0/16,10.20.0.8/32" + /> + )} +
+ + + + + {intl.get('当前生效值')}: + {configInfo?.trustProxy || '-'} + {configInfo && `(${sourceMap[configInfo.source]})`} + + +
+ +
+ + + {intl.get('客户端 IP 诊断')} + + + + + {diagnostic && ( + <> + + + {diagnostic.remoteAddress || '-'} + + + + {diagnostic.forwardedFor.join(', ') || '-'} + + + + + {diagnostic.clientIp || '-'} + + + + +
`${record.hop}-${record.ip}`} + dataSource={diagnostic.hops} + columns={[ + { + title: intl.get('距离青龙'), + dataIndex: 'hop', + width: 120, + render: (value) => `${value} ${intl.get('跳')}`, + }, + { title: 'IP', dataIndex: 'ip' }, + { + title: intl.get('判定'), + dataIndex: 'status', + width: 140, + render: (value: keyof typeof statusMap) => ( + + {statusMap[value].text} + + ), + }, + ]} + /> + + )} + + + + ); +}; + +export default ClientIp; diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index dc0f17f9..e1d7490c 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -35,6 +35,7 @@ import './index.less'; import useResizeObserver from '@react-hook/resize-observer'; import SystemLog from './systemLog'; import Dependence from './dependence'; +import ClientIp from './clientIp'; const { Text } = Typography; const isDemoEnv = window.__ENV__DeployEnv === 'demo'; @@ -50,7 +51,7 @@ const Setting = () => { reloadTheme, systemInfo, } = useOutletContext(); - console.log('user',user) + console.log('user', user); const columns = [ { title: intl.get('名称'), @@ -345,6 +346,11 @@ const Setting = () => { label: intl.get('登录日志'), children: , }, + { + key: 'client-ip', + label: intl.get('客户端 IP'), + children: , + }, { key: 'dependence', label: intl.get('依赖设置'), diff --git a/src/pages/setting/loginLog.tsx b/src/pages/setting/loginLog.tsx index e025351d..b6534938 100644 --- a/src/pages/setting/loginLog.tsx +++ b/src/pages/setting/loginLog.tsx @@ -1,12 +1,10 @@ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; -import { Typography, Table, Tag, Button, Spin, message } from 'antd'; +import { Table, Tag, Button, Popconfirm, message } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import dayjs from 'dayjs'; -const { Text, Link } = Typography; - enum LoginStatus { '成功', '失败', @@ -17,70 +15,111 @@ enum LoginStatusColor { 'error', } -const columns = [ - { - title: intl.get('序号'), - width: 50, - render: (text: string, record: any, index: number) => { - return index + 1; +const LoginLog = ({ data, height }: { data: Array; height: number }) => { + const [blockedIps, setBlockedIps] = useState([]); + + const getIpBlacklist = () => { + request + .get(`${config.apiPrefix}user/ip-blacklist`) + .then(({ code, data }) => { + if (code === 200) { + setBlockedIps(data || []); + } + }); + }; + + useEffect(() => { + getIpBlacklist(); + }, []); + + const updateIpBlacklist = async (ip: string, blocked: boolean) => { + const response = blocked + ? await request.delete(`${config.apiPrefix}user/ip-blacklist`, { + data: { ip }, + }) + : await request.put(`${config.apiPrefix}user/ip-blacklist`, { ip }); + if (response.code === 200) { + setBlockedIps(response.data || []); + message.success(response.message); + } + }; + + const columns = [ + { + title: intl.get('序号'), + width: 50, + render: (text: string, record: any, index: number) => index + 1, }, - }, - { - title: intl.get('登录时间'), - dataIndex: 'timestamp', - key: 'timestamp', - width: 120, - render: (text: string, record: any) => { - return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'); + { + title: intl.get('登录时间'), + dataIndex: 'timestamp', + key: 'timestamp', + width: 120, + render: (text: string, record: any) => + dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'), }, - }, - { - title: intl.get('登录地址'), - dataIndex: 'address', - width: 120, - key: 'address', - }, - { - title: intl.get('登录IP'), - dataIndex: 'ip', - width: 100, - key: 'ip', - }, - { - title: intl.get('登录设备'), - dataIndex: 'platform', - key: 'platform', - width: 80, - }, - { - title: intl.get('登录状态'), - dataIndex: 'status', - key: 'status', - width: 80, - render: (text: string, record: any) => { - return ( + { + title: intl.get('登录地址'), + dataIndex: 'address', + width: 120, + key: 'address', + }, + { + title: intl.get('登录IP'), + dataIndex: 'ip', + width: 100, + key: 'ip', + }, + { + title: intl.get('登录设备'), + dataIndex: 'platform', + key: 'platform', + width: 80, + }, + { + title: intl.get('登录状态'), + dataIndex: 'status', + key: 'status', + width: 80, + render: (text: string, record: any) => ( {intl.get(LoginStatus[record.status])} - ); + ), }, - }, -]; + { + title: intl.get('操作'), + key: 'action', + width: 100, + render: (text: string, record: any) => { + if (!record.ip || record.status !== 1) { + return null; + } + const blocked = blockedIps.includes(record.ip); + const label = blocked ? '移出黑名单' : '加入黑名单'; + return ( + updateIpBlacklist(record.ip, blocked)} + > + + + ); + }, + }, + ]; -const LoginLog = ({ - data, - height, -}: { - data: Array; - height: number; -}) => { return ( <>
`${record.ip}-${record.timestamp}`} size="middle" scroll={{ x: 1000, y: height }} /> From 276dfc238235d7ebedbf1469c85aae354527df4e Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 16:29:06 +0800 Subject: [PATCH 075/123] perf: bound runtime memory and storage usage --- back/api/cron.ts | 26 ++- back/api/index.ts | 2 + back/api/log.ts | 27 ++- back/api/retention.ts | 78 +++++++ back/api/subscription.ts | 18 +- back/api/system.ts | 6 + back/data/system.ts | 2 + back/services/cron.ts | 53 ++++- back/services/metrics.ts | 80 +++++-- back/services/retention.ts | 155 ++++++++++++++ back/services/subscription.ts | 13 +- back/services/system.ts | 32 ++- back/shared/logReader.ts | 109 ++++++++++ back/shared/retention.ts | 64 ++++++ docker/Dockerfile.debian | 2 + docker/Dockerfile.debian310 | 2 + ...RMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md | 200 ++++++++++++++++++ ecosystem.config.js | 6 +- shell/share.sh | 9 +- src/locales/en-US.json | 17 ++ src/locales/zh-CN.json | 17 ++ src/pages/crontab/logModal.tsx | 71 +++++-- src/pages/log/index.tsx | 2 +- src/pages/setting/index.tsx | 1 - src/pages/setting/other.tsx | 157 ++++++++++++++ src/pages/setting/systemLog.tsx | 4 +- test/back/ecosystem.test.cjs | 28 +++ test/back/logReader.test.cjs | 72 +++++++ test/back/metrics.test.cjs | 34 +++ test/back/retention.test.cjs | 131 ++++++++++++ 30 files changed, 1350 insertions(+), 68 deletions(-) create mode 100644 back/api/retention.ts create mode 100644 back/services/retention.ts create mode 100644 back/shared/logReader.ts create mode 100644 back/shared/retention.ts create mode 100644 docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md create mode 100644 test/back/ecosystem.test.cjs create mode 100644 test/back/logReader.test.cjs create mode 100644 test/back/metrics.test.cjs create mode 100644 test/back/retention.test.cjs diff --git a/back/api/cron.ts b/back/api/cron.ts index 0067a0c8..abd7f236 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -307,13 +307,35 @@ export default (app: Router) => { params: Joi.object({ id: Joi.number().required(), }), + query: Joi.object({ + offset: Joi.number().integer().min(0).optional(), + limit: Joi.number() + .integer() + .min(1) + .max(1024 * 1024) + .optional(), + tail: Joi.boolean().optional(), + t: Joi.string().optional(), + }).unknown(true), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const result = await cronService.log(req.params.id); - return res.send({ code: 200, data: result.content, logStatus: result.status }); + const result = await cronService.log(req.params.id, { + offset: req.query.offset as unknown as number, + limit: req.query.limit as unknown as number, + tail: req.query.tail as unknown as boolean, + }); + return res.send({ + code: 200, + data: result.content, + logStatus: result.status, + offset: result.offset, + nextOffset: result.nextOffset, + total: result.total, + truncated: result.truncated, + }); } catch (e) { return next(e); } diff --git a/back/api/index.ts b/back/api/index.ts index cc196429..9d1b7411 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -13,6 +13,7 @@ import update from './update'; import dashboard from './dashboard'; import health from './health'; import clientIp from './clientIp'; +import retention from './retention'; export default () => { const app = Router(); @@ -30,6 +31,7 @@ export default () => { dashboard(app); health(app); clientIp(app); + retention(app); return app; }; diff --git a/back/api/log.ts b/back/api/log.ts index d642d510..aed4708c 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -12,6 +12,7 @@ import { } from '../config/util'; import LogService from '../services/log'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; +import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader'; const route = Router(); const blacklist = ['.tmp']; @@ -34,6 +35,20 @@ export default (app: Router) => { route.get( '/detail', + celebrate({ + query: Joi.object({ + path: Joi.string().allow('').optional(), + file: Joi.string().required(), + offset: Joi.number().integer().min(0).optional(), + limit: Joi.number() + .integer() + .min(1) + .max(MAX_LOG_CHUNK_BYTES) + .optional(), + tail: Joi.boolean().optional(), + t: Joi.string().optional(), + }).unknown(true), + }), async (req: Request, res: Response, next: NextFunction) => { try { const logService = Container.get(LogService); @@ -52,11 +67,19 @@ export default (app: Router) => { where: { log_path: logPath, status: InstanceStatus.running }, }); - const content = await getFileContentByName(finalPath); + const chunk = await readLogChunk(finalPath, { + offset: req.query.offset as unknown as number, + limit: req.query.limit as unknown as number, + tail: req.query.tail as unknown as boolean, + }); res.send({ code: 200, - data: removeAnsi(content), + data: removeAnsi(chunk.content), logStatus: runningInstance ? 'running' : undefined, + offset: chunk.offset, + nextOffset: chunk.nextOffset, + total: chunk.total, + truncated: chunk.truncated, }); } catch (e) { return next(e); diff --git a/back/api/retention.ts b/back/api/retention.ts new file mode 100644 index 00000000..c75f9f64 --- /dev/null +++ b/back/api/retention.ts @@ -0,0 +1,78 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { celebrate, Joi } from 'celebrate'; +import { Container } from 'typedi'; +import RetentionService from '../services/retention'; +import { MAX_RETENTION_DAYS } from '../shared/retention'; + +const route = Router(); +const policySchema = { + runningInstanceRetentionDays: Joi.number() + .integer() + .min(0) + .max(MAX_RETENTION_DAYS) + .required(), + cronStatRetentionDays: Joi.number() + .integer() + .min(0) + .max(MAX_RETENTION_DAYS) + .required(), +}; +const cleanupSchema = { + ...policySchema, + dependenceCacheTypes: Joi.array() + .items(Joi.string().valid('node', 'python3')) + .unique() + .default([]), + compactDatabase: Joi.boolean().default(false), +}; + +export default (app: Router) => { + app.use('/system/storage-retention', route); + + route.put( + '/config', + celebrate({ body: Joi.object(policySchema) }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const service = Container.get(RetentionService); + const data = await service.updatePolicy(req.body); + res.send({ code: 200, data }); + } catch (error) { + next(error); + } + }, + ); + + route.post( + '/preview', + celebrate({ body: Joi.object(cleanupSchema) }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const service = Container.get(RetentionService); + const data = await service.preview(req.body); + res.send({ code: 200, data }); + } catch (error) { + next(error); + } + }, + ); + + route.post( + '/cleanup', + celebrate({ + body: Joi.object({ + ...cleanupSchema, + confirmation: Joi.string().valid('CLEAN').required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const service = Container.get(RetentionService); + const data = await service.cleanup(req.body); + res.send({ code: 200, data }); + } catch (error) { + next(error); + } + }, + ); +}; diff --git a/back/api/subscription.ts b/back/api/subscription.ts index dd365a27..7efd00ae 100644 --- a/back/api/subscription.ts +++ b/back/api/subscription.ts @@ -148,13 +148,27 @@ export default (app: Router) => { params: Joi.object({ id: Joi.number().required(), }), + query: Joi.object({ + offset: Joi.number().integer().min(0).optional(), + limit: Joi.number() + .integer() + .min(1) + .max(1024 * 1024) + .optional(), + tail: Joi.boolean().optional(), + t: Joi.string().optional(), + }).unknown(true), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.log(req.params.id); - return res.send({ code: 200, data }); + const result = await subscriptionService.log(req.params.id, { + offset: req.query.offset as unknown as number, + limit: req.query.limit as unknown as number, + tail: req.query.tail as unknown as boolean, + }); + return res.send({ code: 200, data: result.content, ...result }); } catch (e) { return next(e); } diff --git a/back/api/system.ts b/back/api/system.ts index d11af688..8b3829ce 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -350,6 +350,11 @@ export default (app: Router) => { query: { startTime: Joi.string().allow('').optional(), endTime: Joi.string().allow('').optional(), + limit: Joi.number() + .integer() + .min(1) + .max(1024 * 1024) + .optional(), t: Joi.string().optional(), }, }), @@ -361,6 +366,7 @@ export default (app: Router) => { req.query as { startTime?: string; endTime?: string; + limit?: number; }, ); } catch (e) { diff --git a/back/data/system.ts b/back/data/system.ts index 8ae1ab60..b61c837b 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -41,6 +41,8 @@ export interface SystemConfigInfo { linuxMirror?: string; timezone?: string; globalSshKey?: string; + runningInstanceRetentionDays?: number; + cronStatRetentionDays?: number; } export interface LoginLogInfo { diff --git a/back/services/cron.ts b/back/services/cron.ts index 3a7282a5..e28e710c 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -32,6 +32,7 @@ import { t } from '../shared/i18n'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; import { isEmpty } from 'lodash'; +import { LogReadOptions, readLogChunk } from '../shared/logReader'; @Service() export default class CronService { @@ -766,27 +767,63 @@ export default class CronService { await this.setCrontab(); } - public async log(id: number): Promise<{ content: string; status: string }> { + public async log( + id: number, + options: LogReadOptions = {}, + ): Promise<{ + content: string; + status: string; + offset: number; + nextOffset: number; + total: number; + truncated: boolean; + }> { const doc = await this.getDb({ id }); if (!doc) { - return { content: '', status: 'empty' }; + return { + content: '', + status: 'empty', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; } if (doc.log_name === '/dev/null') { - return { content: t('日志设置为忽略'), status: 'ignored' }; + return { + content: t('日志设置为忽略'), + status: 'ignored', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; } const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const logFileExist = doc.log_path && (await fileExist(absolutePath)); if (logFileExist) { - const content = await getFileContentByName(`${absolutePath}`); + const chunk = await readLogChunk(`${absolutePath}`, options); const isRunning = typeof doc.status === 'number' && [CrontabStatus.running, CrontabStatus.queued].includes(doc.status); - return { content, status: isRunning ? 'running' : 'completed' }; + return { + ...chunk, + status: isRunning ? 'running' : 'completed', + }; } else { - return typeof doc.status === 'number' && + const status = + typeof doc.status === 'number' && [CrontabStatus.queued, CrontabStatus.running].includes(doc.status) - ? { content: t('运行中...'), status: 'running' } - : { content: t('日志不存在...'), status: 'notFound' }; + ? 'running' + : 'notFound'; + return { + content: status === 'running' ? t('运行中...') : t('日志不存在...'), + status, + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; } } diff --git a/back/services/metrics.ts b/back/services/metrics.ts index 7ed44664..08a859a4 100644 --- a/back/services/metrics.ts +++ b/back/services/metrics.ts @@ -8,16 +8,19 @@ interface Metric { tags?: Record; } -class MetricsService { - private metrics: Metric[] = []; +export const MAX_METRIC_SAMPLES = 1000; +const METRIC_RETENTION_MS = 60 * 60 * 1000; + +export class MetricsService { + private metrics: Array = new Array(MAX_METRIC_SAMPLES); + private metricCount = 0; + private nextMetricIndex = 0; private static instance: MetricsService; private constructor() { // 定期清理旧数据 - setInterval(() => { - const oneHourAgo = Date.now() - 3600000; - this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo); - }, 60000); + const cleanupTimer = setInterval(() => this.removeExpiredMetrics(), 60000); + cleanupTimer.unref(); } static getInstance(): MetricsService { @@ -28,12 +31,14 @@ class MetricsService { } record(name: string, value: number, tags?: Record) { - this.metrics.push({ + this.metrics[this.nextMetricIndex] = { name, value, timestamp: Date.now(), tags, - }); + }; + this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES; + this.metricCount = Math.min(this.metricCount + 1, MAX_METRIC_SAMPLES); } measure(name: string, fn: () => void, tags?: Record) { @@ -46,7 +51,11 @@ class MetricsService { } } - async measureAsync(name: string, fn: () => Promise, tags?: Record) { + async measureAsync( + name: string, + fn: () => Promise, + tags?: Record, + ) { const start = performance.now(); try { await fn(); @@ -57,28 +66,61 @@ class MetricsService { } getMetrics(name?: string, tags?: Record) { - let filtered = this.metrics; - + this.removeExpiredMetrics(); + let filtered = this.getMetricSnapshot(); + if (name) { - filtered = filtered.filter(m => m.name === name); + filtered = filtered.filter((m) => m.name === name); } - + if (tags) { - filtered = filtered.filter(m => { + filtered = filtered.filter((m) => { if (!m.tags) return false; - return Object.entries(tags).every(([key, value]) => m.tags![key] === value); + return Object.entries(tags).every( + ([key, value]) => m.tags![key] === value, + ); }); } + const values = filtered.map((metric) => metric.value); return { count: filtered.length, - average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length, - min: Math.min(...filtered.map(m => m.value)), - max: Math.max(...filtered.map(m => m.value)), + average: values.length + ? values.reduce((acc, value) => acc + value, 0) / values.length + : 0, + min: values.length ? Math.min(...values) : 0, + max: values.length ? Math.max(...values) : 0, metrics: filtered, }; } + private getMetricSnapshot(): Metric[] { + if (this.metricCount < MAX_METRIC_SAMPLES) { + return this.metrics.slice(0, this.metricCount) as Metric[]; + } + return [ + ...this.metrics.slice(this.nextMetricIndex), + ...this.metrics.slice(0, this.nextMetricIndex), + ] as Metric[]; + } + + private removeExpiredMetrics() { + const oldestTimestamp = Date.now() - METRIC_RETENTION_MS; + const retained = this.getMetricSnapshot().filter( + (metric) => metric.timestamp > oldestTimestamp, + ); + if (retained.length === this.metricCount) return; + + this.metrics = new Array(MAX_METRIC_SAMPLES); + this.metricCount = 0; + this.nextMetricIndex = 0; + for (const metric of retained) { + this.metrics[this.nextMetricIndex] = metric; + this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES; + this.metricCount++; + } + } + report() { const report = { timestamp: Date.now(), @@ -89,4 +131,4 @@ class MetricsService { } } -export const metricsService = MetricsService.getInstance(); \ No newline at end of file +export const metricsService = MetricsService.getInstance(); diff --git a/back/services/retention.ts b/back/services/retention.ts new file mode 100644 index 00000000..851a4a3c --- /dev/null +++ b/back/services/retention.ts @@ -0,0 +1,155 @@ +import fs from 'fs/promises'; +import path from 'path'; +import dayjs from 'dayjs'; +import { Op } from 'sequelize'; +import { Service } from 'typedi'; +import config from '../config'; +import { CrontabStatModel } from '../data/cronStats'; +import { sequelize } from '../data'; +import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; +import { AuthDataType, SystemModel } from '../data/system'; +import { + DependenceCacheType, + getDirectorySize, + isDependenceCacheType, + normalizeRetentionPolicy, + RetentionPolicy, +} from '../shared/retention'; + +export interface StorageCleanupRequest extends RetentionPolicy { + dependenceCacheTypes?: DependenceCacheType[]; + compactDatabase?: boolean; +} + +function runningInstanceWhere(days: number) { + const cutoff = dayjs().subtract(days, 'day').unix(); + return { + status: { [Op.ne]: InstanceStatus.running }, + [Op.or]: [ + { finished_at: { [Op.lt]: cutoff } }, + { + finished_at: { [Op.is]: null }, + started_at: { [Op.lt]: cutoff }, + }, + ], + }; +} + +function cronStatWhere(days: number) { + return { + date: { [Op.lt]: dayjs().subtract(days, 'day').format('YYYY-MM-DD') }, + }; +} + +@Service() +export default class RetentionService { + public async updatePolicy(policy: Partial) { + const normalized = normalizeRetentionPolicy(policy); + const systemConfig = await SystemModel.findOne({ + where: { type: AuthDataType.systemConfig }, + }); + if (!systemConfig) { + throw new Error('System config not found'); + } + await SystemModel.update( + { + info: { + ...systemConfig.info, + ...normalized, + }, + }, + { where: { id: systemConfig.id } }, + ); + return normalized; + } + + public async preview(request: StorageCleanupRequest) { + const policy = normalizeRetentionPolicy(request); + const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter( + isDependenceCacheType, + ); + const [runningInstances, cronStats, dependenceCaches] = await Promise.all([ + policy.runningInstanceRetentionDays > 0 + ? RunningInstanceModel.count({ + where: runningInstanceWhere(policy.runningInstanceRetentionDays), + }) + : 0, + policy.cronStatRetentionDays > 0 + ? CrontabStatModel.count({ + where: cronStatWhere(policy.cronStatRetentionDays), + }) + : 0, + Promise.all( + dependenceCacheTypes.map(async (type) => ({ + type, + bytes: await getDirectorySize( + path.join(config.dependenceCachePath, type), + ), + })), + ), + ]); + + return { + policy, + runningInstances, + cronStats, + dependenceCaches, + dependenceCacheBytes: dependenceCaches.reduce( + (total, cache) => total + cache.bytes, + 0, + ), + compactDatabase: Boolean(request.compactDatabase), + }; + } + + public async cleanup(request: StorageCleanupRequest) { + const preview = await this.preview(request); + const deleted = await sequelize.transaction(async (transaction) => { + const runningInstances = + preview.policy.runningInstanceRetentionDays > 0 + ? await RunningInstanceModel.destroy({ + where: runningInstanceWhere( + preview.policy.runningInstanceRetentionDays, + ), + transaction, + }) + : 0; + const cronStats = + preview.policy.cronStatRetentionDays > 0 + ? await CrontabStatModel.destroy({ + where: cronStatWhere(preview.policy.cronStatRetentionDays), + transaction, + }) + : 0; + return { runningInstances, cronStats }; + }); + + const dependenceCaches = []; + for (const cache of preview.dependenceCaches) { + await fs.rm(path.join(config.dependenceCachePath, cache.type), { + recursive: true, + force: true, + }); + dependenceCaches.push(cache); + } + + if ( + request.compactDatabase && + (deleted.runningInstances || deleted.cronStats) + ) { + await sequelize.query('VACUUM'); + } + + return { + deleted: { + ...deleted, + dependenceCaches, + dependenceCacheBytes: preview.dependenceCacheBytes, + }, + compactedDatabase: Boolean( + request.compactDatabase && + (deleted.runningInstances || deleted.cronStats), + ), + }; + } +} diff --git a/back/services/subscription.ts b/back/services/subscription.ts index a387bb1b..6d9e35fa 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -33,6 +33,7 @@ import { CrontabModel } from '../data/cron'; import CrontabService from './cron'; import taskLimit from '../shared/pLimit'; import { logStreamManager } from '../shared/logStreamManager'; +import { LogReadOptions, readLogChunk } from '../shared/logReader'; @Service() export default class SubscriptionService { @@ -366,14 +367,20 @@ export default class SubscriptionService { } } - public async log(id: number) { + public async log(id: number, options: LogReadOptions = {}) { const doc = await this.getDb({ id }); if (!doc || !doc.log_path) { - return ''; + return { + content: '', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; } const absolutePath = await handleLogPath(doc.log_path as string); - return await getFileContentByName(absolutePath); + return await readLogChunk(absolutePath, options); } public async logs(id: number) { diff --git a/back/services/system.ts b/back/services/system.ts index e6848170..9cb644b9 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -2,7 +2,6 @@ import { spawn } from 'cross-spawn'; import { Response } from 'express'; import fs from 'fs'; import { Agent, request } from 'undici'; -import sum from 'lodash/sum'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; @@ -467,6 +466,7 @@ export default class SystemService { query: { startTime?: string; endTime?: string; + limit?: number; }, ) { const startTime = dayjs(query.startTime || undefined) @@ -481,8 +481,30 @@ export default class SystemService { .filter((x) => x.title.endsWith('.log')) .filter((x) => x.createTime >= startTime && x.createTime <= endTime); + const limit = Math.min( + Math.max(Number(query.limit) || 1024 * 1024, 1), + 1024 * 1024, + ); + const total = logs.reduce((size, log) => size + (log.size || 0), 0); + let remaining = limit; + const selected: Array< + (typeof logs)[number] & { start: number; length: number } + > = []; + for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) { + const log = logs[index]; + const size = log.size || 0; + const length = Math.min(size, remaining); + if (length > 0) { + selected.unshift({ ...log, start: size - length, length }); + remaining -= length; + } + } + + const contentLength = selected.reduce((size, log) => size + log.length, 0); res.set({ - 'Content-Length': sum(logs.map((x) => x.size)), + 'Content-Length': contentLength, + 'X-QL-Log-Total': total, + 'X-QL-Log-Truncated': total > contentLength ? 'true' : 'false', }); (function sendFiles(res, fileNames) { if (fileNames.length === 0) { @@ -494,13 +516,17 @@ export default class SystemService { if (currentLog) { const currentFileStream = fs.createReadStream( path.join(config.systemLogPath, currentLog.title), + { + start: currentLog.start, + end: currentLog.start + currentLog.length - 1, + }, ); currentFileStream.on('end', () => { sendFiles(res, fileNames); }); currentFileStream.pipe(res, { end: false }); } - })(res, logs); + })(res, selected); } public async deleteSystemLog() { diff --git a/back/shared/logReader.ts b/back/shared/logReader.ts new file mode 100644 index 00000000..c1cc5ae0 --- /dev/null +++ b/back/shared/logReader.ts @@ -0,0 +1,109 @@ +import fs from 'fs/promises'; + +export const DEFAULT_LOG_CHUNK_BYTES = 256 * 1024; +export const MAX_LOG_CHUNK_BYTES = 1024 * 1024; + +export interface LogReadOptions { + offset?: number; + limit?: number; + tail?: boolean; +} + +export interface LogChunk { + content: string; + offset: number; + nextOffset: number; + total: number; + truncated: boolean; +} + +function normalizeLimit(limit?: number) { + if (!Number.isFinite(limit)) return DEFAULT_LOG_CHUNK_BYTES; + return Math.min(Math.max(Math.trunc(limit!), 4), MAX_LOG_CHUNK_BYTES); +} + +function isUtf8ContinuationByte(byte: number) { + return (byte & 0xc0) === 0x80; +} + +function completeUtf8End(buffer: Buffer, start: number, end: number) { + if (end <= start) return start; + + let sequenceStart = end - 1; + while ( + sequenceStart > start && + isUtf8ContinuationByte(buffer[sequenceStart]) + ) { + sequenceStart--; + } + + const firstByte = buffer[sequenceStart]; + const expectedLength = + firstByte < 0x80 + ? 1 + : firstByte < 0xe0 + ? 2 + : firstByte < 0xf0 + ? 3 + : 4; + return end - sequenceStart < expectedLength ? sequenceStart : end; +} + +export async function readLogChunk( + filePath: string, + options: LogReadOptions = {}, +): Promise { + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(filePath, 'r'); + const { size: total } = await handle.stat(); + const limit = normalizeLimit(options.limit); + const requestedOffset = Number.isFinite(options.offset) + ? Math.trunc(options.offset!) + : undefined; + const requestedStart = + options.tail || requestedOffset === undefined + ? Math.max(total - limit, 0) + : Math.min(Math.max(requestedOffset, 0), total); + const length = Math.min(limit + 3, total - requestedStart); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read( + buffer, + 0, + length, + requestedStart, + ); + let leadingBytes = 0; + while ( + leadingBytes < bytesRead && + isUtf8ContinuationByte(buffer[leadingBytes]) + ) { + leadingBytes++; + } + const offset = requestedStart + leadingBytes; + const candidateEnd = Math.min(leadingBytes + limit, bytesRead); + const contentEnd = completeUtf8End(buffer, leadingBytes, candidateEnd); + const nextOffset = requestedStart + contentEnd; + + return { + content: buffer.subarray(leadingBytes, contentEnd).toString('utf8'), + offset, + nextOffset, + total, + truncated: offset > 0 || nextOffset < total, + }; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return { + content: '', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; + } + throw error; + } finally { + await handle?.close(); + } +} diff --git a/back/shared/retention.ts b/back/shared/retention.ts new file mode 100644 index 00000000..815d8694 --- /dev/null +++ b/back/shared/retention.ts @@ -0,0 +1,64 @@ +import fs from 'fs/promises'; +import { Dirent } from 'fs'; +import path from 'path'; + +export const MAX_RETENTION_DAYS = 3650; +export const DEPENDENCE_CACHE_TYPES = ['node', 'python3'] as const; + +export type DependenceCacheType = (typeof DEPENDENCE_CACHE_TYPES)[number]; + +export interface RetentionPolicy { + runningInstanceRetentionDays: number; + cronStatRetentionDays: number; +} + +export function normalizeRetentionDays(value: unknown) { + const days = Number(value); + if (!Number.isFinite(days)) return 0; + return Math.min(Math.max(Math.trunc(days), 0), MAX_RETENTION_DAYS); +} + +export function normalizeRetentionPolicy( + policy: Partial, +): RetentionPolicy { + return { + runningInstanceRetentionDays: normalizeRetentionDays( + policy.runningInstanceRetentionDays, + ), + cronStatRetentionDays: normalizeRetentionDays(policy.cronStatRetentionDays), + }; +} + +export function isDependenceCacheType( + value: string, +): value is DependenceCacheType { + return DEPENDENCE_CACHE_TYPES.includes(value as DependenceCacheType); +} + +export async function getDirectorySize(rootPath: string): Promise { + const pending = [rootPath]; + let total = 0; + + while (pending.length > 0) { + const currentPath = pending.pop()!; + let entries: Dirent[]; + try { + entries = await fs.readdir(currentPath, { withFileTypes: true }); + } catch (error: any) { + if (error?.code === 'ENOENT') continue; + throw error; + } + + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const entryPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile()) { + total += (await fs.stat(entryPath)).size; + } + } + } + + return total; +} diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 5abe80ed..09bbbeae 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -70,8 +70,10 @@ 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 && \ + npm cache clean --force && \ rm -rf /root/.cache && \ rm -rf /root/.npm && \ + rm -rf /var/lib/apt/lists/* && \ rm -rf /etc/apt/apt.conf.d/docker-clean && \ ulimit -c 0 diff --git a/docker/Dockerfile.debian310 b/docker/Dockerfile.debian310 index 7ef70f1d..36bc0d41 100644 --- a/docker/Dockerfile.debian310 +++ b/docker/Dockerfile.debian310 @@ -69,8 +69,10 @@ 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 && \ + npm cache clean --force && \ rm -rf /root/.cache && \ rm -rf /root/.npm && \ + rm -rf /var/lib/apt/lists/* && \ rm -rf /etc/apt/apt.conf.d/docker-clean && \ ulimit -c 0 diff --git a/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md b/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md new file mode 100644 index 00000000..8df6a3bf --- /dev/null +++ b/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md @@ -0,0 +1,200 @@ +# 性能与未关闭 Issue 梳理(2026-08-16) + +## 范围与结论 + +- 基线:`origin/develop` @ `31f78e4d`,运行镜像 `whyour/qinglong:debian`。 +- GitHub 当前有 86 个未关闭 issue;逐项读取了正文,并读取了其中 48 个有评论 issue 的全部评论。 +- 待机内存问题有效且可量化。主要问题不是 Node.js 语言本身,而是三个 Node 隔离进程、PM2,以及没有上限的逐请求指标保留共同叠加。 +- 稳定待机没有持续块设备写入;磁盘问题主要是活跃期的重复日志、每次任务生命周期的多次 SQLite 事务、无界历史表和日志读取/保留策略。 +- 建议先做可回归验证的小步优化,不建议以“改写为 Go”作为当前性能问题的前置条件。 + +## 实测基线 + +### 内存 + +| 项目 | 结果 | +| --- | ---: | +| `docker stats` 稳定待机 | 约 193 MiB | +| cgroup `memory.current` | 约 234–244 MB | +| HTTP worker PSS / RSS | 约 75 MiB / 107 MiB | +| gRPC worker PSS / RSS | 约 57 MiB / 89 MiB | +| cluster 主进程 PSS / RSS | 约 46 MiB / 77 MiB | +| PM2 PSS / RSS | 约 24 MiB / 52 MiB | + +对 `/api/health` 发出 50,000 次成功请求后,等待客户端退出及 5 秒回收: + +- cgroup 从 `244,035,584` 增至 `348,925,952` 字节,净增约 105 MB; +- HTTP worker RSS 从约 107 MB 增至约 208 MB; +- 块设备写入量没有变化。 + +这与 `back/middlewares/monitoring.ts` 和 `back/services/metrics.ts` 一致:每个 HTTP 请求同时进入一个 1,000 条队列和一个按一小时保留、没有数量上限的原始指标数组。后者会随每小时请求数线性增长,并不是 GC 可以主动释放的短期对象。 + +### 磁盘写入与占用 + +- 稳定后连续 30 秒,cgroup `wbytes` 保持 `1,077,248` 不变;当前没有证据表明待机状态持续刷盘。 +- SQLite 使用 `journal_mode=delete`、`synchronous=2`(FULL)、4 KiB page。一次任务执行至少涉及运行实例创建、任务状态更新、运行实例完成、任务恢复空闲、统计更新等多次独立事务,活跃期会产生额外 journal 与同步写放大。 +- Winston 会写 `/ql/data/log`,同一日志又经 console 被 PM2 写入 `~/.pm2/logs`。主进程、HTTP worker、gRPC worker 都会创建 logger/file transport;PM2 日志没有代码内保留上限,只在 reload 时 `pm2 flush`。 +- 任务 stderr 同时进入任务日志和系统日志;错误输出较多时会再次放大写入。 +- 日志详情接口使用 `readFile(..., 'utf8')` 整文件加载,既造成瞬时内存峰值,也对应 #2256 的超长日志超时。 + +镜像与主要目录: + +| 项目 | 大小 | +| --- | ---: | +| 镜像 | 599,130,109 bytes | +| `/ql` | 215 MiB | +| `/ql/node_modules` | 174 MiB | +| `/ql/static` | 32 MiB | +| `/usr/local/lib/node_modules` | 85 MiB | +| `/usr/local/lib/python3.11` | 40 MiB | +| `/var/lib/apt/lists` | 19 MiB | +| 全新 `/ql/data` | 5.9 MiB | + +## 建议修复批次 + +### P0:低风险、可量化收益 + +1. 将原始逐请求指标改为固定容量 ring buffer,或直接改成计数器、直方图和最近慢请求;给所有维度设置基数上限。增加 10 万请求内存回归测试,目标是回落后增量小于 10 MiB。 +2. 日志只保留一个持久化落点:生产环境选择“Winston 文件 + PM2 stdout 丢弃”或“console + 受控 PM2 rotation”,避免双写。统一由单进程持有系统日志文件 transport。 +3. 日志 API 增加 `tail/offset/limit` 与最大响应字节数,前端使用增量加载,不再整文件读入内存。 +4. 给 PM2 日志、`RunningInstances`、任务统计和依赖下载缓存增加明确的容量/时间保留策略;清理任务输出被删除的字节数和剩余大小。 +5. Dockerfile 删除 apt lists,审计全局 Node 模块与应用依赖的重复内容;构建产物中不要保留仅构建期需要的包。 + +当前修复分支落实情况: + +- P0-1 已完成:逐请求原始指标改为 1,000 条固定容量环形缓冲区,并补充 50,000 次写入回归测试。 +- P0-2 已完成:容器内 PM2 输出直接接入 PID 1 标准输出/错误,停止写入 `~/.pm2/logs` 的重复持久化副本。 +- P0-3 已完成:日志接口默认 256 KiB、最大 1 MiB,支持 tail/offset 增量读取,并保证 UTF-8 字符不被分块破坏;浏览器视图最大保留 1 MiB。 +- P0-4 已完成:系统设置提供运行实例/任务统计保留天数,默认 0(禁用);清理前展示记录数与依赖缓存字节数,清理接口要求明确确认。依赖缓存和 SQLite `VACUUM` 均为手动勾选,不在启动或升级时自动执行。 +- P0-5 已完成:Debian 镜像构建后清理 npm cache 和 apt lists。 + +### P1:轻量架构调整 + +1. 主进程只加载 cluster/bootstrap 必需模块,把 Express、监控、HTTP 中间件和业务容器全部延迟到 HTTP worker。目标是先回收主进程约 30–40 MiB private memory。 +2. Metrics 定时器只在实际使用指标的 worker 中启动,并对 path/tag 做归一化,防止高基数值撑大内存。 +3. 将任务“开始”和“结束”各自需要的状态、实例、统计写入合并到事务;避免一个生命周期五个以上独立提交。 +4. 在本地文件系统上基准比较 SQLite `WAL + synchronous=NORMAL + busy_timeout` 与现状;确认断电语义和网络文件系统兼容后再作为可选配置发布。 +5. 任务日志保留从全局天数扩展为“全局默认 + 单任务覆盖 + 总容量上限”,而不是只增加更多清理 cron。 + +### P2:需要设计评审 + +- 提供 Lite 模式:调度器与 HTTP 同进程运行,省去 gRPC worker;标准模式继续保持隔离。需要故障域、重启和任务注册一致性测试。 +- 若继续推进集群/多节点,先定义控制面、执行器、任务租约、心跳、幂等和日志归属;不要以“共享 SQLite/MySQL”替代分布式调度设计。 + +## 86 个未关闭 Issue 的逐项建议 + +状态含义:`立即处理` 是确认有效且影响当前主线;`轻量处理` 是小型代码/文档/回归测试;`架构议题` 需要 RFC 或独立 epic;`关闭/合并` 表示已修复、重复、支持问题或需要提交者用最新版本重现。 + +### 立即处理(10) + +| Issue | 结论 | 建议 | +| --- | --- | --- | +| #2256 | 有效,整文件读取日志造成超时和内存峰值 | 日志分片、tail、最大响应大小 | +| #2742 | 有效,与 #3057 合并 | 用内存基线和压力回归替代“运行几天观察” | +| #2743 | 有效,与 #2742/#3057 合并 | 优先修复指标保留和进程基线,再复测受限容器 | +| #2871 | 有效,2.21.0 仍有反馈 | 与 #2902 合并,修复源地址生成并加发行版矩阵测试 | +| #2902 | 有效,重复 #2871 | 合并后关闭 | +| #3017 | 有效安全 epic | 拆成凭据迁移、scope、SSRF、错误脱敏、更新完整性等独立任务 | +| #3051 | 当前回归仍需验证 | 对 shell status payload 建端到端测试,重建最新镜像复测 | +| #3055 | 用户报告有效,但依赖库单测 5/6 字段都可注册 | 增加真实 gRPC 注册与触发集成测试,排查发行镜像/存量任务迁移 | +| #3057 | 有效,实测已复现 | 先修无界指标和多进程基线,不需要先改写语言 | +| #3060 | 有效的容器权限/依赖安装回归 | 覆盖匿名卷、bind mount、rootless 三类安装 smoke test | + +### 轻量处理或局部设计(36) + +| Issue | 结论与最小处理 | +| --- | --- | +| #2015 | 当前仅支持整组 AND/OR;若要嵌套条件,定义轻量 filter AST,避免继续堆 UI 特判 | +| #2236 | 与 #2421/#3014 合并为一个 cron 可视化 issue | +| #2254 | 增加有限重试策略;必须有退避、最大次数和不可重试退出码 | +| #2269 | 增加时区、5/6 字段、DST 的调度集成测试;最新版本无法复现则关闭 | +| #2341 | 与 #2688 合并,把 boolean 实例模式升级为 `replace/skip/parallel/queue` 枚举 | +| #2360 | 标签在列表中恢复可见与筛选入口,纯前端小改 | +| #2418 | 配置方法已有评论解法;仅保留消息超长时的截断/分片问题 | +| #2421 | 合并到 #3014 | +| #2635 | 备注字段是小型 schema/UI 变更,可与名称/标签排序统一设计 | +| #2645 | 增加依赖唯一键、恢复数据去重迁移和幂等安装测试 | +| #2687 | 用最新版本和代理 URL 重现;补代理/超时错误提示 | +| #2688 | 合并到 #2341;当前“单实例”会杀旧任务,不等同于“跳过新任务” | +| #2701 | 需要最小仓库配置样本;JSON 控制字符应在输入边界给出字段级错误 | +| #2715 | Server 酱 tags 是局部通知配置扩展 | +| #2756 | 重测 Chrome、base path、缓存与 health 401;补登录跳转循环保护 | +| #2757 | 复用订阅现有 `autoAddCron` 配置,明确新增任务默认启停状态 | +| #2780 | `ql check` 应在系统包失败时停止并回滚/给出恢复指令,不能继续破坏可运行环境 | +| #2782 | 通知免打扰时段是局部配置;明确延迟发送还是静默丢弃 | +| #2797 | 保留;展开 AggregateError 内部原因并为每种通知后端返回可操作错误 | +| #2801 | 私有仓库凭据与 SSH/HTTPS 模式做连通性诊断,信息不足时转支持问题 | +| #2859 | 最新版请求重现;增加 `env.js` 镜像启动 smoke test和缓存头校验 | +| #2863 | 区分官方 Docker 与 npm/Linux 安装支持等级;若继续支持后者则加 CI | +| #2883 | 最新版复测临时编辑保存;失败时保留请求与后端路径错误 | +| #2886 | 支持 requirements 文件需要锁定来源、工作目录和隔离策略,可先支持显式路径 | +| #2896 | Synology 内核缺少随机字节能力,先做环境诊断与明确错误,不宜静默降级安全随机数 | +| #2901 | 当前默认监听 `::` 并回退 `0.0.0.0`;增加 IPv6 Docker/host 网络 smoke test | +| #2925 | 信息不足;收集完整日志与重启方式,最新镜像不可复现则关闭 | +| #2927 | 使用成熟 ANSI 清理实现,覆盖复合 SGR,而不是只匹配 `\x1b[数字m` | +| #2984 | 通知审计有效,但需先确定脱敏、保留期和容量上限,避免制造新磁盘问题 | +| #3013 | 与性能修复合并:全局默认、单任务覆盖、总容量上限 | +| #3014 | 合并 #2236/#2421,前端生成器必须始终展示最终 cron 文本 | +| #3016 | 有效文档任务;从路由/validation 自动生成 OpenAPI,避免手工文档漂移 | +| #3020 | 有效小改;配置编辑器启用 `wordWrap: 'on'` 并保留开关 | +| #3027 | 需要明确旧值 `1` 的历史语义;用显式 `@once/@boot/disabled` 替代魔法数字 | +| #3054 | 有效纯 UI 改进;整行点击打开历史日志,操作按钮阻止冒泡 | +| #3058 | 有效安全需求;应用增加环境变量名称 allowlist,默认最小权限并审计拒绝事件 | + +### 架构议题,单独立项(12) + +| Issue | 处理建议 | +| --- | --- | +| #1656 | 捕获任意脚本网络请求需要代理或运行时注入,成本和隐私风险高;不作为普通日志小改 | +| #1695 | 与 #2596 合并为“集群执行器”RFC | +| #1821 | 多数据库不等于多节点;只有明确 HA/共享控制面设计后再评估 MySQL/PostgreSQL | +| #2434 | PowerShell 运行时会增加镜像体积和维护矩阵,作为可选外部执行器评估 | +| #2464 | Bun 属于额外运行时矩阵,不应为单一性能假设直接加入基础镜像 | +| #2481 | 每任务 Python 虚拟环境需要依赖缓存、生命周期和磁盘配额设计 | +| #2596 | 合并到 #1695 | +| #2642 | 与 #2769/#2905 合并为多租户权限模型 RFC | +| #2656 | .NET 8 作为可选运行时/派生镜像评估,不进入默认镜像 | +| #2736 | 条件触发工作流已接近 n8n 类产品,应独立产品/RFC,不混入 cron 小功能 | +| #2769 | 合并到多租户 RFC | +| #2905 | 合并到多租户 RFC;PR #2818 作为设计输入 | + +### 建议关闭、合并或转支持(28) + +| Issue | 理由 | +| --- | --- | +| #795 | HTTP 明文本质上应通过 HTTPS 解决;前端自定义“加盐”不能阻止重放。补反代 HTTPS 文档后关闭 | +| #796 | 可由数据库/API 导出;需求长期无上下文,关闭或改为 CSV 导出新需求 | +| #854 | 客户端 IP 已由 #3059 对应功能覆盖;应用调用审计并入 #2984 | +| #2042 | 当前 `NotReg` 已显式处理 NULL 与 notLike;最新版回归测试通过后关闭 | +| #2217 | Home Assistant 插件仓库属于独立打包生态,转社区集成 | +| #2219 | 当前已有运行实例历史与逐实例停止按钮,关闭为已实现 | +| #2329 | 评论已给出 Open API/文件方案,转文档 FAQ 后关闭 | +| #2340 | 老旧 IPQ/固件上的 V8 Bus error 属运行时/硬件兼容;用当前多架构镜像复测,否则关闭 | +| #2350 | 环境变量值如何分隔主要由脚本协议定义;补文档后关闭 | +| #2398 | 单一外站 DNS 解析问题且无环境信息,转网络支持 | +| #2404 | 与 #3057 合并,保留一个性能 epic | +| #2525 | 非官方旧容器/npm 安装路径;最新版本不可复现则关闭 | +| #2567 | 2024 年无任何环境信息的容器 entrypoint 错误,要求当前镜像重现,否则关闭 | +| #2705 | 当前已有全局 cron concurrency 与最多五个重复实例限制,补 UI/文档后关闭 | +| #2772 | 与 #2340 合并为特定软路由 CPU/固件兼容问题 | +| #2788 | maintainer 已说明黑名单语义;补订阅文档后关闭 | +| #2792 | PM2 版本提示是镜像更新/支持问题,当前镜像复测后关闭 | +| #2793 | 自动重启会掩盖 OOM;合并到性能问题,不单独实现定时自杀 | +| #2798 | Node 在特定硬件启动即 fatal,合并硬件兼容问题 | +| #2895 | Debian 中加载 musl 二进制通常是第三方依赖选错平台,转依赖支持 | +| #2903 | 当前运行时代码未使用 chokidar 监听 node_modules;最新镜像复测后关闭 | +| #2914 | maintainer 指定 2.20.1 已修复,当前版本复测后关闭 | +| #2948 | 评论已有正确升级链和 `/ql/data` 挂载方案;补升级文档后关闭 | +| #2964 | 本文已提供资源基线,并入 #3057 后关闭 | +| #2967 | maintainer 已解释黑白名单语义;补文档后关闭 | +| #3052 | `2ac4f07f` 已修复 dashboard open scope,关闭为已修复 | +| #3053 | `2ac4f07f` 已加入 INI 预览/强制打开处理,关闭为已修复 | +| #3059 | `31f78e4d` 已加入 IP 黑名单、客户端 IP 诊断和 trust proxy 配置,关闭为已修复 | + +## 建议的 Issue 管理动作 + +1. 先关闭明确已实现的 #2219、#3052、#3053、#3059。 +2. 将 #2404、#2742、#2743 合并到 #3057,并把本文内存实测作为验收基线。 +3. 将 #2236、#2421 合并到 #3014;#2871 合并 #2902;#1695 合并 #2596;#2642/#2769/#2905 合并。 +4. 对“需要最新版复现”的旧 bug 统一贴模板,给出 14 天补充窗口;没有版本、架构、日志、最小复现则关闭为 stale/support。 +5. 轻量修复和架构 epic 使用不同 label/milestone,避免大设计长期占据回归队列。 diff --git a/ecosystem.config.js b/ecosystem.config.js index 1d476b70..95e702bf 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,3 +1,5 @@ +const isContainer = process.env.QL_CONTAINER === 'true'; + module.exports = { apps: [ { @@ -7,7 +9,9 @@ module.exports = { wait_ready: true, listen_timeout: 5000, source_map_support: true, - time: true, + time: !isContainer, + out_file: isContainer ? '/proc/1/fd/1' : undefined, + error_file: isContainer ? '/proc/1/fd/2' : undefined, script: 'static/build/app.js', env: { http_proxy: '', diff --git a/shell/share.sh b/shell/share.sh index f182e4b7..2f02840e 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -305,8 +305,13 @@ reload_pm2() { # Kill any existing node processes for qinglong pkill -f "node.*static/build/app.js" 2>/dev/null || true - # Start node directly in the background - nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 & + # Start node directly in the background. In containers, keep application + # output on the container streams instead of duplicating Winston logs on disk. + 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 & + fi local node_pid=$! t '已使用 Node.js 直接启动服务 (PID: %s)' "$node_pid" diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 58c115a8..42103041 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -366,6 +366,23 @@ "添加标签成功": "Tags added successfully", "清空日志": "Clear Logs", "清除依赖缓存": "Clean dependency cache", + "历史数据保留与手动清理": "History retention and manual cleanup", + "保留天数为0时禁用对应清理,预览不会删除数据": "Set retention to 0 to disable that cleanup. Preview never deletes data.", + "任务统计": "Task statistics", + "清除 Node 依赖缓存": "Clean Node dependency cache", + "清除 Python 依赖缓存": "Clean Python dependency cache", + "清理后压缩数据库": "Compact database after cleanup", + "保存设置": "Save settings", + "预览清理": "Preview cleanup", + "确认清理存储数据": "Confirm storage cleanup", + "确认清理": "Clean now", + "将删除历史运行实例": "Historical running instances to delete", + "将删除任务统计": "Task statistics to delete", + "将清除依赖缓存": "Dependency caches to clear", + "清理依赖缓存后相关依赖需要重新安装": "Dependencies must be reinstalled after their cache is cleared.", + "数据库压缩期间可能暂时阻塞请求": "Database compaction may briefly block requests.", + "此操作不可恢复,请确认已完成必要备份": "This action cannot be undone. Confirm that required backups are complete.", + "清理完成": "Cleanup complete", "清除成功": "Clean successful", "源文件": "Source File", "激活成功": "Activation successful", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index bdc5d522..244333db 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -366,6 +366,23 @@ "添加标签成功": "添加标签成功", "清空日志": "清空日志", "清除依赖缓存": "清除依赖缓存", + "历史数据保留与手动清理": "历史数据保留与手动清理", + "保留天数为0时禁用对应清理,预览不会删除数据": "保留天数为0时禁用对应清理,预览不会删除数据", + "任务统计": "任务统计", + "清除 Node 依赖缓存": "清除 Node 依赖缓存", + "清除 Python 依赖缓存": "清除 Python 依赖缓存", + "清理后压缩数据库": "清理后压缩数据库", + "保存设置": "保存设置", + "预览清理": "预览清理", + "确认清理存储数据": "确认清理存储数据", + "确认清理": "确认清理", + "将删除历史运行实例": "将删除历史运行实例", + "将删除任务统计": "将删除任务统计", + "将清除依赖缓存": "将清除依赖缓存", + "清理依赖缓存后相关依赖需要重新安装": "清理依赖缓存后相关依赖需要重新安装", + "数据库压缩期间可能暂时阻塞请求": "数据库压缩期间可能暂时阻塞请求", + "此操作不可恢复,请确认已完成必要备份": "此操作不可恢复,请确认已完成必要备份", + "清理完成": "清理完成", "清除成功": "清除成功", "源文件": "源文件", "激活成功": "激活成功", diff --git a/src/pages/crontab/logModal.tsx b/src/pages/crontab/logModal.tsx index aa83c23c..4fe5a5e1 100644 --- a/src/pages/crontab/logModal.tsx +++ b/src/pages/crontab/logModal.tsx @@ -16,11 +16,12 @@ import { CheckCircleOutlined, } from "@ant-design/icons"; import { PageLoading } from "@ant-design/pro-layout"; -import { logEnded } from "@/utils"; import { CrontabStatus } from "./type"; import Ansi from "ansi-to-react"; const { Countdown } = Statistic; +const LOG_CHUNK_BYTES = 256 * 1024; +const MAX_LOG_VIEW_CHARS = 1024 * 1024; const CronLogModal = ({ cron, @@ -38,35 +39,49 @@ const CronLogModal = ({ const [executing, setExecuting] = useState(true); const [isPhone, setIsPhone] = useState(false); const scrollInfoRef = useRef({ value: 0, down: true }); + const logOffsetRef = useRef(); + const valueRef = useRef(value); + const pollTimerRef = useRef>(); const uniqPath = logUrl ? logUrl : String(cron?.id); const getCronLog = (isFirst?: boolean) => { if (isFirst) { setLoading(true); } + const baseUrl = logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`; + const separator = baseUrl.includes("?") ? "&" : "?"; + const offset = isFirst ? undefined : logOffsetRef.current; + const pagination = `${separator}limit=${LOG_CHUNK_BYTES}${ + isFirst ? "&tail=true" : offset !== undefined ? `&offset=${offset}` : "" + }`; request - .get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`) - .then(({ code, data, logStatus }) => { - if ( - code === 200 && - localStorage.getItem("logCron") === uniqPath && - data !== value - ) { - const log = (data as string) || intl.get("暂无日志"); - setValue(log); - const hasNext = logStatus === 'running'; - if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) { - setTimeout(() => { - autoScroll(); - }); - } - setExecuting(hasNext); - if (hasNext) { - setTimeout(() => { - autoScroll(); - getCronLog(); - }, 2000); - } + .get(`${baseUrl}${pagination}`) + .then(({ code, data, logStatus, nextOffset }) => { + if (code !== 200 || localStorage.getItem("logCron") !== uniqPath) { + return; + } + + const hasNext = logStatus === "running"; + const chunk = (data as string) || ""; + let log = isFirst ? chunk : `${valueRef.current}${chunk}`; + if (!log && !hasNext) { + log = intl.get("暂无日志"); + } + if (log.length > MAX_LOG_VIEW_CHARS) { + log = log.slice(-MAX_LOG_VIEW_CHARS); + } + valueRef.current = log; + setValue(log); + if (typeof nextOffset === "number") { + logOffsetRef.current = nextOffset; + } + setExecuting(hasNext); + + if (chunk || !hasNext) { + autoScroll(); + } + if (hasNext) { + pollTimerRef.current = setTimeout(() => getCronLog(), 2000); } }) .finally(() => { @@ -89,6 +104,9 @@ const CronLogModal = ({ }; const cancel = () => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + } localStorage.removeItem("logCron"); handleCancel(); }; @@ -117,12 +135,19 @@ const CronLogModal = ({ useEffect(() => { if (cron && cron.id) { + logOffsetRef.current = undefined; getCronLog(true); } + return () => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + } + }; }, [cron]); useEffect(() => { if (data) { + valueRef.current = data; setValue(data); } }, [data]); diff --git a/src/pages/log/index.tsx b/src/pages/log/index.tsx index 83ba585f..e90f92e5 100644 --- a/src/pages/log/index.tsx +++ b/src/pages/log/index.tsx @@ -59,7 +59,7 @@ const Log = () => { .get( `${config.apiPrefix}logs/detail?file=${node.title}&path=${ node.parent || '' - }`, + }&tail=true&limit=${1024 * 1024}`, ) .then(({ code, data }) => { if (code === 200) { diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index e1d7490c..00048011 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -51,7 +51,6 @@ const Setting = () => { reloadTheme, systemInfo, } = useOutletContext(); - console.log('user', user); const columns = [ { title: intl.get('名称'), diff --git a/src/pages/setting/other.tsx b/src/pages/setting/other.tsx index d698e34e..7845cff5 100644 --- a/src/pages/setting/other.tsx +++ b/src/pages/setting/other.tsx @@ -62,6 +62,8 @@ const Other = ({ cronConcurrency?: number | null; timezone?: string | null; globalSshKey?: string | null; + runningInstanceRetentionDays?: number | null; + cronStatRetentionDays?: number | null; }>(); const [form] = Form.useForm(); const [exportLoading, setExportLoading] = useState(false); @@ -69,6 +71,11 @@ const Other = ({ const showDownloadProgress = useProgress(intl.get('下载')); const [visible, setVisible] = useState(false); const [selectedModules, setSelectedModules] = useState(['base']); + const [cleanupLoading, setCleanupLoading] = useState(false); + const [dependenceCacheTypes, setDependenceCacheTypes] = useState( + [], + ); + const [compactDatabase, setCompactDatabase] = useState(false); const { enable: enableDarkMode, @@ -136,6 +143,93 @@ const Other = ({ }); }; + const retentionPayload = () => ({ + runningInstanceRetentionDays: + systemConfig?.runningInstanceRetentionDays || 0, + cronStatRetentionDays: systemConfig?.cronStatRetentionDays || 0, + }); + + const saveRetentionPolicy = () => { + request + .put( + `${config.apiPrefix}system/storage-retention/config`, + retentionPayload(), + ) + .then(({ code }) => { + if (code === 200) { + message.success(intl.get('更新成功')); + } + }); + }; + + const formatBytes = (bytes: number) => { + if (!bytes) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB']; + const unit = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${units[unit]}`; + }; + + const previewStorageCleanup = () => { + setCleanupLoading(true); + const payload = { + ...retentionPayload(), + dependenceCacheTypes, + compactDatabase, + }; + request + .post(`${config.apiPrefix}system/storage-retention/preview`, payload) + .then(({ code, data }) => { + if (code !== 200) return; + Modal.confirm({ + width: 560, + centered: true, + title: intl.get('确认清理存储数据'), + okText: intl.get('确认清理'), + cancelText: intl.get('取消'), + okButtonProps: { danger: true }, + content: ( +
+

+ {intl.get('将删除历史运行实例')}: {data.runningInstances} +

+

+ {intl.get('将删除任务统计')}: {data.cronStats} +

+

+ {intl.get('将清除依赖缓存')}: {data.dependenceCaches.length} ( + {formatBytes(data.dependenceCacheBytes)}) +

+ {data.dependenceCaches.length > 0 && ( +

{intl.get('清理依赖缓存后相关依赖需要重新安装')}

+ )} + {compactDatabase && ( +

{intl.get('数据库压缩期间可能暂时阻塞请求')}

+ )} +

{intl.get('此操作不可恢复,请确认已完成必要备份')}

+
+ ), + onOk: () => { + setCleanupLoading(true); + return request + .post(`${config.apiPrefix}system/storage-retention/cleanup`, { + ...payload, + confirmation: 'CLEAN', + }) + .then(({ code }) => { + if (code === 200) { + message.success(intl.get('清理完成')); + } + }) + .finally(() => setCleanupLoading(false)); + }, + }); + }) + .finally(() => setCleanupLoading(false)); + }; + const exportData = () => { setExportLoading(true); request @@ -302,6 +396,69 @@ const Other = ({ + +
+ { + setSystemConfig({ + ...systemConfig, + runningInstanceRetentionDays: value, + }); + }} + /> +
+
+ { + setSystemConfig({ + ...systemConfig, + cronStatRetentionDays: value, + }); + }} + /> +
+
+ setDependenceCacheTypes(value as string[])} + /> +
+
+ setCompactDatabase(event.target.checked)} + > + {intl.get('清理后压缩数据库')} + +
+ + +
{ const { loading, refresh } = useRequest( () => { return request.get( - `${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${range[1]}`, + `${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${ + range[1] + }&limit=${1024 * 1024}`, { responseType: 'blob', }, diff --git a/test/back/ecosystem.test.cjs b/test/back/ecosystem.test.cjs new file mode 100644 index 00000000..6fb7512c --- /dev/null +++ b/test/back/ecosystem.test.cjs @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const configPath = require.resolve('../../ecosystem.config'); + +function loadConfig(containerValue) { + if (containerValue === undefined) { + delete process.env.QL_CONTAINER; + } else { + process.env.QL_CONTAINER = containerValue; + } + delete require.cache[configPath]; + return require(configPath).apps[0]; +} + +test('container logging goes to the container standard streams', () => { + const app = loadConfig('true'); + assert.equal(app.out_file, '/proc/1/fd/1'); + assert.equal(app.error_file, '/proc/1/fd/2'); + assert.equal(app.time, false); +}); + +test('non-container installs keep the PM2 logging defaults', () => { + const app = loadConfig(undefined); + assert.equal(app.out_file, undefined); + assert.equal(app.error_file, undefined); + assert.equal(app.time, true); +}); diff --git a/test/back/logReader.test.cjs b/test/back/logReader.test.cjs new file mode 100644 index 00000000..3df41924 --- /dev/null +++ b/test/back/logReader.test.cjs @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + DEFAULT_LOG_CHUNK_BYTES, + MAX_LOG_CHUNK_BYTES, + readLogChunk, +} = require('../../back/shared/logReader'); + +test('log reader defaults to a bounded tail and supports incremental reads', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-')); + const file = path.join(directory, 'task.log'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + + const initial = 'a'.repeat(DEFAULT_LOG_CHUNK_BYTES * 2); + await fs.writeFile(file, initial); + + const tail = await readLogChunk(file); + assert.equal(tail.content.length, DEFAULT_LOG_CHUNK_BYTES); + assert.equal(tail.offset, DEFAULT_LOG_CHUNK_BYTES); + assert.equal(tail.nextOffset, initial.length); + assert.equal(tail.truncated, true); + + await fs.appendFile(file, 'next'); + const incremental = await readLogChunk(file, { offset: tail.nextOffset }); + assert.equal(incremental.content, 'next'); + assert.equal(incremental.nextOffset, initial.length + 4); +}); + +test('log reader enforces the maximum chunk size', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-')); + const file = path.join(directory, 'large.log'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.writeFile(file, 'x'.repeat(MAX_LOG_CHUNK_BYTES + 1024)); + + const chunk = await readLogChunk(file, { limit: Number.MAX_SAFE_INTEGER }); + assert.equal(Buffer.byteLength(chunk.content), MAX_LOG_CHUNK_BYTES); +}); + +test('log reader preserves UTF-8 characters across byte boundaries', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-')); + const file = path.join(directory, 'utf8.log'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.writeFile(file, 'a'.repeat(5) + '中文日志'); + + const first = await readLogChunk(file, { offset: 0, limit: 7 }); + const second = await readLogChunk(file, { + offset: first.nextOffset, + limit: 7, + }); + const third = await readLogChunk(file, { + offset: second.nextOffset, + limit: 7, + }); + + assert.equal(first.content + second.content + third.content, 'aaaaa中文日志'); + assert.equal((first.content + second.content + third.content).includes('�'), false); +}); + +test('missing logs return an empty chunk', async () => { + const chunk = await readLogChunk('/path/that/does/not/exist.log'); + assert.deepEqual(chunk, { + content: '', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }); +}); diff --git a/test/back/metrics.test.cjs b/test/back/metrics.test.cjs new file mode 100644 index 00000000..d1f45f45 --- /dev/null +++ b/test/back/metrics.test.cjs @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + MAX_METRIC_SAMPLES, + metricsService, +} = require('../../back/services/metrics'); + +test('metrics keep only the newest bounded samples', () => { + const sampleCount = MAX_METRIC_SAMPLES * 50; + for (let index = 0; index < sampleCount; index++) { + metricsService.record('http_request', index, { + path: `/health/${index}`, + }); + } + + const result = metricsService.getMetrics('http_request'); + assert.equal(result.count, MAX_METRIC_SAMPLES); + assert.equal(result.metrics[0].value, sampleCount - MAX_METRIC_SAMPLES); + assert.equal(result.metrics.at(-1).value, sampleCount - 1); +}); + +test('empty metric queries return finite aggregates', () => { + const result = metricsService.getMetrics('missing_metric'); + assert.deepEqual( + { + count: result.count, + average: result.average, + min: result.min, + max: result.max, + }, + { count: 0, average: 0, min: 0, max: 0 }, + ); +}); diff --git a/test/back/retention.test.cjs b/test/back/retention.test.cjs new file mode 100644 index 00000000..89c7f756 --- /dev/null +++ b/test/back/retention.test.cjs @@ -0,0 +1,131 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { Op } = require('sequelize'); + +const { + getDirectorySize, + normalizeRetentionPolicy, +} = require('../../back/shared/retention'); + +test('retention policy defaults to disabled and clamps invalid values', () => { + assert.deepEqual(normalizeRetentionPolicy({}), { + runningInstanceRetentionDays: 0, + cronStatRetentionDays: 0, + }); + assert.deepEqual( + normalizeRetentionPolicy({ + runningInstanceRetentionDays: -10, + cronStatRetentionDays: 99999, + }), + { + runningInstanceRetentionDays: 0, + cronStatRetentionDays: 3650, + }, + ); +}); + +test('directory preview counts files recursively and ignores symlinks', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-')); + const nested = path.join(directory, 'nested'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.mkdir(nested); + await fs.writeFile(path.join(directory, 'one'), Buffer.alloc(7)); + await fs.writeFile(path.join(nested, 'two'), Buffer.alloc(11)); + await fs.symlink(path.join(nested, 'two'), path.join(directory, 'link')); + + assert.equal(await getDirectorySize(directory), 18); + assert.equal(await getDirectorySize(path.join(directory, 'missing')), 0); +}); + +test('cleanup previews first, protects running instances, and uses explicit options', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-')); + const nodeCache = path.join(directory, 'node'); + await fs.mkdir(nodeCache); + await fs.writeFile(path.join(nodeCache, 'cache'), Buffer.alloc(13)); + + const config = require('../../back/config').default; + const originalCachePath = config.dependenceCachePath; + const RunningInstanceModel = {}; + const CrontabStatModel = {}; + const InstanceStatus = { running: 0 }; + const sequelize = {}; + const stubModule = (modulePath, exports) => { + require.cache[require.resolve(modulePath)] = { + id: require.resolve(modulePath), + filename: require.resolve(modulePath), + loaded: true, + exports, + children: [], + paths: [], + }; + }; + stubModule('../../back/data', { sequelize }); + stubModule('../../back/data/cronStats', { CrontabStatModel }); + stubModule('../../back/data/runningInstance', { + InstanceStatus, + RunningInstanceModel, + }); + stubModule('../../back/data/system', { + AuthDataType: { systemConfig: 'systemConfig' }, + SystemModel: {}, + }); + delete require.cache[require.resolve('../../back/services/retention')]; + const RetentionService = require('../../back/services/retention').default; + + t.after(async () => { + config.dependenceCachePath = originalCachePath; + await fs.rm(directory, { recursive: true, force: true }); + }); + + let instanceWhere; + let vacuumed = false; + let instanceCountCalls = 0; + let statCountCalls = 0; + config.dependenceCachePath = directory; + RunningInstanceModel.count = async ({ where }) => { + instanceCountCalls++; + instanceWhere = where; + return 2; + }; + RunningInstanceModel.destroy = async ({ where }) => { + instanceWhere = where; + return 2; + }; + CrontabStatModel.count = async () => { + statCountCalls++; + return 3; + }; + CrontabStatModel.destroy = async () => 3; + sequelize.transaction = async (callback) => callback({}); + sequelize.query = async (query) => { + vacuumed = query === 'VACUUM'; + return []; + }; + + const service = new RetentionService(); + const disabledPreview = await service.preview({ + runningInstanceRetentionDays: 0, + cronStatRetentionDays: 0, + }); + assert.equal(disabledPreview.runningInstances, 0); + assert.equal(disabledPreview.cronStats, 0); + assert.equal(instanceCountCalls, 0); + assert.equal(statCountCalls, 0); + + const result = await service.cleanup({ + runningInstanceRetentionDays: 30, + cronStatRetentionDays: 90, + dependenceCacheTypes: ['node'], + compactDatabase: true, + }); + + assert.equal(instanceWhere.status[Op.ne], InstanceStatus.running); + assert.equal(result.deleted.runningInstances, 2); + assert.equal(result.deleted.cronStats, 3); + assert.equal(result.deleted.dependenceCacheBytes, 13); + assert.equal(vacuumed, true); + await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' }); +}); From 02e02491c4f9341afb90b427dba43012532c0194 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 17:03:15 +0800 Subject: [PATCH 076/123] fix: avoid duplicate container stdout logging --- ...RMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md | 31 +++++++++++++++++++ ecosystem.config.js | 4 +-- test/back/ecosystem.test.cjs | 6 ++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md b/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md index 8df6a3bf..d73e16e8 100644 --- a/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md +++ b/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md @@ -68,6 +68,37 @@ - P0-4 已完成:系统设置提供运行实例/任务统计保留天数,默认 0(禁用);清理前展示记录数与依赖缓存字节数,清理接口要求明确确认。依赖缓存和 SQLite `VACUUM` 均为手动勾选,不在启动或升级时自动执行。 - P0-5 已完成:Debian 镜像构建后清理 npm cache 和 apt lists。 +## 优化后实测(提交 `276dfc23`,同机 Node 24 / Docker Desktop arm64) + +### 指标内存 + +| 压力 | 基线 | 优化后 | 结果 | +| --- | ---: | ---: | --- | +| 50,000 条保留样本 | 50,000 | 1,000 | 减少 98% | +| 50,000 条 GC 后堆增量 | 8.76 MiB | 0.16 MiB | 减少约 98.2% | +| 500,000 条 GC 后堆增量 | 88.33 MiB | 0.11 MiB | 减少约 99.9% | +| 500,000 条写入耗时(5 轮平均) | 149.6 ms | 74.4 ms | 约快 50% | +| 500,000 条统计查询 | `RangeError` 栈溢出 | 正常 | 消除故障 | + +真实容器用并发 50 发出 50,000 次 `/api/health`:两者空闲均约 161 MiB;基线负载后即时增加约 108.5 MiB、稳定后仍增加约 17.6 MiB,优化版分别约 81.0 MiB 和 8.4 MiB,改善约 25% 和 52%。三轮 10,000 请求耗时基线平均 3.86 秒、优化版 4.05 秒,差约 4.8% 且落在轮次波动内,因此不宣称 HTTP 吞吐提升。 + +### 大日志读取 + +对 100 MiB 日志做三轮读取: + +| 指标 | 基线整文件读取 | 优化后 tail 读取 | 结果 | +| --- | ---: | ---: | --- | +| 返回数据 | 100 MiB | 256 KiB | 减少 99.75%(400 倍) | +| 平均耗时 | 56.1 ms | 1.34 ms | 约快 41.9 倍 | +| 堆峰值增量 | 100.11 MiB | 0.317 MiB | 减少约 99.7% | +| RSS 峰值增量 | 115.18 MiB | 0.349 MiB | 减少约 99.7% | + +### 日志写入、SQLite 与镜像 + +- PM2 实际容器测试中,基线为约 7,288 行输出额外生成了 888,026 bytes 的 `qinglong-out.log`;优化后 PM2 日志目录没有文件,Docker stdout 每行只出现一次。测试同时发现 `/proc/1/fd/1` 会造成 Docker stdout 重复,最终改为容器中 `out_file/error_file=/dev/null`,由 `pm2-runtime` 单独转发 stdout。 +- 合成 100,000 条运行实例和 100,000 条任务统计的 SQLite 数据库为 24,481,792 bytes。删除 90% 历史记录耗时 0.26 秒,文件大小不会立刻变化;可选 `VACUUM` 耗时 0.05 秒,文件降至 2,465,792 bytes,回收约 89.9%。 +- 当前 Debian 镜像 `/var/lib/apt/lists` 实测 19,527,172 bytes(18.62 MiB),新 Dockerfile 会在同一个安装层删除。Dockerfile `--check` 无警告;完整镜像构建在 Debian 包升级网络等待阶段停止,因此不虚报最终镜像总大小。 + ### P1:轻量架构调整 1. 主进程只加载 cluster/bootstrap 必需模块,把 Express、监控、HTTP 中间件和业务容器全部延迟到 HTTP worker。目标是先回收主进程约 30–40 MiB private memory。 diff --git a/ecosystem.config.js b/ecosystem.config.js index 95e702bf..d7c150e2 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -10,8 +10,8 @@ module.exports = { listen_timeout: 5000, source_map_support: true, time: !isContainer, - out_file: isContainer ? '/proc/1/fd/1' : undefined, - error_file: isContainer ? '/proc/1/fd/2' : undefined, + out_file: isContainer ? '/dev/null' : undefined, + error_file: isContainer ? '/dev/null' : undefined, script: 'static/build/app.js', env: { http_proxy: '', diff --git a/test/back/ecosystem.test.cjs b/test/back/ecosystem.test.cjs index 6fb7512c..34de98ab 100644 --- a/test/back/ecosystem.test.cjs +++ b/test/back/ecosystem.test.cjs @@ -13,10 +13,10 @@ function loadConfig(containerValue) { return require(configPath).apps[0]; } -test('container logging goes to the container standard streams', () => { +test('container logging relies on pm2-runtime stdout without persistent copies', () => { const app = loadConfig('true'); - assert.equal(app.out_file, '/proc/1/fd/1'); - assert.equal(app.error_file, '/proc/1/fd/2'); + assert.equal(app.out_file, '/dev/null'); + assert.equal(app.error_file, '/dev/null'); assert.equal(app.time, false); }); From afe9b5702b034cc958bb9cf9e4218c595e929a40 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 22:41:46 +0800 Subject: [PATCH 077/123] chore: remove internal performance triage report --- ...RMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md | 231 ------------------ 1 file changed, 231 deletions(-) delete mode 100644 docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md diff --git a/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md b/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md deleted file mode 100644 index d73e16e8..00000000 --- a/docs/PERFORMANCE_AND_OPEN_ISSUE_TRIAGE_2026-08-16.md +++ /dev/null @@ -1,231 +0,0 @@ -# 性能与未关闭 Issue 梳理(2026-08-16) - -## 范围与结论 - -- 基线:`origin/develop` @ `31f78e4d`,运行镜像 `whyour/qinglong:debian`。 -- GitHub 当前有 86 个未关闭 issue;逐项读取了正文,并读取了其中 48 个有评论 issue 的全部评论。 -- 待机内存问题有效且可量化。主要问题不是 Node.js 语言本身,而是三个 Node 隔离进程、PM2,以及没有上限的逐请求指标保留共同叠加。 -- 稳定待机没有持续块设备写入;磁盘问题主要是活跃期的重复日志、每次任务生命周期的多次 SQLite 事务、无界历史表和日志读取/保留策略。 -- 建议先做可回归验证的小步优化,不建议以“改写为 Go”作为当前性能问题的前置条件。 - -## 实测基线 - -### 内存 - -| 项目 | 结果 | -| --- | ---: | -| `docker stats` 稳定待机 | 约 193 MiB | -| cgroup `memory.current` | 约 234–244 MB | -| HTTP worker PSS / RSS | 约 75 MiB / 107 MiB | -| gRPC worker PSS / RSS | 约 57 MiB / 89 MiB | -| cluster 主进程 PSS / RSS | 约 46 MiB / 77 MiB | -| PM2 PSS / RSS | 约 24 MiB / 52 MiB | - -对 `/api/health` 发出 50,000 次成功请求后,等待客户端退出及 5 秒回收: - -- cgroup 从 `244,035,584` 增至 `348,925,952` 字节,净增约 105 MB; -- HTTP worker RSS 从约 107 MB 增至约 208 MB; -- 块设备写入量没有变化。 - -这与 `back/middlewares/monitoring.ts` 和 `back/services/metrics.ts` 一致:每个 HTTP 请求同时进入一个 1,000 条队列和一个按一小时保留、没有数量上限的原始指标数组。后者会随每小时请求数线性增长,并不是 GC 可以主动释放的短期对象。 - -### 磁盘写入与占用 - -- 稳定后连续 30 秒,cgroup `wbytes` 保持 `1,077,248` 不变;当前没有证据表明待机状态持续刷盘。 -- SQLite 使用 `journal_mode=delete`、`synchronous=2`(FULL)、4 KiB page。一次任务执行至少涉及运行实例创建、任务状态更新、运行实例完成、任务恢复空闲、统计更新等多次独立事务,活跃期会产生额外 journal 与同步写放大。 -- Winston 会写 `/ql/data/log`,同一日志又经 console 被 PM2 写入 `~/.pm2/logs`。主进程、HTTP worker、gRPC worker 都会创建 logger/file transport;PM2 日志没有代码内保留上限,只在 reload 时 `pm2 flush`。 -- 任务 stderr 同时进入任务日志和系统日志;错误输出较多时会再次放大写入。 -- 日志详情接口使用 `readFile(..., 'utf8')` 整文件加载,既造成瞬时内存峰值,也对应 #2256 的超长日志超时。 - -镜像与主要目录: - -| 项目 | 大小 | -| --- | ---: | -| 镜像 | 599,130,109 bytes | -| `/ql` | 215 MiB | -| `/ql/node_modules` | 174 MiB | -| `/ql/static` | 32 MiB | -| `/usr/local/lib/node_modules` | 85 MiB | -| `/usr/local/lib/python3.11` | 40 MiB | -| `/var/lib/apt/lists` | 19 MiB | -| 全新 `/ql/data` | 5.9 MiB | - -## 建议修复批次 - -### P0:低风险、可量化收益 - -1. 将原始逐请求指标改为固定容量 ring buffer,或直接改成计数器、直方图和最近慢请求;给所有维度设置基数上限。增加 10 万请求内存回归测试,目标是回落后增量小于 10 MiB。 -2. 日志只保留一个持久化落点:生产环境选择“Winston 文件 + PM2 stdout 丢弃”或“console + 受控 PM2 rotation”,避免双写。统一由单进程持有系统日志文件 transport。 -3. 日志 API 增加 `tail/offset/limit` 与最大响应字节数,前端使用增量加载,不再整文件读入内存。 -4. 给 PM2 日志、`RunningInstances`、任务统计和依赖下载缓存增加明确的容量/时间保留策略;清理任务输出被删除的字节数和剩余大小。 -5. Dockerfile 删除 apt lists,审计全局 Node 模块与应用依赖的重复内容;构建产物中不要保留仅构建期需要的包。 - -当前修复分支落实情况: - -- P0-1 已完成:逐请求原始指标改为 1,000 条固定容量环形缓冲区,并补充 50,000 次写入回归测试。 -- P0-2 已完成:容器内 PM2 输出直接接入 PID 1 标准输出/错误,停止写入 `~/.pm2/logs` 的重复持久化副本。 -- P0-3 已完成:日志接口默认 256 KiB、最大 1 MiB,支持 tail/offset 增量读取,并保证 UTF-8 字符不被分块破坏;浏览器视图最大保留 1 MiB。 -- P0-4 已完成:系统设置提供运行实例/任务统计保留天数,默认 0(禁用);清理前展示记录数与依赖缓存字节数,清理接口要求明确确认。依赖缓存和 SQLite `VACUUM` 均为手动勾选,不在启动或升级时自动执行。 -- P0-5 已完成:Debian 镜像构建后清理 npm cache 和 apt lists。 - -## 优化后实测(提交 `276dfc23`,同机 Node 24 / Docker Desktop arm64) - -### 指标内存 - -| 压力 | 基线 | 优化后 | 结果 | -| --- | ---: | ---: | --- | -| 50,000 条保留样本 | 50,000 | 1,000 | 减少 98% | -| 50,000 条 GC 后堆增量 | 8.76 MiB | 0.16 MiB | 减少约 98.2% | -| 500,000 条 GC 后堆增量 | 88.33 MiB | 0.11 MiB | 减少约 99.9% | -| 500,000 条写入耗时(5 轮平均) | 149.6 ms | 74.4 ms | 约快 50% | -| 500,000 条统计查询 | `RangeError` 栈溢出 | 正常 | 消除故障 | - -真实容器用并发 50 发出 50,000 次 `/api/health`:两者空闲均约 161 MiB;基线负载后即时增加约 108.5 MiB、稳定后仍增加约 17.6 MiB,优化版分别约 81.0 MiB 和 8.4 MiB,改善约 25% 和 52%。三轮 10,000 请求耗时基线平均 3.86 秒、优化版 4.05 秒,差约 4.8% 且落在轮次波动内,因此不宣称 HTTP 吞吐提升。 - -### 大日志读取 - -对 100 MiB 日志做三轮读取: - -| 指标 | 基线整文件读取 | 优化后 tail 读取 | 结果 | -| --- | ---: | ---: | --- | -| 返回数据 | 100 MiB | 256 KiB | 减少 99.75%(400 倍) | -| 平均耗时 | 56.1 ms | 1.34 ms | 约快 41.9 倍 | -| 堆峰值增量 | 100.11 MiB | 0.317 MiB | 减少约 99.7% | -| RSS 峰值增量 | 115.18 MiB | 0.349 MiB | 减少约 99.7% | - -### 日志写入、SQLite 与镜像 - -- PM2 实际容器测试中,基线为约 7,288 行输出额外生成了 888,026 bytes 的 `qinglong-out.log`;优化后 PM2 日志目录没有文件,Docker stdout 每行只出现一次。测试同时发现 `/proc/1/fd/1` 会造成 Docker stdout 重复,最终改为容器中 `out_file/error_file=/dev/null`,由 `pm2-runtime` 单独转发 stdout。 -- 合成 100,000 条运行实例和 100,000 条任务统计的 SQLite 数据库为 24,481,792 bytes。删除 90% 历史记录耗时 0.26 秒,文件大小不会立刻变化;可选 `VACUUM` 耗时 0.05 秒,文件降至 2,465,792 bytes,回收约 89.9%。 -- 当前 Debian 镜像 `/var/lib/apt/lists` 实测 19,527,172 bytes(18.62 MiB),新 Dockerfile 会在同一个安装层删除。Dockerfile `--check` 无警告;完整镜像构建在 Debian 包升级网络等待阶段停止,因此不虚报最终镜像总大小。 - -### P1:轻量架构调整 - -1. 主进程只加载 cluster/bootstrap 必需模块,把 Express、监控、HTTP 中间件和业务容器全部延迟到 HTTP worker。目标是先回收主进程约 30–40 MiB private memory。 -2. Metrics 定时器只在实际使用指标的 worker 中启动,并对 path/tag 做归一化,防止高基数值撑大内存。 -3. 将任务“开始”和“结束”各自需要的状态、实例、统计写入合并到事务;避免一个生命周期五个以上独立提交。 -4. 在本地文件系统上基准比较 SQLite `WAL + synchronous=NORMAL + busy_timeout` 与现状;确认断电语义和网络文件系统兼容后再作为可选配置发布。 -5. 任务日志保留从全局天数扩展为“全局默认 + 单任务覆盖 + 总容量上限”,而不是只增加更多清理 cron。 - -### P2:需要设计评审 - -- 提供 Lite 模式:调度器与 HTTP 同进程运行,省去 gRPC worker;标准模式继续保持隔离。需要故障域、重启和任务注册一致性测试。 -- 若继续推进集群/多节点,先定义控制面、执行器、任务租约、心跳、幂等和日志归属;不要以“共享 SQLite/MySQL”替代分布式调度设计。 - -## 86 个未关闭 Issue 的逐项建议 - -状态含义:`立即处理` 是确认有效且影响当前主线;`轻量处理` 是小型代码/文档/回归测试;`架构议题` 需要 RFC 或独立 epic;`关闭/合并` 表示已修复、重复、支持问题或需要提交者用最新版本重现。 - -### 立即处理(10) - -| Issue | 结论 | 建议 | -| --- | --- | --- | -| #2256 | 有效,整文件读取日志造成超时和内存峰值 | 日志分片、tail、最大响应大小 | -| #2742 | 有效,与 #3057 合并 | 用内存基线和压力回归替代“运行几天观察” | -| #2743 | 有效,与 #2742/#3057 合并 | 优先修复指标保留和进程基线,再复测受限容器 | -| #2871 | 有效,2.21.0 仍有反馈 | 与 #2902 合并,修复源地址生成并加发行版矩阵测试 | -| #2902 | 有效,重复 #2871 | 合并后关闭 | -| #3017 | 有效安全 epic | 拆成凭据迁移、scope、SSRF、错误脱敏、更新完整性等独立任务 | -| #3051 | 当前回归仍需验证 | 对 shell status payload 建端到端测试,重建最新镜像复测 | -| #3055 | 用户报告有效,但依赖库单测 5/6 字段都可注册 | 增加真实 gRPC 注册与触发集成测试,排查发行镜像/存量任务迁移 | -| #3057 | 有效,实测已复现 | 先修无界指标和多进程基线,不需要先改写语言 | -| #3060 | 有效的容器权限/依赖安装回归 | 覆盖匿名卷、bind mount、rootless 三类安装 smoke test | - -### 轻量处理或局部设计(36) - -| Issue | 结论与最小处理 | -| --- | --- | -| #2015 | 当前仅支持整组 AND/OR;若要嵌套条件,定义轻量 filter AST,避免继续堆 UI 特判 | -| #2236 | 与 #2421/#3014 合并为一个 cron 可视化 issue | -| #2254 | 增加有限重试策略;必须有退避、最大次数和不可重试退出码 | -| #2269 | 增加时区、5/6 字段、DST 的调度集成测试;最新版本无法复现则关闭 | -| #2341 | 与 #2688 合并,把 boolean 实例模式升级为 `replace/skip/parallel/queue` 枚举 | -| #2360 | 标签在列表中恢复可见与筛选入口,纯前端小改 | -| #2418 | 配置方法已有评论解法;仅保留消息超长时的截断/分片问题 | -| #2421 | 合并到 #3014 | -| #2635 | 备注字段是小型 schema/UI 变更,可与名称/标签排序统一设计 | -| #2645 | 增加依赖唯一键、恢复数据去重迁移和幂等安装测试 | -| #2687 | 用最新版本和代理 URL 重现;补代理/超时错误提示 | -| #2688 | 合并到 #2341;当前“单实例”会杀旧任务,不等同于“跳过新任务” | -| #2701 | 需要最小仓库配置样本;JSON 控制字符应在输入边界给出字段级错误 | -| #2715 | Server 酱 tags 是局部通知配置扩展 | -| #2756 | 重测 Chrome、base path、缓存与 health 401;补登录跳转循环保护 | -| #2757 | 复用订阅现有 `autoAddCron` 配置,明确新增任务默认启停状态 | -| #2780 | `ql check` 应在系统包失败时停止并回滚/给出恢复指令,不能继续破坏可运行环境 | -| #2782 | 通知免打扰时段是局部配置;明确延迟发送还是静默丢弃 | -| #2797 | 保留;展开 AggregateError 内部原因并为每种通知后端返回可操作错误 | -| #2801 | 私有仓库凭据与 SSH/HTTPS 模式做连通性诊断,信息不足时转支持问题 | -| #2859 | 最新版请求重现;增加 `env.js` 镜像启动 smoke test和缓存头校验 | -| #2863 | 区分官方 Docker 与 npm/Linux 安装支持等级;若继续支持后者则加 CI | -| #2883 | 最新版复测临时编辑保存;失败时保留请求与后端路径错误 | -| #2886 | 支持 requirements 文件需要锁定来源、工作目录和隔离策略,可先支持显式路径 | -| #2896 | Synology 内核缺少随机字节能力,先做环境诊断与明确错误,不宜静默降级安全随机数 | -| #2901 | 当前默认监听 `::` 并回退 `0.0.0.0`;增加 IPv6 Docker/host 网络 smoke test | -| #2925 | 信息不足;收集完整日志与重启方式,最新镜像不可复现则关闭 | -| #2927 | 使用成熟 ANSI 清理实现,覆盖复合 SGR,而不是只匹配 `\x1b[数字m` | -| #2984 | 通知审计有效,但需先确定脱敏、保留期和容量上限,避免制造新磁盘问题 | -| #3013 | 与性能修复合并:全局默认、单任务覆盖、总容量上限 | -| #3014 | 合并 #2236/#2421,前端生成器必须始终展示最终 cron 文本 | -| #3016 | 有效文档任务;从路由/validation 自动生成 OpenAPI,避免手工文档漂移 | -| #3020 | 有效小改;配置编辑器启用 `wordWrap: 'on'` 并保留开关 | -| #3027 | 需要明确旧值 `1` 的历史语义;用显式 `@once/@boot/disabled` 替代魔法数字 | -| #3054 | 有效纯 UI 改进;整行点击打开历史日志,操作按钮阻止冒泡 | -| #3058 | 有效安全需求;应用增加环境变量名称 allowlist,默认最小权限并审计拒绝事件 | - -### 架构议题,单独立项(12) - -| Issue | 处理建议 | -| --- | --- | -| #1656 | 捕获任意脚本网络请求需要代理或运行时注入,成本和隐私风险高;不作为普通日志小改 | -| #1695 | 与 #2596 合并为“集群执行器”RFC | -| #1821 | 多数据库不等于多节点;只有明确 HA/共享控制面设计后再评估 MySQL/PostgreSQL | -| #2434 | PowerShell 运行时会增加镜像体积和维护矩阵,作为可选外部执行器评估 | -| #2464 | Bun 属于额外运行时矩阵,不应为单一性能假设直接加入基础镜像 | -| #2481 | 每任务 Python 虚拟环境需要依赖缓存、生命周期和磁盘配额设计 | -| #2596 | 合并到 #1695 | -| #2642 | 与 #2769/#2905 合并为多租户权限模型 RFC | -| #2656 | .NET 8 作为可选运行时/派生镜像评估,不进入默认镜像 | -| #2736 | 条件触发工作流已接近 n8n 类产品,应独立产品/RFC,不混入 cron 小功能 | -| #2769 | 合并到多租户 RFC | -| #2905 | 合并到多租户 RFC;PR #2818 作为设计输入 | - -### 建议关闭、合并或转支持(28) - -| Issue | 理由 | -| --- | --- | -| #795 | HTTP 明文本质上应通过 HTTPS 解决;前端自定义“加盐”不能阻止重放。补反代 HTTPS 文档后关闭 | -| #796 | 可由数据库/API 导出;需求长期无上下文,关闭或改为 CSV 导出新需求 | -| #854 | 客户端 IP 已由 #3059 对应功能覆盖;应用调用审计并入 #2984 | -| #2042 | 当前 `NotReg` 已显式处理 NULL 与 notLike;最新版回归测试通过后关闭 | -| #2217 | Home Assistant 插件仓库属于独立打包生态,转社区集成 | -| #2219 | 当前已有运行实例历史与逐实例停止按钮,关闭为已实现 | -| #2329 | 评论已给出 Open API/文件方案,转文档 FAQ 后关闭 | -| #2340 | 老旧 IPQ/固件上的 V8 Bus error 属运行时/硬件兼容;用当前多架构镜像复测,否则关闭 | -| #2350 | 环境变量值如何分隔主要由脚本协议定义;补文档后关闭 | -| #2398 | 单一外站 DNS 解析问题且无环境信息,转网络支持 | -| #2404 | 与 #3057 合并,保留一个性能 epic | -| #2525 | 非官方旧容器/npm 安装路径;最新版本不可复现则关闭 | -| #2567 | 2024 年无任何环境信息的容器 entrypoint 错误,要求当前镜像重现,否则关闭 | -| #2705 | 当前已有全局 cron concurrency 与最多五个重复实例限制,补 UI/文档后关闭 | -| #2772 | 与 #2340 合并为特定软路由 CPU/固件兼容问题 | -| #2788 | maintainer 已说明黑名单语义;补订阅文档后关闭 | -| #2792 | PM2 版本提示是镜像更新/支持问题,当前镜像复测后关闭 | -| #2793 | 自动重启会掩盖 OOM;合并到性能问题,不单独实现定时自杀 | -| #2798 | Node 在特定硬件启动即 fatal,合并硬件兼容问题 | -| #2895 | Debian 中加载 musl 二进制通常是第三方依赖选错平台,转依赖支持 | -| #2903 | 当前运行时代码未使用 chokidar 监听 node_modules;最新镜像复测后关闭 | -| #2914 | maintainer 指定 2.20.1 已修复,当前版本复测后关闭 | -| #2948 | 评论已有正确升级链和 `/ql/data` 挂载方案;补升级文档后关闭 | -| #2964 | 本文已提供资源基线,并入 #3057 后关闭 | -| #2967 | maintainer 已解释黑白名单语义;补文档后关闭 | -| #3052 | `2ac4f07f` 已修复 dashboard open scope,关闭为已修复 | -| #3053 | `2ac4f07f` 已加入 INI 预览/强制打开处理,关闭为已修复 | -| #3059 | `31f78e4d` 已加入 IP 黑名单、客户端 IP 诊断和 trust proxy 配置,关闭为已修复 | - -## 建议的 Issue 管理动作 - -1. 先关闭明确已实现的 #2219、#3052、#3053、#3059。 -2. 将 #2404、#2742、#2743 合并到 #3057,并把本文内存实测作为验收基线。 -3. 将 #2236、#2421 合并到 #3014;#2871 合并 #2902;#1695 合并 #2596;#2642/#2769/#2905 合并。 -4. 对“需要最新版复现”的旧 bug 统一贴模板,给出 14 天补充窗口;没有版本、架构、日志、最小复现则关闭为 stale/support。 -5. 轻量修复和架构 epic 使用不同 label/milestone,避免大设计长期占据回归队列。 From f71fd5ebd65279209e2d51b265ba51869194542c Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 17 Aug 2026 08:55:53 +0800 Subject: [PATCH 078/123] fix: propagate initialization errors and reduce request metrics --- back/api/user.ts | 8 +-- back/middlewares/monitoring.ts | 66 ++++++----------------- test/back/monitoring.test.cjs | 73 +++++++++++++++++++++++++ test/back/user-api.test.cjs | 99 ++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 53 deletions(-) create mode 100644 test/back/monitoring.test.cjs create mode 100644 test/back/user-api.test.cjs diff --git a/back/api/user.ts b/back/api/user.ts index c1a18146..4d526389 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -80,8 +80,8 @@ export default (app: Router) => { return res.send({ code: 450, message: t('未知错误') }); } const userService = Container.get(UserService); - await userService.updateUsernameAndPassword(req.body); - res.send({ code: 200, message: t('更新成功') }); + const result = await userService.updateUsernameAndPassword(req.body); + res.send(result); } catch (e) { return next(e); } @@ -282,8 +282,8 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); - await userService.updateUsernameAndPassword(req.body); - res.send({ code: 200, message: t('更新成功') }); + const result = await userService.updateUsernameAndPassword(req.body); + res.send(result); } catch (e) { return next(e); } diff --git a/back/middlewares/monitoring.ts b/back/middlewares/monitoring.ts index d0d3f2f4..ab9b6c01 100644 --- a/back/middlewares/monitoring.ts +++ b/back/middlewares/monitoring.ts @@ -3,46 +3,34 @@ import Logger from '../loaders/logger'; import { performance } from 'perf_hooks'; import { metricsService } from '../services/metrics'; -interface RequestMetrics { - method: string; - path: string; - duration: number; - statusCode: number; - timestamp: number; - platform?: string; -} - -const requestMetrics: RequestMetrics[] = []; +const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health']; +const HTTP_METRIC_SAMPLE_INTERVAL = 10; +let requestSampleOffset = 0; export const monitoringMiddleware = ( req: Request, res: Response, next: NextFunction, ) => { + if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) { + return next(); + } + const start = performance.now(); const originalEnd = res.end; res.end = function (chunk?: any, encoding?: any, cb?: any) { const duration = performance.now() - start; - const metric: RequestMetrics = { - method: req.method, - path: req.path, - duration, - statusCode: res.statusCode, - timestamp: Date.now(), - platform: req.platform, - }; - - requestMetrics.push(metric); - metricsService.record('http_request', duration, { - method: req.method, - path: req.path, - statusCode: res.statusCode.toString(), - ...(req.platform && { platform: req.platform }), - }); - - if (requestMetrics.length > 1000) { - requestMetrics.shift(); + const shouldSample = requestSampleOffset === 0; + requestSampleOffset = + (requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL; + if (shouldSample) { + metricsService.record('http_request', duration, { + method: req.method, + path: req.path, + statusCode: res.statusCode.toString(), + ...(req.platform && { platform: req.platform }), + }); } if (duration > 1000) { @@ -58,23 +46,3 @@ export const monitoringMiddleware = ( next(); }; - -export const getMetrics = () => { - return { - totalRequests: requestMetrics.length, - averageDuration: - requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) / - requestMetrics.length, - requestsByMethod: requestMetrics.reduce((acc, curr) => { - acc[curr.method] = (acc[curr.method] || 0) + 1; - return acc; - }, {} as Record), - requestsByPlatform: requestMetrics.reduce((acc, curr) => { - if (curr.platform) { - acc[curr.platform] = (acc[curr.platform] || 0) + 1; - } - return acc; - }, {} as Record), - recentRequests: requestMetrics.slice(-10), - }; -}; diff --git a/test/back/monitoring.test.cjs b/test/back/monitoring.test.cjs new file mode 100644 index 00000000..eec34833 --- /dev/null +++ b/test/back/monitoring.test.cjs @@ -0,0 +1,73 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { monitoringMiddleware } = require('../../back/middlewares/monitoring'); +const { metricsService } = require('../../back/services/metrics'); + +function createResponse() { + return { + statusCode: 200, + ended: false, + end() { + this.ended = true; + }, + }; +} + +test('health probes bypass request metric retention with or without base URL', () => { + const before = metricsService.getMetrics('http_request', { + path: '/api/health', + }).count; + let nextCalled = false; + + for (const path of ['/api/health', '/ql/api/health']) { + const response = createResponse(); + monitoringMiddleware({ method: 'GET', path }, response, () => { + nextCalled = true; + }); + response.end(); + assert.equal(response.ended, true); + } + + const after = metricsService.getMetrics('http_request', { + path: '/api/health', + }).count; + assert.equal(nextCalled, true); + assert.equal(after, before); +}); + +test('non-health requests keep bounded service metrics', () => { + const path = '/api/monitoring-test'; + const before = metricsService.getMetrics('http_request', { path }).count; + const response = createResponse(); + + monitoringMiddleware( + { method: 'GET', path, platform: 'desktop' }, + response, + () => {}, + ); + response.end(); + + const after = metricsService.getMetrics('http_request', { path }).count; + assert.equal(response.ended, true); + assert.equal(after, before + 1); +}); + +test('ordinary request metrics are sampled instead of retained per request', () => { + const path = '/api/monitoring-sampling-test'; + const before = metricsService.getMetrics('http_request', { path }).count; + + for (let index = 0; index < 30; index += 1) { + const response = createResponse(); + monitoringMiddleware( + { method: 'GET', path, platform: 'desktop' }, + response, + () => {}, + ); + response.end(); + } + + const recorded = + metricsService.getMetrics('http_request', { path }).count - before; + assert.ok(recorded >= 2 && recorded <= 3); +}); diff --git a/test/back/user-api.test.cjs b/test/back/user-api.test.cjs new file mode 100644 index 00000000..42e6a69f --- /dev/null +++ b/test/back/user-api.test.cjs @@ -0,0 +1,99 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const express = require('express'); +const { Container } = require('typedi'); + +test('initialization returns the username and password validation result', async (t) => { + const originalGet = Container.get; + const userServicePath = require.resolve('../../back/services/user'); + const originalUserService = require.cache[userServicePath]; + const i18nPath = require.resolve('../../back/shared/i18n'); + const originalI18n = require.cache[i18nPath]; + const utilPath = require.resolve('../../back/config/util'); + const originalUtil = require.cache[utilPath]; + const authPath = require.resolve('../../back/shared/auth'); + const originalAuth = require.cache[authPath]; + require.cache[userServicePath] = { + id: userServicePath, + filename: userServicePath, + loaded: true, + exports: { __esModule: true, default: class UserService {} }, + children: [], + paths: [], + }; + require.cache[utilPath] = { + id: utilPath, + filename: utilPath, + loaded: true, + exports: { getToken: () => '', isDemoEnv: () => false }, + children: [], + paths: [], + }; + require.cache[authPath] = { + id: authPath, + filename: authPath, + loaded: true, + exports: { isDefaultAuthInfo: () => false }, + children: [], + paths: [], + }; + require.cache[i18nPath] = { + id: i18nPath, + filename: i18nPath, + loaded: true, + exports: { t: (message) => message }, + children: [], + paths: [], + }; + Container.get = () => ({ + updateUsernameAndPassword: async () => ({ + code: 400, + message: 'password rejected', + }), + }); + t.after(() => { + Container.get = originalGet; + if (originalUserService) { + require.cache[userServicePath] = originalUserService; + } else { + delete require.cache[userServicePath]; + } + if (originalI18n) { + require.cache[i18nPath] = originalI18n; + } else { + delete require.cache[i18nPath]; + } + if (originalUtil) { + require.cache[utilPath] = originalUtil; + } else { + delete require.cache[utilPath]; + } + if (originalAuth) { + require.cache[authPath] = originalAuth; + } else { + delete require.cache[authPath]; + } + }); + + const app = express.Router(); + require('../../back/api/user').default(app); + const userRouter = app.stack.find((layer) => layer.name === 'router').handle; + const initRoute = userRouter.stack.find( + (layer) => layer.route?.path === '/init', + ); + const handler = initRoute.route.stack.at(-1).handle; + let responseBody; + + await handler( + { body: { username: 'admin', password: 'admin' } }, + { send: (body) => (responseBody = body) }, + (error) => { + throw error; + }, + ); + + assert.deepEqual(responseBody, { + code: 400, + message: 'password rejected', + }); +}); From dfa73af1043162f8a89ed9c517796ef75a20c177 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 18 Aug 2026 00:03:06 +0800 Subject: [PATCH 079/123] fix: preserve container startup diagnostics --- back/schedule/health.ts | 100 +++++++++++++++++++++++++---- ecosystem.config.js | 4 +- shell/check.sh | 76 ++++++++++++++++------ shell/lang/en.sh | 6 ++ shell/lang/zh.sh | 6 ++ test/back/ecosystem.test.cjs | 4 +- test/back/schedule-health.test.cjs | 86 +++++++++++++++++++++++++ 7 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 test/back/schedule-health.test.cjs diff --git a/back/schedule/health.ts b/back/schedule/health.ts index dc90f7e6..04068c67 100644 --- a/back/schedule/health.ts +++ b/back/schedule/health.ts @@ -1,28 +1,104 @@ import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; +import fs from 'fs/promises'; +import path from 'path'; +import { request } from 'undici'; import { HealthCheckRequest, HealthCheckResponse } from '../protos/health'; import config from '../config'; -import { promiseExec } from '../config/util'; + +function formatError(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + + const detailedError = error as Error & { + cause?: unknown; + code?: string; + errors?: unknown[]; + }; + const details = [error.message, detailedError.code]; + if (Array.isArray(detailedError.errors)) { + details.push(...detailedError.errors.map(formatError)); + } else if (detailedError.cause) { + details.push(formatError(detailedError.cause)); + } + + return [...new Set(details.filter(Boolean))].join(': ') || error.name; +} + +async function getRecentSystemLog(lineLimit = 300): Promise { + try { + const entries = await fs.readdir(config.systemLogPath); + const latestLog = entries + .filter((entry) => entry.endsWith('.log')) + .sort() + .at(-1); + if (!latestLog) { + return `No system log found in ${config.systemLogPath}`; + } + const content = await fs.readFile( + path.join(config.systemLogPath, latestLog), + 'utf8', + ); + return content.split('\n').slice(-lineLimit).join('\n').trim(); + } catch (error) { + return `Unable to read system log from ${config.systemLogPath}: ${ + error instanceof Error ? error.message : String(error) + }`; + } +} const check = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { switch (call.request.service) { - case 'cron': - const res = await promiseExec( - `curl -s --noproxy '*' http://localhost:${config.port}/api/system`, - ); - - if (res.includes('200')) { - return callback(null, { status: 1 }); + case 'cron': { + const healthUrl = `http://localhost:${config.port}${ + config.baseUrl || '' + }/api/health`; + let failure = ''; + try { + const response = await request(healthUrl, { + method: 'GET', + headersTimeout: 5000, + bodyTimeout: 5000, + }); + const body = (await response.body.json()) as { + code?: number; + data?: { status?: string }; + }; + if ( + response.statusCode >= 200 && + response.statusCode < 300 && + body.code === 200 && + body.data?.status === 'ok' + ) { + return callback(null, { status: 1 }); + } + failure = `HTTP ${response.statusCode}: ${JSON.stringify(body)}`; + } catch (error) { + failure = formatError(error); } - const qinglongErrLog = await promiseExec( - `tail -n 300 ~/.pm2/logs/qinglong-error.log`, - ); + const systemLog = await getRecentSystemLog(); + const containerHint = + process.env.QL_CONTAINER === 'true' + ? 'PM2 file logging is disabled in containers. Check `docker logs ` for early startup errors.' + : 'Check `pm2 logs qinglong --lines 300` for early startup errors.'; return callback( - new Error(`${qinglongErrLog || ''}\n${res}`.trim()), + new Error( + [ + `HTTP health check failed: ${healthUrl}`, + failure, + containerHint, + `Recent system log (${config.systemLogPath}):`, + systemLog, + ] + .filter(Boolean) + .join('\n'), + ), ); + } default: return callback(null, { status: 1 }); diff --git a/ecosystem.config.js b/ecosystem.config.js index d7c150e2..dc9b1e91 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -11,7 +11,9 @@ module.exports = { source_map_support: true, time: !isContainer, out_file: isContainer ? '/dev/null' : undefined, - error_file: isContainer ? '/dev/null' : undefined, + // Do not persist PM2 logs in containers, but keep early startup errors + // visible through `docker logs` before Winston is initialized. + error_file: isContainer ? '/proc/1/fd/2' : undefined, script: 'static/build/app.js', env: { http_proxy: '', diff --git a/shell/check.sh b/shell/check.sh index ab01fe95..99925104 100755 --- a/shell/check.sh +++ b/shell/check.sh @@ -23,37 +23,75 @@ copy_dep() { } pm2_log() { - t '---> pm2日志' - local panelOut="/root/.pm2/logs/qinglong-out.log" - local panelError="/root/.pm2/logs/qinglong-error.log" - tail -n 300 "$panelOut" - tail -n 300 "$panelError" + t '---> 服务诊断信息' + pm2 status || true + + local systemLogDir="$dir_data/syslog" + local latestSystemLog + latestSystemLog=$(find "$systemLogDir" -maxdepth 1 -type f -name '*.log' 2>/dev/null | sort | tail -n 1) + if [[ -n "$latestSystemLog" ]]; then + t '---> 最近的系统日志: %s' "$latestSystemLog" + tail -n 300 "$latestSystemLog" + else + t '---> 未找到系统日志: %s' "$systemLogDir" + fi + + if [[ "$QL_CONTAINER" == "true" ]]; then + t '---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>' + return + fi + + local pm2Home="${PM2_HOME:-$HOME/.pm2}" + local panelOut="$pm2Home/logs/qinglong-out.log" + local panelError="$pm2Home/logs/qinglong-error.log" + [[ -f "$panelOut" ]] && tail -n 300 "$panelOut" + [[ -f "$panelError" ]] && tail -n 300 "$panelError" } check_ql() { - local api=$(curl -s --noproxy "*" "http://localhost:${ql_port}") + local basePath="${ql_base_url%/}" + local api + local attempt + for ((attempt = 1; attempt <= 10; attempt++)); do + api=$(curl -s --max-time 2 --noproxy "*" "http://localhost:${ql_port}${basePath}/") + [[ $api =~ "
" ]] && break + sleep 1 + done t '\n=====> 检测面板' echo -e "\n\n$api\n" if [[ $api =~ "
" ]]; then t '=====> 面板服务启动正常\n' + else + t '=====> 面板服务启动异常,请检查上方诊断信息\n' + return 1 fi } check_pm2() { - pm2_log local currentTimeStamp=$(date +%s) - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/api/system?t=$currentTimeStamp" \ - -H 'Accept: */*' \ - -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ - -H "Referer: http://localhost:${ql_port}/crontab" \ - -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ - --compressed - ) + local basePath="${ql_base_url%/}" + local api + local attempt + for ((attempt = 1; attempt <= 10; attempt++)); do + api=$( + curl -s --max-time 2 --noproxy "*" "http://localhost:${ql_port}${basePath}/api/health?t=$currentTimeStamp" \ + -H 'Accept: */*' \ + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ + -H "Referer: http://localhost:${ql_port}/crontab" \ + -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ + --compressed + ) + [[ $api == *'"code":200'* && $api == *'"status":"ok"'* ]] && break + sleep 1 + done t '\n=====> 检测后台' echo -e "\n\n$api\n" - if [[ $api =~ "{\"code\"" ]]; then + if [[ $api == *'"code":200'* && $api == *'"status":"ok"'* ]]; then t '=====> 后台服务启动正常\n' + else + pm2_log + t '=====> 后台服务启动异常,请检查上方诊断信息\n' + return 1 fi } @@ -63,10 +101,12 @@ main() { reset_env copy_dep - check_ql - check_pm2 reload_pm2 + local checkStatus=0 + check_ql || checkStatus=1 + check_pm2 || checkStatus=1 t '\n=====> 检测结束\n' + return $checkStatus } main diff --git a/shell/lang/en.sh b/shell/lang/en.sh index 7e823d25..38778c9c 100644 --- a/shell/lang/en.sh +++ b/shell/lang/en.sh @@ -82,10 +82,16 @@ declare -gA LANG_MESSAGES=( ['---> 复制一份 %s 为 %s\n']='---> Copying %s to %s\n' ['---> 通知文件复制完成\n']='---> Notification files copied\n' ['---> pm2日志']='---> pm2 log' + ['---> 服务诊断信息']='---> Service diagnostics' + ['---> 最近的系统日志: %s']='---> Recent system log: %s' + ['---> 未找到系统日志: %s']='---> No system log found: %s' + ['---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>']='---> PM2 logs are not persisted in containers; run docker logs --tail 300 for early startup errors' ['\n=====> 检测面板']='\n=====> Checking panel' ['=====> 面板服务启动正常\n']='=====> Panel service running normally\n' + ['=====> 面板服务启动异常,请检查上方诊断信息\n']='=====> Panel failed to start; check the diagnostics above\n' ['\n=====> 检测后台']='\n=====> Checking backend' ['=====> 后台服务启动正常\n']='=====> Backend service running normally\n' + ['=====> 后台服务启动异常,请检查上方诊断信息\n']='=====> Backend failed to start; check the diagnostics above\n' ['=====> 开始检测']='=====> Starting check' ['\n=====> 检测结束\n']='\n=====> Check complete\n' # rmlog.sh diff --git a/shell/lang/zh.sh b/shell/lang/zh.sh index 0a3c668c..e1935675 100644 --- a/shell/lang/zh.sh +++ b/shell/lang/zh.sh @@ -82,10 +82,16 @@ declare -gA LANG_MESSAGES=( ['---> 复制一份 %s 为 %s\n']='---> 复制一份 %s 为 %s\n' ['---> 通知文件复制完成\n']='---> 通知文件复制完成\n' ['---> pm2日志']='---> pm2日志' + ['---> 服务诊断信息']='---> 服务诊断信息' + ['---> 最近的系统日志: %s']='---> 最近的系统日志: %s' + ['---> 未找到系统日志: %s']='---> 未找到系统日志: %s' + ['---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>']='---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>' ['\n=====> 检测面板']='\n=====> 检测面板' ['=====> 面板服务启动正常\n']='=====> 面板服务启动正常\n' + ['=====> 面板服务启动异常,请检查上方诊断信息\n']='=====> 面板服务启动异常,请检查上方诊断信息\n' ['\n=====> 检测后台']='\n=====> 检测后台' ['=====> 后台服务启动正常\n']='=====> 后台服务启动正常\n' + ['=====> 后台服务启动异常,请检查上方诊断信息\n']='=====> 后台服务启动异常,请检查上方诊断信息\n' ['=====> 开始检测']='=====> 开始检测' ['\n=====> 检测结束\n']='\n=====> 检测结束\n' # rmlog.sh diff --git a/test/back/ecosystem.test.cjs b/test/back/ecosystem.test.cjs index 34de98ab..155124c4 100644 --- a/test/back/ecosystem.test.cjs +++ b/test/back/ecosystem.test.cjs @@ -13,10 +13,10 @@ function loadConfig(containerValue) { return require(configPath).apps[0]; } -test('container logging relies on pm2-runtime stdout without persistent copies', () => { +test('container logging discards stdout and forwards startup errors without persistent copies', () => { const app = loadConfig('true'); assert.equal(app.out_file, '/dev/null'); - assert.equal(app.error_file, '/dev/null'); + assert.equal(app.error_file, '/proc/1/fd/2'); assert.equal(app.time, false); }); diff --git a/test/back/schedule-health.test.cjs b/test/back/schedule-health.test.cjs new file mode 100644 index 00000000..7a5eb62b --- /dev/null +++ b/test/back/schedule-health.test.cjs @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const config = require('../../back/config').default; +const { check } = require('../../back/schedule/health'); + +function runCheck(service = 'cron') { + return new Promise((resolve) => { + check({ request: { service } }, (error, response) => { + resolve({ error, response }); + }); + }); +} + +test('schedule health check uses the HTTP health endpoint and system logs', async (t) => { + const originalBaseUrl = config.baseUrl; + const originalPort = config.port; + const originalSystemLogPath = config.systemLogPath; + const originalContainer = process.env.QL_CONTAINER; + + t.after(() => { + config.baseUrl = originalBaseUrl; + config.port = originalPort; + config.systemLogPath = originalSystemLogPath; + if (originalContainer === undefined) { + delete process.env.QL_CONTAINER; + } else { + process.env.QL_CONTAINER = originalContainer; + } + }); + + await t.test( + 'returns serving for a healthy prefixed HTTP service', + async () => { + let requestedUrl = ''; + const server = http.createServer((request, response) => { + requestedUrl = request.url; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ code: 200, data: { status: 'ok' } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + config.baseUrl = '/ql'; + config.port = server.address().port; + + const result = await runCheck(); + assert.equal(result.error, null); + assert.deepEqual(result.response, { status: 1 }); + assert.equal(requestedUrl, '/ql/api/health'); + }, + ); + + await t.test( + 'reports recent system logs when HTTP startup fails', + async () => { + const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-health-')); + t.after(() => fs.rm(logDir, { recursive: true, force: true })); + const lines = Array.from( + { length: 305 }, + (_, index) => `line-${index + 1}`, + ); + await fs.writeFile(path.join(logDir, '2026-08-16.log'), 'older-log'); + await fs.writeFile(path.join(logDir, '2026-08-17.log'), lines.join('\n')); + config.systemLogPath = logDir; + config.port = 1; + process.env.QL_CONTAINER = 'true'; + + const result = await runCheck(); + assert.equal(result.response, undefined); + assert.match(result.error.message, /ECONNREFUSED|connect/); + assert.match( + result.error.message, + /http:\/\/localhost:1\/ql\/api\/health/, + ); + assert.match(result.error.message, /docker logs /); + assert.match(result.error.message, /line-305/); + assert.doesNotMatch(result.error.message, /line-1\n/); + assert.doesNotMatch(result.error.message, /qinglong-error\.log/); + }, + ); +}); From decdab093f0f82daa8d86e17f7bd03e231a69142 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 18 Aug 2026 23:56:11 +0800 Subject: [PATCH 080/123] perf: speed up large file tree listings --- back/config/util.ts | 129 +++++++++++++++++++++-------- test/back/fileTreeListing.test.cjs | 57 +++++++++++++ 2 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 test/back/fileTreeListing.test.cjs diff --git a/back/config/util.ts b/back/config/util.ts index f22f7e01..649f97a1 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -214,48 +214,105 @@ export function dirSort(a: IFile, b: IFile): number { } } +const FILE_SYSTEM_READ_CONCURRENCY = 32; + +type FileSystemTaskRunner = (task: () => Promise) => Promise; + +function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner { + let activeCount = 0; + const queue: Array<() => void> = []; + + const runNext = () => { + while (activeCount < concurrency && queue.length > 0) { + activeCount += 1; + queue.shift()?.(); + } + }; + + return (task: () => Promise) => + new Promise((resolve, reject) => { + queue.push(() => { + task() + .then(resolve, reject) + .finally(() => { + activeCount -= 1; + runNext(); + }); + }); + runNext(); + }); +} + +async function readDirsWithRunner( + dir: string, + baseDir: string, + blacklist: string[], + sort: (a: IFile, b: IFile) => number, + runFileSystemTask: FileSystemTaskRunner, +): Promise { + const relativePath = path.relative(baseDir, dir); + const entries = await runFileSystemTask(() => + fs.readdir(dir, { withFileTypes: true }), + ); + + const items = await Promise.all( + entries.map(async (entry): Promise => { + if (blacklist.includes(entry.name) || entry.isSymbolicLink()) { + return undefined; + } + + const subPath = path.join(dir, entry.name); + const stats = await runFileSystemTask(() => fs.lstat(subPath)); + if (stats.isSymbolicLink()) { + return undefined; + } + const key = path.join(relativePath, entry.name); + + if (stats.isDirectory()) { + const children = await readDirsWithRunner( + subPath, + baseDir, + blacklist, + sort, + runFileSystemTask, + ); + return { + title: entry.name, + key, + type: 'directory', + parent: relativePath, + createTime: stats.birthtime.getTime(), + children, + }; + } + + return { + title: entry.name, + type: 'file', + key, + parent: relativePath, + size: stats.size, + createTime: stats.birthtime.getTime(), + }; + }), + ); + + return items.filter((item): item is IFile => Boolean(item)).sort(sort); +} + export async function readDirs( dir: string, baseDir: string = '', blacklist: string[] = [], sort: (a: IFile, b: IFile) => number = dirSort, ): Promise { - const relativePath = path.relative(baseDir, dir); - const files = await fs.readdir(dir); - const result: IFile[] = []; - - for (const file of files) { - const subPath = path.join(dir, file); - const stats = await fs.lstat(subPath); - const key = path.join(relativePath, file); - - if (blacklist.includes(file) || stats.isSymbolicLink()) { - continue; - } - - if (stats.isDirectory()) { - const children = await readDirs(subPath, baseDir, blacklist, sort); - result.push({ - title: file, - key, - type: 'directory', - parent: relativePath, - createTime: stats.birthtime.getTime(), - children: children.sort(sort), - }); - } else { - result.push({ - title: file, - type: 'file', - key, - parent: relativePath, - size: stats.size, - createTime: stats.birthtime.getTime(), - }); - } - } - - return result.sort(sort); + return readDirsWithRunner( + dir, + baseDir, + blacklist, + sort, + createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY), + ); } export async function readDir( diff --git a/test/back/fileTreeListing.test.cjs b/test/back/fileTreeListing.test.cjs new file mode 100644 index 00000000..711d3b96 --- /dev/null +++ b/test/back/fileTreeListing.test.cjs @@ -0,0 +1,57 @@ +require('ts-node/register/transpile-only'); + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { afterEach, test } = require('node:test'); +const { readDirs } = require('../../back/config/util'); + +const temporaryDirectories = []; + +async function temporaryRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-file-tree-')); + temporaryDirectories.push(root); + return root; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +test('readDirs preserves the recursive tree contract and ignores blocked entries', async () => { + const root = await temporaryRoot(); + await fs.mkdir(path.join(root, 'folder')); + await fs.mkdir(path.join(root, 'node_modules')); + await fs.writeFile(path.join(root, 'z.js'), 'z'); + await fs.writeFile(path.join(root, 'folder', 'a.js'), 'alpha'); + await fs.writeFile(path.join(root, 'node_modules', 'hidden.js'), 'hidden'); + await fs.symlink(path.join(root, 'folder'), path.join(root, 'linked-folder')); + + const result = await readDirs(root, root, ['node_modules'], (a, b) => { + if (a.type === b.type) return a.title.localeCompare(b.title); + return a.type === 'directory' ? -1 : 1; + }); + + assert.deepEqual( + result.map(({ title, type, key, parent }) => ({ + title, + type, + key, + parent, + })), + [ + { title: 'folder', type: 'directory', key: 'folder', parent: '' }, + { title: 'z.js', type: 'file', key: 'z.js', parent: '' }, + ], + ); + assert.equal(result[0].children.length, 1); + assert.equal(result[0].children[0].title, 'a.js'); + assert.equal(result[0].children[0].key, path.join('folder', 'a.js')); + assert.equal(result[0].children[0].parent, 'folder'); + assert.equal(result[0].children[0].size, 5); +}); From 88200f2805516b98cc09a41079da146f6d9638d7 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 20 Aug 2026 00:56:59 +0800 Subject: [PATCH 081/123] feat: add ql service log command --- shell/update.sh | 20 +++++++-- src/locales/en-US.json | 2 +- src/locales/zh-CN.json | 2 +- src/pages/error/index.tsx | 2 +- test/back/qlServiceLogCommand.test.cjs | 56 ++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 test/back/qlServiceLogCommand.test.cjs diff --git a/shell/update.sh b/shell/update.sh index cdd6a6dc..c78191f5 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -211,6 +211,11 @@ run_extra_shell() { fi } +## 查看青龙服务日志 +show_service_logs() { + pm2 logs qinglong "$@" +} + ## 脚本用法 usage() { t "$cmd_update 命令使用方法:" @@ -221,10 +226,11 @@ usage() { echo -e "5. $cmd_update rmlog # 删除旧日志" echo -e "6. $cmd_update bot # 启动tg-bot" echo -e "7. $cmd_update check # 检测青龙环境并修复" - echo -e "8. $cmd_update resetlet # 重置登录错误次数" - echo -e "9. $cmd_update resettfa # 禁用两步登录" - echo -e "10. $cmd_update resetpwd # 修改登录密码" - echo -e "11. $cmd_update resetname # 修改登录用户名" + echo -e "8. $cmd_update log [pm2-log-options] # 查看青龙服务日志" + 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() { @@ -471,6 +477,12 @@ main() { local p8="${8}" local p9="${9}" local p10="${10}" + + if [[ "$p1" == "log" ]]; then + show_service_logs "${@:2}" + return $? + fi + local log_dir="${p1}" make_dir "$dir_log/$log_dir" local log_time=$(date "+%Y-%m-%d-%H-%M-%S") diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 42103041..ec5a7127 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -2,7 +2,7 @@ "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 ' on the host machine", "2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container", "24小时未运行": "Idle 24h+", - "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. If the problem persists, execute 'pm2 logs' inside the container and copy the results", + "3. 如果无法解决,容器内执行 ql log,拷贝执行结果": "3. If the problem persists, execute 'ql log' inside the container and copy the results", "6位数字": "6-digit number", "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK push icon, custom push icon (requires iOS 15 or above to display)", "BARK推送消息的分组,默认为qinglong": "BARK push message grouping, default is qinglong", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 244333db..ae12cf39 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -2,7 +2,7 @@ "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小时未运行", - "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果", + "3. 如果无法解决,容器内执行 ql log,拷贝执行结果": "3. 如果无法解决,容器内执行 ql log,拷贝执行结果", "6位数字": "6位数字", "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)", "BARK推送消息的分组,默认为qinglong": "BARK推送消息的分组,默认为qinglong", diff --git a/src/pages/error/index.tsx b/src/pages/error/index.tsx index df8f5a12..6389ea89 100644 --- a/src/pages/error/index.tsx +++ b/src/pages/error/index.tsx @@ -87,7 +87,7 @@ const Error = () => {
{intl.get('2. 容器内执行 ql check、ql update')}
{intl.get( - '3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果', + '3. 如果无法解决,容器内执行 ql log,拷贝执行结果', )} {intl.get('提交 issue')} diff --git a/test/back/qlServiceLogCommand.test.cjs b/test/back/qlServiceLogCommand.test.cjs new file mode 100644 index 00000000..4f64dabb --- /dev/null +++ b/test/back/qlServiceLogCommand.test.cjs @@ -0,0 +1,56 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { after, test } = require('node:test'); + +const root = path.resolve(__dirname, '../..'); +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-log-command-')); + +after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + +function writeExecutable(filePath, content) { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +test('ql log scopes PM2 output to qinglong and forwards log options', () => { + const shellDir = path.join(fixtureRoot, 'shell'); + const binDir = path.join(fixtureRoot, 'bin'); + fs.mkdirSync(shellDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + + fs.copyFileSync( + path.join(root, 'shell/update.sh'), + path.join(shellDir, 'update.sh'), + ); + fs.writeFileSync( + path.join(shellDir, 'share.sh'), + 'load_ql_envs() { :; }\nimport_config() { :; }\n', + ); + fs.writeFileSync(path.join(shellDir, 'api.sh'), ''); + fs.writeFileSync(path.join(shellDir, 'env.sh'), ''); + writeExecutable( + path.join(binDir, 'pm2'), + '#!/usr/bin/env bash\nprintf "%s\\n" "$@"\n', + ); + + const result = spawnSync( + '/bin/bash', + [path.join(shellDir, 'update.sh'), 'log', '--lines', '25', '--nostream'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + QL_DIR: fixtureRoot, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'logs\nqinglong\n--lines\n25\n--nostream\n'); + assert.equal(fs.existsSync(path.join(fixtureRoot, 'data/log/log')), false); +}); From 134cd8337c5fb0f82428bdc30794efb6d41a2741 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 20 Aug 2026 09:41:42 +0800 Subject: [PATCH 082/123] fix: read ql runtime logs from syslog --- shell/update.sh | 53 ++++++++++++++- test/back/qlServiceLogCommand.test.cjs | 90 +++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/shell/update.sh b/shell/update.sh index c78191f5..466f941e 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -213,7 +213,56 @@ run_extra_shell() { ## 查看青龙服务日志 show_service_logs() { - pm2 logs qinglong "$@" + local lines="15" + local no_stream="false" + while [[ $# -gt 0 ]]; do + case "$1" in + --lines) + if [[ ! "${2:-}" =~ ^[0-9]+$ ]]; then + echo "ql log: --lines requires a non-negative integer" >&2 + return 2 + fi + lines="$2" + shift 2 + ;; + --lines=*) + lines="${1#--lines=}" + if [[ ! "$lines" =~ ^[0-9]+$ ]]; then + echo "ql log: --lines requires a non-negative integer" >&2 + return 2 + fi + shift + ;; + --nostream) + no_stream="true" + shift + ;; + *) + echo "ql log: unknown option: $1" >&2 + return 2 + ;; + esac + done + + local system_log_dir="$dir_data/syslog" + local latest_system_log + latest_system_log=$(ls -1t "$system_log_dir"/*.log* 2>/dev/null | head -n 1) + if [[ "$lines" != "0" ]] && [[ -f "$latest_system_log" ]]; then + tail -n "$lines" "$latest_system_log" + fi + + if [[ "$no_stream" == "true" ]]; then + if [[ -z "$latest_system_log" ]]; then + echo "ql log: no system log found in $system_log_dir" >&2 + return 1 + fi + return 0 + fi + + # 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" + tail -n 0 --retry -F "$current_system_log" } ## 脚本用法 @@ -226,7 +275,7 @@ usage() { echo -e "5. $cmd_update rmlog # 删除旧日志" echo -e "6. $cmd_update bot # 启动tg-bot" echo -e "7. $cmd_update check # 检测青龙环境并修复" - echo -e "8. $cmd_update log [pm2-log-options] # 查看青龙服务日志" + echo -e "8. $cmd_update log [--lines ] [--nostream] # 查看青龙运行日志" echo -e "9. $cmd_update resetlet # 重置登录错误次数" echo -e "10. $cmd_update resettfa # 禁用两步登录" echo -e "11. $cmd_update resetpwd # 修改登录密码" diff --git a/test/back/qlServiceLogCommand.test.cjs b/test/back/qlServiceLogCommand.test.cjs index 4f64dabb..74fab549 100644 --- a/test/back/qlServiceLogCommand.test.cjs +++ b/test/back/qlServiceLogCommand.test.cjs @@ -16,9 +16,10 @@ function writeExecutable(filePath, content) { fs.writeFileSync(filePath, content, { mode: 0o755 }); } -test('ql log scopes PM2 output to qinglong and forwards log options', () => { - const shellDir = path.join(fixtureRoot, 'shell'); - const binDir = path.join(fixtureRoot, 'bin'); +function prepareFixture(name) { + const testRoot = path.join(fixtureRoot, name); + const shellDir = path.join(testRoot, 'shell'); + const binDir = path.join(testRoot, 'bin'); fs.mkdirSync(shellDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true }); @@ -28,29 +29,100 @@ test('ql log scopes PM2 output to qinglong and forwards log options', () => { ); fs.writeFileSync( path.join(shellDir, 'share.sh'), - 'load_ql_envs() { :; }\nimport_config() { :; }\n', + [ + 'dir_data="${QL_DATA_DIR:-$QL_DIR/data}"', + 'load_ql_envs() { :; }', + 'import_config() { :; }', + '', + ].join('\n'), ); fs.writeFileSync(path.join(shellDir, 'api.sh'), ''); fs.writeFileSync(path.join(shellDir, 'env.sh'), ''); writeExecutable( path.join(binDir, 'pm2'), - '#!/usr/bin/env bash\nprintf "%s\\n" "$@"\n', + '#!/usr/bin/env bash\necho "pm2 must not be called" >&2\nexit 99\n', + ); + + return { binDir, shellDir, testRoot }; +} + +test('ql log reads the requested system log history without PM2', () => { + const { binDir, shellDir, testRoot } = prepareFixture('host'); + const systemLogDir = path.join(testRoot, 'data/syslog'); + fs.mkdirSync(systemLogDir, { recursive: true }); + fs.writeFileSync( + path.join(systemLogDir, '2026-08-20.log'), + 'system-one\nsystem-two\nsystem-three\n', ); const result = spawnSync( '/bin/bash', - [path.join(shellDir, 'update.sh'), 'log', '--lines', '25', '--nostream'], + [path.join(shellDir, 'update.sh'), 'log', '--lines', '2', '--nostream'], { encoding: 'utf8', env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, - QL_DIR: fixtureRoot, + QL_CONTAINER: 'false', + QL_DIR: testRoot, }, }, ); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, 'logs\nqinglong\n--lines\n25\n--nostream\n'); - assert.equal(fs.existsSync(path.join(fixtureRoot, 'data/log/log')), false); + assert.equal(result.stdout, 'system-two\nsystem-three\n'); + assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); +}); + +test('ql log follows the current Winston system log without PM2', () => { + const { binDir, shellDir, testRoot } = prepareFixture('container'); + const systemLogDir = path.join(testRoot, 'data/syslog'); + const currentDate = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim(); + const currentLog = path.join(systemLogDir, `${currentDate}.log`); + fs.mkdirSync(systemLogDir, { recursive: true }); + fs.writeFileSync(currentLog, 'system-one\n'); + writeExecutable( + path.join(binDir, 'tail'), + '#!/usr/bin/env bash\nprintf "tail"\nprintf " <%s>" "$@"\nprintf "\\n"\n', + ); + + const result = spawnSync( + '/bin/bash', + [path.join(shellDir, 'update.sh'), 'log', '--lines', '0'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + QL_CONTAINER: 'true', + QL_DIR: testRoot, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`); + assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); +}); + +test('ql log reports when no persisted system log exists', () => { + const { binDir, shellDir, testRoot } = prepareFixture('container-nostream'); + + const result = spawnSync( + '/bin/bash', + [path.join(shellDir, 'update.sh'), 'log', '--nostream'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + QL_CONTAINER: 'true', + QL_DIR: testRoot, + }, + }, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /no system log found/); }); From 0e975d1d6dc840f58230ae9688ec331cf80a9771 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 22 Aug 2026 01:20:04 +0800 Subject: [PATCH 083/123] fix: show cron validation field errors --- back/validation/schedule.ts | 2 +- src/pages/crontab/modal.tsx | 1 + src/utils/http.tsx | 12 ++++---- src/utils/httpError.ts | 30 ++++++++++++++++++++ test/back/cron-validation.test.cjs | 45 ++++++++++++++++++++++++++++++ test/front/http-error.test.cjs | 29 +++++++++++++++++++ 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/utils/httpError.ts create mode 100644 test/back/cron-validation.test.cjs create mode 100644 test/front/http-error.test.cjs diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index db9d31f8..0e1df1b7 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -44,7 +44,7 @@ export const commonCronSchema = { name: Joi.string().optional(), command: Joi.string().required(), schedule: scheduleSchema, - labels: Joi.array().optional(), + labels: Joi.array().optional().allow(null), sub_id: Joi.number().optional().allow(null), extra_schedules: Joi.array().optional().allow(null), task_before: Joi.string().optional().allow('').allow(null), diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index dfe9fa21..3313f458 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -28,6 +28,7 @@ const CronModal = ({ const method = cron?.id ? 'put' : 'post'; const payload = { ...values, + labels: values.labels || [], schedule: scheduleType !== ScheduleType.Normal ? scheduleTypeMap[scheduleType] diff --git a/src/utils/http.tsx b/src/utils/http.tsx index e45ab9c0..904fd076 100644 --- a/src/utils/http.tsx +++ b/src/utils/http.tsx @@ -9,8 +9,9 @@ import axios, { AxiosResponse, InternalAxiosRequestConfig, } from 'axios'; +import { getErrorDetails, ValidationErrorResponse } from './httpError'; -export interface IResponseData { +export interface IResponseData extends ValidationErrorResponse { code?: number; data?: any; message?: string; @@ -43,6 +44,7 @@ const errorHandler = function ( const msg = error.response.data ? error.response.data.message || error.message : error.response.statusText; + const errorDetails = getErrorDetails(error.response.data); const responseStatus = error.response.status; if ([502, 504].includes(responseStatus)) { history.push('/error'); @@ -60,12 +62,10 @@ const errorHandler = function ( msg && notification.error({ message: msg, - description: error.response?.data?.errors ? ( + description: errorDetails.length ? ( <> - {error.response?.data?.errors?.map((item: any) => ( -
- {item.message} ({item.value}) -
+ {errorDetails.map((detail, index) => ( +
{detail}
))} ) : undefined, diff --git a/src/utils/httpError.ts b/src/utils/httpError.ts new file mode 100644 index 00000000..8e292d67 --- /dev/null +++ b/src/utils/httpError.ts @@ -0,0 +1,30 @@ +export interface ValidationErrorResponse { + errors?: Array<{ message?: string; value?: unknown }>; + validation?: Record< + string, + { source?: string; keys?: string[]; message?: string } + >; +} + +export const getErrorDetails = (data?: ValidationErrorResponse) => { + const details = + data?.errors?.map((item) => + item.value === undefined + ? item.message + : `${item.message} (${String(item.value)})`, + ) || []; + + Object.values(data?.validation || {}).forEach((validation) => { + if (validation.keys?.length) { + validation.keys.forEach((key) => { + details.push( + validation.message ? `${key}: ${validation.message}` : key, + ); + }); + } else if (validation.message) { + details.push(validation.message); + } + }); + + return details.filter((detail): detail is string => Boolean(detail)); +}; diff --git a/test/back/cron-validation.test.cjs b/test/back/cron-validation.test.cjs new file mode 100644 index 00000000..ddf5f9a1 --- /dev/null +++ b/test/back/cron-validation.test.cjs @@ -0,0 +1,45 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const configPath = require.resolve('../../back/config'); +require.cache[configPath] = { + id: configPath, + filename: configPath, + loaded: true, + exports: { __esModule: true, default: { logPath: '/ql/data/log' } }, + children: [], + paths: [], +}; + +const { commonCronSchema } = require('../../back/validation/schedule'); +const { Joi } = require('celebrate'); + +const schema = Joi.object(commonCronSchema); + +test('cron validation accepts leading-zero schedules and legacy null labels', () => { + const result = schema.validate({ + name: 'legacy cron', + command: 'task legacy.js', + schedule: '01 7 * * *', + labels: null, + }); + + assert.equal(result.error, undefined); +}); + +test('cron validation identifies invalid fields', () => { + const result = schema.validate( + { + name: 'invalid cron', + command: 'task invalid.js', + schedule: '01 7 * * *', + allow_multiple_instances: '', + }, + { abortEarly: false }, + ); + + assert.deepEqual( + [...new Set(result.error.details.map((detail) => detail.path.join('.')))], + ['allow_multiple_instances'], + ); +}); diff --git a/test/front/http-error.test.cjs b/test/front/http-error.test.cjs new file mode 100644 index 00000000..32cbd332 --- /dev/null +++ b/test/front/http-error.test.cjs @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { getErrorDetails } = require('../../src/utils/httpError'); + +test('validation errors include the failing field names', () => { + const details = getErrorDetails({ + message: 'Validation failed', + validation: { + body: { + source: 'body', + keys: ['labels', 'allow_multiple_instances'], + message: 'request body contains invalid values', + }, + }, + }); + + assert.deepEqual(details, [ + 'labels: request body contains invalid values', + 'allow_multiple_instances: request body contains invalid values', + ]); +}); + +test('existing API error details remain visible', () => { + assert.deepEqual( + getErrorDetails({ errors: [{ message: 'duplicate value', value: 'foo' }] }), + ['duplicate value (foo)'], + ); +}); From 5f6049d80aa4d6adfe4fb1fd6823d4c75d388cf0 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 29 Aug 2026 19:10:30 +0800 Subject: [PATCH 084/123] fix: secure file routes and dependency management --- back/api/config.ts | 12 +- back/api/log.ts | 24 +--- back/api/script.ts | 24 +--- back/loaders/express.ts | 23 +--- back/middlewares/protectedPathCase.ts | 23 ++++ back/services/dependence.ts | 69 +++++++++++ back/services/system.ts | 16 ++- package.json | 4 +- pnpm-lock.yaml | 18 +-- test/back/dependence-cache-status.test.cjs | 125 ++++++++++++++++++++ test/back/deprecated-file-routes.test.cjs | 82 ++++++++++++++ test/back/protected-path-case.test.cjs | 58 ++++++++++ test/back/system-dependence-cache.test.cjs | 126 +++++++++++++++++++++ 13 files changed, 525 insertions(+), 79 deletions(-) create mode 100644 back/middlewares/protectedPathCase.ts create mode 100644 test/back/dependence-cache-status.test.cjs create mode 100644 test/back/deprecated-file-routes.test.cjs create mode 100644 test/back/protected-path-case.test.cjs create mode 100644 test/back/system-dependence-cache.test.cjs diff --git a/back/api/config.ts b/back/api/config.ts index 23084cfe..6eb4d997 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -98,13 +98,11 @@ export default (app: Router) => { route.get( '/:file', - async (req: Request, res: Response, next: NextFunction) => { - try { - const configService = Container.get(ConfigService); - await configService.getFile(req.params.file, res); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /configs/detail 接口'), + }); }, ); }; diff --git a/back/api/log.ts b/back/api/log.ts index aed4708c..1820f4cc 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -5,7 +5,6 @@ import { Logger } from 'winston'; import config from '../config'; import { t } from '../shared/i18n'; import { - getFileContentByName, readDirs, removeAnsi, rmPath, @@ -89,24 +88,11 @@ export default (app: Router) => { route.get( '/:file', - async (req: Request, res: Response, next: NextFunction) => { - try { - const logService = Container.get(LogService); - const finalPath = logService.checkFilePath( - (req.query.path as string) || '', - (req.params.file as string) || '', - ); - if (!finalPath || blacklist.includes(req.query.path as string)) { - return res.send({ - code: 403, - message: t('暂无权限'), - }); - } - const content = await getFileContentByName(finalPath); - res.send({ code: 200, data: content }); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /logs/detail 接口'), + }); }, ); diff --git a/back/api/script.ts b/back/api/script.ts index 9be941d6..11ba9137 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -103,25 +103,11 @@ export default (app: Router) => { route.get( '/:file', - celebrate({ - params: Joi.object({ - file: Joi.string().required(), - }), - query: Joi.object({ - path: Joi.string().optional().allow(''), - }).unknown(true), - }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const scriptService = Container.get(ScriptService); - const content = await scriptService.getFile( - req.query?.path as string || '', - req.params.file, - ); - res.send({ code: 200, data: content }); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /scripts/detail 接口'), + }); }, ); diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 676e59ed..12bca534 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -14,6 +14,7 @@ import { AuthInfo } from '../data/system'; import path from 'path'; import { t } from '../shared/i18n'; import { AppScope } from '../data/open'; +import protectedPathCase from '../middlewares/protectedPathCase'; function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { const setting = value?.trim(); @@ -36,25 +37,9 @@ export default ({ app }: { app: Application }) => { app.set('trust proxy', resolveTrustProxy()); app.use(cors()); - // Security: Path normalization middleware to prevent case variation attacks - app.use((req, res, next) => { - const originalPath = req.path; - const normalizedPath = originalPath.toLowerCase(); - - // Block requests with case variations on protected paths - if ( - originalPath !== normalizedPath && - (normalizedPath.startsWith('/api/') || - normalizedPath.startsWith('/open/')) - ) { - return res.status(400).json({ - code: 400, - message: 'Invalid path format', - }); - } - - next(); - }); + // Security: Reject case variations under protected API namespaces before + // authentication checks can interpret the request differently from routing. + app.use(protectedPathCase); // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness diff --git a/back/middlewares/protectedPathCase.ts b/back/middlewares/protectedPathCase.ts new file mode 100644 index 00000000..21d2b62e --- /dev/null +++ b/back/middlewares/protectedPathCase.ts @@ -0,0 +1,23 @@ +import type { NextFunction, Request, Response } from 'express'; + +export default function protectedPathCase( + req: Request, + res: Response, + next: NextFunction, +) { + const originalPath = req.path; + const normalizedPath = originalPath.toLowerCase(); + + if ( + originalPath !== normalizedPath && + (normalizedPath.startsWith('/api/') || + normalizedPath.startsWith('/open/')) + ) { + return res.status(400).json({ + code: 400, + message: 'Invalid path format', + }); + } + + return next(); +} diff --git a/back/services/dependence.ts b/back/services/dependence.ts index d384ec76..c33ff43f 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -16,6 +16,7 @@ import { getPid, killTask, promiseExecSuccess, + concurrentRun, getInstallCommand, getUninstallCommand, getGetCommand, @@ -110,6 +111,11 @@ export default class DependenceService { query: any = {}, ): Promise { let condition = query; + const dependenceType = + type && DependenceTypes[type] !== undefined + ? DependenceTypes[type] + : undefined; + await this.refreshInstalledStatuses(dependenceType); if (type && DependenceTypes[type] !== undefined) { condition.type = DependenceTypes[type]; } @@ -132,6 +138,69 @@ export default class DependenceService { } } + private async refreshInstalledStatuses(type?: DependenceTypes) { + const cacheDependenceTypes = [ + DependenceTypes.nodejs, + DependenceTypes.python3, + ]; + if (type !== undefined && !cacheDependenceTypes.includes(type)) { + return; + } + + const docs = await DependenceModel.findAll({ + where: { + status: DependenceStatus.installed, + type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type, + }, + }); + const checks = await concurrentRun( + docs.map((doc) => async () => { + return (await this.isDependenceInstalled(doc)) ? undefined : doc.id; + }), + 5, + ); + const missingIds = (checks || []).filter( + (id): id is number => id !== undefined, + ); + + if (missingIds.length) { + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { where: { id: missingIds } }, + ); + } + } + + private async isDependenceInstalled(dependency: Dependence) { + let depName = dependency.name.trim(); + const depVersionStr = versionDependenceCommandTypes[dependency.type]; + let depVersion = ''; + if (depName.includes(depVersionStr)) { + const symbolRegx = new RegExp( + `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, + ); + const [, parsedName, parsedVersion] = depName.match(symbolRegx) || []; + if (parsedVersion && parsedName) { + depName = parsedName; + depVersion = parsedVersion; + } + } + + const depInfo = ( + await promiseExecSuccess(getGetCommand(dependency.type, depName)) + ) + .replace(/\s{2,}/, ' ') + .replace(/\s+$/, ''); + const nameMatches = + (dependency.type === DependenceTypes.nodejs && + depInfo.split(' ')?.[0] === depName) || + dependency.type === DependenceTypes.python3; + + return Boolean( + depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)), + ); + } + public installDependenceOneByOne( docs: Dependence[], isInstall: boolean = true, diff --git a/back/services/system.ts b/back/services/system.ts index 9cb644b9..6bfec73e 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -609,10 +609,18 @@ export default class SystemService { if (!type || !['node', 'python3'].includes(type)) { return { code: 400, message: t('参数错误') }; } - try { - const finalPath = path.join(config.dependenceCachePath, type); - await fs.promises.rm(finalPath, { recursive: true }); - } catch (error) { } + const finalPath = path.join(config.dependenceCachePath, type); + await fs.promises.rm(finalPath, { recursive: true, force: true }); + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { + where: { + type: + type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3, + status: DependenceStatus.installed, + }, + }, + ); return { code: 200 }; } } diff --git a/package.json b/package.json index a068f533..56ff1e32 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ } }, "overrides": { - "sqlite3": "npm:@whyour/sqlite3@1.1.0", + "sqlite3": "npm:@whyour/sqlite3@1.1.2", "@codemirror/state": "6.5.4", "@codemirror/view": "6.39.16" } @@ -109,7 +109,7 @@ "request-ip": "3.3.0", "sequelize": "^6.37.5", "sockjs": "^0.3.24", - "sqlite3": "npm:@whyour/sqlite3@1.1.0", + "sqlite3": "npm:@whyour/sqlite3@1.1.2", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", "undici": "^7.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c273f87d..c4247703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,7 +1,7 @@ lockfileVersion: '6.0' overrides: - sqlite3: npm:@whyour/sqlite3@1.1.0 + sqlite3: npm:@whyour/sqlite3@1.1.2 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 @@ -116,13 +116,13 @@ dependencies: version: 3.3.0 sequelize: specifier: ^6.37.5 - version: 6.37.5(@whyour/sqlite3@1.1.0) + version: 6.37.5(@whyour/sqlite3@1.1.2) sockjs: specifier: ^0.3.24 version: 0.3.24 sqlite3: - specifier: npm:@whyour/sqlite3@1.1.0 - version: /@whyour/sqlite3@1.1.0 + specifier: npm:@whyour/sqlite3@1.1.2 + version: /@whyour/sqlite3@1.1.2 toad-scheduler: specifier: ^3.0.1 version: 3.0.1 @@ -3190,7 +3190,7 @@ packages: resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==} engines: {node: '>= 18'} dependencies: - sqlite3: /@whyour/sqlite3@1.1.0 + sqlite3: /@whyour/sqlite3@1.1.2 transitivePeerDependencies: - encoding - supports-color @@ -5614,8 +5614,8 @@ packages: - supports-color dev: true - /@whyour/sqlite3@1.1.0: - resolution: {integrity: sha512-5g9xsjkv9sUepCvBBKmev6JSKSKjp0ebpW25uSsuV5kurIYrd7AnkbffNMNIswxwsQJSLPewVEX5ROsQfETfnA==} + /@whyour/sqlite3@1.1.2: + resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==} requiresBuild: true peerDependenciesMeta: node-gyp: @@ -13939,7 +13939,7 @@ packages: engines: {node: '>= 10.0.0'} dev: false - /sequelize@6.37.5(@whyour/sqlite3@1.1.0): + /sequelize@6.37.5(@whyour/sqlite3@1.1.2): resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==} engines: {node: '>=10.0.0'} peerDependencies: @@ -13984,7 +13984,7 @@ packages: retry-as-promised: 7.0.4 semver: 7.6.3 sequelize-pool: 7.1.0 - sqlite3: /@whyour/sqlite3@1.1.0 + sqlite3: /@whyour/sqlite3@1.1.2 toposort-class: 1.0.1 uuid: 8.3.2 validator: 13.12.0 diff --git a/test/back/dependence-cache-status.test.cjs b/test/back/dependence-cache-status.test.cjs new file mode 100644 index 00000000..a23cd30a --- /dev/null +++ b/test/back/dependence-cache-status.test.cjs @@ -0,0 +1,125 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('dependency listing marks cache entries missing from disk for reinstall', async (t) => { + const moduleStubs = new Map(); + const stubModule = (modulePath, exports) => { + const resolved = require.resolve(modulePath); + moduleStubs.set(resolved, require.cache[resolved]); + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + children: [], + paths: [], + }; + }; + + const DependenceStatus = { + installing: 0, + installed: 1, + installFailed: 2, + }; + const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 }; + const docs = [ + { id: 1, name: 'missing-package', type: 0, status: 1 }, + { id: 2, name: 'present-package', type: 0, status: 1 }, + ]; + const updates = []; + const DependenceModel = { + findAll: async ({ where }) => { + if (where.status === DependenceStatus.installed) { + return docs; + } + return docs.map((doc) => ({ + ...doc, + status: updates.some((update) => update.ids.includes(doc.id)) + ? DependenceStatus.installFailed + : doc.status, + })); + }, + update: async ({ status }, { where }) => { + updates.push({ status, ids: where.id }); + }, + }; + + stubModule('../../back/config', { + __esModule: true, + default: {}, + }); + stubModule('../../back/data/dependence', { + Dependence: class Dependence {}, + DependenceModel, + DependenceStatus, + DependenceTypes, + versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' }, + }); + stubModule('../../back/config/util', { + concurrentRun: async (tasks) => Promise.all(tasks.map((task) => task())), + detectOS: async () => 'Alpine', + fileExist: async () => false, + getGetCommand: (_type, name) => `check:${name}`, + getInstallCommand: () => '', + getPid: async () => 0, + getUninstallCommand: () => '', + killTask: async () => {}, + promiseExecSuccess: async (command) => + command === 'check:present-package' ? 'present-package 1.0.0\n' : '', + }); + stubModule('../../back/config/const', { + LINUX_DEPENDENCE_COMMAND: { Alpine: {} }, + }); + stubModule('../../back/shared/pLimit', { + __esModule: true, + default: {}, + }); + stubModule('../../back/shared/i18n', { + t: (message) => message, + tf: (message) => message, + }); + stubModule('../../back/services/sock', { + __esModule: true, + default: class SockService {}, + }); + + const servicePath = require.resolve('../../back/services/dependence'); + const originalService = require.cache[servicePath]; + delete require.cache[servicePath]; + t.after(() => { + if (originalService) { + require.cache[servicePath] = originalService; + } else { + delete require.cache[servicePath]; + } + for (const [resolved, original] of moduleStubs) { + if (original) { + require.cache[resolved] = original; + } else { + delete require.cache[resolved]; + } + } + }); + + const DependenceService = require('../../back/services/dependence').default; + const service = new DependenceService({}, {}); + const result = await service.dependencies({ + searchValue: '', + type: 'nodejs', + status: '', + }); + + assert.deepEqual(updates, [ + { status: DependenceStatus.installFailed, ids: [1] }, + ]); + assert.deepEqual( + result.map(({ id, status }) => ({ id, status })), + [ + { id: 1, status: DependenceStatus.installFailed }, + { id: 2, status: DependenceStatus.installed }, + ], + ); + + await service.dependencies({ searchValue: '', type: 'linux', status: '' }); + assert.equal(updates.length, 1); +}); diff --git a/test/back/deprecated-file-routes.test.cjs b/test/back/deprecated-file-routes.test.cjs new file mode 100644 index 00000000..76330db5 --- /dev/null +++ b/test/back/deprecated-file-routes.test.cjs @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const express = require('express'); + +function mockModule(modulePath, exports) { + const filename = require.resolve(modulePath); + require.cache[filename] = { + id: filename, + filename, + loaded: true, + exports, + children: [], + paths: [], + }; +} + +mockModule('../../back/config', { + __esModule: true, + default: { + bakPath: '/tmp', + blackFileList: [], + configPath: '/tmp', + logPath: '/tmp', + logs: { level: 'info' }, + rootPath: '/tmp', + scriptPath: '/tmp', + systemLogPath: '/tmp', + writePathList: ['/tmp'], + }, +}); +mockModule('../../back/shared/i18n', { + t: (message) => message, +}); +mockModule('../../back/config/util', { + fileExist: async () => false, + readDir: async () => [], + readDirs: async () => [], + removeAnsi: (content) => content, + rmPath: async () => {}, +}); +mockModule('../../back/shared/utils', { + writeFileWithLock: async () => {}, +}); +for (const service of ['config', 'script', 'log']) { + mockModule(`../../back/services/${service}`, { + __esModule: true, + default: class {}, + }); +} +mockModule('../../back/data/runningInstance', { + InstanceStatus: { running: 'running' }, + RunningInstanceModel: { findOne: async () => null }, +}); + +const deprecatedRoutes = [ + ['config', '/configs/detail'], + ['script', '/scripts/detail'], + ['log', '/logs/detail'], +]; + +for (const [moduleName, replacement] of deprecatedRoutes) { + test(`${moduleName} filename route points callers to its detail API`, () => { + const app = express.Router(); + require(`../../back/api/${moduleName}`).default(app); + const router = app.stack.find((layer) => layer.name === 'router').handle; + const deprecatedRoute = router.stack.find( + (layer) => layer.route?.path === '/:file', + ); + const handler = deprecatedRoute.route.stack.at(-1).handle; + let responseBody; + + handler( + { params: { file: 'Example.js' }, query: {} }, + { send: (body) => (responseBody = body) }, + ); + + assert.deepEqual(responseBody, { + code: 410, + message: `接口已下线,请使用 ${replacement} 接口`, + }); + }); +} diff --git a/test/back/protected-path-case.test.cjs b/test/back/protected-path-case.test.cjs new file mode 100644 index 00000000..f4e290bc --- /dev/null +++ b/test/back/protected-path-case.test.cjs @@ -0,0 +1,58 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const protectedPathCase = require( + '../../back/middlewares/protectedPathCase', +).default; + +function runMiddleware(path) { + let status; + let body; + let nextCalled = false; + const response = { + status(value) { + status = value; + return this; + }, + json(value) { + body = value; + return this; + }, + }; + + protectedPathCase({ path }, response, () => { + nextCalled = true; + }); + + return { status, body, nextCalled }; +} + +test('rejects case variations under protected API namespaces', () => { + for (const path of [ + '/Api/configs/detail', + '/api/Configs/detail', + '/OPEN/scripts/detail', + '/open/scripts/Detail', + ]) { + assert.deepEqual(runMiddleware(path), { + status: 400, + body: { code: 400, message: 'Invalid path format' }, + nextCalled: false, + }); + } +}); + +test('allows normalized protected paths and unrelated paths', () => { + for (const path of [ + '/api/configs/detail', + '/open/scripts/detail', + '/OpenApi/status', + '/assets/AppBundle.js', + ]) { + assert.deepEqual(runMiddleware(path), { + status: undefined, + body: undefined, + nextCalled: true, + }); + } +}); diff --git a/test/back/system-dependence-cache.test.cjs b/test/back/system-dependence-cache.test.cjs new file mode 100644 index 00000000..c06dd3fb --- /dev/null +++ b/test/back/system-dependence-cache.test.cjs @@ -0,0 +1,126 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +test('clearing a dependency cache marks installed entries for reinstall', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-dependence-')); + const nodeCache = path.join(directory, 'node'); + await fs.mkdir(nodeCache); + await fs.writeFile(path.join(nodeCache, 'package'), 'cached'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + + const moduleStubs = new Map(); + const stubModule = (modulePath, exports) => { + const resolved = require.resolve(modulePath); + moduleStubs.set(resolved, require.cache[resolved]); + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + children: [], + paths: [], + }; + }; + const updates = []; + const DependenceStatus = { installed: 1, installFailed: 2 }; + const DependenceTypes = { nodejs: 0, python3: 1 }; + + stubModule('../../back/config', { + __esModule: true, + default: { dependenceCachePath: directory }, + }); + stubModule('../../back/config/const', { + NotificationModeStringMap: {}, + TASK_COMMAND: 'task', + }); + stubModule('../../back/config/util', { + getPid: async () => 0, + killTask: async () => {}, + parseContentVersion: () => '', + parseVersion: () => '', + promiseExec: async () => '', + readDirs: async () => [], + rmPath: async () => {}, + setSystemTimezone: async () => true, + updateLinuxMirrorFile: async () => {}, + }); + stubModule('../../back/data/dependence', { + DependenceModel: { + update: async (values, options) => updates.push({ values, options }), + }, + DependenceStatus, + DependenceTypes, + }); + stubModule('../../back/data/notify', {}); + stubModule('../../back/data/system', { + AuthDataType: {}, + SystemModel: {}, + }); + stubModule('../../back/shared/pLimit', { + __esModule: true, + default: {}, + }); + stubModule('../../back/shared/i18n', { + setLang: () => {}, + t: (message) => message, + }); + stubModule('../../back/services/notify', { + __esModule: true, + default: class NotificationService {}, + }); + stubModule('../../back/services/schedule', { + __esModule: true, + default: class ScheduleService {}, + }); + stubModule('../../back/services/sock', { + __esModule: true, + default: class SockService {}, + }); + + const servicePath = require.resolve('../../back/services/system'); + const originalService = require.cache[servicePath]; + delete require.cache[servicePath]; + t.after(() => { + if (originalService) { + require.cache[servicePath] = originalService; + } else { + delete require.cache[servicePath]; + } + for (const [resolved, original] of moduleStubs) { + if (original) { + require.cache[resolved] = original; + } else { + delete require.cache[resolved]; + } + } + }); + + const SystemService = require('../../back/services/system').default; + const service = new SystemService({}, {}, {}); + assert.deepEqual(await service.cleanDependence('node'), { code: 200 }); + assert.deepEqual(await service.cleanDependence('python3'), { code: 200 }); + await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' }); + assert.deepEqual(updates, [ + { + values: { status: DependenceStatus.installFailed }, + options: { + where: { + type: DependenceTypes.nodejs, + status: DependenceStatus.installed, + }, + }, + }, + { + values: { status: DependenceStatus.installFailed }, + options: { + where: { + type: DependenceTypes.python3, + status: DependenceStatus.installed, + }, + }, + }, + ]); +}); From be2580d0e8c080a762f809199e425a92a9371c06 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 5 Sep 2026 17:03:31 +0800 Subject: [PATCH 085/123] feat: show today's failed tasks from dashboard Add a failure list with task names, commands, accumulated failure counts and latest log links. Keep recovered and deleted tasks visible in today's statistics. Fixes #3065 --- back/api/dashboard.ts | 37 ++++++++ src/locales/en-US.json | 6 +- src/locales/zh-CN.json | 6 +- src/pages/dashboard/failureModal.tsx | 117 ++++++++++++++++++++++++ src/pages/dashboard/index.tsx | 14 ++- test/back/dashboard-failures.test.cjs | 126 ++++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 src/pages/dashboard/failureModal.tsx create mode 100644 test/back/dashboard-failures.test.cjs diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 5e9438a3..52e0b90d 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -107,6 +107,43 @@ export default (app: Router) => { }, ); + route.get( + '/failures', + async (req: Request, res: Response, next: NextFunction) => { + try { + const rows = (await CrontabStatModel.findAll({ + attributes: ['ref_id', [fn('SUM', col('fail_count')), 'fail_count']], + where: { date: dayjs().format('YYYY-MM-DD'), fail_count: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col('fail_count')), 'DESC'], ['ref_id', 'ASC']], + raw: true, + })) as any[]; + const crons = rows.length > 0 ? await CrontabModel.findAll({ + attributes: ['id', 'name', 'command'], + where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } }, + raw: true, + }) : []; + const cronMap = new Map(crons.map((cron) => [cron.id, cron])); + res.send({ + code: 200, + data: rows.map((row) => { + const id = Number(row.ref_id); + const cron = cronMap.get(id); + return { + id, + name: cron?.name || cron?.command || tf('任务#%s', id), + command: cron?.command || '', + failCount: Number(row.fail_count), + deleted: !cron, + }; + }), + }); + } catch (e) { + next(e); + } + }, + ); + route.get( '/trend', async (req: Request, res: Response, next: NextFunction) => { diff --git a/src/locales/en-US.json b/src/locales/en-US.json index ec5a7127..5d6d11e7 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -657,5 +657,9 @@ "已完成": "Completed", "已停止": "Stopped", "退出码": "Exit Code", - "结束": "End" + "结束": "End", + "失败次数": "Failure count", + "最新日志": "Latest log", + "加载失败": "Failed to load", + "重试": "Retry" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index ae12cf39..b0493a74 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -657,5 +657,9 @@ "已完成": "已完成", "已停止": "已停止", "退出码": "退出码", - "结束": "结束" + "结束": "结束", + "失败次数": "失败次数", + "最新日志": "最新日志", + "加载失败": "加载失败", + "重试": "重试" } diff --git a/src/pages/dashboard/failureModal.tsx b/src/pages/dashboard/failureModal.tsx new file mode 100644 index 00000000..a9d0f472 --- /dev/null +++ b/src/pages/dashboard/failureModal.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Modal, Table, Tag } from 'antd'; +import intl from 'react-intl-universal'; +import { request } from '@/utils/http'; +import config from '@/utils/config'; +import CronLogModal from '../crontab/logModal'; + +interface FailedTask { + id: number; + name: string; + command: string; + failCount: number; + deleted: boolean; +} + +export default function FailureModal({ onCancel }: { onCancel: () => void }) { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [reload, setReload] = useState(0); + const [logCron, setLogCron] = useState(null); + + useEffect(() => { + let active = true; + setLoading(true); + setFailed(false); + request + .get(`${config.apiPrefix}dashboard/failures`) + .then((response) => { + if (!active) return; + if (response.code !== 200) + throw new Error('Failed to load dashboard failures'); + setTasks(response.data); + }) + .catch(() => { + if (active) setFailed(true); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [reload]); + + return ( + <> + + {failed ? ( + setReload((value) => value + 1)}> + {intl.get('重试')} + + } + /> + ) : ( + + loading={loading} + dataSource={tasks} + rowKey="id" + size="small" + scroll={{ x: 650 }} + pagination={{ pageSize: 10, showSizeChanger: false }} + columns={[ + { + title: intl.get('定时任务'), + dataIndex: 'name', + ellipsis: true, + render: (name, task) => ( + <> + {name} {task.deleted && {intl.get('已删除')}} + + ), + }, + { title: intl.get('命令'), dataIndex: 'command', ellipsis: true }, + { + title: intl.get('失败次数'), + dataIndex: 'failCount', + width: 100, + }, + { + title: intl.get('日志'), + width: 120, + render: (_, task) => ( + + ), + }, + ]} + /> + )} + + {logCron && ( + setLogCron(null)} /> + )} + + ); +} diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 6f428f95..d77070de 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -16,6 +16,7 @@ import { SharedContext } from '@/layouts'; import { request } from '@/utils/http'; import config from '@/utils/config'; import CronLogModal from '../crontab/logModal'; +import FailureModal from './failureModal'; interface Overview { total: number; @@ -93,6 +94,7 @@ const Dashboard = () => { const [labels, setLabels] = useState([]); const [logCron, setLogCron] = useState(null); const [loading, setLoading] = useState(true); + const [showFailures, setShowFailures] = useState(false); const fetchData = useCallback(async () => { try { @@ -193,7 +195,16 @@ const Dashboard = () => { } />
- } /> + setShowFailures(true)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setShowFailures(true); + } + }} + >} />} /> @@ -350,6 +361,7 @@ const Dashboard = () => { + {showFailures && setShowFailures(false)} />} {logCron && ( { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const crons = db.define('Cron', { + name: DataTypes.STRING, + command: DataTypes.STRING, + }); + const stats = db.define('Stat', { + ref_id: DataTypes.INTEGER, + date: DataTypes.STRING, + fail_count: DataTypes.INTEGER, + success_count: DataTypes.INTEGER, + }); + const replacements = { + '../../back/data/cron': { CrontabModel: crons }, + '../../back/data/cronStats': { CrontabStatModel: stats }, + '../../back/data/runningInstance': {}, + '../../back/shared/i18n': { tf: (format, id) => format.replace('%s', id) }, + }; + for (const [modulePath, exports] of Object.entries(replacements)) { + const resolved = require.resolve(modulePath); + const original = require.cache[resolved]; + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + }; + t.after(() => { + if (original) require.cache[resolved] = original; + else delete require.cache[resolved]; + }); + } + await db.sync(); + await crons.bulkCreate([ + { id: 1, name: 'Recovered task', command: 'task recovered.js' }, + { id: 2, name: '', command: 'task unnamed.js' }, + { id: 3, name: 'Successful task', command: 'task success.js' }, + ]); + const today = dayjs().format('YYYY-MM-DD'); + await stats.bulkCreate([ + { ref_id: 1, date: today, fail_count: 2, success_count: 1 }, + { ref_id: 2, date: today, fail_count: 1, success_count: 0 }, + { ref_id: 3, date: today, fail_count: 0, success_count: 4 }, + { ref_id: 4, date: today, fail_count: 3, success_count: 0 }, + { + ref_id: 3, + date: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), + fail_count: 10, + }, + ]); + const router = express.Router(); + require('../../back/api/dashboard').default(router); + const dashboard = router.stack.find( + (layer) => layer.name === 'router', + ).handle; + const handler = dashboard.stack.find( + (layer) => layer.route?.path === '/failures', + ).route.stack[0].handle; + let response; + await handler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { + code: 200, + data: [ + { id: 4, name: '任务#4', command: '', failCount: 3, deleted: true }, + { + id: 1, + name: 'Recovered task', + command: 'task recovered.js', + failCount: 2, + deleted: false, + }, + { + id: 2, + name: 'task unnamed.js', + command: 'task unnamed.js', + failCount: 1, + deleted: false, + }, + ], + }); + await stats.destroy({ where: {} }); + await handler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { code: 200, data: [] }); + await stats.drop(); + let forwarded; + await handler( + {}, + { send: () => assert.fail('must forward database errors') }, + (error) => { + forwarded = error; + }, + ); + assert.ok(forwarded instanceof Error); +}); From 4df52094f2f7b960d857a6b2e627c79427071dc8 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 5 Sep 2026 18:10:53 +0800 Subject: [PATCH 086/123] fix: harden authentication and file access security --- .gitignore | 3 + back/api/config.ts | 30 ++- back/api/script.ts | 48 +++-- back/api/user.ts | 40 +++- back/config/share.ts | 12 +- back/data/system.ts | 2 + back/loaders/express.ts | 23 ++- back/loaders/sock.ts | 22 ++- back/services/config.ts | 30 +-- back/services/grpc.ts | 2 +- back/services/http.ts | 2 +- back/services/log.ts | 8 +- back/services/script.ts | 8 +- back/services/user.ts | 141 ++++++++++++-- back/shared/auth.ts | 24 ++- back/shared/authMutation.ts | 17 ++ back/shared/fileAccess.ts | 57 ++++++ back/shared/password.ts | 30 +++ test/back/auth-security.test.cjs | 74 +++++++ test/back/file-access-security.test.cjs | 47 +++++ test/back/http-security.test.cjs | 213 ++++++++++++++++++++ test/back/listen-security.test.cjs | 67 +++++++ test/back/sock-security.test.cjs | 72 +++++++ test/back/user-api.test.cjs | 2 +- test/back/user-security.test.cjs | 248 ++++++++++++++++++++++++ test/helpers/load-security-module.cjs | 42 ++++ 26 files changed, 1165 insertions(+), 99 deletions(-) create mode 100644 back/shared/authMutation.ts create mode 100644 back/shared/fileAccess.ts create mode 100644 back/shared/password.ts create mode 100644 test/back/auth-security.test.cjs create mode 100644 test/back/file-access-security.test.cjs create mode 100644 test/back/http-security.test.cjs create mode 100644 test/back/listen-security.test.cjs create mode 100644 test/back/sock-security.test.cjs create mode 100644 test/back/user-security.test.cjs create mode 100644 test/helpers/load-security-module.cjs diff --git a/.gitignore b/.gitignore index 0b651bca..2428ab44 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ __pycache__ # local Kubernetes overlays /deploy/kubernetes/overlays/local/ + +# Local security audit artifacts +/audit/ diff --git a/back/api/config.ts b/back/api/config.ts index 6eb4d997..c61772f5 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -9,6 +9,7 @@ import { SAMPLE_FILES } from '../config/const'; import { t } from '../shared/i18n'; import ConfigService from '../services/config'; import { writeFileWithLock } from '../shared/utils'; +import { resolveFileAccess } from '../shared/fileAccess'; const route = Router(); export default (app: Router) => { @@ -78,14 +79,12 @@ export default (app: Router) => { basePath = join(config.rootPath, 'data/scripts'); } const cleanName = name.replace(/^data\/scripts\//, ''); - const resolvedPath = join(basePath, cleanName); - const normalized = join(resolvedPath); - // Verify the resolved path stays within allowed directory - if (!normalized.startsWith(basePath)) { - return res.send({ code: 403, message: t('文件路径无效') }); - } - // Check blacklist on actual filename (not user input) - if (config.blackFileList.includes(basename(normalized))) { + const normalized = resolveFileAccess( + basePath, + [cleanName], + config.blackFileList, + ); + if (!normalized) { return res.send({ code: 403, message: t('文件无法访问') }); } await writeFileWithLock(normalized, content); @@ -96,13 +95,10 @@ export default (app: Router) => { }, ); - route.get( - '/:file', - (req: Request, res: Response) => { - return res.send({ - code: 410, - message: t('接口已下线,请使用 /configs/detail 接口'), - }); - }, - ); + route.get('/:file', (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /configs/detail 接口'), + }); + }); }; diff --git a/back/api/script.ts b/back/api/script.ts index 11ba9137..99a63ad3 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; +import { resolveFileAccess } from '../shared/fileAccess'; import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util'; import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; @@ -14,15 +16,17 @@ const route = Router(); function isPathAllowed(targetPath: string): boolean { const resolved = path.resolve(targetPath); - return config.writePathList.some((x) => resolved.startsWith(x)); + return config.writePathList.some((x) => + Boolean(resolveFileAccess(x, [resolved], config.blackFileList)), + ); } const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, config.scriptPath); + cb(null, config.tmpPath); }, filename: function (req, file, cb) { - cb(null, file.originalname); + cb(null, randomUUID()); }, }); const upload = multer({ storage: storage }); @@ -50,6 +54,15 @@ export default (app: Router) => { 'package-lock.json', ]; if (req.query.path) { + if ( + !resolveFileAccess( + config.scriptPath, + [req.query.path as string], + config.blackFileList, + ) + ) { + return res.send({ code: 403, message: t('暂无权限') }); + } result = await readDir( req.query.path as string, config.scriptPath, @@ -77,7 +90,8 @@ export default (app: Router) => { logger.error('🔥 error: %o', e); return next(e); } - }); + }, + ); route.get( '/detail', @@ -91,7 +105,7 @@ export default (app: Router) => { try { const scriptService = Container.get(ScriptService); const content = await scriptService.getFile( - req.query?.path as string || '', + (req.query?.path as string) || '', req.query.file as string, ); res.send({ code: 200, data: content }); @@ -101,18 +115,21 @@ export default (app: Router) => { }, ); - route.get( - '/:file', - (req: Request, res: Response) => { - return res.send({ - code: 410, - message: t('接口已下线,请使用 /scripts/detail 接口'), - }); - }, - ); + route.get('/:file', (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /scripts/detail 接口'), + }); + }); route.post( '/', + (req: Request, res: Response, next: NextFunction) => { + res.on('finish', () => { + if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined); + }); + next(); + }, upload.single('file'), celebrate({ body: Joi.object({ @@ -156,7 +173,8 @@ export default (app: Router) => { if (!isPathAllowed(uploadPath)) { return res.send({ code: 403, message: t('暂无权限') }); } - await fs.rename(req.file.path, uploadPath); + await fs.copyFile(req.file.path, uploadPath); + await fs.unlink(req.file.path); return res.send({ code: 200 }); } diff --git a/back/api/user.ts b/back/api/user.ts index 4d526389..4e92b39b 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -22,7 +22,25 @@ const storage = multer.diskStorage({ cb(null, key + ext); }, }); -const upload = multer({ storage: storage }); +const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024, files: 1 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const imageTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + }; + if (imageTypes[ext] !== file.mimetype) { + return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片'))); + } + cb(null, true); + }, +}); export default (app: Router) => { app.use('/user', route); @@ -35,8 +53,8 @@ export default (app: Router) => { }), celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -70,8 +88,8 @@ export default (app: Router) => { '/', celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -156,11 +174,12 @@ export default (app: Router) => { route.put( '/two-factor/login', + rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), celebrate({ body: Joi.object({ code: Joi.string().required(), - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -272,17 +291,18 @@ export default (app: Router) => { route.put( '/init', + rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); - const result = await userService.updateUsernameAndPassword(req.body); + const result = await userService.initializeUser(req.body); res.send(result); } catch (e) { return next(e); diff --git a/back/config/share.ts b/back/config/share.ts index 8577feea..24c52fa7 100644 --- a/back/config/share.ts +++ b/back/config/share.ts @@ -1,3 +1,5 @@ +import { randomInt } from 'crypto'; + export function createRandomString(min: number, max: number): string { const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; const english = [ @@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string { arr.push(getOne(ENGLISH)); arr.push(getOne(special)); - const len = min + Math.floor(Math.random() * (max - min + 1)); + const len = min + randomInt(max - min + 1); for (let i = 4; i < len; i++) { - arr.push(config[Math.floor(Math.random() * config.length)]); + arr.push(config[randomInt(config.length)]); } const newArr = []; for (let j = 0; j < len; j++) { - newArr.push(arr.splice(Math.random() * arr.length, 1)[0]); + newArr.push(arr.splice(randomInt(arr.length), 1)[0]); } function getOne(arr: any[]) { - return arr[Math.floor(Math.random() * arr.length)]; + return arr[randomInt(arr.length)]; } return newArr.join(''); -} \ No newline at end of file +} diff --git a/back/data/system.ts b/back/data/system.ts index b61c837b..af20e8ba 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -75,6 +75,8 @@ export interface AuthInfo { lastaddr: string; platform: string; isTwoFactorChecking: boolean; + twoFactorExpiresAt?: number; + lastTwoFactorStep?: number; token: string; tokens: Record; twoFactorActivated: boolean; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 12bca534..20c05774 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -48,7 +48,26 @@ export default ({ app }: { app: Application }) => { } app.get(`${config.api.prefix}/env.js`, serveEnv); - app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); + app.use( + `${config.api.prefix}/static`, + express.static(config.uploadPath, { + setHeaders: (res) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'"); + }, + }), + ); + + const credentialPaths = ['/api', '/open'].flatMap((prefix) => + ['/user/login', '/user/init', '/user/two-factor/login'].map( + (route) => `${prefix}${route}`, + ), + ); + app.use( + credentialPaths, + bodyParser.json({ limit: '16kb' }), + bodyParser.urlencoded({ limit: '16kb', extended: false }), + ); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); @@ -123,7 +142,7 @@ export default ({ app }: { app: Application }) => { } const authInfo = await shareStore.getAuthInfo(); - if (isValidToken(authInfo, headerToken, req.platform)) { + if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) { return next(); } diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index 5ab0b497..b99b721f 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -8,26 +8,44 @@ import { isValidToken } from '../shared/auth'; import config from '../config'; export default async ({ server }: { server: Server }) => { - const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } }); + const echo = sockJs.createServer({ + prefix: `${config.baseUrl}/api/ws`, + log: () => {}, + }); const sockService = Container.get(SockService); echo.on('connection', async (conn) => { if (!conn.headers || !conn.url || !conn.pathname) { conn.close('404'); + return; } const authInfo = await shareStore.getAuthInfo(); const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); - if (isValidToken(authInfo, headerToken, platform)) { + if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) { sockService.addClient(conn); + const checkSession = setInterval(async () => { + try { + const current = await shareStore.getAuthInfo(); + if ( + !isValidToken(current, headerToken, platform, config.jwt.secret) + ) { + conn.close('401'); + } + } catch { + conn.close('401'); + } + }, 1000); + checkSession.unref(); conn.on('data', (message) => { conn.write(message); }); conn.on('close', function () { + clearInterval(checkSession); sockService.removeClient(conn); }); diff --git a/back/services/config.ts b/back/services/config.ts index 0c5ea30a..55335bd3 100644 --- a/back/services/config.ts +++ b/back/services/config.ts @@ -1,10 +1,10 @@ -import { Service, Inject } from 'typedi'; -import path, { join } from 'path'; +import { Service } from 'typedi'; import config from '../config'; import { getFileContentByName } from '../config/util'; import { t } from '../shared/i18n'; import { Response } from 'express'; import { request } from 'undici'; +import { resolveFileAccess } from '../shared/fileAccess'; @Service() export default class ConfigService { @@ -15,21 +15,13 @@ export default class ConfigService { if (!filePath) { return res.send({ code: 403, message: t('文件无法访问') }); } - const normalized = path.normalize(filePath); - if (normalized.startsWith('..') || path.isAbsolute(normalized)) { - return res.send({ code: 403, message: t('文件无法访问') }); - } - const resolvedRoot = path.resolve(config.rootPath, normalized); - const resolvedConfig = path.resolve(config.configPath, normalized); - const isValidPath = - resolvedRoot.startsWith(config.scriptPath) || - resolvedRoot.startsWith(config.configPath) || - resolvedConfig.startsWith(config.scriptPath) || - resolvedConfig.startsWith(config.configPath); - if (!isValidPath) { - return res.send({ code: 403, message: t('文件无法访问') }); - } - if (config.blackFileList.includes(path.basename(normalized))) { + const scriptFile = filePath.startsWith('data/scripts/'); + const resolved = resolveFileAccess( + scriptFile ? config.scriptPath : config.configPath, + [scriptFile ? filePath.slice('data/scripts/'.length) : filePath], + config.blackFileList, + ); + if (!resolved) { return res.send({ code: 403, message: t('文件无法访问') }); } @@ -38,10 +30,8 @@ export default class ConfigService { `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, ); content = await res.body.text(); - } else if (filePath.startsWith('data/scripts/')) { - content = await getFileContentByName(join(config.rootPath, filePath)); } else { - content = await getFileContentByName(join(config.configPath, filePath)); + content = await getFileContentByName(resolved); } res.send({ code: 200, data: content }); diff --git a/back/services/grpc.ts b/back/services/grpc.ts index 2a69843f..2e8c6bb6 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -40,7 +40,7 @@ export class GrpcServerService { const grpcPort = config.grpcPort; const hostsToTry = [ config.bindHostGrpc, - ...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : []) + ...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : []) ]; const bindAsync = promisify(this.server.bindAsync).bind(this.server); diff --git a/back/services/http.ts b/back/services/http.ts index a47d49c8..f6d6dba3 100644 --- a/back/services/http.ts +++ b/back/services/http.ts @@ -12,7 +12,7 @@ export class HttpServerService { async initialize(expressApp: express.Application, port: number) { const hostsToTry = [ config.bindHost, - ...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : []) + ...(config.bindHost === '::' ? ['0.0.0.0'] : []) ]; let lastError: Error | null = null; diff --git a/back/services/log.ts b/back/services/log.ts index ab3f16e4..cd40116d 100644 --- a/back/services/log.ts +++ b/back/services/log.ts @@ -1,3 +1,4 @@ +import { resolveFileAccess } from '../shared/fileAccess'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; @@ -8,7 +9,10 @@ export default class LogService { constructor(@Inject('logger') private logger: winston.Logger) {} public checkFilePath(filePath: string, fileName: string) { - const finalPath = path.resolve(config.logPath, filePath, fileName); - return finalPath.startsWith(config.logPath) ? finalPath : ''; + return resolveFileAccess( + config.logPath, + [filePath || '', fileName], + config.blackFileList, + ); } } diff --git a/back/services/script.ts b/back/services/script.ts index 52921c6c..ef4a5e2c 100644 --- a/back/services/script.ts +++ b/back/services/script.ts @@ -1,3 +1,4 @@ +import { resolveFileAccess } from '../shared/fileAccess'; import { Service, Inject } from 'typedi'; import winston from 'winston'; import path, { join } from 'path'; @@ -65,8 +66,11 @@ export default class ScriptService { } public checkFilePath(filePath: string, fileName: string) { - const finalPath = path.resolve(config.scriptPath, filePath, fileName); - return finalPath.startsWith(config.scriptPath) ? finalPath : ''; + return resolveFileAccess( + config.scriptPath, + [filePath || '', fileName], + config.blackFileList, + ); } public async getFile(filePath: string, fileName: string) { diff --git a/back/services/user.ts b/back/services/user.ts index 73ed8dd7..b7f5cf9b 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -26,6 +26,13 @@ import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; import { t, tf } from '../shared/i18n'; import { getClientIp, normalizeClientIp } from '../shared/clientIp'; +import { isDefaultAuthInfo } from '../shared/auth'; +import { + hashPassword, + isPasswordHash, + verifyPassword, +} from '../shared/password'; +import { serializeAuthMutation } from '../shared/authMutation'; @Service() export default class UserService { @@ -38,7 +45,15 @@ export default class UserService { private sockService: SockService, ) {} + @serializeAuthMutation public async login( + payloads: { username: string; password: string }, + req: Request, + ): Promise { + return this.authenticate(payloads, req); + } + + private async authenticate( payloads: { username: string; password: string; @@ -48,6 +63,9 @@ export default class UserService { ): Promise { let { username, password } = payloads; const content = await this.getAuthInfo(); + if (isDefaultAuthInfo(content)) { + return { code: 450, message: t('请先初始化') }; + } const timestamp = Date.now(); const ip = getClientIp(req); const query = new IP2Region(); @@ -100,14 +118,13 @@ export default class UserService { }; } - if ( - username === cUsername && - password === cPassword && - twoFactorActivated && - needTwoFactor - ) { + const passwordMatches = + username === cUsername && (await verifyPassword(password, cPassword)); + + if (passwordMatches && twoFactorActivated && needTwoFactor) { await this.updateAuthInfo(content, { isTwoFactorChecking: true, + twoFactorExpiresAt: timestamp + 5 * 60 * 1000, }); return { code: 420, @@ -115,7 +132,7 @@ export default class UserService { }; } - if (username === cUsername && password === cPassword) { + if (passwordMatches) { const data = createRandomString(50, 100); const expiration = twoFactorActivated ? '60d' : '20d'; let token = jwt.sign({ data }, config.jwt.secret, { @@ -138,6 +155,9 @@ export default class UserService { ); await this.updateAuthInfo(content, { + password: isPasswordHash(cPassword) + ? cPassword + : await hashPassword(password), token, tokens: updatedTokens, lastlogon: timestamp, @@ -146,6 +166,7 @@ export default class UserService { lastaddr: address, platform: req.platform, isTwoFactorChecking: false, + twoFactorExpiresAt: 0, }); this.notificationService.notify( t('登录通知'), @@ -231,6 +252,7 @@ export default class UserService { } } + @serializeAuthMutation public async logout(platform: string, tokenValue: string): Promise { if (!platform || !tokenValue) { this.logger.warn('Invalid logout parameters - empty platform or token'); @@ -291,6 +313,7 @@ export default class UserService { ); } + @serializeAuthMutation public async blockIp(ip: string): Promise { const authInfo = await this.getAuthInfo(); const blockedIps = uniq([ @@ -301,6 +324,7 @@ export default class UserService { return blockedIps; } + @serializeAuthMutation public async unblockIp(ip: string): Promise { const authInfo = await this.getAuthInfo(); const normalizedIp = normalizeClientIp(ip); @@ -316,6 +340,33 @@ export default class UserService { return doc; } + @serializeAuthMutation + public async initializeUser({ + username, + password, + }: { + username: string; + password: string; + }) { + const authInfo = await this.getAuthInfo(); + if (!isDefaultAuthInfo(authInfo)) { + return { code: 450, message: t('未知错误') }; + } + if (password === 'admin') { + return { code: 400, message: t('密码不能设置为admin') }; + } + await this.updateAuthInfo(authInfo, { + username, + password: await hashPassword(password), + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + return { code: 200, message: t('更新成功') }; + } + + @serializeAuthMutation public async updateUsernameAndPassword({ username, password, @@ -327,24 +378,37 @@ export default class UserService { return { code: 400, message: t('密码不能设置为admin') }; } const authInfo = await this.getAuthInfo(); - await this.updateAuthInfo(authInfo, { username, password }); + await this.updateAuthInfo(authInfo, { + username, + password: await hashPassword(password), + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); return { code: 200, message: t('更新成功') }; } + @serializeAuthMutation public async updateAvatar(avatar: string) { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { avatar }); return { code: 200, data: avatar, message: t('更新成功') }; } + @serializeAuthMutation public async initTwoFactor() { const secret = authenticator.generateSecret(); const authInfo = await this.getAuthInfo(); + if (authInfo.twoFactorActivated) { + throw new Error(t('请先关闭两步验证')); + } const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); return { secret, url: otpauth }; } + @serializeAuthMutation public async activeTwoFactor(code: string) { const authInfo = await this.getAuthInfo(); const isValid = authenticator.verify({ @@ -352,11 +416,18 @@ export default class UserService { secret: authInfo.twoFactorSecret, }); if (isValid) { - await this.updateAuthInfo(authInfo, { twoFactorActivated: true }); + await this.updateAuthInfo(authInfo, { + twoFactorActivated: true, + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); } return isValid; } + @serializeAuthMutation public async twoFactorLogin( { username, @@ -367,15 +438,28 @@ export default class UserService { ) { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; - if (!isTwoFactorChecking) { + const now = Date.now(); + const retries = authInfo.retries || 0; + if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) { + return { code: 410, message: t('失败次数过多,请稍后重试') }; + } + if ( + !isTwoFactorChecking || + !authInfo.twoFactorActivated || + !authInfo.twoFactorExpiresAt || + authInfo.twoFactorExpiresAt <= now + ) { return { code: 450, message: t('未知错误') }; } - const isValid = authenticator.verify({ - token: code, - secret: twoFactorSecret, - }); + const step = Math.floor(now / 30000); + const isValid = + username === authInfo.username && + (await verifyPassword(password, authInfo.password)) && + authInfo.lastTwoFactorStep !== step && + authenticator.verify({ token: code, secret: twoFactorSecret }); if (isValid) { - return this.login({ username, password }, req, false); + await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step }); + return this.authenticate({ username, password }, req, false); } else { const ip = getClientIp(req); const query = new IP2Region(); @@ -388,6 +472,9 @@ export default class UserService { .join(' '); } await this.updateAuthInfo(authInfo, { + retries: retries + 1, + lastlogon: now, + isTwoFactorChecking: retries + 1 < 5, lastip: ip, lastaddr: address, platform: req.platform, @@ -396,11 +483,16 @@ export default class UserService { } } + @serializeAuthMutation public async deactivateTwoFactor() { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { twoFactorActivated: false, twoFactorSecret: '', + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, }); return true; } @@ -421,6 +513,9 @@ export default class UserService { type: AuthDataType.authConfig, info: result, }); + if (info.tokens && Object.keys(info.tokens).length === 0) { + this.sockService.getClients().forEach((conn) => conn.close('401')); + } } public async getNotificationMode(): Promise { @@ -562,6 +657,7 @@ export default class UserService { return undefined; } + @serializeAuthMutation public async resetAuthInfo(info: Partial) { const { retries, twoFactorActivated, password, username } = info; if (password === 'admin') { @@ -578,6 +674,21 @@ export default class UserService { (x) => !isNil(x), ); + if (password !== undefined) { + payload.password = await hashPassword(password); + } + if ( + password !== undefined || + username !== undefined || + twoFactorActivated !== undefined + ) { + Object.assign(payload, { + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + } await this.updateAuthInfo(authInfo, payload); } } diff --git a/back/shared/auth.ts b/back/shared/auth.ts index 5bfd694a..2a0e7fcf 100644 --- a/back/shared/auth.ts +++ b/back/shared/auth.ts @@ -1,11 +1,8 @@ import { AuthInfo, TokenInfo } from '../data/system'; +import jwt from 'jsonwebtoken'; export function isDefaultAuthInfo(authInfo: AuthInfo): boolean { - return ( - Object.keys(authInfo).length === 2 && - authInfo.username === 'admin' && - authInfo.password === 'admin' - ); + return authInfo.username === 'admin' && authInfo.password === 'admin'; } /** @@ -21,11 +18,21 @@ export function isValidToken( authInfo: AuthInfo | null | undefined, headerToken: string, platform: string, + secret: string, ): boolean { if (!authInfo || !headerToken) { return false; } + try { + const claims = jwt.verify(headerToken, secret, { algorithms: ['HS384'] }); + if (typeof claims === 'string' || typeof claims.exp !== 'number') { + return false; + } + } catch { + return false; + } + const { token = '', tokens = {} } = authInfo; // Check legacy token field @@ -46,7 +53,12 @@ export function isValidToken( return headerToken === platformTokens; } else if (Array.isArray(platformTokens)) { // New format: array of TokenInfo objects - return platformTokens.some((t: TokenInfo) => t && t.value === headerToken); + return platformTokens.some( + (t: TokenInfo) => + t && + t.value === headerToken && + (t.expiration === undefined || t.expiration > Date.now() / 1000), + ); } // Unexpected type - log warning and reject diff --git a/back/shared/authMutation.ts b/back/shared/authMutation.ts new file mode 100644 index 00000000..b041ddd7 --- /dev/null +++ b/back/shared/authMutation.ts @@ -0,0 +1,17 @@ +// All account mutations in the HTTP service share one queue. In particular, +// a login that read old credentials must finish before a password reset revokes +// its session, and two initialization requests must not both claim the account. +let pending: Promise = Promise.resolve(); + +export function serializeAuthMutation( + _target: object, + _key: string, + descriptor: PropertyDescriptor, +) { + const method = descriptor.value; + descriptor.value = function (this: unknown, ...args: unknown[]) { + const result = pending.then(() => method.apply(this, args)); + pending = result.catch(() => undefined); + return result; + }; +} diff --git a/back/shared/fileAccess.ts b/back/shared/fileAccess.ts new file mode 100644 index 00000000..2cda7e88 --- /dev/null +++ b/back/shared/fileAccess.ts @@ -0,0 +1,57 @@ +import fs from 'fs'; +import path from 'path'; + +function isWithin(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ); +} + +/** Resolve existing files and not-yet-created children without following an + * existing symlink outside the root. Blacklisted directories cover descendants. */ +export function resolveFileAccess( + root: string, + parts: string[], + blacklist: string[] = [], +): string { + if (parts.some((part) => typeof part !== 'string' || part.includes('\0'))) { + return ''; + } + const resolvedRoot = path.resolve(root); + const target = path.resolve(resolvedRoot, ...parts); + if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return ''; + const isBlocked = (relative: string) => + relative.split(path.sep).some((part) => blacklist.includes(part)); + if (isBlocked(path.relative(resolvedRoot, target))) return ''; + try { + const realRoot = fs.realpathSync(resolvedRoot); + let existing = target; + const missing: string[] = []; + while (!fs.existsSync(existing)) { + // existsSync is false for a dangling symlink; never treat one as absent. + try { + fs.lstatSync(existing); + return ''; + } catch (error: any) { + if (error.code !== 'ENOENT') return ''; + } + if (existing === resolvedRoot) return ''; + missing.unshift(path.basename(existing)); + existing = path.dirname(existing); + } + const realTarget = path.resolve(fs.realpathSync(existing), ...missing); + if ( + !isWithin(realRoot, realTarget) || + isBlocked(path.relative(realRoot, realTarget)) + ) { + return ''; + } + return target; + } catch { + return ''; + } +} diff --git a/back/shared/password.ts b/back/shared/password.ts new file mode 100644 index 00000000..f59150df --- /dev/null +++ b/back/shared/password.ts @@ -0,0 +1,30 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'crypto'; +import { promisify } from 'util'; + +const deriveKey = promisify(scrypt); +const HASH_PREFIX = 'scrypt$'; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const key = (await deriveKey(password, salt, 64)) as Buffer; + return `${HASH_PREFIX}${salt}$${key.toString('hex')}`; +} + +export function isPasswordHash(password: string): boolean { + return /^scrypt\$[a-f0-9]{32}\$[a-f0-9]{128}$/.test(password); +} + +export async function verifyPassword( + password: string, + stored: string, +): Promise { + if (!isPasswordHash(stored)) { + // Existing installations migrate after a successful password check. + const input = Buffer.from(password); + const expected = Buffer.from(stored); + return input.length === expected.length && timingSafeEqual(input, expected); + } + const [, salt, hash] = stored.split('$'); + const key = (await deriveKey(password, salt, 64)) as Buffer; + return timingSafeEqual(key, Buffer.from(hash, 'hex')); +} diff --git a/test/back/auth-security.test.cjs b/test/back/auth-security.test.cjs new file mode 100644 index 00000000..9c483f7f --- /dev/null +++ b/test/back/auth-security.test.cjs @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); +const { isValidToken } = load( + path.join(__dirname, '../../back/shared/auth.ts'), +); +const { hashPassword, verifyPassword } = load( + path.join(__dirname, '../../back/shared/password.ts'), +); +const secret = 'security-test-secret'; +const token = (payload = {}, options = {}) => + jwt.sign(payload, secret, { + algorithm: 'HS384', + expiresIn: '1h', + ...options, + }); +const info = (value) => ({ token: value, tokens: {} }); + +test('valid legacy and platform sessions retain compatibility', () => { + const value = token(); + for (const auth of [ + info(value), + { tokens: { desktop: value } }, + { tokens: { desktop: [{ value }] } }, + ]) { + assert.equal(isValidToken(auth, value, 'desktop', secret), true); + } + assert.equal( + isValidToken({ tokens: { mobile: value } }, value, 'desktop', secret), + false, + ); +}); + +test('session membership never bypasses JWT signature, expiry, algorithm or nbf', () => { + const values = [ + token({}, { expiresIn: -1 }), + token({}, { algorithm: 'HS256' }), + token({ nbf: Math.floor(Date.now() / 1000) + 60 }), + jwt.sign({}, 'wrong-secret', { algorithm: 'HS384', expiresIn: '1h' }), + jwt.sign({}, secret, { algorithm: 'HS384' }), + 'not-a-token', + ]; + for (const value of values) + assert.equal(isValidToken(info(value), value, 'desktop', secret), false); + assert.equal(isValidToken({ tokens: {} }, token(), 'desktop', secret), false); + assert.equal(isValidToken(null, token(), 'desktop', secret), false); + assert.equal(isValidToken(info(''), '', 'desktop', secret), false); +}); + +test('platform token metadata can shorten but cannot extend JWT expiration', () => { + const value = token(); + assert.equal( + isValidToken( + { tokens: { desktop: [{ value, expiration: 1 }] } }, + value, + 'desktop', + secret, + ), + false, + ); +}); + +test('passwords are salted and legacy plaintext can migrate without changing the password', async () => { + const first = await hashPassword('owner-password'); + const second = await hashPassword('owner-password'); + assert.notEqual(first, second); + assert.equal(await verifyPassword('owner-password', first), true); + assert.equal(await verifyPassword('wrong-password', first), false); + assert.equal(await verifyPassword(first, first), false); + assert.equal(await verifyPassword('old-password', 'old-password'), true); + assert.equal(await verifyPassword('wrong', 'old-password'), false); +}); diff --git a/test/back/file-access-security.test.cjs b/test/back/file-access-security.test.cjs new file mode 100644 index 00000000..0536ad38 --- /dev/null +++ b/test/back/file-access-security.test.cjs @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const { resolveFileAccess } = load( + path.join(__dirname, '../../back/shared/fileAccess.ts'), +); + +test('file access enforces directory boundaries, blacklist descendants and symlinks', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-file-security-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const root = path.join(tmp, 'config'); + fs.mkdirSync(path.join(root, 'grpc'), { recursive: true }); + fs.mkdirSync(path.join(tmp, 'config-other')); + fs.writeFileSync(path.join(root, 'normal.txt'), 'normal'); + fs.writeFileSync(path.join(root, 'grpc', 'client.key'), 'test-only'); + fs.symlinkSync(path.join(tmp, 'config-other'), path.join(root, 'outside')); + fs.symlinkSync(path.join(root, 'grpc'), path.join(root, 'alias')); + fs.symlinkSync(path.join(root, 'normal.txt'), path.join(root, 'safe-link')); + fs.symlinkSync(path.join(tmp, 'missing'), path.join(root, 'dangling')); + for (const input of [ + '../config-other/secret', + '/etc/passwd', + 'grpc/client.key', + 'alias/client.key', + 'outside/new.txt', + 'dangling', + 'auth.json', + 'nested/auth.json', + '', + ]) { + assert.equal( + resolveFileAccess(root, [input], ['grpc', 'auth.json']), + '', + input, + ); + } + for (const input of ['normal.txt', 'safe-link', 'new-dir/new.txt']) { + assert.equal( + resolveFileAccess(root, [input], ['grpc']), + path.join(root, input), + input, + ); + } +}); diff --git a/test/back/http-security.test.cjs b/test/back/http-security.test.cjs new file mode 100644 index 00000000..3c9b6b8f --- /dev/null +++ b/test/back/http-security.test.cjs @@ -0,0 +1,213 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); + +test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-')); + for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp']) + fs.mkdirSync(path.join(tmp, dir), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL'); + fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal'); + const secret = 'http-security-test'; + const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 }); + const auth = { + username: 'owner', + password: 'configured', + token: valid, + tokens: { desktop: [{ value: expired }] }, + }; + const configSource = fs.readFileSync( + path.join(__dirname, '../../back/config/index.ts'), + 'utf8', + ); + const whitelistSource = configSource + .slice(configSource.indexOf('apiWhiteList:')) + .match(/apiWhiteList:\s*\[([\s\S]*?)\]/)[1]; + const config = { + api: { prefix: '/api' }, + apiWhiteList: [...whitelistSource.matchAll(/['"]([^'"]+)['"]/g)].map( + (x) => x[1], + ), + jwt: { secret }, + rootPath: tmp, + configPath: path.join(tmp, 'config/'), + scriptPath: path.join(tmp, 'scripts/'), + uploadPath: path.join(tmp, 'upload'), + tmpPath: path.join(tmp, 'tmp'), + blackFileList: ['auth.json', 'grpc'], + baseUrl: '/panel', + }; + const apps = [ + { + scopes: ['configs'], + tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }], + }, + { + scopes: ['envs'], + tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }], + }, + ]; + let initialized = 0; + const User = class {}; + const user = { + initializeUser: async () => { + initialized++; + return { code: 200 }; + }, + getAuthInfo: async () => auth, + }; + const mocks = { + '../config': config, + '../config/util': { + getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''), + getPlatform: () => 'desktop', + getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'), + }, + '../shared/i18n': { t: (x) => x }, + '../shared/store': { + shareStore: { getAuthInfo: async () => auth, getApps: async () => apps }, + }, + '../config/serverEnv': { serveEnv: (_req, res) => res.end() }, + '../services/user': User, + '../data/open': {}, + '../data/system': {}, + '../shared/utils': { + writeFileWithLock: (p, content) => fs.promises.writeFile(p, content), + }, + }; + const Config = load(path.join(__dirname, '../../back/services/config.ts'), { + ...mocks, + typedi: { Service: () => (x) => x }, + }).default; + const configService = new Config(); + mocks['../services/config'] = Config; + mocks.typedi = { + Container: { get: (x) => (x === User ? user : configService) }, + }; + mocks['../api'] = () => { + const router = express.Router(); + load(path.join(__dirname, '../../back/api/user.ts'), mocks).default(router); + load(path.join(__dirname, '../../back/api/config.ts'), mocks).default( + router, + ); + router.get('/envs', (_req, res) => res.json({ code: 200 })); + return router; + }; + const app = express(); + load(path.join(__dirname, '../../back/loaders/express.ts'), mocks).default({ + app, + }); + const server = app.listen(0, '127.0.0.1'); + t.after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + server.on('listening', resolve); + server.on('error', reject); + }); + const request = async (url, token, method = 'GET', body) => { + const response = await fetch( + `http://127.0.0.1:${server.address().port}${url}`, + { + method, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'Content-Type': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }, + ); + return { status: response.status, body: await response.json() }; + }; + for (const url of [ + '/api/user/init', + '/open/user/init', + '/panel/api/user/init', + '/panel/open/user/init', + ]) { + const response = await request(url, undefined, 'PUT', { + username: 'attacker', + password: 'changed', + }); + assert.equal(response.body.code, 450, url); + } + assert.equal(initialized, 0); + assert.equal( + ( + await request('/api/user/login', undefined, 'POST', { + username: 'x', + password: 'x'.repeat(20000), + }) + ).status, + 413, + ); + fs.writeFileSync( + path.join(tmp, 'upload/legacy.html'), + '', + ); + const legacyUpload = await fetch( + `http://127.0.0.1:${server.address().port}/api/static/legacy.html`, + ); + assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff'); + assert.equal( + legacyUpload.headers.get('content-security-policy'), + "sandbox; default-src 'none'", + ); + for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) { + assert.equal( + (await request(`${prefix}/envs`, expired)).status, + 401, + prefix, + ); + assert.equal( + (await request(`${prefix}/envs`, valid)).body.code, + 200, + prefix, + ); + assert.equal((await request(`${prefix}/envs`)).status, 401, prefix); + } + assert.equal((await request('/open/envs', 'env-app')).body.code, 200); + assert.equal( + (await request('/open/configs/detail?path=normal.txt', 'env-app')).status, + 401, + ); + assert.equal( + (await request('/open/configs/detail?path=normal.txt', 'config-app')).body + .data, + 'normal', + ); + assert.equal( + (await request('/open/configs/detail?path=grpc/client.key', 'config-app')) + .body.code, + 403, + ); + assert.equal( + ( + await request('/open/configs/save', 'config-app', 'POST', { + name: 'grpc/client.key', + content: 'changed', + }) + ).body.code, + 403, + ); + assert.equal( + fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'), + 'SENTINEL', + ); + assert.equal( + ( + await request('/Api/user/init', undefined, 'PUT', { + username: 'x', + password: 'y', + }) + ).status, + 400, + ); +}); diff --git a/test/back/listen-security.test.cjs b/test/back/listen-security.test.cjs new file mode 100644 index 00000000..f21092ab --- /dev/null +++ b/test/back/listen-security.test.cjs @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const logger = { debug() {}, warn() {}, error() {} }; + +for (const host of ['127.0.0.1', '::1', '192.0.2.1', '::']) { + test(`HTTP binding ${host} never broadens an explicit private address`, async () => { + const Http = load(path.join(__dirname, '../../back/services/http.ts'), { + '../config': { bindHost: host }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + }).HttpServerService; + const instance = new Http(); + const attempted = []; + instance.tryListen = async (_app, _port, address) => { + attempted.push(address); + throw new Error('unavailable'); + }; + await assert.rejects(instance.initialize({}, 5700)); + assert.deepEqual(attempted, host === '::' ? ['::', '0.0.0.0'] : [host]); + }); + test(`gRPC binding ${host} never broadens an explicit private address`, async () => { + const attempted = []; + const Grpc = load(path.join(__dirname, '../../back/services/grpc.ts'), { + '../config': { bindHostGrpc: host, grpcPort: 5500 }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + '@grpc/grpc-js': { + Server: class { + addService() {} + bindAsync(address, _credentials, cb) { + attempted.push(address); + cb(new Error('unavailable')); + } + }, + ServerCredentials: { + createSsl(_ca, _certs, requireClientCert) { + assert.equal(requireClientCert, true); + return {}; + }, + }, + }, + '../protos/cron': { CronService: {} }, + '../protos/health': { HealthService: {} }, + '../protos/api': { ApiService: {} }, + '../schedule/addCron': {}, + '../schedule/delCron': {}, + '../schedule/health': {}, + '../schedule/api': {}, + '../config/grpcCerts': { + initGrpcCerts: async () => ({ + caCert: 'test', + serverCert: 'test', + serverKey: 'test', + }), + }, + }).GrpcServerService; + await assert.rejects(new Grpc().initialize()); + assert.deepEqual( + attempted, + host === '::' ? ['[::]:5500', '0.0.0.0:5500'] : [`${host}:5500`], + ); + }); +} diff --git a/test/back/sock-security.test.cjs b/test/back/sock-security.test.cjs new file mode 100644 index 00000000..e7ba59bf --- /dev/null +++ b/test/back/sock-security.test.cjs @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); + +test('WebSocket connections reject expired tokens and close when sessions are revoked', async () => { + const secret = 'sock-test'; + const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 }); + let auth = { token: valid, tokens: { desktop: [{ value: expired }] } }; + let onConnection; + const clients = new Set(); + const Sock = class {}; + const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), { + sockjs: { + createServer: () => ({ + on: (_event, fn) => { + onConnection = fn; + }, + installHandlers() {}, + }), + }, + typedi: { + Container: { + get: () => ({ + addClient: (c) => clients.add(c), + removeClient: (c) => clients.delete(c), + }), + }, + }, + '../services/sock': Sock, + '../config': { jwt: { secret }, baseUrl: '' }, + '../config/util': { getPlatform: () => 'desktop' }, + '../shared/store': { shareStore: { getAuthInfo: async () => auth } }, + }).default; + await loader({ server: {} }); + const connection = (token) => { + const c = new EventEmitter(); + Object.assign(c, { + headers: {}, + pathname: '/api/ws/a/b/websocket', + url: `/api/ws/a/b/websocket?token=${token}`, + close() { + this.closed = true; + this.emit('close'); + }, + write() {}, + }); + return c; + }; + const rejected = connection(expired); + await onConnection(rejected); + assert.equal(rejected.closed, true); + assert.equal(clients.size, 0); + const accepted = connection(valid); + await onConnection(accepted); + assert.equal(clients.size, 1); + auth = { token: '', tokens: {} }; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + accepted.close(); + reject(new Error('revoked socket remained open')); + }, 3000); + accepted.once('close', () => { + clearTimeout(timeout); + resolve(); + }); + }); + assert.equal(clients.size, 0); +}); diff --git a/test/back/user-api.test.cjs b/test/back/user-api.test.cjs index 42e6a69f..f0c275a6 100644 --- a/test/back/user-api.test.cjs +++ b/test/back/user-api.test.cjs @@ -46,7 +46,7 @@ test('initialization returns the username and password validation result', async paths: [], }; Container.get = () => ({ - updateUsernameAndPassword: async () => ({ + initializeUser: async () => ({ code: 400, message: 'password rejected', }), diff --git a/test/back/user-security.test.cjs b/test/back/user-security.test.cjs new file mode 100644 index 00000000..963aadcd --- /dev/null +++ b/test/back/user-security.test.cjs @@ -0,0 +1,248 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const { authenticator } = require('@otplib/preset-default'); +const load = require('../helpers/load-security-module.cjs'); +const { verifyPassword, isPasswordHash } = load( + path.join(__dirname, '../../back/shared/password.ts'), +); +const req = { + platform: 'desktop', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, +}; + +function fixture(initial) { + let auth = structuredClone(initial); + let closed = 0; + const model = { + findOne: async () => ({ id: 1 }), + update: async () => [1, [{}]], + create: async (x) => x, + findAll: async () => [], + destroy: async () => {}, + }; + const mocks = { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../config': { jwt: { secret: 'test-secret' }, maxTokensPerPlatform: 10 }, + '../data/system': { + AuthDataType: { authConfig: 'authConfig', loginLog: 'loginLog' }, + LoginStatus: { fail: 0, success: 1 }, + SystemModel: model, + }, + '../config/util': { + createRandomString: () => + require('node:crypto').randomBytes(32).toString('hex'), + }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + '../shared/store': { + shareStore: { + getAuthInfo: async () => auth, + updateAuthInfo: async (x) => { + auth = x; + }, + }, + }, + '../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) }, + '../shared/clientIp': { + getClientIp: () => '127.0.0.1', + normalizeClientIp: (x) => x, + }, + ip2region: class { + search() { + return null; + } + }, + }; + const User = load( + path.join(__dirname, '../../back/services/user.ts'), + mocks, + ).default; + const user = new User( + { warn() {}, info() {} }, + {}, + { getClients: () => [{ close: () => closed++ }] }, + ); + user.notificationService = { notify() {} }; + return { + user, + get auth() { + return auth; + }, + get closed() { + return closed; + }, + }; +} +const initialized = () => ({ + username: 'owner', + password: 'old-password', + token: 'stolen-token', + tokens: { desktop: [{ value: 'stolen-token' }] }, + retries: 0, + lastlogon: 0, +}); + +test('initialization checks state inside a serialized mutation', async () => { + const f = fixture({ username: 'admin', password: 'admin' }); + assert.equal( + (await f.user.login({ username: 'admin', password: 'admin' }, req)).code, + 450, + ); + assert.deepEqual(f.auth, { username: 'admin', password: 'admin' }); + const results = await Promise.all([ + f.user.initializeUser({ username: 'owner', password: 'first-password' }), + f.user.initializeUser({ + username: 'attacker', + password: 'second-password', + }), + ]); + assert.deepEqual( + results.map((x) => x.code), + [200, 450], + ); + assert.equal(f.auth.username, 'owner'); + assert.equal(await verifyPassword('first-password', f.auth.password), true); + assert.equal( + ( + await f.user.initializeUser({ + username: 'attacker', + password: 'third-password', + }) + ).code, + 450, + ); +}); + +test('password change revokes sessions and closes connected clients', async () => { + const f = fixture(initialized()); + await f.user.updateUsernameAndPassword({ + username: 'owner', + password: 'new-password', + }); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(f.closed, 1); + assert.equal(await verifyPassword('new-password', f.auth.password), true); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 400, + ); + const login = await f.user.login( + { username: 'owner', password: 'new-password' }, + req, + ); + assert.equal(login.code, 200); + jwt.verify(login.data.token, 'test-secret', { algorithms: ['HS384'] }); +}); + +test('a concurrent old-password login cannot restore a session after reset', async () => { + const f = fixture(initialized()); + const results = await Promise.all([ + f.user.login({ username: 'owner', password: 'old-password' }, req), + f.user.resetAuthInfo({ password: 'new-password' }), + ]); + assert.equal(results[0].code, 200); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(await verifyPassword('new-password', f.auth.password), true); +}); + +test('legacy plaintext migrates after a successful login', async () => { + const f = fixture(initialized()); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 200, + ); + assert.equal(isPasswordHash(f.auth.password), true); + assert.equal(await verifyPassword('old-password', f.auth.password), true); +}); + +test('TOTP failures are counted serially and further attempts are throttled', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 420, + ); + const badCode = + authenticator.generate(secret) === '000000' ? '111111' : '000000'; + const results = await Promise.all( + Array.from({ length: 120 }, () => + f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: badCode }, + req, + ), + ), + ); + assert.equal(f.auth.retries, 3); + assert.equal(results.filter((x) => x.code === 430).length, 3); + assert.equal(results.filter((x) => x.code === 410).length, 117); +}); + +test('TOTP challenge expires and valid codes cannot be reused in the same step', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + f.auth.twoFactorExpiresAt = Date.now() - 1; + const payload = { + username: 'owner', + password: 'old-password', + code: authenticator.generate(secret), + }; + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 450); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 200); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 430); +}); + +test('active two-factor secret cannot be silently replaced and disabling revokes sessions', async () => { + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: authenticator.generateSecret(), + }); + await assert.rejects(f.user.initTwoFactor()); + await f.user.deactivateTwoFactor(); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(f.auth.twoFactorSecret, ''); +}); + +test('default credentials with historical metadata still require initialization', async () => { + const f = fixture({ + username: 'admin', + password: 'admin', + retries: 1, + token: 'old-default-session', + }); + assert.equal( + (await f.user.login({ username: 'admin', password: 'admin' }, req)).code, + 450, + ); + assert.equal( + ( + await f.user.initializeUser({ + username: 'owner', + password: 'new-password', + }) + ).code, + 200, + ); + assert.equal(f.auth.token, ''); +}); diff --git a/test/helpers/load-security-module.cjs b/test/helpers/load-security-module.cjs new file mode 100644 index 00000000..fd286ab6 --- /dev/null +++ b/test/helpers/load-security-module.cjs @@ -0,0 +1,42 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const ts = require('typescript'); + +module.exports = function loadSecurityModule( + file, + mocks = {}, + cache = new Map(), +) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const module = { exports: {} }; + cache.set(file, module); + const localRequire = createRequire(file); + const requireModule = (name) => { + if (Object.hasOwn(mocks, name)) return mocks[name]; + if (name.startsWith('.')) { + const target = path.resolve(path.dirname(file), name); + if (fs.existsSync(`${target}.ts`)) { + return loadSecurityModule(`${target}.ts`, mocks, cache); + } + } + return localRequire(name); + }; + const { outputText } = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + experimentalDecorators: true, + esModuleInterop: true, + }, + }); + // Use the host realm: express-unless checks RegExp with instanceof. + new Function('require', 'module', 'exports', '__dirname', outputText)( + requireModule, + module, + module.exports, + path.dirname(file), + ); + return module.exports; +}; From d62d8f3025fa90a143f303a9e2580c3cee80199a Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 12 Sep 2026 03:03:25 +0800 Subject: [PATCH 087/123] fix: support Chinese cron log names (#3068) --- back/shared/i18n.ts | 4 +- back/validation/schedule.ts | 4 +- src/locales/en-US.json | 2 +- src/locales/zh-CN.json | 2 +- src/pages/crontab/modal.tsx | 6 +- test/back/cron-log-name.test.cjs | 110 +++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 test/back/cron-log-name.test.cjs diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 561bc1d9..1afa6e88 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -94,8 +94,8 @@ const messages: Record> = { '日志设置为忽略': 'Log set to ignore', '定时规则不能为空': 'Schedule rule cannot be empty', '无效的定时规则': 'Invalid schedule rule', - '日志名称只能包含字母、数字、下划线和连字符': - 'Log name can only contain letters, numbers, underscores, and hyphens', + '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符': + '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', '该 IP 已被列入黑名单': 'This IP address has been blocked', diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index 0e1df1b7..ac0632c5 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -75,7 +75,7 @@ export const commonCronSchema = { } if ( - !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( + !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test( value, ) ) { @@ -87,7 +87,7 @@ export const commonCronSchema = { return value; }) .messages({ - 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', + 'string.pattern.base': '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符', 'string.max': '日志名称不能超过100个字符', 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', }), diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 5d6d11e7..dd38d0b6 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -309,7 +309,7 @@ "日志删除频率": "Log Deletion Frequency", "日志名称": "Log Name", "日志名称不能超过100个字符": "Log name cannot exceed 100 characters", - "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", + "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators", "日志文件": "Log files", "日志管理": "Log Management", "时": "hour(s)", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index b0493a74..667d9b9d 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -308,7 +308,7 @@ "日志删除频率": "日志删除频率", "日志名称": "日志名称", "日志名称不能超过100个字符": "日志名称不能超过100个字符", - "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", + "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符", "日志文件": "日志文件", "日志管理": "日志管理", "时": "时", diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index 3313f458..cf9f9582 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -212,12 +212,14 @@ const CronModal = ({ return Promise.reject(intl.get('日志名称不能超过100个字符')); } if ( - !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( + !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test( value, ) ) { return Promise.reject( - intl.get('日志名称只能包含字母、数字、下划线和连字符'), + intl.get( + '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符', + ), ); } return Promise.resolve(); diff --git a/test/back/cron-log-name.test.cjs b/test/back/cron-log-name.test.cjs new file mode 100644 index 00000000..2c7c721b --- /dev/null +++ b/test/back/cron-log-name.test.cjs @@ -0,0 +1,110 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const ts = require('typescript'); +const loadModule = require('../helpers/load-security-module.cjs'); + +const { commonCronSchema } = loadModule( + path.join(__dirname, '../../back/validation/schedule.ts'), + { '../config': { logPath: '/ql/data/log/' } }, +); + +// Execute the actual form validator without mounting the entire task modal. +const source = ts.createSourceFile( + 'modal.tsx', + fs.readFileSync( + path.join(__dirname, '../../src/pages/crontab/modal.tsx'), + 'utf8', + ), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +let validateForm; +function visit(node) { + if ( + ts.isJsxOpeningElement(node) && + node.attributes.properties.some( + (attr) => + ts.isJsxAttribute(attr) && + attr.name.text === 'name' && + attr.initializer?.text === 'log_name', + ) + ) { + const rules = node.attributes.properties.find( + (attr) => ts.isJsxAttribute(attr) && attr.name.text === 'rules', + ); + const validator = rules.initializer.expression.elements[0].properties.find( + (property) => property.name.text === 'validator', + ).initializer; + const { outputText } = ts.transpileModule( + `const validate = ${validator.getText(source)};`, + { compilerOptions: { target: ts.ScriptTarget.ES2017 } }, + ); + validateForm = new Function('intl', `${outputText}\nreturn validate;`)({ + get: (key) => key, + }); + } + ts.forEachChild(node, visit); +} +visit(source); +assert.equal(typeof validateForm, 'function'); + +test('form and API accept Chinese log names and existing supported names', async () => { + for (const value of [ + '', + null, + undefined, + '测试', + '任务_測試-2026.log', + '分组/每日签到/', + '𠮷/扩展汉字', + 'legacy_name-123.log', + 'legacy/path/', + '/ql/data/log/中文日志', + '/dev/null', + '中'.repeat(100), + ]) { + await validateForm(undefined, value); + assert.equal( + commonCronSchema.log_name.validate(value).error, + undefined, + value, + ); + } +}); + +test('form and API still reject unsafe relative names and excessive length', async () => { + for (const value of [ + '.', + '..', + '../测试', + '测试/../日志', + '测试/./日志', + '测试//日志', + '测试\\日志', + '测试 日志', + '测试;echo', + '测试$(id)', + '测试`id`', + '测试\n日志', + '测试\0日志', + '测试😀', + '中'.repeat(101), + ]) { + await assert.rejects(validateForm(undefined, value), undefined, value); + assert.ok(commonCronSchema.log_name.validate(value).error, value); + } +}); + +test('API restricts absolute log paths to the configured directory or /dev/null', () => { + for (const value of [ + '/tmp/测试', + '/ql/data/log/../测试', + '/ql/data/log-other/测试', + ]) { + const { error } = commonCronSchema.log_name.validate(value); + assert.equal(error.details[0].type, 'string.unsafePath', value); + } +}); From a94e66505495c7a36a6e17e2fe85d9283990af85 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 13 Sep 2026 00:32:41 +0800 Subject: [PATCH 088/123] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=B0=B1=E7=BB=AA=EF=BC=8C=E4=BC=98=E5=8C=96=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=92=8C=E6=9E=84=E5=BB=BA=E5=BC=80=E9=94=80=20(#3069?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: harden task lifecycle and scheduler readiness * fix: confine log writes to the configured log directory * fix: verify complete build artifacts and untracked inputs * fix: reconcile scheduler state and make stop win startup races * fix: isolate cron generations and serialize scheduler recovery --- .dockerignore | 13 + .github/workflows/build-docker-image.yml | 42 +- .github/workflows/validate.yml | 31 + back/api/cron.ts | 14 + back/api/health.ts | 4 +- back/api/system.ts | 7 +- back/app.ts | 29 +- back/config/util.ts | 56 +- back/data/cron.ts | 2 + back/loaders/db.ts | 38 +- back/loaders/initData.ts | 4 +- back/protos/cron.proto | 5 +- back/protos/cron.ts | 25 +- back/schedule/addCron.ts | 9 + back/schedule/client.ts | 72 ++- back/schedule/health.ts | 3 + back/services/cron.ts | 644 ++++++++++++-------- back/services/health.ts | 6 +- back/services/http.ts | 8 +- back/services/schedule.ts | 147 +++-- back/services/subscription.ts | 84 +-- back/shared/childProcess.ts | 78 +++ back/shared/logStreamManager.ts | 160 ++--- back/shared/runCron.ts | 69 ++- back/shared/schedulerMutationLock.ts | 38 ++ back/shared/schedulerReadiness.ts | 77 +++ back/shared/schemaMigrations.ts | 70 +++ docker/Dockerfile | 16 +- docker/Dockerfile.310 | 16 +- docker/Dockerfile.debian | 24 +- docker/Dockerfile.debian310 | 24 +- docker/build-manifest.cjs | 28 + docker/verify-build.cjs | 49 ++ ecosystem.config.js | 3 + package.json | 3 + scripts/benchmark-execution.cjs | 96 +++ scripts/write-build-info.cjs | 27 + shell/api.sh | 47 +- shell/node_path_cache.sh | 118 ++++ shell/otask.sh | 8 +- shell/share.sh | 24 +- shell/task.sh | 16 +- test/back/build-provenance.test.cjs | 150 +++++ test/back/env-name-parsing.test.cjs | 96 +++ test/back/execution-lifecycle.test.cjs | 236 +++++++ test/back/http-exclusive-listen.test.cjs | 31 + test/back/log-path-security.test.cjs | 158 +++++ test/back/manual-execution.test.cjs | 85 +++ test/back/manual-stop-claim.test.cjs | 215 +++++++ test/back/node-path-cache.test.cjs | 173 ++++++ test/back/node-path-lock.test.cjs | 178 ++++++ test/back/primary-apm.test.cjs | 24 + test/back/scheduler-mutation.test.cjs | 274 +++++++++ test/back/scheduler-readiness.test.cjs | 85 +++ test/back/scheduler-reconciliation.test.cjs | 152 +++++ test/back/schema-migrations.test.cjs | 127 ++++ test/back/shell-api-parsing.test.cjs | 184 ++++++ test/back/stop-race.test.cjs | 167 +++++ test/back/subscription-cleanup.test.cjs | 53 ++ test/back/task-time.test.cjs | 116 ++++ test/back/worker-apm.test.cjs | 84 +++ 61 files changed, 4214 insertions(+), 608 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/validate.yml create mode 100644 back/shared/childProcess.ts create mode 100644 back/shared/schedulerMutationLock.ts create mode 100644 back/shared/schedulerReadiness.ts create mode 100644 back/shared/schemaMigrations.ts create mode 100644 docker/build-manifest.cjs create mode 100644 docker/verify-build.cjs create mode 100644 scripts/benchmark-execution.cjs create mode 100644 scripts/write-build-info.cjs create mode 100644 shell/node_path_cache.sh create mode 100644 test/back/build-provenance.test.cjs create mode 100644 test/back/env-name-parsing.test.cjs create mode 100644 test/back/execution-lifecycle.test.cjs create mode 100644 test/back/http-exclusive-listen.test.cjs create mode 100644 test/back/log-path-security.test.cjs create mode 100644 test/back/manual-execution.test.cjs create mode 100644 test/back/manual-stop-claim.test.cjs create mode 100644 test/back/node-path-cache.test.cjs create mode 100644 test/back/node-path-lock.test.cjs create mode 100644 test/back/primary-apm.test.cjs create mode 100644 test/back/scheduler-mutation.test.cjs create mode 100644 test/back/scheduler-readiness.test.cjs create mode 100644 test/back/scheduler-reconciliation.test.cjs create mode 100644 test/back/schema-migrations.test.cjs create mode 100644 test/back/shell-api-parsing.test.cjs create mode 100644 test/back/stop-race.test.cjs create mode 100644 test/back/subscription-cleanup.test.cjs create mode 100644 test/back/task-time.test.cjs create mode 100644 test/back/worker-apm.test.cjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1f28245d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitnexus +.claude +.codex +.env* +node_modules +data +audit +docs +test +src/.umi* +**/__pycache__ +**/*.log diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index e00a3c54..7fbe1dcb 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -15,6 +15,9 @@ permissions: contents: read jobs: + validate: + uses: ./.github/workflows/validate.yml + code_gitlab: runs-on: ubuntu-latest steps: @@ -63,6 +66,7 @@ jobs: git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed" build-static: + needs: validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -72,6 +76,7 @@ jobs: - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -80,6 +85,14 @@ jobs: pnpm install --frozen-lockfile pnpm build:front pnpm build:back + pnpm build:info + + - uses: actions/upload-artifact@v6 + with: + name: qinglong-static-${{ github.sha }} + path: static/ + if-no-files-found: error + include-hidden-files: true - name: copy to static repo env: @@ -151,11 +164,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -208,7 +226,7 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host @@ -232,11 +250,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -288,7 +311,7 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host @@ -313,11 +336,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -355,7 +383,7 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host @@ -380,11 +408,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -422,7 +455,7 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host @@ -451,6 +484,7 @@ jobs: - uses: actions/setup-node@v3 with: + node-version: "20" cache: "pnpm" - name: build front and back diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..e6cfeb9f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,31 @@ +name: Validate + +on: + pull_request: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + regression: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: cp .env.example .env + - run: pnpm test + - run: pnpm build:back + - run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json + - uses: actions/upload-artifact@v6 + with: + name: execution-baseline + path: execution-baseline.json diff --git a/back/api/cron.ts b/back/api/cron.ts index abd7f236..a13114ee 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -10,12 +10,26 @@ import { InstanceStatus, } from '../data/runningInstance'; import { t } from '../shared/i18n'; +import cronClient from '../schedule/client'; const route = Router(); export default (app: Router) => { app.use('/crons', route); + route.use(async (req, res, next) => { + // Keep stop/status callbacks available even when the scheduler is down. + if (['POST', 'PUT', 'DELETE'].includes(req.method) && + ['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) { + try { + await cronClient.readiness.ensureReady(); + } catch (error) { + return next(error); + } + } + return next(); + }); + route.get( '/views', async (req: Request, res: Response, next: NextFunction) => { diff --git a/back/api/health.ts b/back/api/health.ts index bb961fa1..738e8ec1 100644 --- a/back/api/health.ts +++ b/back/api/health.ts @@ -11,8 +11,8 @@ export default (app: Router) => { try { const healthService = Container.get(HealthService); const health = await healthService.check(); - res.status(200).send({ - code: 200, + res.status(health.status === 'ok' ? 200 : 503).send({ + code: health.status === 'ok' ? 200 : 503, data: health, }); } catch (err: any) { diff --git a/back/api/system.ts b/back/api/system.ts index 8b3829ce..831725a6 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -273,8 +273,11 @@ export default (app: Router) => { }, onEnd: async (cp, endTime, diff) => { // Close the stream after task completion - await logStreamManager.closeStream(await handleLogPath(logPath)); - res.end(); + try { + await logStreamManager.closeStream(await handleLogPath(logPath)); + } finally { + res.end(); + } }, onError: async (message: string) => { res.write(message); diff --git a/back/app.ts b/back/app.ts index 96df1a80..693e34e1 100644 --- a/back/app.ts +++ b/back/app.ts @@ -11,6 +11,7 @@ import { monitoringMiddleware } from './middlewares/monitoring'; import { errStack } from './config/util'; import { type GrpcServerService } from './services/grpc'; import { type HttpServerService } from './services/http'; +import cronClient from './schedule/client'; interface WorkerMetadata { id: number; @@ -79,6 +80,11 @@ class Application { ); // If gRPC worker died, restart it and wait for it to be ready if (metadata.serviceType === 'grpc') { + try { + this.httpWorker?.send('scheduler-unavailable'); + } catch (error) { + Logger.warn('Unable to notify HTTP worker of scheduler exit'); + } const newGrpcWorker = this.forkWorker('grpc'); this.waitForWorkerReady(newGrpcWorker, 30000) .then(() => { @@ -132,7 +138,14 @@ class Application { } private forkWorker(serviceType: string): Worker { - const worker = cluster.fork({ SERVICE_TYPE: serviceType }); + const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType }; + // PM2's fork launcher is inherited by our own cluster workers. Their APM + // messages go to this primary, not PM2, and duplicate its sampling work. + // Keep primary monitoring and allow restoring the inherited worker APM. + if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') { + workerEnv.pmx = 'false'; + } + const worker = cluster.fork(workerEnv); this.workerMetadataMap.set(worker.id, { id: worker.id, @@ -264,17 +277,9 @@ class Application { process.on('message', async (msg) => { if (msg === 'shutdown') { this.gracefulShutdown(serviceType); - } else if (msg === 'reregister-crons' && serviceType === 'http') { - // Re-register cron jobs when gRPC worker restarts - try { - Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...'); - const CronService = (await import('./services/cron')).default; - const cronService = Container.get(CronService); - await cronService.autosave_crontab(); - Logger.info('[boot] Cron jobs re-registered successfully'); - } catch (error) { - Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`); - } + } else if (serviceType === 'http' && + (msg === 'reregister-crons' || msg === 'scheduler-unavailable')) { + cronClient.readiness.invalidate(); } }); diff --git a/back/config/util.ts b/back/config/util.ts index 649f97a1..e6390ea9 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -12,6 +12,7 @@ import { DependenceTypes } from '../data/dependence'; import { FormData } from 'undici'; import os from 'os'; import { maybeSudo, isInContainer } from './container'; +import { resolveFileAccess } from '../shared/fileAccess'; export * from './share'; @@ -144,7 +145,8 @@ export async function handleLogPath( logPath: string, data: string = '', ): Promise { - const absolutePath = path.resolve(config.logPath, logPath); + const absolutePath = resolveFileAccess(config.logPath, [logPath]); + if (!absolutePath) throw new Error('Log path is outside the log directory'); const logFileExist = await fileExist(absolutePath); if (!logFileExist) { await createFile(absolutePath, data); @@ -487,18 +489,48 @@ export function psTree(pid: number): Promise { }); } -export async function killTask(pid: number) { - const pids = await psTree(pid); - - if (pids.length) { - try { - [pid, ...pids].reverse().forEach((x) => { - process.kill(x, 15); - }); - } catch (error) { } - } else { - process.kill(pid, 2); +export async function killTask(pid: number, waitForExit = false) { + const descendants = await psTree(pid); + if (!waitForExit) { + if (descendants.length) { + try { + [pid, ...descendants] + .reverse() + .forEach((target) => process.kill(target, 15)); + } catch {} + } else process.kill(pid, 2); + return; } + const pids = [...descendants.reverse(), pid]; + const signal = (target: number, sig: NodeJS.Signals) => { + try { + process.kill(target, sig); + } catch (error: any) { + if (error.code !== 'ESRCH') throw error; + } + }; + for (const target of pids) signal(target, 'SIGTERM'); + const alive = (target: number) => { + try { + process.kill(target, 0); + return true; + } catch (error: any) { + if (error.code === 'ESRCH') return false; + throw error; + } + }; + const wait = async () => { + const deadline = Date.now() + 1000; + while (pids.some(alive) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return pids.filter(alive); + }; + let remaining = await wait(); + for (const target of remaining) signal(target, 'SIGKILL'); + remaining = await wait(); + if (remaining.length) + throw new Error(`Task processes did not exit: ${remaining.join(', ')}`); } export async function getPid(cmd: string) { diff --git a/back/data/cron.ts b/back/data/cron.ts index 32b0b554..c0c0ad0e 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -13,6 +13,7 @@ export class Crontab { pid?: number; isDisabled?: 1 | 0; log_path?: string; + queued_token?: string | null; isPinned?: 1 | 0; labels?: string[]; last_running_time?: number; @@ -83,6 +84,7 @@ export const CrontabModel = sequelize.define('Crontab', { isDisabled: DataTypes.NUMBER, isPinned: DataTypes.NUMBER, log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, labels: DataTypes.JSON, last_running_time: DataTypes.NUMBER, last_execution_time: DataTypes.NUMBER, diff --git a/back/loaders/db.ts b/back/loaders/db.ts index 9a70d91a..44a9c076 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -9,6 +9,7 @@ import { CrontabViewModel } from '../data/cronView'; import { CrontabStatModel } from '../data/cronStats'; import { RunningInstanceModel } from '../data/runningInstance'; import { sequelize } from '../data'; +import { migrateSchema } from '../shared/schemaMigrations'; export default async () => { try { @@ -22,44 +23,11 @@ export default async () => { await CrontabStatModel.sync(); await RunningInstanceModel.sync(); - // 初始化新增字段 - const migrations = [ - { - table: 'CrontabViews', - column: 'filterRelation', - type: 'VARCHAR(255)', - }, - { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, - { table: 'CrontabViews', column: 'type', type: 'NUMBER' }, - { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, - { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, - { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, - { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, - { table: 'Crontabs', column: 'task_before', type: 'TEXT' }, - { table: 'Crontabs', column: 'task_after', type: 'TEXT' }, - { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, - { - table: 'Crontabs', - column: 'allow_multiple_instances', - type: 'NUMBER', - }, - { table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' }, - { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, - { table: 'Envs', column: 'labels', type: 'JSON' }, - ]; - - for (const migration of migrations) { - try { - await sequelize.query( - `alter table ${migration.table} add column ${migration.column} ${migration.type}`, - ); - } catch (error) { - // Column already exists or other error, continue - } - } + await migrateSchema(sequelize); Logger.info('[boot] DB loaded'); } catch (error) { Logger.error('[boot] DB load failed', error); + throw error; } }; diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 7662ae87..8854841a 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -17,6 +17,7 @@ import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../conf import OpenService from '../services/open'; import { shareStore } from '../shared/store'; import Logger from './logger'; +import cronClient from '../schedule/client'; import { AppModel } from '../data/open'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; import { setLang, systemLang } from '../shared/i18n'; @@ -236,7 +237,8 @@ export default async () => { } catch { } // 初始化保存一次ck和定时任务数据 - await cronService.autosave_crontab(); + cronClient.readiness.configure(() => cronService.autosave_crontab(true)); + await cronClient.readiness.recover(); await envService.set_envs(); diff --git a/back/protos/cron.proto b/back/protos/cron.proto index e88d6b1d..505eabe6 100644 --- a/back/protos/cron.proto +++ b/back/protos/cron.proto @@ -17,7 +17,10 @@ message ICron { string name = 5; } -message AddCronRequest { repeated ICron crons = 1; } +message AddCronRequest { + repeated ICron crons = 1; + bool replace = 2; +} message AddCronResponse {} diff --git a/back/protos/cron.ts b/back/protos/cron.ts index 82d1c44c..75832810 100644 --- a/back/protos/cron.ts +++ b/back/protos/cron.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.21.12 +// protoc v3.17.3 // source: back/protos/cron.proto /* eslint-disable */ @@ -35,6 +35,7 @@ export interface ICron { export interface AddCronRequest { crons: ICron[]; + replace: boolean; } export interface AddCronResponse { @@ -232,7 +233,7 @@ export const ICron: MessageFns = { }; function createBaseAddCronRequest(): AddCronRequest { - return { crons: [] }; + return { crons: [], replace: false }; } export const AddCronRequest: MessageFns = { @@ -240,6 +241,9 @@ export const AddCronRequest: MessageFns = { for (const v of message.crons) { ICron.encode(v!, writer.uint32(10).fork()).join(); } + if (message.replace !== false) { + writer.uint32(16).bool(message.replace); + } return writer; }, @@ -258,6 +262,14 @@ export const AddCronRequest: MessageFns = { message.crons.push(ICron.decode(reader, reader.uint32())); continue; } + case 2: { + if (tag !== 16) { + break; + } + + message.replace = reader.bool(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -268,7 +280,10 @@ export const AddCronRequest: MessageFns = { }, fromJSON(object: any): AddCronRequest { - return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] }; + return { + crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [], + replace: isSet(object.replace) ? globalThis.Boolean(object.replace) : false, + }; }, toJSON(message: AddCronRequest): unknown { @@ -276,6 +291,9 @@ export const AddCronRequest: MessageFns = { if (message.crons?.length) { obj.crons = message.crons.map((e) => ICron.toJSON(e)); } + if (message.replace !== false) { + obj.replace = message.replace; + } return obj; }, @@ -285,6 +303,7 @@ export const AddCronRequest: MessageFns = { fromPartial, I>>(object: I): AddCronRequest { const message = createBaseAddCronRequest(); message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || []; + message.replace = object.replace ?? false; return message; }, }; diff --git a/back/schedule/addCron.ts b/back/schedule/addCron.ts index d651de10..d3c2fdcf 100644 --- a/back/schedule/addCron.ts +++ b/back/schedule/addCron.ts @@ -69,6 +69,15 @@ const addCron = ( return; } + // Recovery replaces the whole snapshot, including deletions and disabled jobs. + // Validation above must finish before touching the previous schedule. + if (call.request.replace) { + for (const jobs of scheduleStacks.values()) { + for (const job of jobs) job?.cancel(); + } + scheduleStacks.clear(); + } + // ===== 第二遍:注册所有任务 ===== for (const item of call.request.crons) { const { id, schedule, command, extra_schedules, name } = item; diff --git a/back/schedule/client.ts b/back/schedule/client.ts index 30daacf9..3802288e 100644 --- a/back/schedule/client.ts +++ b/back/schedule/client.ts @@ -1,4 +1,4 @@ -import { credentials } from '@grpc/grpc-js'; +import { credentials, status, Metadata } from '@grpc/grpc-js'; import { AddCronRequest, AddCronResponse, @@ -9,7 +9,41 @@ import { import config from '../config'; import { getGrpcCerts } from '../config/grpcCerts'; +import { HealthService } from '../protos/health'; +import { SchedulerReadiness } from '../shared/schedulerReadiness'; + class Client { + readonly readiness = new SchedulerReadiness(() => this.probe()); + + private async waitForReady(timeoutMs: number) { + try { + await new Promise((resolve, reject) => { + this.client.waitForReady(Date.now() + timeoutMs, (err) => + err ? reject(err) : resolve() + ); + }); + } catch (error) { + this.readiness.invalidate(); + throw Object.assign( + error instanceof Error ? error : new Error(String(error)), + { status: 503 } + ); + } + } + + private async probe(): Promise { + await this.waitForReady(1000); + await new Promise((resolve, reject) => { + this.client.makeUnaryRequest( + HealthService.check.path, + HealthService.check.requestSerialize, + HealthService.check.responseDeserialize, + { service: 'scheduler' }, + { deadline: Date.now() + 1000 }, + (err, res) => err ? reject(err) : res?.status === 1 ? resolve() : reject(new Error('Scheduler unavailable')), + ); + }); + } private _client: CronClient | null = null; private get client(): CronClient { @@ -28,22 +62,40 @@ class Client { return this._client; } - addCron(request: AddCronRequest['crons']): Promise { + async addCron( + request: AddCronRequest['crons'], + replace = false + ): Promise { + await this.waitForReady(2000); return new Promise((resolve, reject) => { - this.client.addCron({ crons: request }, (err, res) => { - if (err) { - reject(err); + this.client.addCron( + { crons: request, replace }, + new Metadata(), + { deadline: Date.now() + 5000 }, + (err, res) => { + if (err) { + if (err.code === status.UNAVAILABLE) { + this.readiness.invalidate(); + Object.assign(err, { status: 503 }); + } + return reject(err); + } + resolve(res); } - resolve(res); - }); + ); }); } - delCron(request: DeleteCronRequest['ids']): Promise { + async delCron(request: DeleteCronRequest['ids']): Promise { + await this.waitForReady(2000); return new Promise((resolve, reject) => { - this.client.delCron({ ids: request }, (err, res) => { + this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => { if (err) { - reject(err); + if (err.code === status.UNAVAILABLE) { + this.readiness.invalidate(); + Object.assign(err, { status: 503 }); + } + return reject(err); } resolve(res); }); diff --git a/back/schedule/health.ts b/back/schedule/health.ts index 04068c67..2184f775 100644 --- a/back/schedule/health.ts +++ b/back/schedule/health.ts @@ -52,6 +52,9 @@ const check = async ( callback: sendUnaryData, ) => { switch (call.request.service) { + // Local scheduler liveness only: never call HTTP from this probe. + case 'scheduler': + return callback(null, { status: 1 }); case 'cron': { const healthUrl = `http://localhost:${config.port}${ config.baseUrl || '' diff --git a/back/services/cron.ts b/back/services/cron.ts index e28e710c..a20f0966 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -1,3 +1,8 @@ +import { randomUUID } from 'crypto'; +import { + withSchedulerMutation, + schedulerRegistrationError, +} from '../shared/schedulerMutationLock'; import { Service, Inject } from 'typedi'; import winston from 'winston'; import config from '../config'; @@ -13,7 +18,6 @@ import { getFileContentByName, fileExist, killTask, - killAllTasks, getUniqPath, safeJSONParse, isDemoEnv, @@ -31,8 +35,10 @@ import { writeFileWithLock } from '../shared/utils'; import { t } from '../shared/i18n'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; +import { observeChildProcess, asError } from '../shared/childProcess'; import { isEmpty } from 'lodash'; import { LogReadOptions, readLogChunk } from '../shared/logReader'; +import { resolveFileAccess } from '../shared/fileAccess'; @Service() export default class CronService { @@ -97,42 +103,45 @@ export default class CronService { } public async create(payload: Crontab): Promise { - const tab = new Crontab(payload); - tab.saved = false; - tab.log_name = await this.getLogName(tab); - const doc = await this.insert(tab); + return withSchedulerMutation(async () => { + const tab = new Crontab(payload); + tab.saved = false; + tab.log_name = await this.getLogName(tab); + const doc = await this.insert(tab); - if (isDemoEnv()) { - return doc; - } - - if (this.shouldUseCronClient(doc)) { - try { - await cronClient.addCron([ - { - name: doc.name || '', - id: String(doc.id), - schedule: doc.schedule!, - command: this.makeCommand(doc), - extra_schedules: doc.extra_schedules || [], - }, - ]); - } catch (error: any) { - // gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务" - // (DB 和 crontab.list 有记录但调度器永远不会执行) - await CrontabModel.destroy({ where: { id: doc.id } }); - this.logger.error( - '[crontab] Failed to register cron job in scheduler, task creation rolled back:', - error?.message || error, - ); - throw new Error( - `${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`, - ); + if (isDemoEnv()) { + return doc; } - } - await this.setCrontab(); - return doc; + if (this.shouldUseCronClient(doc)) { + try { + await cronClient.addCron([ + { + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + }, + ]); + } catch (error: any) { + // gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务" + // (DB 和 crontab.list 有记录但调度器永远不会执行) + await CrontabModel.destroy({ where: { id: doc.id } }); + this.logger.error( + '[crontab] Failed to register cron job in scheduler, task creation rolled back:', + error?.message || error, + ); + throw schedulerRegistrationError( + `${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`, + error, + ); + } + } + + await this.setCrontab(); + return doc; + }); } public async insert(payload: Crontab): Promise { @@ -140,69 +149,74 @@ export default class CronService { } public async update(payload: Partial): Promise { - const doc = await this.getDb({ id: payload.id }); - const tab = new Crontab({ ...doc, ...payload }); - tab.saved = false; - tab.log_name = await this.getLogName(tab); - const newDoc = await this.updateDb(tab); + return withSchedulerMutation(async () => { + const doc = await this.getDb({ id: payload.id }); + const tab = new Crontab({ ...doc, ...payload }); + tab.saved = false; + tab.log_name = await this.getLogName(tab); + const newDoc = await this.updateDb(tab); - if (doc.isDisabled === 1 || isDemoEnv()) { - return newDoc; - } + if (doc.isDisabled === 1 || isDemoEnv()) { + return newDoc; + } - try { - await cronClient.delCron([String(newDoc.id)]); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to unregister cron job in scheduler:', - error?.message || error, - ); - } - - if (this.shouldUseCronClient(newDoc)) { try { - await cronClient.addCron([ - { - name: doc.name || '', - id: String(newDoc.id), - schedule: newDoc.schedule!, - command: this.makeCommand(newDoc), - extra_schedules: newDoc.extra_schedules || [], - }, - ]); + await cronClient.delCron([String(newDoc.id)]); } catch (error: any) { - // gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册 - await CrontabModel.update(doc, { where: { id: doc.id } }); - if (this.shouldUseCronClient(doc)) { - try { - await cronClient.addCron([ - { - name: doc.name || '', - id: String(doc.id), - schedule: doc.schedule!, - command: this.makeCommand(doc), - extra_schedules: doc.extra_schedules || [], - }, - ]); - } catch (_recoveryError: any) { - this.logger.warn( - '[crontab] Failed to restore old cron job in scheduler after rollback:', - _recoveryError?.message || _recoveryError, - ); - } - } - this.logger.error( - '[crontab] Failed to register updated cron job in scheduler, update rolled back:', + this.logger.warn( + '[crontab] Failed to unregister cron job in scheduler:', error?.message || error, ); - throw new Error( - `${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`, - ); } - } - await this.setCrontab(); - return newDoc; + if (this.shouldUseCronClient(newDoc)) { + try { + await cronClient.addCron([ + { + name: doc.name || '', + id: String(newDoc.id), + schedule: newDoc.schedule!, + command: this.makeCommand(newDoc), + extra_schedules: newDoc.extra_schedules || [], + }, + ]); + } catch (error: any) { + // gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册 + await CrontabModel.update(omit(doc, ['queued_token']), { + where: { id: doc.id }, + }); + if (this.shouldUseCronClient(doc)) { + try { + await cronClient.addCron([ + { + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + }, + ]); + } catch (_recoveryError: any) { + this.logger.warn( + '[crontab] Failed to restore old cron job in scheduler after rollback:', + _recoveryError?.message || _recoveryError, + ); + } + } + this.logger.error( + '[crontab] Failed to register updated cron job in scheduler, update rolled back:', + error?.message || error, + ); + throw schedulerRegistrationError( + `${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`, + error, + ); + } + } + + await this.setCrontab(); + return newDoc; + }); } public async updateDb(payload: Crontab): Promise { @@ -290,16 +304,18 @@ export default class CronService { } public async remove(ids: number[]) { - await CrontabModel.destroy({ where: { id: ids } }); - try { - await cronClient.delCron(ids.map(String)); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to unregister cron job in scheduler:', - error?.message || error, - ); - } - await this.setCrontab(); + return withSchedulerMutation(async () => { + await CrontabModel.destroy({ where: { id: ids } }); + try { + await cronClient.delCron(ids.map(String)); + } catch (error: any) { + this.logger.warn( + '[crontab] Failed to unregister cron job in scheduler:', + error?.message || error, + ); + } + await this.setCrontab(); + }); } public async pin(ids: number[]) { @@ -570,46 +586,99 @@ export default class CronService { } public async run(ids: number[]) { + const queuedToken = randomUUID(); await CrontabModel.update( - { status: CrontabStatus.queued }, + { status: CrontabStatus.queued, queued_token: queuedToken }, { where: { id: ids } }, ); ids.forEach((id) => { - this.runSingle(id); + this.runSingle(id, queuedToken); }); } public async stop(ids: number[]) { const docs = await CrontabModel.findAll({ where: { id: ids } }); + // Cancel the queued snapshot first, so a late spawn cannot claim it. for (const doc of docs) { - // Kill all running instances of this task - try { - if (doc.pid) { - await killTask(doc.pid); - } - const command = doc.command.replace(/\s+/g, ' ').trim(); - await killAllTasks(command); - this.logger.info( - `[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, + if (doc.status === CrontabStatus.queued) { + const [cancelled] = await CrontabModel.update( + { status: CrontabStatus.idle, pid: null, queued_token: null } as any, + { + where: { + id: doc.id, + status: CrontabStatus.queued, + [Op.and]: [ + where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }), + where(colFn('queued_token'), { + [Op.eq]: doc.queued_token ?? null, + }), + ], + }, + } ); + // A concurrent claim may have won; capture its PID before signalling. + if (!cancelled) await doc.reload(); + } + } + const stoppingInstances = await RunningInstanceModel.findAll({ + attributes: ['id', 'pid', 'cron_id'], + where: { cron_id: ids, status: InstanceStatus.running }, + }); + const targets = new Set( + [ + ...stoppingInstances.map((instance) => instance.pid), + ...docs + .filter((doc) => doc.status === CrontabStatus.running) + .map((doc) => doc.pid), + ].filter((pid): pid is number => typeof pid === 'number' && pid > 0) + ); + const stopped = new Set(); + for (const pid of targets) { + try { + await killTask(pid, true); + stopped.add(pid); } catch (error) { this.logger.error( - `[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`, + '[panel][停止任务失败] PID: %s, 错误: %s', + pid, + asError(error).message ); } } - - // Mark all running instances as stopped - const finishedAt = dayjs().unix(); - await RunningInstanceModel.update( - { status: InstanceStatus.stopped, finished_at: finishedAt }, - { where: { cron_id: ids, status: InstanceStatus.running } }, - ); - - await CrontabModel.update( - { status: CrontabStatus.idle, pid: undefined }, - { where: { id: ids } }, - ); + const stoppedIds = stoppingInstances + .filter((instance) => instance.pid && stopped.has(instance.pid)) + .map((instance) => instance.id!); + if (stoppedIds.length) { + await RunningInstanceModel.update( + { status: InstanceStatus.stopped, finished_at: dayjs().unix() }, + { where: { id: stoppedIds } } + ); + } + for (const doc of docs) { + if ( + doc.status !== CrontabStatus.running || + (doc.pid && !stopped.has(doc.pid)) + ) + continue; + const remaining = await RunningInstanceModel.count({ + where: { cron_id: doc.id, status: InstanceStatus.running }, + }); + if (remaining) continue; + await CrontabModel.update( + { status: CrontabStatus.idle, pid: null, queued_token: null } as any, + { + where: { + id: doc.id, + status: CrontabStatus.running, + [Op.and]: [ + where(colFn('queued_token'), { [Op.eq]: doc.queued_token ?? null }), + where(colFn('pid'), { [Op.eq]: doc.pid ?? null }), + where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }), + ], + }, + } + ); + } } public async stopInstance(instanceId: number) { @@ -646,125 +715,191 @@ export default class CronService { return { code: 200, message: t('实例已停止') }; } - private async runSingle(cronId: number): Promise { - return taskLimit.manualRunWithCronLimit(() => { - return new Promise(async (resolve: any) => { + private async runSingle( + cronId: number, + expectedToken?: string, + ): Promise { + return taskLimit.manualRunWithCronLimit(async () => { + let absolutePath: string | undefined; + let logPath: string | undefined; + let queuedLogPath: string | null | undefined; + let queuedToken: string | null = null; + let claimed = false; + try { const cron = await this.getDb({ id: cronId }); - const params = { - name: cron.name, - command: cron.command, - schedule: cron.schedule, - extra_schedules: cron.extra_schedules, - }; - if (cron.status !== CrontabStatus.queued) { - resolve(params); - return; - } - - this.logger.info( - `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, - ); - - let { id, command, log_name } = cron; - + if ( + cron.status !== CrontabStatus.queued || + (expectedToken !== undefined && cron.queued_token !== expectedToken) + ) return; + queuedToken = cron.queued_token ?? null; + queuedLogPath = cron.log_path ?? null; + const { id, command, log_name } = cron; const uniqPath = log_name === '/dev/null' || !log_name ? await getUniqPath(command, `${id}`) : log_name; const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); - const logDirPath = path.resolve(config.logPath, `${uniqPath}`); - await fs.mkdir(logDirPath, { recursive: true }); - const logPath = `${uniqPath}/${logTime}.log`; - const absolutePath = path.resolve(config.logPath, `${logPath}`); + logPath = `${uniqPath}/${logTime}.log`; + absolutePath = resolveFileAccess(config.logPath, [logPath]); + if (!absolutePath) + throw new Error('Log path is outside the log directory'); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + const outputPath = absolutePath; const cp = spawn( `real_log_path=${logPath} no_delay=true ${this.makeCommand( cron, - true, + true )}`, - { shell: '/bin/bash' }, + { shell: '/bin/bash' } ); - - await CrontabModel.update( - { status: CrontabStatus.running, pid: cp.pid, log_path: logPath }, - { where: { id } }, - ); - cp.stdout.on('data', async (data) => { - await logStreamManager.write(absolutePath, data.toString()); + // Install observers before the first await: very short children may already exit. + const { completed } = observeChildProcess(cp, { + onStart: async () => { + try { + const [count] = await CrontabModel.update( + { + status: CrontabStatus.running, + pid: cp.pid, + log_path: logPath, + }, + { + where: { + id, + status: CrontabStatus.queued, + [Op.and]: [ + where(colFn('queued_token'), { [Op.eq]: queuedToken }), + where(colFn('log_path'), { [Op.eq]: queuedLogPath }), + ], + }, + } + ); + if (count !== 1) + throw new Error( + 'Task was stopped or superseded before startup' + ); + claimed = true; + } catch (error) { + if (cp.pid) await killTask(cp.pid, true); + throw error; + } + }, + onStdout: (message) => logStreamManager.write(outputPath, message), + onStderr: (message) => logStreamManager.write(outputPath, message), }); - cp.stderr.on('data', async (data) => { - this.logger.info( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', - command, - data.toString(), - ); - await logStreamManager.write(absolutePath, data.toString()); - }); - cp.on('error', async (err) => { + const result = await completed; + if (result.error) { this.logger.error( - '[panel][创建任务失败] 命令: %s, 错误信息: %j', - command, - err, + '[panel][执行任务失败] 任务ID: %s, 错误: %s', + id, + result.error.message ); - await logStreamManager.write(absolutePath, JSON.stringify(err)); - }); - - cp.on('exit', async (code) => { - this.logger.info( - '[panel][执行任务结束] 参数: %s, 退出码: %j', - JSON.stringify(params), - code, + } + this.logger.info( + '[panel][执行任务结束] 任务ID: %s, 退出码: %j', + id, + result.code + ); + return { ...cron, pid: cp.pid, ...result } as any; + } catch (error) { + this.logger.error( + '[panel][创建任务失败] 任务ID: %s, 错误: %s', + cronId, + asError(error).message + ); + } finally { + try { + if (absolutePath) await logStreamManager.closeStream(absolutePath); + } catch (error) { + this.logger.error( + '[panel][关闭任务日志失败] %s', + asError(error).message ); - await logStreamManager.closeStream(absolutePath); - resolve({ ...params, pid: cp.pid, code }); - }); - }); + } + try { + // Do not overwrite a newer run's state or its script-reported exit code. + await CrontabModel.update( + { status: CrontabStatus.idle, pid: null, queued_token: null } as any, + { + where: { + id: cronId, + [Op.and]: where(colFn('queued_token'), { [Op.eq]: queuedToken }), + [Op.or]: [ + ...(queuedLogPath !== undefined && !claimed + ? [ + { + status: CrontabStatus.queued, + [Op.and]: where(colFn('log_path'), { + [Op.eq]: queuedLogPath, + }), + }, + ] + : []), + ...(claimed && logPath + ? [{ log_path: logPath, status: CrontabStatus.running }] + : []), + ], + }, + } + ); + } catch (error) { + this.logger.error( + '[panel][清理任务状态失败] %s', + asError(error).message + ); + } + } }); } public async disabled(ids: number[]) { - await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); - try { - await cronClient.delCron(ids.map(String)); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to unregister cron job in scheduler:', - error?.message || error, - ); - } - await this.setCrontab(); + return withSchedulerMutation(async () => { + await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); + try { + await cronClient.delCron(ids.map(String)); + } catch (error: any) { + this.logger.warn( + '[crontab] Failed to unregister cron job in scheduler:', + error?.message || error, + ); + } + await this.setCrontab(); + }); } public async enabled(ids: number[]) { - await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); - const docs = await CrontabModel.findAll({ where: { id: ids } }); - const crons = docs - .filter((x) => this.shouldUseCronClient(x)) - .map((doc) => ({ - name: doc.name || '', - id: String(doc.id), - schedule: doc.schedule!, - command: this.makeCommand(doc), - extra_schedules: doc.extra_schedules || [], - })); + return withSchedulerMutation(async () => { + await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); + const docs = await CrontabModel.findAll({ where: { id: ids } }); + const crons = docs + .filter((x) => this.shouldUseCronClient(x)) + .map((doc) => ({ + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + })); - if (isDemoEnv()) { - return; - } + if (isDemoEnv()) { + return; + } - try { - await cronClient.addCron(crons); - } catch (error: any) { - // gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册 - await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); - this.logger.error( - '[crontab] Failed to register cron job in scheduler, enable rolled back:', - error?.message || error, - ); - throw new Error( - `${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`, - ); - } - await this.setCrontab(); + try { + await cronClient.addCron(crons); + } catch (error: any) { + // gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册 + await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); + this.logger.error( + '[crontab] Failed to register cron job in scheduler, enable rolled back:', + error?.message || error, + ); + throw schedulerRegistrationError( + `${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`, + error, + ); + } + await this.setCrontab(); + }); } public async log( @@ -957,39 +1092,42 @@ export default class CronService { }); } - public async autosave_crontab() { - const tabs = await this.crontabs(); - const regularCrons = tabs.data - .filter( - (x) => - x.isDisabled !== 1 && - this.shouldUseCronClient(x), - ) - .map((doc) => ({ - name: doc.name || '', - id: String(doc.id), - schedule: doc.schedule!, - command: this.makeCommand(doc), - extra_schedules: doc.extra_schedules || [], - })); + public async autosave_crontab(requireScheduler = false) { + return withSchedulerMutation(async () => { + const tabs = await this.crontabs(); + const regularCrons = tabs.data + .filter( + (x) => + x.isDisabled !== 1 && + this.shouldUseCronClient(x), + ) + .map((doc) => ({ + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + })); - if (isDemoEnv()) { - await writeFileWithLock(config.crontabFile, ''); - return; - } + if (isDemoEnv()) { + await writeFileWithLock(config.crontabFile, ''); + return; + } - // 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。 - // gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。 - // 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。 - await this.setCrontab(tabs); - try { - await cronClient.addCron(regularCrons); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to register cron job in scheduler:', - error?.message || error, - ); - } + // 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。 + // gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。 + // 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。 + await this.setCrontab(tabs); + try { + await cronClient.addCron(regularCrons, requireScheduler); + } catch (error: any) { + this.logger.warn( + '[crontab] Failed to register cron job in scheduler:', + error?.message || error, + ); + if (requireScheduler) throw error; + } + }); } public async bootTask() { @@ -998,13 +1136,7 @@ export default class CronService { (x) => !x.isDisabled && this.isBootSchedule(x.schedule), ); if (bootTasks.length > 0) { - await CrontabModel.update( - { status: CrontabStatus.queued }, - { where: { id: bootTasks.map((t) => t.id!) } }, - ); - for (const task of bootTasks) { - this.runSingle(task.id!); - } + await this.run(bootTasks.map((task) => task.id!)); } } } diff --git a/back/services/health.ts b/back/services/health.ts index d971768d..3c86f5fa 100644 --- a/back/services/health.ts +++ b/back/services/health.ts @@ -1,6 +1,6 @@ import { Service } from 'typedi'; import Logger from '../loaders/logger'; -import { GrpcServerService } from './grpc'; +import cronClient from '../schedule/client'; import { HttpServerService } from './http'; interface HealthStatus { @@ -23,7 +23,6 @@ export class HealthService { private startTime = Date.now(); constructor( - private grpcServerService: GrpcServerService, private httpServerService: HttpServerService, ) {} @@ -56,8 +55,7 @@ export class HealthService { } try { - const grpcServer = this.grpcServerService.getServer(); - if (!grpcServer) { + if (!(await cronClient.readiness.check())) { status.services.grpc = false; status.status = 'error'; } diff --git a/back/services/http.ts b/back/services/http.ts index f6d6dba3..44ee30ee 100644 --- a/back/services/http.ts +++ b/back/services/http.ts @@ -39,7 +39,13 @@ export class HttpServerService { private async tryListen(expressApp: express.Application, port: number, host: string): Promise { return new Promise((resolve, reject) => { - const server = expressApp.listen(port, host, () => { + // 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); }); diff --git a/back/services/schedule.ts b/back/services/schedule.ts index df938674..884894d9 100644 --- a/back/services/schedule.ts +++ b/back/services/schedule.ts @@ -11,6 +11,7 @@ import { import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; import { spawn } from 'cross-spawn'; +import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess'; export interface ScheduleTaskType { id?: number; @@ -27,7 +28,7 @@ export interface TaskCallbacks { startTime: dayjs.Dayjs, ) => Promise; onEnd?: ( - cp: ChildProcessWithoutNullStreams, + cp: ChildProcessWithoutNullStreams | undefined, endTime: dayjs.Dayjs, diff: number, ) => Promise; @@ -63,73 +64,85 @@ export default class ScheduleService { ) { const { runOrigin, ...others } = params; - return taskLimit[this.taskLimitMap[runOrigin]](others, () => { - return new Promise(async (resolve, reject) => { - this.logger.info( - `[panel][开始执行任务] 参数: ${JSON.stringify({ - ...others, - command, - })}`, - ); - - try { - const startTime = dayjs(); - await callbacks.onBefore?.(startTime); - - const cp = spawn(command, { shell: '/bin/bash' }); - - callbacks.onStart?.(cp, startTime); - completionTime === 'start' && resolve(cp.pid); - - cp.stdout.on('data', async (data) => { - await callbacks.onLog?.(data.toString()); - }); - - cp.stderr.on('data', async (data) => { - this.logger.info( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', - command, - data.toString(), - ); - await callbacks.onError?.(data.toString()); - }); - - cp.on('error', async (err) => { - this.logger.error( - '[panel][创建任务失败] 命令: %s, 错误信息: %j', - command, - err, - ); - await callbacks.onError?.(JSON.stringify(err)); - }); - - cp.on('exit', async (code) => { - this.logger.info( - '[panel][执行任务结束] 参数: %s, 退出码: %j', - JSON.stringify({ - ...others, - command, - }), - code, - ); - const endTime = dayjs(); - await callbacks.onEnd?.( - cp, - endTime, - endTime.diff(startTime, 'seconds'), - ); - resolve({ ...others, pid: cp.pid, code }); - }); - } catch (error) { - this.logger.error( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', - command, - error, - ); - await callbacks.onError?.(JSON.stringify(error)); - } - }); + let resolveStart!: (pid: number | undefined) => void; + let rejectStart!: (error: Error) => void; + const startResult = new Promise((resolve, reject) => { + resolveStart = resolve; + rejectStart = reject; }); + // Most scheduled callers only observe completion (or intentionally detach). + void startResult.catch(() => {}); + const completion = taskLimit[this.taskLimitMap[runOrigin]]( + others, + async () => { + const startTime = dayjs(); + let cp: ChildProcessWithoutNullStreams | undefined; + let result: ProcessResult = { code: null, signal: null }; + try { + this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id); + await callbacks.onBefore?.(startTime); + cp = spawn(command, { shell: '/bin/bash' }); + const child = cp; + const observed = observeChildProcess(child, { + onStart: async () => { + await callbacks.onStart?.(child, startTime); + }, + onStdout: callbacks.onLog, + onStderr: callbacks.onError, + }); + observed.started.then(resolveStart, rejectStart); + result = await observed.completed; + } catch (error) { + result.error = asError(error); + rejectStart(result.error); + } + + if (result.error) { + this.logger.error( + '[panel][执行任务失败] 任务ID: %s, 错误: %s', + others.id, + result.error.message, + ); + try { + await callbacks.onError?.(result.error.message); + } catch (error) { + this.logger.error( + '[panel][任务错误回调失败] %s', + asError(error).message, + ); + } + } + // Cleanup also runs after setup/spawn failure, and only after both pipes drain. + const endTime = dayjs(); + try { + await callbacks.onEnd?.( + cp, + endTime, + endTime.diff(startTime, 'seconds'), + ); + } catch (error) { + result.error ??= asError(error); + this.logger.error( + '[panel][任务结束回调失败] %s', + asError(error).message, + ); + } + this.logger.info( + '[panel][执行任务结束] 任务ID: %s, 退出码: %j', + others.id, + result.code, + ); + return { ...others, pid: cp?.pid, ...result }; + }, + ).catch((error) => { + // Queue/setup failures must not become unhandled rejections in detached callers. + rejectStart(asError(error)); + this.logger.error('[panel][任务队列失败] %s', asError(error).message); + return { ...others, code: null, signal: null, error: asError(error) }; + }); + + // Returning a PID must not release the execution slot while the process runs. + return completionTime === 'start' ? startResult : completion; } async createCronTask( diff --git a/back/services/subscription.ts b/back/services/subscription.ts index 6d9e35fa..513d239f 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -159,48 +159,54 @@ export default class SubscriptionService { ); }, onEnd: async (cp, endTime, diff) => { - const sub = await this.getDb({ id: doc.id }); - const absolutePath = await handleLogPath(sub.log_path as string); - - // 执行 sub_after - let afterStr = ''; + let absolutePath: string | undefined; try { - if (sub.sub_after) { - await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`); - afterStr = await promiseExec(sub.sub_after); + const sub = await this.getDb({ id: doc.id }); + absolutePath = await handleLogPath(sub.log_path as string); + + // 执行 sub_after + let afterStr = ''; + try { + if (sub.sub_after) { + await logStreamManager.write( + absolutePath, + `\n\n## ${t('执行after命令...')}\n\n`, + ); + afterStr = await promiseExec(sub.sub_after); + } + } catch (error: any) { + afterStr = + (error.stderr && error.stderr.toString()) || JSON.stringify(error); + } + if (afterStr) { + await logStreamManager.write(absolutePath, `${afterStr}\n`); + } + + await logStreamManager.write( + absolutePath, + '\n' + + tf( + '## 执行结束... %s 耗时 %s 秒', + endTime.format('YYYY-MM-DD HH:mm:ss'), + String(diff), + ) + + LOG_END_SYMBOL, + ); + } finally { + try { + if (absolutePath) await logStreamManager.closeStream(absolutePath); + } finally { + await SubscriptionModel.update( + { status: SubscriptionStatus.idle, pid: null } as any, + { where: { id: doc.id } }, + ); + this.sockService.sendMessage({ + type: 'runSubscriptionEnd', + message: t('订阅执行完成'), + references: [doc.id as number], + }); } - } catch (error: any) { - afterStr = - (error.stderr && error.stderr.toString()) || JSON.stringify(error); } - if (afterStr) { - await logStreamManager.write(absolutePath, `${afterStr}\n`); - } - - await logStreamManager.write( - absolutePath, - '\n' + - tf( - '## 执行结束... %s 耗时 %s 秒', - endTime.format('YYYY-MM-DD HH:mm:ss'), - String(diff), - ) + - LOG_END_SYMBOL, - ); - - // Close the stream after task completion - await logStreamManager.closeStream(absolutePath); - - await SubscriptionModel.update( - { status: SubscriptionStatus.idle, pid: undefined }, - { where: { id: sub.id } }, - ); - - this.sockService.sendMessage({ - type: 'runSubscriptionEnd', - message: t('订阅执行完成'), - references: [doc.id as number], - }); }, onError: async (message: string) => { const sub = await this.getDb({ id: doc.id }); diff --git a/back/shared/childProcess.ts b/back/shared/childProcess.ts new file mode 100644 index 00000000..a5cacb5f --- /dev/null +++ b/back/shared/childProcess.ts @@ -0,0 +1,78 @@ +import { ChildProcessWithoutNullStreams } from 'child_process'; +import { Readable } from 'stream'; + +export interface ProcessResult { + code: number | null; + signal: NodeJS.Signals | null; + error?: Error; +} + +export function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +/** Attach immediately after spawn, before awaiting database or user callbacks. */ +export function observeChildProcess( + child: ChildProcessWithoutNullStreams, + callbacks: { + onStart?: () => Promise; + onStdout?: (message: string) => Promise; + onStderr?: (message: string) => Promise; + } = {}, +) { + let failure: Error | undefined; + const recordError = (error: unknown) => { + failure ??= asError(error); + }; + const spawned = new Promise((resolve, reject) => { + child.once('spawn', resolve); + // Keep the listener through close: errors can occur after a successful spawn. + child.on('error', (error) => { + recordError(error); + reject(error); + }); + }); + const closed = new Promise((resolve) => { + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const started = spawned.then(async () => { + await callbacks.onStart?.(); + return child.pid; + }); + // The caller can ask only for completion, without an unhandled start rejection. + const ready = started.catch(recordError); + + const consume = async ( + stream: Readable, + callback?: (message: string) => Promise, + ) => { + // StringDecoder in Readable preserves UTF-8 characters split across chunks. + stream.setEncoding('utf8'); + let callbackFailed = false; + try { + for await (const chunk of stream) { + await ready; + if (!callbackFailed && callback) { + try { + await callback(String(chunk)); + } catch (error) { + recordError(error); + callbackFailed = true; + } + } + // Even if a log sink fails, drain the pipe so the child can finish. + } + } catch (error) { + recordError(error); + } + }; + const output = Promise.all([ + consume(child.stdout, callbacks.onStdout), + consume(child.stderr, callbacks.onStderr), + ]); + const completed = Promise.all([closed, ready, output]).then(([result]) => ({ + ...result, + error: failure, + })); + return { started, completed }; +} diff --git a/back/shared/logStreamManager.ts b/back/shared/logStreamManager.ts index 815ce409..ff2ff1ec 100644 --- a/back/shared/logStreamManager.ts +++ b/back/shared/logStreamManager.ts @@ -1,5 +1,8 @@ import { createWriteStream, WriteStream } from 'fs'; import { EventEmitter } from 'events'; +import path from 'path'; +import config from '../config'; +import { resolveFileAccess } from './fileAccess'; /** * Manages write streams for log files to improve performance by avoiding repeated file opens @@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter { private streams: Map = new Map(); private pendingWrites: Map> = new Map(); - /** - * Write data to a log file using a managed stream - * @param filePath - Absolute path to the log file - * @param data - Data to write to the log file - */ - async write(filePath: string, data: string): Promise { - // Wait for any pending writes to this file to complete - const pending = this.pendingWrites.get(filePath); - if (pending) { - await pending; - } + private closingStreams = new Map>(); + private closedStreams = new WeakSet(); + private streamErrors = new Map(); - // Create a new promise for this write operation - const writePromise = new Promise((resolve, reject) => { - let stream = this.streams.get(filePath); - - if (!stream) { - // Create a new write stream if one doesn't exist - stream = createWriteStream(filePath, { flags: 'a' }); - this.streams.set(filePath, stream); - - // Handle stream errors - stream.on('error', (error) => { - this.emit('error', { filePath, error }); - // Remove the stream from the map on error - this.streams.delete(filePath); - reject(error); - }); - } - - // Write the data - const canContinue = stream.write(data, 'utf8', (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - - // Handle backpressure - if (!canContinue) { - stream.once('drain', () => { - // Stream is ready for more data - }); - } - }); - - this.pendingWrites.set(filePath, writePromise); - - try { - await writePromise; - } finally { - this.pendingWrites.delete(filePath); - } + constructor(private readonly logRoot = config.logPath) { + super(); } - /** - * Close the stream for a specific file path - * @param filePath - Absolute path to the log file - */ - async closeStream(filePath: string): Promise { - // Wait for any pending writes to complete - const pending = this.pendingWrites.get(filePath); - if (pending) { - await pending.catch(() => { - // Ignore errors on pending writes during close - }); + /** Register each write synchronously, so concurrent callers cannot lose the tail. */ + async write(filePath: string, data: string): Promise { + if (this.closingStreams.has(filePath)) { + throw new Error(`Log stream is closing: ${filePath}`); } + const previous = this.pendingWrites.get(filePath) || Promise.resolve(); + const pending = previous.then( + () => + new Promise((resolve, reject) => { + const failure = this.streamErrors.get(filePath); + if (failure) return reject(failure); + let stream = this.streams.get(filePath); + if (!stream) { + // Validate only when opening: subsequent chunks reuse the same descriptor. + const root = path.resolve(this.logRoot); + const target = path.resolve(filePath); + if ( + !target.startsWith(root + path.sep) || + !resolveFileAccess(root, [target]) + ) { + return reject(new Error('Log path is outside the log directory')); + } + stream = createWriteStream(target, { flags: 'a' }); + this.streams.set(filePath, stream); + const current = stream; + stream.once('close', () => this.closedStreams.add(current)); + stream.on('error', (error) => { + this.streamErrors.set(filePath, error); + // EventEmitter's unobserved "error" event would crash the caller. + if (this.listenerCount('error') > 0) + this.emit('error', { filePath, error }); + }); + } + stream.write(data, 'utf8', (error) => + error ? reject(error) : resolve(), + ); + }), + ); + this.pendingWrites.set(filePath, pending); + // Keep the tail until close, including failures; never reopen a failed log mid-run. + return pending; + } - const stream = this.streams.get(filePath); - if (stream) { - return new Promise((resolve) => { - stream.end(() => { - this.streams.delete(filePath); - resolve(); - }); - }); + async closeStream(filePath: string): Promise { + const closing = this.closingStreams.get(filePath); + if (closing) return closing; + const pending = this.pendingWrites.get(filePath); + const result = (async () => { + let failure: unknown; + try { + await pending; + } catch (error) { + failure = error; + } + const stream = this.streams.get(filePath); + try { + if (stream && !this.closedStreams.has(stream)) { + await new Promise((resolve) => { + stream.once('close', resolve); + if (failure || stream.destroyed) stream.destroy(); + else stream.end(); + }); + } + failure ||= this.streamErrors.get(filePath); + if (failure) throw failure; + } finally { + this.streams.delete(filePath); + this.pendingWrites.delete(filePath); + this.streamErrors.delete(filePath); + } + })(); + this.closingStreams.set(filePath, result); + try { + await result; + } finally { + this.closingStreams.delete(filePath); } } @@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter { * Close all open streams */ async closeAll(): Promise { - const closePromises = Array.from(this.streams.keys()).map((filePath) => + const paths = new Set([ + ...this.streams.keys(), + ...this.pendingWrites.keys(), + ]); + const closePromises = Array.from(paths).map((filePath) => this.closeStream(filePath), ); await Promise.all(closePromises); diff --git a/back/shared/runCron.ts b/back/shared/runCron.ts index c451bf4f..29609e7a 100644 --- a/back/shared/runCron.ts +++ b/back/shared/runCron.ts @@ -4,15 +4,13 @@ import Logger from '../loaders/logger'; import { ICron } from '../protos/cron'; import { CrontabModel, CrontabStatus } from '../data/cron'; import { killTask } from '../config/util'; -import { - RunningInstanceModel, - InstanceStatus, -} from '../data/runningInstance'; +import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance'; import dayjs from 'dayjs'; +import { observeChildProcess, asError } from './childProcess'; export function runCron(cmd: string, cron: ICron): Promise { - return taskLimit.runWithCronLimit(cron, () => { - return new Promise(async (resolve: any) => { + return taskLimit.runWithCronLimit(cron, async () => { + try { // Check if the cron is already running and stop it (only if multiple instances are not allowed) try { const existingCron = await CrontabModel.findOne({ @@ -38,7 +36,12 @@ export function runCron(cmd: string, cron: ICron): Promise { const stoppedAt = dayjs().unix(); await RunningInstanceModel.update( { status: InstanceStatus.stopped, finished_at: stoppedAt }, - { where: { cron_id: Number(cron.id), status: InstanceStatus.running } }, + { + where: { + cron_id: Number(cron.id), + status: InstanceStatus.running, + }, + }, ); // Update the status to idle after killing await CrontabModel.update( @@ -60,33 +63,37 @@ export function runCron(cmd: string, cron: ICron): Promise { ); const cp = spawn(cmd, { shell: '/bin/bash' }); - cp.stderr.on('data', (data) => { - Logger.info( - '[schedule][执行任务失败] 命令: %s, 错误信息: %j', - cmd, - data.toString(), - ); + const { completed } = observeChildProcess(cp, { + onStderr: async (message) => { + Logger.info( + '[schedule][任务标准错误] 命令: %s, 信息: %s', + cmd, + message, + ); + }, }); - cp.on('error', (err) => { + const result = await completed; + if (result.error) { Logger.error( - '[schedule][创建任务失败] 命令: %s, 错误信息: %j', + '[schedule][执行任务失败] 命令: %s, 错误: %s', cmd, - err, + result.error.message, ); - }); - - cp.on('exit', async (code) => { - taskLimit.removeQueuedCron(cron.id); - Logger.info( - '[schedule][执行任务结束] 参数: %s, 退出码: %j', - JSON.stringify({ - ...cron, - command: cmd, - }), - code, - ); - resolve({ ...cron, command: cmd, pid: cp.pid, code }); - }); - }); + } + Logger.info( + '[schedule][执行任务结束] 任务ID: %s, 退出码: %j', + cron.id, + result.code, + ); + return { ...cron, command: cmd, pid: cp.pid, ...result } as any; + } catch (error) { + Logger.error( + '[schedule][创建任务失败] 命令: %s, 错误: %s', + cmd, + asError(error).message, + ); + } finally { + taskLimit.removeQueuedCron(cron.id); + } }); } diff --git a/back/shared/schedulerMutationLock.ts b/back/shared/schedulerMutationLock.ts new file mode 100644 index 00000000..afa2d4e0 --- /dev/null +++ b/back/shared/schedulerMutationLock.ts @@ -0,0 +1,38 @@ +import lockfile from 'proper-lockfile'; +import config from '../config'; + +// HTTP and gRPC both mutate cron definitions. Hold one shared lock from the +// initial DB read/write through scheduler registration (including rollback). +// Recovery takes the same lock before reading its replacement snapshot. +export async function withSchedulerMutation( + operation: () => Promise, +): Promise { + let release: () => Promise; + try { + release = await lockfile.lock(config.crontabFile, { + realpath: false, + lockfilePath: `${config.crontabFile}.scheduler.lock`, + stale: 30000, + update: 10000, + retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 }, + }); + } catch (cause) { + throw Object.assign( + new Error('Scheduler configuration is busy', { cause }), + { + status: 503, + }, + ); + } + try { + return await operation(); + } finally { + await release(); + } +} + +export function schedulerRegistrationError(message: string, cause: any): Error { + return Object.assign(new Error(message, { cause }), { + status: cause?.status === 503 ? 503 : 500, + }); +} diff --git a/back/shared/schedulerReadiness.ts b/back/shared/schedulerReadiness.ts new file mode 100644 index 00000000..f2100695 --- /dev/null +++ b/back/shared/schedulerReadiness.ts @@ -0,0 +1,77 @@ +// Recovery is single-flight and retried only while unavailable, never at idle. +export class SchedulerReadiness { + private ready = false; + private generation = 0; + private restore?: () => Promise; + private pending?: Promise; + private retry?: NodeJS.Timeout; + + constructor(private probe: () => Promise, private retryMs = 1000) {} + + configure(restore: () => Promise) { + this.restore = restore; + } + + invalidate() { + this.ready = false; + this.generation++; + void this.recover(); + } + + recover(): Promise { + if (this.pending) return this.pending; + if (!this.restore) return Promise.resolve(false); + clearTimeout(this.retry); + const generation = this.generation; + this.ready = false; + this.pending = (async () => { + try { + await this.probe(); + await this.restore!(); + await this.probe(); + this.ready = generation === this.generation; + } catch { + this.ready = false; + } + return this.ready; + })().finally(() => { + this.pending = undefined; + if (!this.ready) { + this.retry = setTimeout(() => void this.recover(), this.retryMs); + this.retry.unref(); + } + }); + return this.pending; + } + + async check(): Promise { + if (!this.ready) return false; + const generation = this.generation; + try { + await this.probe(); + return this.ready && generation === this.generation; + } catch { + this.invalidate(); + return false; + } + } + + async ensureReady(timeoutMs = 2000): Promise { + let timer: NodeJS.Timeout | undefined; + try { + const available = await Promise.race([ + (async () => (await this.check()) || (await this.recover()))(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + if (!available) { + throw Object.assign(new Error('Scheduler is recovering; try again later'), { + status: 503, + }); + } + } finally { + clearTimeout(timer); + } + } +} diff --git a/back/shared/schemaMigrations.ts b/back/shared/schemaMigrations.ts new file mode 100644 index 00000000..ed7a6658 --- /dev/null +++ b/back/shared/schemaMigrations.ts @@ -0,0 +1,70 @@ +import { QueryTypes, Sequelize } from 'sequelize'; + +// Append new entries; IDs are persisted and must not be renumbered or reused. +const columns = [ + { + table: 'CrontabViews', + column: 'filterRelation', + type: 'VARCHAR(255)', + }, + { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, + { table: 'CrontabViews', column: 'type', type: 'NUMBER' }, + { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, + { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, + { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, + { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, + { table: 'Crontabs', column: 'task_before', type: 'TEXT' }, + { table: 'Crontabs', column: 'task_after', type: 'TEXT' }, + { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, + { + table: 'Crontabs', + column: 'allow_multiple_instances', + type: 'NUMBER', + }, + { table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' }, + { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, + { table: 'Envs', column: 'labels', type: 'JSON' }, + { table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' }, +]; + +export async function migrateSchema(database: Sequelize): Promise { + await database.transaction(async (transaction) => { + await database.query( + 'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)', + { transaction }, + ); + const applied = await database.query<{ id: string }>( + 'SELECT "id" FROM "SchemaMigrations"', + { type: QueryTypes.SELECT, transaction }, + ); + const appliedIds = new Set(applied.map(({ id }) => id)); + for (const { table, column, type } of columns) { + const id = `add-${table}-${column}`; + const fields = await database.query<{ name: string }>( + `PRAGMA table_info("${table}")`, + { + type: QueryTypes.SELECT, + transaction, + }, + ); + if (fields.length === 0) + throw new Error(`Migration table is missing: ${table}`); + if (!fields.some((field) => field.name === column)) { + // table/column/type come only from the static migration manifest above. + await database.query( + `ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`, + { transaction }, + ); + } + if (!appliedIds.has(id)) { + await database.query( + 'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)', + { + replacements: { id, appliedAt: new Date().toISOString() }, + transaction, + }, + ); + } + } + }); +} diff --git a/docker/Dockerfile b/docker/Dockerfile index f61ef404..7b75859e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -20,7 +20,7 @@ RUN set -x \ && npm_config_target_platform=linux \ npm_config_target_arch="${NODE_ARCH}" \ npm_config_target_libc=musl \ - pnpm install --prod + pnpm install --prod --frozen-lockfile FROM python:3.11-alpine @@ -73,15 +73,19 @@ RUN set -x \ && ulimit -c 0 ARG SOURCE_COMMIT +LABEL org.opencontainers.image.revision=${SOURCE_COMMIT} RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ && cd ${QL_DIR} \ + && if [ -n "${SOURCE_COMMIT}" ]; then git fetch --depth=1 origin "${SOURCE_COMMIT}" && git reset --hard FETCH_HEAD; fi \ && cp -f .env.example .env \ && chmod 777 ${QL_DIR}/shell/*.sh \ - && chmod 777 ${QL_DIR}/docker/*.sh \ - && git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ - && mkdir -p ${QL_DIR}/static \ - && cp -rf /static/* ${QL_DIR}/static \ - && rm -rf /static + && chmod 777 ${QL_DIR}/docker/*.sh + +# Downloaded by CI from the build-static job in this workflow run. +COPY static/ /ql/static/ +COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ +COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml +RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ diff --git a/docker/Dockerfile.310 b/docker/Dockerfile.310 index 520b1e0e..7b414513 100644 --- a/docker/Dockerfile.310 +++ b/docker/Dockerfile.310 @@ -20,7 +20,7 @@ RUN set -x \ && npm_config_target_platform=linux \ npm_config_target_arch="${NODE_ARCH}" \ npm_config_target_libc=musl \ - pnpm install --prod + pnpm install --prod --frozen-lockfile FROM python:3.10-alpine @@ -73,15 +73,19 @@ RUN set -x \ && ulimit -c 0 ARG SOURCE_COMMIT +LABEL org.opencontainers.image.revision=${SOURCE_COMMIT} RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ && cd ${QL_DIR} \ + && if [ -n "${SOURCE_COMMIT}" ]; then git fetch --depth=1 origin "${SOURCE_COMMIT}" && git reset --hard FETCH_HEAD; fi \ && cp -f .env.example .env \ && chmod 777 ${QL_DIR}/shell/*.sh \ - && chmod 777 ${QL_DIR}/docker/*.sh \ - && git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ - && mkdir -p ${QL_DIR}/static \ - && cp -rf /static/* ${QL_DIR}/static \ - && rm -rf /static + && chmod 777 ${QL_DIR}/docker/*.sh + +# Downloaded by CI from the build-static job in this workflow run. +COPY static/ /ql/static/ +COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ +COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml +RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 09bbbeae..fed71e8b 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -12,7 +12,7 @@ RUN set -x && \ apt-get install --no-install-recommends -y libatomic1 && \ npm i -g pnpm@8.3.1 && \ cd /tmp/build && \ - pnpm install --prod + pnpm install --prod --frozen-lockfile FROM python:3.11.14-slim-bookworm @@ -82,15 +82,19 @@ RUN mkdir -p ${QL_DIR} && \ USER qinglong ARG SOURCE_COMMIT -RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \ - cd ${QL_DIR} && \ - cp -f .env.example .env && \ - chmod 777 ${QL_DIR}/shell/*.sh && \ - chmod 777 ${QL_DIR}/docker/*.sh && \ - git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \ - mkdir -p ${QL_DIR}/static && \ - cp -rf /tmp/static/* ${QL_DIR}/static && \ - rm -rf /tmp/static +LABEL org.opencontainers.image.revision=${SOURCE_COMMIT} +RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ + && cd ${QL_DIR} \ + && if [ -n "${SOURCE_COMMIT}" ]; then git fetch --depth=1 origin "${SOURCE_COMMIT}" && git reset --hard FETCH_HEAD; fi \ + && cp -f .env.example .env \ + && chmod 777 ${QL_DIR}/shell/*.sh \ + && chmod 777 ${QL_DIR}/docker/*.sh + +# Downloaded by CI from the build-static job in this workflow run. +COPY --chown=qinglong:qinglong static/ /ql/static/ +COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ +COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml +RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ diff --git a/docker/Dockerfile.debian310 b/docker/Dockerfile.debian310 index 36bc0d41..907da2a1 100644 --- a/docker/Dockerfile.debian310 +++ b/docker/Dockerfile.debian310 @@ -12,7 +12,7 @@ RUN set -x && \ apt-get install --no-install-recommends -y libatomic1 && \ npm i -g pnpm@8.3.1 && \ cd /tmp/build && \ - pnpm install --prod + pnpm install --prod --frozen-lockfile FROM python:3.10-slim-bookworm @@ -82,15 +82,19 @@ RUN mkdir -p ${QL_DIR} && \ USER qinglong ARG SOURCE_COMMIT -RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \ - cd ${QL_DIR} && \ - cp -f .env.example .env && \ - chmod 777 ${QL_DIR}/shell/*.sh && \ - chmod 777 ${QL_DIR}/docker/*.sh && \ - git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \ - mkdir -p ${QL_DIR}/static && \ - cp -rf /tmp/static/* ${QL_DIR}/static && \ - rm -rf /tmp/static +LABEL org.opencontainers.image.revision=${SOURCE_COMMIT} +RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ + && cd ${QL_DIR} \ + && if [ -n "${SOURCE_COMMIT}" ]; then git fetch --depth=1 origin "${SOURCE_COMMIT}" && git reset --hard FETCH_HEAD; fi \ + && cp -f .env.example .env \ + && chmod 777 ${QL_DIR}/shell/*.sh \ + && chmod 777 ${QL_DIR}/docker/*.sh + +# Downloaded by CI from the build-static job in this workflow run. +COPY --chown=qinglong:qinglong static/ /ql/static/ +COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ +COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml +RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ diff --git a/docker/build-manifest.cjs b/docker/build-manifest.cjs new file mode 100644 index 00000000..d2ef9620 --- /dev/null +++ b/docker/build-manifest.cjs @@ -0,0 +1,28 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +// Include every artifact, including imported backend modules and frontend chunks. +function collectBuildFiles(root = 'static') { + const files = {}; + function visit(relative) { + for (const name of fs.readdirSync(path.join(root, relative)).sort()) { + const entry = relative ? `${relative}/${name}` : name; + if (entry === 'build-info.json') continue; + const file = path.join(root, entry); + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink()) + throw new Error(`Build output symlink: ${entry}`); + if (stat.isDirectory()) visit(entry); + else if (stat.isFile()) { + files[entry] = crypto + .createHash('sha256') + .update(fs.readFileSync(file)) + .digest('hex'); + } else throw new Error(`Unsupported build output: ${entry}`); + } + } + visit(''); + return files; +} +module.exports = { collectBuildFiles }; diff --git a/docker/verify-build.cjs b/docker/verify-build.cjs new file mode 100644 index 00000000..8e117da4 --- /dev/null +++ b/docker/verify-build.cjs @@ -0,0 +1,49 @@ +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { collectBuildFiles } = require('./build-manifest.cjs'); + +const manifest = JSON.parse(fs.readFileSync('static/build-info.json', 'utf8')); +const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim(); +const lockfileSha256 = crypto + .createHash('sha256') + .update(fs.readFileSync('pnpm-lock.yaml')) + .digest('hex'); +if ( + manifest.dirty !== false || + manifest.sourceCommit !== sourceCommit || + manifest.lockfileSha256 !== lockfileSha256 +) { + throw new Error( + 'Build artifacts do not match the checked-out source and lockfile. Rebuild from the same clean commit.', + ); +} + +if (process.argv[2]) { + const dependencyLock = crypto + .createHash('sha256') + .update(fs.readFileSync(process.argv[2])) + .digest('hex'); + if (dependencyLock !== manifest.lockfileSha256) + throw new Error( + 'Production dependencies were built from a different lockfile.', + ); +} + +const actual = collectBuildFiles(); +if ( + manifest.version !== 1 || + !manifest.files || + JSON.stringify(Object.keys(actual).sort()) !== + JSON.stringify(Object.keys(manifest.files).sort()) +) + throw new Error( + 'Build output file set mismatch. Rebuild the complete artifacts.', + ); +for (const [file, hash] of Object.entries(actual)) { + if (hash !== manifest.files[file]) + throw new Error(`Build output checksum mismatch: ${file}`); +} +console.log(`Verified build artifacts for ${sourceCommit}`); diff --git a/ecosystem.config.js b/ecosystem.config.js index dc9b1e91..6546ed4f 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -4,6 +4,9 @@ module.exports = { apps: [ { name: 'qinglong', + // Keep process supervision; enable injected diagnostics only on demand + // in containers. Standalone installs retain PM2's monitoring default. + pmx: !isContainer || process.env.QL_PRIMARY_APM === 'true', max_restarts: 5, kill_timeout: 1000, wait_ready: true, diff --git a/package.json b/package.json index 56ff1e32..6018bbab 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "start:front": "max dev", "build:front": "max build", "build:back": "tsc -p back/tsconfig.json", + "test": "TS_NODE_PROJECT=back/tsconfig.json node -r ts-node/register/transpile-only --test test/back/*.test.cjs test/front/*.test.cjs", + "benchmark:execution": "TS_NODE_PROJECT=back/tsconfig.json node -r ts-node/register/transpile-only scripts/benchmark-execution.cjs", + "build:info": "node scripts/write-build-info.cjs", "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}'", diff --git a/scripts/benchmark-execution.cjs b/scripts/benchmark-execution.cjs new file mode 100644 index 00000000..553e5694 --- /dev/null +++ b/scripts/benchmark-execution.cjs @@ -0,0 +1,96 @@ +// Microbenchmark of the process observer, not a container or scheduler benchmark. +const fs = require('node:fs'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { spawn, execFileSync } = require('node:child_process'); +const { performance } = require('node:perf_hooks'); +const { observeChildProcess } = require('../back/shared/childProcess'); + +const args = process.argv.slice(2).filter((arg) => arg !== '--'); +const runsArg = args.find((arg) => arg.startsWith('--runs=')); +const runs = runsArg ? Number(runsArg.slice(7)) : 30; +if (!Number.isInteger(runs) || runs < 3 || runs > 10000) + throw new Error('runs must be between 3 and 10000'); +const output = args.find((arg) => arg.startsWith('--output='))?.slice(9); +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +const scenarios = [ + { name: 'empty-node', command: '' }, + { + name: 'log-1MiB', + command: 'process.stdout.write("x".repeat(1024 * 1024))', + bytes: 1024 * 1024, + }, + { name: 'spawn-failure', shell: '/nonexistent-ql-benchmark-shell' }, +]; + +(async () => { + const report = { + schema: 1, + observerSha256: crypto + .createHash('sha256') + .update(fs.readFileSync('back/shared/childProcess.ts')) + .digest('hex'), + benchmarkSha256: crypto + .createHash('sha256') + .update(fs.readFileSync(__filename)) + .digest('hex'), + scope: + 'local process observer only; parent CPU excludes child CPU; no panel/container load measured', + sourceCommit: git('rev-parse', 'HEAD'), + dirty: git('status', '--porcelain') !== '', + lockfileSha256: crypto + .createHash('sha256') + .update(fs.readFileSync('pnpm-lock.yaml')) + .digest('hex'), + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: os.cpus()[0]?.model, + cores: os.cpus().length, + timestamp: new Date().toISOString(), + runs, + scenarios: [], + }; + for (const scenario of scenarios) { + const samples = []; + const cpuStart = process.cpuUsage(); + for (let index = 0; index < runs; index++) { + const start = performance.now(); + let bytes = 0; + const child = scenario.shell + ? spawn('true', { shell: scenario.shell }) + : spawn(process.execPath, ['-e', scenario.command]); + const result = await observeChildProcess(child, { + onStdout: async (chunk) => { + bytes += Buffer.byteLength(chunk); + }, + }).completed; + if (scenario.shell ? !result.error : result.error || result.code !== 0) + throw new Error(`Unexpected result: ${scenario.name}`); + if (bytes !== (scenario.bytes || 0)) + throw new Error(`Output loss: ${scenario.name}`); + samples.push({ + wallMs: performance.now() - start, + bytes, + code: result.code, + error: result.error?.message, + }); + } + const sorted = samples.map((sample) => sample.wallMs).sort((a, b) => a - b); + const percentile = (p) => sorted[Math.ceil(sorted.length * p) - 1]; + report.scenarios.push({ + name: scenario.name, + parentCpuMicros: process.cpuUsage(cpuStart), + p50Ms: percentile(0.5), + p95Ms: percentile(0.95), + ...(runs >= 100 ? { p99Ms: percentile(0.99) } : {}), + samples, + }); + } + const json = JSON.stringify(report, null, 2) + '\n'; + if (output) fs.writeFileSync(output, json); + else process.stdout.write(json); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/write-build-info.cjs b/scripts/write-build-info.cjs new file mode 100644 index 00000000..44358b7b --- /dev/null +++ b/scripts/write-build-info.cjs @@ -0,0 +1,27 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { collectBuildFiles } = require('../docker/build-manifest.cjs'); + +const root = process.cwd(); +for (const output of ['static/build/app.js', 'static/dist/index.html']) { + if (!fs.existsSync(path.join(root, output))) + throw new Error(`Missing build output: ${output}`); +} +const git = (...args) => + execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +const manifest = { + version: 1, + files: collectBuildFiles(), + sourceCommit: git('rev-parse', 'HEAD'), + dirty: git('status', '--porcelain', '--untracked-files=all') !== '', + lockfileSha256: crypto + .createHash('sha256') + .update(fs.readFileSync('pnpm-lock.yaml')) + .digest('hex'), +}; +fs.writeFileSync( + 'static/build-info.json', + JSON.stringify(manifest, null, 2) + '\n', +); diff --git a/shell/api.sh b/shell/api.sh index 59f3b5f9..2cf9a9ca 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -10,16 +10,39 @@ create_token() { } get_token() { - if [[ -f $file_auth_token ]]; then - __ql_token__=$(cat $file_auth_token | jq -r .value) - local expiration=$(cat $file_auth_token | jq -r .expiration) - local currentTimeStamp=$(date +%s) - if [[ $currentTimeStamp -ge $expiration ]]; then - create_token + local token_data expiration token_value + local currentTimeStamp=${EPOCHSECONDS:-$(date +%s)} + if [[ -f "$file_auth_token" ]] && token_data=$(jq -er \ + 'select((.expiration | type) == "number" and (.value | type) == "string" and (.value | length) > 0 and (.value | test("[\r\n]") | not)) | .expiration, .value' \ + "$file_auth_token" 2>/dev/null); then + expiration=${token_data%%$'\n'*} + token_value=${token_data#*$'\n'} + if [[ "$expiration" =~ ^[1-9][0-9]{0,10}$ && -n "$token_value" && "$token_value" != *$'\n'* && "$token_value" != *$'\r'* ]] && \ + (( currentTimeStamp < expiration )); then + __ql_token__=$token_value + return 0 fi - else - create_token fi + create_token +} + +# Read the status response once; preserve multiline error messages and the +# legacy code/message variables used by the shell API callers. +ql_parse_status_response() { + # Both task status and statistics endpoints normally return this exact body. + # Match the whole document; all other JSON and malformed responses still + # use jq. Keep its missing-message result for existing callers. + if [[ "$1" == '{"code":200}' ]]; then + code=200 + message=null + return 0 + fi + local parsed + code="" + message="" + parsed=$(jq -r '.code, .message' <<< "$1") || return $? + code=${parsed%%$'\n'*} + message=${parsed#*$'\n'} } add_cron_api() { @@ -142,7 +165,7 @@ update_cron() { local lastExecutingTime="${5:-0}" local runningTime="${6:-0}" local exitCode="${7:-}" - local currentTimeStamp=$(date +%s) + local currentTimeStamp=${EPOCHSECONDS:-$(date +%s)} local dataRaw="{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime" if [[ -n $exitCode ]]; then dataRaw="${dataRaw},\"exit_code\":$exitCode" @@ -156,8 +179,7 @@ update_cron() { --data-raw "$dataRaw" \ --compressed ) - code=$(echo "$api" | jq -r .code) - message=$(echo "$api" | jq -r .message) + ql_parse_status_response "$api" || true if [[ $code != 200 ]]; then if [[ ! $message ]]; then message="$api" @@ -240,8 +262,7 @@ record_cron_stat() { --data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}" \ --compressed ) - code=$(echo "$api" | jq -r .code) - message=$(echo "$api" | jq -r .message) + ql_parse_status_response "$api" || true if [[ $code != 200 ]]; then if [[ ! $message ]]; then message="$api" diff --git a/shell/node_path_cache.sh b/shell/node_path_cache.sh new file mode 100644 index 00000000..69a0691f --- /dev/null +++ b/shell/node_path_cache.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Cache discovery only, never the installed packages. Ordinary installs at the +# same root are visible immediately. TTL bounds changes outside known inputs. +ql_node_path_cache_key() { + local pnpm_bin node_bin name directory file + pnpm_bin=$(type -P pnpm) || return 1 + node_bin=$(type -P node) || return 1 + { + printf '%s\n' 'v1' "$EUID" "$PWD" "$PATH" "${HOME:-}" \ + "${PNPM_HOME:-}" "${XDG_CONFIG_HOME:-}" "${XDG_DATA_HOME:-}" \ + "$pnpm_bin" "$node_bin" + for name in ${!npm_config_@} ${!NPM_CONFIG_@}; do + printf '%s=%s\n' "$name" "${!name}" + done + # Follow executable symlinks, detecting upgrades at the same command path. + stat -Lc '%n:%i:%s:%Y:%Z' "$pnpm_bin" "$node_bin" 2>/dev/null || \ + stat -Lf '%N:%i:%z:%m:%c' "$pnpm_bin" "$node_bin" 2>/dev/null + directory=$PWD + while :; do + for file in "$directory/.npmrc" "$directory/pnpm-workspace.yaml"; do + if [[ -f "$file" ]]; then + printf '%s\n' "$file" + cat -- "$file" + printf '\n' + fi + done + [[ "$directory" == / ]] && break + directory=${directory%/*} + [[ -n "$directory" ]] || directory=/ + done + for file in "${HOME:-}/.npmrc" \ + "${XDG_CONFIG_HOME:-${HOME:-}/.config}/pnpm/rc" \ + "${npm_config_userconfig:-${NPM_CONFIG_USERCONFIG:-/dev/null}}" \ + "${npm_config_globalconfig:-${NPM_CONFIG_GLOBALCONFIG:-/dev/null}}"; do + if [[ -f "$file" ]]; then + printf '%s\n' "$file" + cat -- "$file" + printf '\n' + fi + done + } | cksum +} + +ql_read_node_path_cache() { + local cache="$1" key="$2" now + local stored_key="" stored_at="" stored_path="" + now=${EPOCHSECONDS:-$(date +%s)} + if [[ -f "$cache" && ! -L "$cache" && -O "$cache" ]]; then + if ! { + IFS= read -r stored_key && IFS= read -r stored_at && IFS= read -r stored_path + } < "$cache"; then + return 1 + fi + if [[ "$stored_key" == "$key" && "$stored_at" =~ ^(0|[1-9][0-9]{0,10})$ && "$stored_path" == /* ]] && \ + (( now >= stored_at && now - stored_at < 60 )); then + printf '%s\n' "$stored_path" + return 0 + fi + fi + return 1 +} + +# Keep the lock descriptor in a subshell so sourcing this helper never changes +# the caller's descriptors. A crashed refresher releases the kernel lock. +ql_refresh_node_global_path() ( + local cache="$1" key="$2" now result previous_umask + local lock="${cache}.lock" + previous_umask=$(umask) + umask 077 + if type -P flock &>/dev/null && mkdir -p -- "$dir_tmp" 2>/dev/null; then + if [[ ! -L "$lock" && ( ! -e "$lock" || ( -f "$lock" && -O "$lock" ) ) ]] && \ + { exec 9>> "$lock"; } 2>/dev/null; then + # Bound the wait; absent/unsupported flock or contention falls back to + # independent discovery. Never remove the lock file while waiters exist. + if flock -w 2 9 2>/dev/null; then + if ql_read_node_path_cache "$cache" "$key"; then + return 0 + fi + fi + fi + fi + + # Private lock creation must not change pnpm's inherited creation mask. + umask "$previous_umask" + now=${EPOCHSECONDS:-$(date +%s)} + result=$(pnpm root -g 9>&- 2>/dev/null) || return $? + # Never cache failed, empty, multiline or non-absolute answers. + if [[ "$result" == /* && "$result" != *$'\n'* && "$result" != *$'\r'* ]]; then + ( + umask 077 + mkdir -p -- "$dir_tmp" || exit 0 + local temporary + temporary=$(mktemp "${cache}.XXXXXX") || exit 0 + if printf '%s\n%s\n%s\n' "$key" "$now" "$result" > "$temporary"; then + mv -f -- "$temporary" "$cache" || rm -f -- "$temporary" + else + rm -f -- "$temporary" + fi + ) 2>/dev/null + fi + printf '%s\n' "$result" +) + +ql_get_node_global_path() { + if [[ "${QL_NODE_PATH_CACHE:-1}" == 0 ]]; then + pnpm root -g 2>/dev/null + return $? + fi + + local key cache + cache="${dir_tmp}/pnpm-root-${EUID}.cache" + key=$(ql_node_path_cache_key) || { pnpm root -g 2>/dev/null; return $?; } + if ql_read_node_path_cache "$cache" "$key"; then + return 0 + fi + ql_refresh_node_global_path "$cache" "$key" +} diff --git a/shell/otask.sh b/shell/otask.sh index d311cce7..5dcb579d 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -95,7 +95,13 @@ append_node_dependency_path() { # 用户依赖目录加入 NODE_PATH,替代 symlink 到 node_modules 的方式 export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${dir_dep}" - local pnpm_global_path=$(pnpm root -g 2>/dev/null) + local pnpm_global_path + if [[ -f "$dir_shell/node_path_cache.sh" ]]; then + . "$dir_shell/node_path_cache.sh" + pnpm_global_path=$(ql_get_node_global_path) || true + else + pnpm_global_path=$(pnpm root -g 2>/dev/null) || true + fi if [[ -n "$pnpm_global_path" ]]; then export QL_NODE_GLOBAL_PATH="$pnpm_global_path" export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${pnpm_global_path}" diff --git a/shell/share.sh b/shell/share.sh index 2f02840e..8a38a746 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -368,9 +368,16 @@ format_timestamp() { get_env_array() { exported_variables=() - while IFS= read -r line; do - exported_variables+=("$line") - done < <(grep '^export ' $file_env | awk '{print $2}' | cut -d= -f1) + # Preserve the legacy export-line/second-field rules without evaluating values. + local export_name_program='/^export / { name = $2; sub(/=.*/, "", name); print name }' + if [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then + builtin mapfile -t exported_variables < <(awk "$export_name_program" "$file_env") + else + # macOS still ships Bash 3, which does not provide mapfile. + while IFS= read -r line; do + exported_variables+=("$line") + done < <(awk "$export_name_program" "$file_env") + fi } clear_env() { @@ -411,9 +418,14 @@ run_task_after() { } handle_task_end() { - local etime=$(date "+$time_format") - local end_time=$(format_time "$time_format" "$etime") - local end_timestamp=$(format_timestamp "$time_format" "$etime") + local etime end_time end_timestamp + if [[ $is_macos -ne 1 && $time_format == '%Y-%m-%d %H:%M:%S' ]]; then + IFS='|' read -r end_time end_timestamp < <(date "+$time_format|%s") + else + etime=$(date "+$time_format") + end_time=$(format_time "$time_format" "$etime") + end_timestamp=$(format_timestamp "$time_format" "$etime") + fi local diff_time=$(($end_timestamp - $begin_timestamp)) local exit_code="${_task_exit_code:-0}" [[ "$diff_time" == 0 ]] && diff_time=1 diff --git a/shell/task.sh b/shell/task.sh index e18e18cb..a98bd827 100755 --- a/shell/task.sh +++ b/shell/task.sh @@ -52,8 +52,14 @@ handle_log_path() { fi fi - time=$(date "+$mtime_format") - log_time=$(format_log_time "$mtime_format" "$time") + if [[ $is_macos -ne 1 && $mtime_format == '%Y-%m-%d %H:%M:%S.%3N' ]]; then + # Render both representations from one clock snapshot, without parsing it + # again in a second date process. + IFS='|' read -r time log_time < <(date "+$mtime_format|%Y-%m-%d-%H-%M-%S-%3N") + else + time=$(date "+$mtime_format") + log_time=$(format_log_time "$mtime_format" "$time") + fi if [[ -z $log_name ]]; then log_dir_tmp="${file_param##*/}" if [[ $file_param =~ "/" ]]; then @@ -124,7 +130,11 @@ format_params() { } init_begin_time() { - begin_time=$(format_time "$time_format" "$time") + if [[ $is_macos -ne 1 && $mtime_format == '%Y-%m-%d %H:%M:%S.%3N' && $time_format == '%Y-%m-%d %H:%M:%S' ]]; then + begin_time=${time%.*} + else + begin_time=$(format_time "$time_format" "$time") + fi begin_timestamp=$(format_timestamp "$time_format" "$time") } diff --git a/test/back/build-provenance.test.cjs b/test/back/build-provenance.test.cjs new file mode 100644 index 00000000..d473f3b1 --- /dev/null +++ b/test/back/build-provenance.test.cjs @@ -0,0 +1,150 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); + +test('build verification accepts matching source and rejects stale or dirty artifacts', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-source-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const git = (...args) => + execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); + git('init'); + git('config', 'user.name', 'Build test'); + git('config', 'user.email', 'build-test@example.invalid'); + fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n'); + git('add', '.'); + git('commit', '-m', 'fixture'); + fs.mkdirSync(path.join(dir, 'static/build'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'static/build/app.js'), ''); + fs.writeFileSync(path.join(dir, 'static/dist/index.html'), ''); + const write = path.resolve('scripts/write-build-info.cjs'); + const verify = path.resolve('docker/verify-build.cjs'); + execFileSync(process.execPath, [write], { cwd: dir }); + execFileSync(process.execPath, [verify], { cwd: dir }); + const manifestPath = path.join(dir, 'static/build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath)); + for (const invalid of [ + { ...manifest, sourceCommit: '0'.repeat(40) }, + { ...manifest, dirty: true }, + { ...manifest, lockfileSha256: '0'.repeat(64) }, + ]) { + fs.writeFileSync(manifestPath, JSON.stringify(invalid)); + assert.notEqual( + spawnSync(process.execPath, [verify], { cwd: dir }).status, + 0, + ); + } +}); + +test('release images receive the same-run artifact and verify it before use', () => { + const yaml = require('js-yaml'); + const workflow = yaml.load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + assert.equal(workflow.jobs['build-static'].needs, 'validate'); + const upload = workflow.jobs['build-static'].steps.find(step => step.uses?.startsWith('actions/upload-artifact@')); + assert.equal(upload.with['include-hidden-files'], true); + for (const name of [ + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) { + const job = workflow.jobs[name]; + assert.equal(job.needs, 'build-static'); + const download = job.steps.find((step) => + step.uses?.startsWith('actions/download-artifact@'), + ); + assert.equal(download.with.name, 'qinglong-static-${{ github.sha }}'); + assert.equal(download.with.path, 'static/'); + const build = job.steps.find((step) => + step.uses?.startsWith('docker/build-push-action@'), + ); + assert.match( + build.with['build-args'], + /SOURCE_COMMIT=\$\{\{ github.sha \}\}/, + ); + const dockerfile = fs.readFileSync(build.with.file, 'utf8'); + assert.doesNotMatch(dockerfile, /git clone.*qinglong-static/); + assert.match( + dockerfile, + /git fetch --depth=1 origin "\$\{SOURCE_COMMIT\}"/, + ); + assert.match(dockerfile, /node \/tmp\/verify-build.cjs/); + } +}); + +test('complete artifact manifests reject changed, missing, extra files and untracked build inputs', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-manifest-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const git = (...args) => + execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); + git('init'); + git('config', 'user.name', 'Build test'); + git('config', 'user.email', 'build-test@example.invalid'); + fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n'); + fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n'); + git('add', '.'); + git('commit', '-m', 'fixture'); + const artifacts = [ + 'build/app.js', + 'build/services/http.js', + 'dist/index.html', + 'dist/chunks/main.js', + 'dist/assets/main.css', + 'dist/.well-known/config', + ]; + for (const file of artifacts) { + fs.mkdirSync(path.dirname(path.join(dir, 'static', file)), { + recursive: true, + }); + fs.writeFileSync(path.join(dir, 'static', file), file); + } + const write = path.resolve('scripts/write-build-info.cjs'); + const verify = path.resolve('docker/verify-build.cjs'); + const run = (script) => + spawnSync(process.execPath, [script], { cwd: dir, encoding: 'utf8' }); + assert.equal(run(write).status, 0); + const manifestFile = path.join(dir, 'static/build-info.json'); + const original = fs.readFileSync(manifestFile, 'utf8'); + assert.deepEqual( + Object.keys(JSON.parse(original).files).sort(), + artifacts.sort(), + ); + assert.equal(run(write).status, 0); + assert.equal(fs.readFileSync(manifestFile, 'utf8'), original); + assert.equal(run(verify).status, 0); + for (const name of [ + 'build/services/http.js', + 'dist/chunks/main.js', + 'dist/assets/main.css', + 'dist/.well-known/config', + ]) { + const file = path.join(dir, 'static', name); + fs.writeFileSync(file, 'stale'); + assert.notEqual(run(verify).status, 0); + fs.unlinkSync(file); + assert.notEqual(run(verify).status, 0); + fs.writeFileSync(file, name); + assert.equal(run(verify).status, 0); + } + const extra = path.join(dir, 'static/build/stale.js'); + fs.writeFileSync(extra, 'extra'); + assert.notEqual(run(verify).status, 0); + fs.unlinkSync(extra); + const source = path.join(dir, 'custom.config.js'); + fs.writeFileSync(source, 'untracked build input'); + assert.equal(run(write).status, 0); + assert.equal(JSON.parse(fs.readFileSync(manifestFile)).dirty, true); + assert.notEqual(run(verify).status, 0); + fs.unlinkSync(source); + assert.equal(run(write).status, 0); + assert.equal(run(verify).status, 0); + fs.symlinkSync(path.join(dir, 'pnpm-lock.yaml'), extra); + assert.notEqual(run(write).status, 0); + assert.notEqual(run(verify).status, 0); +}); diff --git a/test/back/env-name-parsing.test.cjs b/test/back/env-name-parsing.test.cjs new file mode 100644 index 00000000..05f99bf1 --- /dev/null +++ b/test/back/env-name-parsing.test.cjs @@ -0,0 +1,96 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const source = fs.readFileSync('shell/share.sh', 'utf8'); +function extract(name) { + const start = source.indexOf(name + '() {'); + assert.ok(start >= 0); + return source.slice(start, source.indexOf('\n}', start) + 2); +} +function fixture(t, content) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-names-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const file = path.join(root, 'env file.sh'); + fs.writeFileSync(file, content); + return { + root, + file, + run(script) { + const r = spawnSync( + '/bin/bash', + [ + '-ec', + extract('get_env_array') + + '\n' + + extract('clear_env') + + '\n' + + script, + ], + { + encoding: 'utf8', + env: { ...process.env, file_env: file, FIXTURE_ROOT: root }, + }, + ); + assert.equal(r.status, 0, r.stderr); + return r.stdout; + }, + }; +} +test('matches legacy pipeline for whitespace, malformed lines, CRLF and final unterminated line', (t) => { + const f = fixture( + t, + '# comment\nexport A=one\nexport B="two words"\n export IGNORED=1\nexport\tIGNORED2=1\nexport C = spaced\nexport \nexport -x X=1\nexport D=a=b\nexport E=crlf\r\nexport F\r\nexport G=last', + ); + assert.equal( + f.run('get_env_array; printf "%s\\0" "${exported_variables[@]}"'), + f.run( + 'while IFS= read -r line; do printf "%s\\0" "$line"; done < <(grep "^export " "$file_env" | awk \'{print $2}\' | cut -d= -f1)', + ), + ); +}); +test('resets prior names for an empty file', (t) => { + const f = fixture(t, ''); + assert.equal( + f.run( + 'exported_variables=(OLD); get_env_array; echo "${#exported_variables[@]}"', + ), + '0\n', + ); +}); +test('extracting names never evaluates command substitutions or shell syntax in values', (t) => { + const f = fixture( + t, + 'export A=$(touch "$FIXTURE_ROOT/unsafe")\nexport B=`touch "$FIXTURE_ROOT/unsafe2"`\n', + ); + assert.equal( + f.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"'), + 'A\nB\n', + ); + assert.equal(fs.existsSync(path.join(f.root, 'unsafe')), false); + assert.equal(fs.existsSync(path.join(f.root, 'unsafe2')), false); +}); +test('clear_env removes listed exports and preserves unrelated values', (t) => { + const f = fixture(t, 'export USER_ONE=1\nexport USER_TWO=2\n'); + f.run( + 'USER_ONE=old; USER_TWO=old; UNRELATED=kept; get_env_array; clear_env; [[ ! ${USER_ONE+x} && ! ${USER_TWO+x} && "$UNRELATED" == kept ]]', + ); +}); +test('large configuration preserves order and duplicate names', (t) => { + const text = Array.from( + { length: 5000 }, + (_, i) => 'export V' + (i % 1000) + '=value with spaces', + ).join('\n'); + const f = fixture(t, text); + const lines = f + .run('get_env_array; printf "%s\\n" "${exported_variables[@]}"') + .trim() + .split('\n'); + assert.equal(lines.length, 5000); + assert.deepEqual( + lines, + Array.from({ length: 5000 }, (_, i) => 'V' + (i % 1000)), + ); +}); diff --git a/test/back/execution-lifecycle.test.cjs b/test/back/execution-lifecycle.test.cjs new file mode 100644 index 00000000..3e0b9e91 --- /dev/null +++ b/test/back/execution-lifecycle.test.cjs @@ -0,0 +1,236 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { spawn } = require('node:child_process'); +const { EventEmitter } = require('node:events'); +const { PassThrough } = require('node:stream'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const { observeChildProcess } = require('../../back/shared/childProcess'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const logger = { info() {}, error() {} }; + +test( + 'spawn failure settles without exit, and the next scheduled run can proceed', + { timeout: 3000 }, + async () => { + let releases = 0; + let active = 0; + const { runCron } = load(path.resolve('back/shared/runCron.ts'), { + 'cross-spawn': { + spawn: () => spawn('true', { shell: '/nonexistent-ql-test-shell' }), + }, + './pLimit': { + runWithCronLimit: async (_cron, fn) => { + active++; + try { + return await fn(); + } finally { + active--; + } + }, + removeQueuedCron: () => releases++, + }, + '../loaders/logger': logger, + '../data/cron': { + CrontabModel: { findOne: async () => null }, + CrontabStatus: {}, + }, + '../data/runningInstance': { + RunningInstanceModel: {}, + InstanceStatus: {}, + }, + '../config/util': { killTask: async () => {} }, + }); + await runCron('true', { id: '1' }); + await runCron('true', { id: '2' }); + assert.equal(active, 0); + assert.equal(releases, 2); + }, +); + +test( + 'completion waits for slow log consumers and preserves the final output', + { timeout: 5000 }, + async () => { + const chunks = []; + const child = spawn(process.execPath, [ + '-e', + 'process.stdout.write("尾行\\n"); process.stderr.write("错误\\n")', + ]); + const observed = observeChildProcess(child, { + onStart: () => delay(25), + onStdout: async (message) => { + await delay(40); + chunks.push(message); + }, + onStderr: async (message) => { + await delay(30); + chunks.push(message); + }, + }); + const result = await observed.completed; + assert.equal(result.code, 0); + assert.equal(result.error, undefined); + assert.match(chunks.join(''), /尾行/); + assert.match(chunks.join(''), /错误/); + }, +); + +test('exit is not completion, and UTF-8 split across writes remains intact', async () => { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = 123; + const chunks = []; + const observed = observeChildProcess(child, { + onStdout: async (data) => chunks.push(data), + }); + child.emit('spawn'); + let done = false; + observed.completed.then(() => { + done = true; + }); + child.emit('exit', 0, null); + const data = Buffer.from('末尾中文'); + child.stdout.write(data.subarray(0, 2)); + await delay(10); + assert.equal(done, false); + child.stdout.end(data.subarray(2)); + child.stderr.end(); + child.emit('close', 0, null); + await observed.completed; + assert.equal(chunks.join(''), '末尾中文'); +}); + +test( + 'failed log sink drains large output instead of blocking the child', + { timeout: 5000 }, + async () => { + const child = spawn(process.execPath, [ + '-e', + 'process.stdout.write("x".repeat(2 * 1024 * 1024))', + ]); + const { completed } = observeChildProcess(child, { + onStdout: async () => { + throw new Error('ENOSPC'); + }, + }); + const result = await completed; + assert.equal(result.code, 0); + assert.equal(result.error.message, 'ENOSPC'); + }, +); + +function scheduleFixture() { + let active = 0; + const limit = async (_params, fn) => { + active++; + try { + return await fn(); + } finally { + active--; + } + }; + const Schedule = load(path.resolve('back/services/schedule.ts'), { + '../shared/pLimit': { + runWithScriptLimit: limit, + runWithSystemLimit: limit, + }, + 'cross-spawn': { + spawn: () => + spawn(process.execPath, [ + '-e', + 'setTimeout(() => process.stdout.write("done"), 150)', + ]), + }, + }).default; + return { service: new Schedule(logger), active: () => active }; +} + +test( + 'PID response keeps queue capacity occupied until cleanup completes', + { timeout: 4000 }, + async () => { + const fixture = scheduleFixture(); + let finish; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const pid = await fixture.service.runTask( + 'ignored', + { + onEnd: async () => { + await delay(20); + finish(); + }, + }, + { id: 'script', runOrigin: 'script' }, + 'start', + ); + assert.ok(pid > 0); + assert.equal(fixture.active(), 1); + await finished; + await delay(0); + assert.equal(fixture.active(), 0); + }, +); + +test( + 'before/error/end callback failures settle and release capacity', + { timeout: 3000 }, + async () => { + const fixture = scheduleFixture(); + let ended = 0; + const result = await fixture.service.runTask( + 'ignored', + { + onBefore: async () => { + throw new Error('setup failed'); + }, + onError: async () => { + throw new Error('sink failed'); + }, + onEnd: async (child) => { + assert.equal(child, undefined); + ended++; + throw new Error('cleanup failed'); + }, + }, + { id: 'system', runOrigin: 'system' }, + ); + assert.equal(result.error.message, 'setup failed'); + assert.equal(ended, 1); + assert.equal(fixture.active(), 0); + }, +); + +test( + 'concurrent writes and closes preserve all bytes, and failed files can be closed', + { timeout: 5000 }, + async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-lifecycle-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const manager = new LogStreamManager(dir); + const file = path.join(dir, 'out.log'); + const lines = Array.from({ length: 500 }, (_, i) => `${i}:末尾\n`); + const writes = lines.map((line) => manager.write(file, line)); + await Promise.all([ + ...writes, + manager.closeStream(file), + manager.closeStream(file), + ]); + assert.equal(await fs.readFile(file, 'utf8'), lines.join('')); + assert.equal(manager.getOpenStreamCount(), 0); + const invalid = path.join(dir, 'missing', 'out.log'); + await assert.rejects(manager.write(invalid, 'fail'), /ENOENT/); + await assert.rejects(manager.closeStream(invalid), /ENOENT/); + assert.equal(manager.getOpenStreamCount(), 0); + await manager.write(file, 'reopened'); + await manager.closeAll(); + assert.match(await fs.readFile(file, 'utf8'), /reopened$/); + }, +); diff --git a/test/back/http-exclusive-listen.test.cjs b/test/back/http-exclusive-listen.test.cjs new file mode 100644 index 00000000..da2157d8 --- /dev/null +++ b/test/back/http-exclusive-listen.test.cjs @@ -0,0 +1,31 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); +const load = require('../helpers/load-security-module.cjs'); +const Http = load('back/services/http.ts', { + '../config': {bindHost: '127.0.0.1'}, + '../loaders/logger': {debug(){},warn(){},error(){}}, + './metrics': {metricsService: {record(){}}}, + typedi: {Service:()=>x=>x}, +}).HttpServerService; +test('HTTP listener preserves private binding and releases its port after shutdown',async(t)=>{ + const app=express();app.get('/',(_q,r)=>r.send('ok')); + const first=new Http();const server=await first.initialize(app,0); + t.after(()=>first.shutdown()); + assert.equal(server.address().address,'127.0.0.1'); + const port=server.address().port; + const response=await fetch(`http://127.0.0.1:${port}/`,{headers:{connection:'close'}});assert.equal(await response.text(),'ok'); + await assert.rejects(new Http().initialize(app,port),e=>e.code==='EADDRINUSE'); + await first.shutdown();const replacement=new Http();t.after(()=>replacement.shutdown()); + const next=await replacement.initialize(app,port);assert.equal(next.address().port,port); +}); +test('custom cluster deployments can restore shared listening',async(t)=>{ + const previous=process.env.QL_HTTP_SHARED_LISTEN; + t.after(()=>{if(previous===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=previous;}); + for(const value of [undefined,'true']){ + if(value===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=value; + let options;const service=new Http();const app=express();const listen=app.listen.bind(app); + app.listen=(opts,cb)=>{options=opts;return listen(opts,cb);}; + await service.initialize(app,0);assert.equal(options.exclusive,value!=='true');await service.shutdown(); + } +}); diff --git a/test/back/log-path-security.test.cjs b/test/back/log-path-security.test.cjs new file mode 100644 index 00000000..b925b977 --- /dev/null +++ b/test/back/log-path-security.test.cjs @@ -0,0 +1,158 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +async function fixture(t) { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-boundary-')); + t.after(() => fs.rm(base, { recursive: true, force: true })); + const root = path.join(base, 'log'); + const outside = path.join(base, 'log-other'); + await fs.mkdir(root); + await fs.mkdir(outside); + const victim = path.join(outside, 'victim.log'); + await fs.writeFile(victim, 'unchanged'); + await fs.symlink(outside, path.join(root, 'escape-dir')); + await fs.symlink(victim, path.join(root, 'escape-file')); + await fs.symlink( + path.join(outside, 'missing.log'), + path.join(root, 'dangling'), + ); + const invalid = [ + path.join(root, '..', 'log-other', 'new.log'), + path.join(root, 'escape-dir', 'new.log'), + path.join(root, 'escape-file'), + path.join(root, 'dangling'), + root, + path.join(root, 'bad\0name'), + ]; + return { root, outside, victim, invalid }; +} + +test('log streams reject traversal, sibling prefixes and escaping symlinks before writing', async (t) => { + const { root, outside, victim, invalid } = await fixture(t); + const manager = new LogStreamManager(root); + for (const target of invalid) { + await assert.rejects( + manager.write(target, 'overwrite'), + /outside the log directory/, + ); + await assert.rejects( + manager.closeStream(target), + /outside the log directory/, + ); + assert.equal(manager.getOpenStreamCount(), 0); + } + assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged'); + assert.deepEqual(await fs.readdir(outside), ['victim.log']); + const folder = path.join(root, '中文 日志'); + await fs.mkdir(folder); + const log = path.join(folder, 'task.log'); + await Promise.all([ + manager.write(log, '开始\n'), + manager.write(log, '结束\n'), + ]); + await manager.closeAll(); + assert.equal(await fs.readFile(log, 'utf8'), '开始\n结束\n'); +}); + +test('log initialization rejects unsafe paths before mkdir or file writes', async (t) => { + const { root, outside, victim, invalid } = await fixture(t); + const { handleLogPath } = load(path.resolve('back/config/util.ts'), { + './index': { logPath: root }, + './share': {}, + '../loaders/logger': {}, + '../shared/utils': { + writeFileWithLock: (file, data) => fs.writeFile(file, data), + }, + '../data/dependence': { DependenceTypes: {} }, + }); + for (const target of invalid) { + await assert.rejects( + handleLogPath(target, 'overwrite'), + /outside the log directory/, + ); + } + await assert.rejects( + handleLogPath('../log-other/new/sub/task.log', 'overwrite'), + /outside the log directory/, + ); + assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged'); + assert.deepEqual(await fs.readdir(outside), ['victim.log']); + const log = await handleLogPath('中文 日志/nested/task.log', 'initial'); + assert.equal(await fs.readFile(log, 'utf8'), 'initial'); + assert.equal(await handleLogPath(log, 'ignored'), log); + assert.equal(await fs.readFile(log, 'utf8'), 'initial'); +}); + +test('manual execution rejects escaping log names before creating directories or spawning', async (t) => { + const { root, outside, victim } = await fixture(t); + let spawned = 0; + let releases = 0; + const errors = []; + const CronService = load(path.resolve('back/services/cron.ts'), { + '../config': { logPath: root }, + '../data/cron': { + CrontabStatus: { queued: 3, idle: 1 }, + CrontabModel: { update: async () => {} }, + }, + '../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} }, + '../config/util': {}, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: async (fn) => { + try { + return await fn(); + } finally { + releases++; + } + }, + }, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': { + logStreamManager: { closeStream: async () => {} }, + }, + 'cross-spawn': { + spawn: () => { + spawned++; + throw new Error('must not spawn'); + }, + }, + }).default; + const service = new CronService({ + info() {}, + error: (...args) => errors.push(args), + }); + for (const log_name of [ + '../log-other/new', + outside, + 'escape-dir/new', + 'dangling', + 'bad\0name', + ]) { + service.getDb = async () => ({ + id: 1, + status: 3, + command: 'ignored', + log_path: '', + log_name, + }); + await service.runSingle(1); + } + assert.equal(spawned, 0); + assert.equal(releases, 5); + assert.equal( + errors.filter((args) => + args.includes('Log path is outside the log directory'), + ).length, + 5, + ); + assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged'); + assert.deepEqual(await fs.readdir(outside), ['victim.log']); +}); diff --git a/test/back/manual-execution.test.cjs b/test/back/manual-execution.test.cjs new file mode 100644 index 00000000..d60897e2 --- /dev/null +++ b/test/back/manual-execution.test.cjs @@ -0,0 +1,85 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { spawn } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +test( + 'manual execution captures a short child before slow status storage and flushes before release', + { timeout: 5000 }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const logs = new LogStreamManager(root); + const updates = []; + let active = 0; + let failSpawn = false; + const CronService = load(path.resolve('back/services/cron.ts'), { + '../config': { logPath: root }, + '../data/cron': { + CrontabStatus: { queued: 3, running: 0, idle: 1 }, + CrontabModel: { + update: async (data, options) => { + await new Promise((resolve) => setTimeout(resolve, 40)); + updates.push({ data, options }); + return [1]; + }, + }, + }, + '../data/runningInstance': { + RunningInstanceModel: {}, + InstanceStatus: {}, + }, + '../config/util': { getUniqPath: async () => 'task' }, + '../config/const': { TASK_PREFIX: 'task ', QL_PREFIX: 'ql ' }, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: async (fn) => { + active++; + try { + return await fn(); + } finally { + active--; + } + }, + }, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': { logStreamManager: logs }, + 'cross-spawn': { + spawn: () => + failSpawn + ? spawn('true', { shell: '/nonexistent-ql-manual-shell' }) + : spawn(process.execPath, [ + '-e', + 'process.stdout.write("末尾\\n")', + ]), + }, + }).default; + const service = new CronService({ info() {}, error() {} }); + service.getDb = async () => ({ + id: 1, + status: 3, + command: 'ignored', + log_path: '', + }); + service.makeCommand = () => 'ignored'; + await service.runSingle(1); + const [file] = await fs.readdir(path.join(root, 'task')); + assert.equal( + await fs.readFile(path.join(root, 'task', file), 'utf8'), + '末尾\n', + ); + assert.equal(logs.getOpenStreamCount(), 0); + assert.equal(active, 0); + assert.equal(updates.at(-1).data.status, 1); + failSpawn = true; + await service.runSingle(1); + assert.equal(active, 0); + assert.equal(updates.at(-1).data.status, 1); + }, +); diff --git a/test/back/manual-stop-claim.test.cjs b/test/back/manual-stop-claim.test.cjs new file mode 100644 index 00000000..91866276 --- /dev/null +++ b/test/back/manual-stop-claim.test.cjs @@ -0,0 +1,215 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); +const { killTask } = require('../../back/config/util'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +for (const conflict of ['stop', 'newer-queue', 'stop-requeue']) { + test( + `manual startup loses its conditional claim after ${conflict} and terminates the late child`, + { timeout: 10000 }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-claim-')); + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(async () => { + await db.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + const crons = db.define('Cron', { + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, + command: DataTypes.STRING, + }); + const instances = db.define('Instance', { + cron_id: DataTypes.INTEGER, + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + }); + await db.sync(); + await crons.create({ + id: 1, + status: 3, + pid: null, + log_path: 'previous.log', + command: 'ignored', + }); + let release, + entered, + child, + released = 0; + const pending = new Promise((resolve) => (entered = resolve)); + const gate = new Promise((resolve) => (release = resolve)); + const logs = new LogStreamManager(root); + t.after(() => logs.closeAll()); + const CronService = load('back/services/cron.ts', { + '../config': { logPath: root }, + '../data/cron': { + CrontabModel: crons, + CrontabStatus: { queued: 3, running: 0, idle: 1 }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, stopped: 2 }, + }, + '../config/util': { + getUniqPath: async () => { + entered(); + await gate; + return 'task'; + }, + killTask, + }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: async (fn) => { + try { + return await fn(); + } finally { + released++; + } + }, + }, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': { logStreamManager: logs }, + 'cross-spawn': { + spawn: () => { + child = spawn(process.execPath, [ + '-e', + 'setInterval(() => {}, 1000)', + ]); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + return child; + }, + }, + }).default; + const service = new CronService({ info() {}, error() {} }); + service.getDb = async () => + (await crons.findByPk(1)).get({ plain: true }); + service.makeCommand = () => 'ignored'; + const running = service.runSingle(1); + await pending; + if (conflict === 'stop-requeue') { + await service.stop([1]); + // Queue again through the real API; leave the new runner pending. + const pendingRuns = []; + service.runSingle = (id, token) => pendingRuns.push({ id, token }); + await service.run([1]); + assert.equal(pendingRuns.length, 1); + assert.equal( + pendingRuns[0].token, + (await crons.findByPk(1)).queued_token, + ); + } else if (conflict === 'stop') await service.stop([1]); + else await crons.update({ log_path: 'newer.log' }, { where: { id: 1 } }); + release(); + const result = await running; + assert.match(result.error.message, /stopped or superseded/); + assert.ok(child.signalCode || child.exitCode !== null); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + const row = await crons.findByPk(1); + assert.equal(row.status, conflict === 'stop' ? 1 : 3); + assert.equal( + row.log_path, + conflict === 'newer-queue' ? 'newer.log' : 'previous.log', + ); + assert.equal(row.pid, null); + assert.equal(released, 1); + }, + ); +} + +test( + 'verified termination waits for a SIGTERM-resistant target to exit', + { timeout: 5000 }, + async (t) => { + const child = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM",()=>{}); process.stdout.write("ready"); setInterval(()=>{},1000)', + ]); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + await new Promise((resolve) => child.stdout.once('data', resolve)); + await killTask(child.pid, true); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + assert.equal(child.signalCode, 'SIGKILL'); + }, +); + +test('a runner waiting for a concurrency slot cannot adopt a newer queued generation', async (t) => { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const crons = db.define('Cron', { + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, + }); + await db.sync(); + await crons.create({ id: 1, status: 1, log_path: 'same.log' }); + const waiting = []; + const CronService = load('back/services/cron.ts', { + '../config': {}, + '../data/cron': { + CrontabModel: crons, + CrontabStatus: { queued: 3, idle: 1, running: 0 }, + }, + '../data/runningInstance': { + RunningInstanceModel: { findAll: async () => [] }, + InstanceStatus: { running: 0 }, + }, + '../config/util': { + getUniqPath: () => assert.fail('stale runner must not prepare a child'), + }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: (fn) => { + waiting.push(fn); + return Promise.resolve(); + }, + }, + '../shared/utils': {}, + '../shared/i18n': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + 'cross-spawn': { spawn: () => assert.fail('stale runner must not spawn') }, + }).default; + const errors = []; + const service = new CronService({ error: (...args) => errors.push(args) }); + await service.run([1]); + const first = (await crons.findByPk(1)).queued_token; + await service.stop([1]); + await service.run([1]); + const second = (await crons.findByPk(1)).queued_token; + assert.notEqual(first, second); + assert.equal(waiting.length, 2); + await waiting[0](); + const row = await crons.findByPk(1); + assert.equal(row.status, 3); + assert.equal(row.queued_token, second); + assert.equal(row.log_path, 'same.log'); + assert.deepEqual(errors, []); +}); diff --git a/test/back/node-path-cache.test.cjs b/test/back/node-path-cache.test.cjs new file mode 100644 index 00000000..08dab052 --- /dev/null +++ b/test/back/node-path-cache.test.cjs @@ -0,0 +1,173 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync, spawn } = require('node:child_process'); +const helper = path.resolve('shell/node_path_cache.sh'); +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-path-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.symlinkSync(process.execPath, path.join(bin, 'node')); + const calls = path.join(root, 'calls'); + fs.writeFileSync( + path.join(bin, 'pnpm'), + '#!/bin/bash\necho call >> "$CALLS"\n[[ "${FAIL:-0}" == 1 ]] && exit 1\nprintf "%s\\n" "${ANSWER:-/test/global/node_modules}"\n', + { mode: 0o755 }, + ); + const env = { + ...process.env, + HOME: root, + PATH: `${bin}:${process.env.PATH}`, + dir_tmp: path.join(root, 'cache'), + CALLS: calls, + HELPER: helper, + }; + const command = '. "$HELPER"; ql_get_node_global_path'; + const run = (extra = {}, code = command) => + spawnSync('/bin/bash', ['-uc', code], { + cwd: root, + env: { ...env, ...extra }, + encoding: 'utf8', + }); + const count = () => + fs.existsSync(calls) + ? fs.readFileSync(calls, 'utf8').trim().split('\n').length + : 0; + return { root, bin, env, command, run, count }; +} +test('warm cache avoids pnpm, preserves spaces, and never evaluates cached text', (t) => { + const f = fixture(t); + const answer = '/test/path with spaces/$(touch injected)'; + assert.equal(f.run({ ANSWER: answer }).stdout.trim(), answer); + assert.equal(f.run({ ANSWER: '/different' }).stdout.trim(), answer); + assert.equal(f.count(), 1); + assert.equal(fs.existsSync(path.join(f.root, 'injected')), false); +}); +test('config, environment, cwd, and executable changes invalidate discovery', (t) => { + const f = fixture(t); + f.run(); + fs.writeFileSync(path.join(f.root, '.npmrc'), 'global-dir=/other\n'); + f.run(); + assert.equal(f.count(), 2); + f.run({ npm_config_global_dir: '/third' }); + assert.equal(f.count(), 3); + f.run({ PNPM_HOME: '/fourth' }); + assert.equal(f.count(), 4); + fs.mkdirSync(path.join(f.root, 'child')); + f.run({}, 'cd child; ' + f.command); + assert.equal(f.count(), 5); + f.run(); + const before = f.count(); + fs.appendFileSync(path.join(f.bin, 'pnpm'), '# upgrade\n'); + f.run(); + assert.equal(f.count(), before + 1); +}); +test('expired or malformed records refresh and failed lookups are not cached', (t) => { + const f = fixture(t); + f.run(); + const file = path.join( + f.env.dir_tmp, + fs.readdirSync(f.env.dir_tmp).find((name) => name.endsWith('.cache')), + ); + let lines = fs.readFileSync(file, 'utf8').split('\n'); + lines[1] = '0'; + fs.writeFileSync(file, lines.join('\n')); + f.run(); + assert.equal(f.count(), 2); + fs.writeFileSync(file, lines[0] + '\n08\n/incorrect\n'); + assert.equal(f.run().status, 0); + assert.equal(f.count(), 3); + fs.writeFileSync(file, lines[0]); + assert.equal(f.run().status, 0); + assert.equal(f.count(), 4); + fs.rmSync(file); + assert.equal(f.run({ FAIL: '1' }).status, 1); + assert.equal(fs.existsSync(file), false); + assert.equal(f.run().status, 0); + assert.equal(f.count(), 6); +}); +test('disabled or unavailable cache falls back without changing discovery output', (t) => { + const f = fixture(t); + for (let i = 0; i < 2; i++) + assert.equal( + f.run({ QL_NODE_PATH_CACHE: '0' }).stdout.trim(), + '/test/global/node_modules', + ); + assert.equal(f.count(), 2); + assert.equal(fs.existsSync(f.env.dir_tmp), false); + const blocked = path.join(f.root, 'file'); + fs.writeFileSync(blocked, ''); + assert.equal( + f.run({ dir_tmp: blocked }).stdout.trim(), + '/test/global/node_modules', + ); + assert.equal(f.count(), 3); +}); +test('concurrent refreshes publish complete records and do not overwrite symlink targets', async (t) => { + const f = fixture(t); + await Promise.all( + Array.from( + { length: 8 }, + () => + new Promise((resolve, reject) => { + const p = spawn('/bin/bash', ['-uc', f.command], { + cwd: f.root, + env: f.env, + }); + let out = ''; + p.stdout.on('data', (c) => (out += c)); + p.once('error', reject); + p.once('close', (code) => { + try { + assert.equal(code, 0); + assert.equal(out.trim(), '/test/global/node_modules'); + resolve(); + } catch (e) { + reject(e); + } + }); + }), + ), + ); + const before = f.count(); + f.run(); + assert.equal(f.count(), before); + const files = fs + .readdirSync(f.env.dir_tmp) + .filter((file) => file.endsWith('.cache')); + assert.equal(files.length, 1); + const file = path.join(f.env.dir_tmp, files[0]); + fs.rmSync(file); + const victim = path.join(f.root, 'victim'); + fs.writeFileSync(victim, 'unchanged'); + fs.symlinkSync(victim, file); + assert.equal(f.run().status, 0); + assert.equal(fs.readFileSync(victim, 'utf8'), 'unchanged'); + assert.equal(fs.lstatSync(file).isSymbolicLink(), false); +}); + +test('task entry tolerates discovery failure with errexit enabled', (t) => { + const f = fixture(t); + const source = fs.readFileSync(path.resolve('shell/otask.sh'), 'utf8'); + const start = source.indexOf('append_node_dependency_path() {'); + const end = source.indexOf('\nenter_script_workdir()', start); + const script = + source.slice(start, end) + + '\nappend_node_dependency_path; printf "continued:%s" "$NODE_PATH"'; + for (const dir of [path.dirname(helper), f.root]) { + const r = f.run( + { + FAIL: '1', + dir_shell: dir, + dir_dep: '/legacy/deps', + NODE_PATH: '/existing', + }, + 'set -e; ' + script, + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'continued:/existing:/legacy/deps'); + } +}); diff --git a/test/back/node-path-lock.test.cjs b/test/back/node-path-lock.test.cjs new file mode 100644 index 00000000..94d03e32 --- /dev/null +++ b/test/back/node-path-lock.test.cjs @@ -0,0 +1,178 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const helper = path.resolve('shell/node_path_cache.sh'); +const hasFlock = spawnSync('/bin/bash', ['-c', 'type -P flock']).status === 0; +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-path-lock-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.symlinkSync(process.execPath, path.join(bin, 'node')); + fs.writeFileSync( + path.join(bin, 'pnpm'), + '#!/bin/bash\nprintf "call\\n" >> "$CALLS"\n[[ -n "${UMASK_FILE:-}" ]] && umask > "$UMASK_FILE"\nsleep "${LOOKUP_DELAY:-0.2}"\n[[ "${FAIL:-0}" == 1 ]] && exit 17\nprintf "%s\\n" "${npm_config_global_dir:-/test/global/node_modules}"\n', + { mode: 0o755 }, + ); + const env = { + ...process.env, + HOME: root, + PATH: bin + ':' + process.env.PATH, + dir_tmp: path.join(root, 'cache'), + CALLS: path.join(root, 'calls'), + HELPER: helper, + }; + const command = '. "$HELPER"; ql_get_node_global_path'; + const run = (extra = {}, code = command) => + spawnSync('/bin/bash', ['-euc', code], { + cwd: root, + env: { ...env, ...extra }, + encoding: 'utf8', + }); + const asyncRun = (extra = {}) => + new Promise((resolve, reject) => { + const child = spawn('/bin/bash', ['-euc', command], { + cwd: root, + env: { ...env, ...extra }, + }); + let out = '', + err = ''; + child.stdout.on('data', (c) => (out += c)); + child.stderr.on('data', (c) => (err += c)); + child.on('error', reject); + child.on('close', (status) => + resolve({ status, stdout: out, stderr: err }), + ); + }); + const count = () => + fs.existsSync(env.CALLS) + ? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length + : 0; + const cache = path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.cache`); + return { + root, + bin, + env, + command, + run, + asyncRun, + count, + cache, + lock: cache + '.lock', + }; +} +function check(r, output = '/test/global/node_modules') { + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), output); +} +test( + 'simultaneous cold and expired lookups share one successful refresh', + { skip: !hasFlock }, + async (t) => { + const f = fixture(t); + for (let batch = 0; batch < 2; batch++) { + if (batch) { + const lines = fs.readFileSync(f.cache, 'utf8').split('\n'); + lines[1] = '0'; + fs.writeFileSync(f.cache, lines.join('\n')); + } + const rows = await Promise.all( + Array.from({ length: 8 }, () => f.asyncRun({ LOOKUP_DELAY: '0.5' })), + ); + rows.forEach((r) => check(r)); + assert.equal(f.count(), batch + 1); + } + assert.equal(fs.statSync(f.lock).mode & 0o777, 0o600); + check(f.run()); + assert.equal(f.count(), 2); + }, +); +test( + 'waiters with different configuration do not reuse another key', + { skip: !hasFlock }, + async (t) => { + const f = fixture(t); + const rows = await Promise.all( + ['/first', '/second'].map((v) => + f.asyncRun({ npm_config_global_dir: v }), + ), + ); + rows.forEach((r, i) => check(r, ['/first', '/second'][i])); + assert.equal(f.count(), 2); + }, +); +test( + 'lock timeout falls back while a holder is still alive', + { skip: !hasFlock }, + async (t) => { + const f = fixture(t); + fs.mkdirSync(f.env.dir_tmp); + const holder = spawn( + '/bin/bash', + ['-c', 'exec 9>> "$LOCK"; flock 9; echo ready; sleep 30'], + { env: { ...f.env, LOCK: f.lock }, detached: true }, + ); + t.after(() => { + try { + process.kill(-holder.pid, 'SIGKILL'); + } catch {} + }); + await new Promise((resolve, reject) => { + holder.stdout.once('data', resolve); + holder.once('error', reject); + }); + const start = Date.now(); + check(f.run()); + assert.ok(Date.now() - start >= 1800); + assert.equal(holder.exitCode, null); + assert.equal(f.count(), 1); + }, +); +test('absent or unsupported flock and unsafe lock paths preserve discovery', (t) => { + const f = fixture(t); + check( + f.run( + {}, + 'type(){ if [[ "$*" == "-P flock" ]]; then return 1; fi; builtin type "$@"; }; ' + + f.command, + ), + ); + fs.rmSync(f.cache); + fs.writeFileSync(path.join(f.bin, 'flock'), '#!/bin/bash\nexit 64\n', { + mode: 0o755, + }); + check(f.run()); + fs.rmSync(f.cache); + fs.rmSync(f.lock, { force: true }); + const victim = path.join(f.root, 'victim'); + fs.writeFileSync(victim, 'untouched'); + fs.symlinkSync(victim, f.lock); + check(f.run()); + assert.equal(fs.readFileSync(victim, 'utf8'), 'untouched'); + assert.equal(f.count(), 3); +}); +test('failed refresh releases lock and does not publish a success', (t) => { + const f = fixture(t); + assert.equal(f.run({ FAIL: '1' }).status, 17); + assert.equal(fs.existsSync(f.cache), false); + check(f.run()); + assert.equal(f.count(), 2); +}); +test('refresh leaves the caller file descriptor and umask unchanged', (t) => { + const f = fixture(t); + const sentinel = path.join(f.root, 'fd'); + const mask = path.join(f.root, 'mask'); + const r = f.run( + { SENTINEL: sentinel, UMASK_FILE: mask }, + 'umask 022; exec 9> "$SENTINEL"; ' + + f.command + + ' >/dev/null; printf preserved >&9; umask', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), '0022'); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'preserved'); + assert.equal(fs.readFileSync(mask, 'utf8').trim(), '0022'); +}); diff --git a/test/back/primary-apm.test.cjs b/test/back/primary-apm.test.cjs new file mode 100644 index 00000000..ab0e09e1 --- /dev/null +++ b/test/back/primary-apm.test.cjs @@ -0,0 +1,24 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const source = fs.readFileSync('ecosystem.config.js', 'utf8'); +const appSource = fs.readFileSync('back/app.ts', 'utf8'); +const ts = require('typescript'); +const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText; +function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];} +test('container APM policy keeps worker isolation and can restore all inherited probes', () => { + for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) { + const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers}; + const conf=config(env);env.pm_id='0';env.pmx=String(conf.pmx); + const calls=[],module={exports:{}}; + vm.runInNewContext(compiled,{module,exports:module.exports,process:{env},require(name){if(name==='cluster')return {fork(options){calls.push({...env,...options});return {id:1,process:{pid:123}}}};if(name==='express')return ()=>({use(){}});return {};}}); + new module.exports().forkWorker('http'); + assert.equal(conf.pmx,primary==='true'); + assert.equal(calls[0].pmx,primary==='true'&&workers==='true'?'true':'false'); + assert.equal(conf.max_restarts,5);assert.equal(conf.script,'static/build/app.js'); + } +}); +test('standalone PM2 monitoring stays enabled', () => { + for(const QL_PRIMARY_APM of [undefined,'true','false']) assert.equal(config({QL_PRIMARY_APM}).pmx,true); +}); diff --git a/test/back/scheduler-mutation.test.cjs b/test/back/scheduler-mutation.test.cjs new file mode 100644 index 00000000..e975e913 --- /dev/null +++ b/test/back/scheduler-mutation.test.cjs @@ -0,0 +1,274 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { setTimeout: delay } = require('node:timers/promises'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); + +function gate() { + let release; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +async function fixture(t) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-scheduler-mutation-'), + ); + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + const crons = db.define('Cron', { + name: DataTypes.STRING, + command: DataTypes.STRING, + schedule: DataTypes.STRING, + isDisabled: DataTypes.INTEGER, + saved: DataTypes.BOOLEAN, + queued_token: DataTypes.STRING, + }); + await db.sync(); + t.after(async () => { + await db.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + const jobs = new Map(); + const client = { + addCron: async (items, replace) => { + if (replace) jobs.clear(); + items.forEach((item) => jobs.set(item.id, item.command)); + }, + delCron: async (ids) => ids.forEach((id) => jobs.delete(id)), + }; + const CronService = load('back/services/cron.ts', { + '../config': { + crontabFile: path.join(root, 'crontab.list'), + logPath: root, + }, + '../data/cron': { + CrontabModel: crons, + Crontab: class { + constructor(options) { + Object.assign(this, { isDisabled: 0 }, options); + } + }, + }, + '../data/runningInstance': {}, + '../config/util': { isDemoEnv: () => false }, + '../config/const': {}, + '../schedule/client': client, + '../shared/pLimit': {}, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + }).default; + const service = new CronService({ error() {}, warn() {}, info() {} }); + service.getLogName = async () => 'task'; + service.shouldUseCronClient = () => true; + service.makeCommand = (doc) => doc.command; + service.crontabs = async () => ({ data: await crons.findAll({ raw: true }) }); + service.setCrontab = async () => {}; + return { service, crons, jobs, client, root }; +} + +for (const operation of ['create', 'update', 'remove', 'disabled', 'enabled']) { + test(`recovery snapshot cannot overwrite concurrent ${operation}`, async (t) => { + const { service, crons, jobs } = await fixture(t); + await crons.create({ + id: 1, + command: 'old', + schedule: '* * * * *', + isDisabled: operation === 'enabled' ? 1 : 0, + }); + const entered = gate(), + release = gate(); + let first = true; + service.setCrontab = async () => { + if (first) { + first = false; + entered.release(); + await release.promise; + } + }; + const recovery = service.autosave_crontab(true); + await entered.promise; + const before = await crons.findAll({ raw: true }); + const mutation = + operation === 'create' + ? service.create({ command: 'new', schedule: '* * * * *' }) + : operation === 'update' + ? service.update({ id: 1, command: 'new' }) + : service[operation]([1]); + await delay(50); + assert.deepEqual( + await crons.findAll({ raw: true }), + before, + 'DB writes must also wait for recovery', + ); + release.release(); + await Promise.all([recovery, mutation]); + const rows = await crons.findAll({ raw: true }); + assert.deepEqual( + [...jobs.entries()].sort(), + rows + .filter((row) => !row.isDisabled) + .map((row) => [String(row.id), row.command]) + .sort(), + ); + }); +} + +test('recovery waits for an admitted mutation to finish registration before reading the DB', async (t) => { + const { service, client, jobs } = await fixture(t); + const entered = gate(), + release = gate(); + const add = client.addCron; + client.addCron = async (items, replace) => { + if (!replace) { + entered.release(); + await release.promise; + } + return add(items, replace); + }; + const mutation = service.create({ command: 'new', schedule: '* * * * *' }); + await entered.promise; + let reads = 0; + const read = service.crontabs; + service.crontabs = async () => { + reads++; + return read(); + }; + const recovery = service.autosave_crontab(true); + await delay(50); + assert.equal(reads, 0); + release.release(); + await Promise.all([mutation, recovery]); + assert.deepEqual([...jobs.values()], ['new']); +}); + +for (const operation of ['create', 'update', 'enabled']) { + test(`${operation} preserves scheduler 503 after rollback and releases its lock`, async (t) => { + const { service, crons, client } = await fixture(t); + if (operation !== 'create') + await crons.create({ + id: 1, + command: 'old', + schedule: '* * * * *', + isDisabled: operation === 'enabled' ? 1 : 0, + }); + const cause = Object.assign(Error('unavailable'), { status: 503 }); + client.addCron = async () => { + throw cause; + }; + const mutation = + operation === 'create' + ? service.create({ command: 'new', schedule: '* * * * *' }) + : operation === 'update' + ? service.update({ id: 1, command: 'new' }) + : service.enabled([1]); + await assert.rejects( + mutation, + (err) => + err.status === 503 && err.cause === cause && /回滚/.test(err.message), + ); + if (operation === 'create') assert.equal(await crons.count(), 0); + else { + const row = await crons.findByPk(1); + assert.equal(row.command, 'old'); + assert.equal(row.isDisabled, operation === 'enabled' ? 1 : 0); + } + client.addCron = async () => {}; + await service.autosave_crontab(true); + }); +} + +test( + 'scheduler lock excludes a second OS process and leaves no lock after completion', + { timeout: 10000 }, + async (t) => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-scheduler-process-'), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const config = { crontabFile: path.join(root, 'crontab.list') }; + const { withSchedulerMutation } = load( + 'back/shared/schedulerMutationLock.ts', + { '../config': config }, + ); + const entered = gate(), + release = gate(); + const owner = withSchedulerMutation(async () => { + entered.release(); + await release.promise; + }); + await entered.promise; + const child = spawn( + process.execPath, + [ + '-e', + ` + const load = require('./test/helpers/load-security-module.cjs'); + const { withSchedulerMutation } = load('back/shared/schedulerMutationLock.ts', {'../config': JSON.parse(process.argv[1])}); + process.send('attempting'); + withSchedulerMutation(async () => process.send('entered')).then(() => process.disconnect()).catch(err => { console.error(err); process.exit(1); }); + `, + JSON.stringify(config), + ], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + release.release(); + }); + const closed = once(child, 'close'); + assert.equal((await once(child, 'message'))[0], 'attempting'); + let acquired = false; + const acquisition = once(child, 'message').then(([message]) => { + acquired = true; + assert.equal(message, 'entered'); + }); + await delay(150); + assert.equal(acquired, false); + release.release(); + await owner; + await acquisition; + assert.equal((await closed)[0], 0); + await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), { + code: 'ENOENT', + }); + }, +); + +test('configuration rollback cannot restore an obsolete manual queue token', async (t) => { + const { service, crons, client } = await fixture(t); + await crons.create({ + id: 1, + command: 'old', + schedule: '* * * * *', + isDisabled: 0, + queued_token: 'old-token', + }); + let first = true; + client.addCron = async () => { + if (first) { + first = false; + await crons.update({ queued_token: 'new-token' }, { where: { id: 1 } }); + throw Object.assign(Error('unavailable'), { status: 503 }); + } + }; + await assert.rejects( + service.update({ id: 1, command: 'new' }), + (err) => err.status === 503, + ); + const row = await crons.findByPk(1); + assert.equal(row.command, 'old'); + assert.equal(row.queued_token, 'new-token'); +}); diff --git a/test/back/scheduler-readiness.test.cjs b/test/back/scheduler-readiness.test.cjs new file mode 100644 index 00000000..1fe68834 --- /dev/null +++ b/test/back/scheduler-readiness.test.cjs @@ -0,0 +1,85 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const {setTimeout:sleep}=require('node:timers/promises'); +const {SchedulerReadiness}=require('../../back/shared/schedulerReadiness'); +const load=require('../helpers/load-security-module.cjs'); +const fs=require('node:fs'); +const ts=require('typescript'); + +test('readiness stays false through failed restoration and retries without idle polling',async()=>{ + let probes=0,restores=0,fail=true; + const state=new SchedulerReadiness(async()=>{probes++;},10); + state.configure(async()=>{restores++;if(fail)throw Error('registration failed');}); + assert.equal(await state.recover(),false);assert.equal(await state.check(),false); + fail=false;await sleep(40);assert.equal(await state.check(),true);assert.ok(restores>=2); + const before=probes;await sleep(40);assert.equal(probes,before); +}); +test('single-flight recovery cannot mark an invalidated generation ready',async()=>{ + let release,restores=0; + const state=new SchedulerReadiness(async()=>{},10); + state.configure(async()=>{if(++restores===1)await new Promise(r=>release=r);}); + const a=state.recover();assert.equal(state.recover(),a);await sleep(1); + state.invalidate();release();assert.equal(await a,false);assert.equal(await state.check(),false); + await sleep(30);assert.equal(await state.check(),true);assert.equal(restores,2); +}); +test('mutation waiting is bounded, a later recovery can succeed',async()=>{ + let release; + const state=new SchedulerReadiness(async()=>{},10);state.configure(()=>new Promise(r=>release=r)); + await assert.rejects(state.ensureReady(15),e=>e.status===503); + release();await sleep(1);assert.equal(await state.check(),true); +}); +test('probe failure immediately invalidates a previously ready scheduler',async()=>{ + let fail=false; + const state=new SchedulerReadiness(async()=>{if(fail)throw Error('unavailable');},10); + state.configure(async()=>{});assert.equal(await state.recover(),true); + fail=true;assert.equal(await state.check(),false); + fail=false;await sleep(40);assert.equal(await state.check(),true); +}); +test('health uses actual readiness and returns HTTP 503 until recovery',async(t)=>{ + let ready=false;const express=require('express'); + const {HealthService}=load('back/services/health.ts',{ + typedi:{Service:()=>x=>x},'../loaders/logger':{error(){}},'./http':{}, + '../schedule/client':{readiness:{check:async()=>ready}}, + }); + const service=new HealthService({getServer:()=>({})}); + const app=express();load('back/api/health.ts',{ + typedi:{get:()=>service},'../services/health':{HealthService},'../loaders/logger':{error(){}}, + }).default(app); + const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();})); + for(const expected of [503,200,503]){ + ready=expected===200;const r=await fetch(`http://127.0.0.1:${server.address().port}/health`);const b=await r.json(); + assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready); + } +}); +test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{ + const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a); + const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText; + const module={exports:{}};let files=0; + new Function('module','isDemoEnv','cronClient','withSchedulerMutation',js)(module,()=>false,{addCron:async()=>{throw Error('registration unavailable');}},fn=>fn()); + const fixture=new module.exports();fixture.crontabs=async()=>({data:[]});fixture.setCrontab=async()=>{files++;};fixture.logger={warn(){}}; + await fixture.autosave_crontab();assert.equal(files,1); + await assert.rejects(fixture.autosave_crontab(true),/registration unavailable/);assert.equal(files,2); +}); +test('scheduler probe uses the cron channel and failed writes are not replayed',async()=>{ + const calls=[];const fake={ + waitForReady(deadline,cb){calls.push(['wait',deadline]);cb();}, + makeUnaryRequest(path,serialize,deserialize,request,options,cb){calls.push(['probe',path,request,options]);cb(null,{status:1});}, + addCron(request,metadata,options,cb){calls.push(['add',request,options]);cb(Object.assign(Error('invalid'),{code:3}));}, + }; + const client=load('back/schedule/client.ts',{ + '../protos/cron':{CronClient:class{constructor(){return fake;}}}, + '../config':{grpcPort:5500},'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})}, + '@grpc/grpc-js':{...require('@grpc/grpc-js'),credentials:{createSsl:()=>({})},status:{UNAVAILABLE:14},Metadata:class{}}, + }).default; + client.readiness.configure(async()=>{});assert.equal(await client.readiness.recover(),true); + assert.ok(calls.filter(x=>x[0]==='probe').every(x=>x[1]==='/com.ql.health.Health/Check'&&x[2].service==='scheduler'&&x[3].deadline>Date.now())); + await assert.rejects(client.addCron([]),/invalid/);assert.equal(calls.filter(x=>x[0]==='add').length,1); +}); +test('scheduler health probe never calls back into HTTP health',async()=>{ + const {check}=load('back/schedule/health.ts',{ + '../config':{},undici:{request:()=>{throw Error('recursive HTTP health call');}}, + }); + const result=await new Promise(resolve=>check({request:{service:'scheduler'}},(error,response)=>resolve({error,response}))); + assert.equal(result.error,null);assert.deepEqual(result.response,{status:1}); +}); diff --git a/test/back/scheduler-reconciliation.test.cjs b/test/back/scheduler-reconciliation.test.cjs new file mode 100644 index 00000000..1380c623 --- /dev/null +++ b/test/back/scheduler-reconciliation.test.cjs @@ -0,0 +1,152 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); +const load = require('../helpers/load-security-module.cjs'); +const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness'); +const { AddCronRequest } = require('../../back/protos/cron'); + +function fixture(client) { + const source = fs.readFileSync('back/services/cron.ts', 'utf8'); + const a = source.indexOf(' public async autosave_crontab('); + const z = source.indexOf(' public async bootTask', a); + const js = ts.transpileModule( + 'class Fixture {\n' + source.slice(a, z) + '}\nmodule.exports=Fixture;', + { compilerOptions: { target: ts.ScriptTarget.ES2020 } }, + ).outputText; + const module = { exports: {} }; + new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', js)( + module, + () => false, + client, + (fn) => fn(), + ); + const service = new module.exports(); + service.setCrontab = async () => {}; + service.logger = { warn() {} }; + service.shouldUseCronClient = () => true; + service.makeCommand = () => 'true'; + return service; +} + +test('recovery reconciles a surviving scheduler after missed deletes and disables, including an empty DB', async () => { + const cancelled = []; + const stacks = new Map( + ['removed', 'disabled', 'kept'].map((id) => [ + id, + [{ cancel: () => cancelled.push(id) }], + ]), + ); + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + 'node-schedule': { + scheduleJob: (id) => ({ cancel: () => cancelled.push(id) }), + }, + '../shared/runCron': {}, + '../loaders/logger': { info() {}, warn() {} }, + '../shared/i18n': { tf: (s) => s }, + }); + const client = { + addCron: async (crons, replace) => { + // Exercise the real protobuf field, not just a JavaScript-only flag. + const request = AddCronRequest.decode( + AddCronRequest.encode({ crons, replace }).finish(), + ); + await new Promise((resolve, reject) => + addCron({ request }, (err) => (err ? reject(err) : resolve())), + ); + }, + }; + const service = fixture(client); + let rows = [ + { id: 'kept', schedule: '* * * * *', isDisabled: 0 }, + { id: 'disabled', schedule: '* * * * *', isDisabled: 1 }, + ]; + service.crontabs = async () => ({ data: rows }); + const state = new SchedulerReadiness(async () => {}); + state.configure(() => service.autosave_crontab(true)); + assert.equal(await state.recover(), true); + assert.equal(await state.check(), true); + assert.deepEqual([...stacks.keys()], ['kept']); + assert.deepEqual(cancelled.sort(), ['disabled', 'kept', 'removed']); + rows = []; + state.invalidate(); + assert.equal(await state.recover(), true); + assert.equal(stacks.size, 0); + assert.equal(await state.check(), true); +}); + +test('invalid replacement leaves the previous schedule intact', async () => { + const stacks = new Map([ + [ + 'old', + [ + { + cancel: () => { + throw Error('must not cancel'); + }, + }, + ], + ], + ]); + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + '../shared/runCron': {}, + '../loaders/logger': {}, + '../shared/i18n': { tf: (s) => s }, + }); + await assert.rejects( + new Promise((resolve, reject) => + addCron( + { + request: { + replace: true, + crons: [{ id: 'bad', schedule: '?', extra_schedules: [] }], + }, + }, + (err) => (err ? reject(err) : resolve()), + ), + ), + ); + assert.deepEqual([...stacks.keys()], ['old']); +}); + +test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => { + for (const method of ['addCron', 'delCron']) { + let invalidations = 0, + writes = 0; + const fake = { + waitForReady: (_deadline, cb) => cb(Error('channel unavailable')), + addCron: () => writes++, + delCron: () => writes++, + }; + const client = load('back/schedule/client.ts', { + '../protos/cron': { + CronClient: class { + constructor() { + return fake; + } + }, + }, + '../config': { grpcPort: 5500 }, + '../config/grpcCerts': { + getGrpcCerts: () => ({ + caCert: 'ca', + clientKey: 'key', + clientCert: 'cert', + }), + }, + '@grpc/grpc-js': { + ...require('@grpc/grpc-js'), + credentials: { createSsl: () => ({}) }, + }, + }).default; + client.readiness.invalidate = () => invalidations++; + await assert.rejects( + client[method]([]), + (err) => err.status === 503 && /channel unavailable/.test(err.message), + ); + assert.equal(invalidations, 1); + assert.equal(writes, 0); + } +}); diff --git a/test/back/schema-migrations.test.cjs b/test/back/schema-migrations.test.cjs new file mode 100644 index 00000000..e1249aff --- /dev/null +++ b/test/back/schema-migrations.test.cjs @@ -0,0 +1,127 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { Sequelize, QueryTypes } = require('sequelize'); +const { migrateSchema } = require('../../back/shared/schemaMigrations'); +const load = require('../helpers/load-security-module.cjs'); + +async function createLegacy(storage = ':memory:', omitEnvs = false) { + const database = new Sequelize({ + dialect: 'sqlite', + storage, + logging: false, + }); + for (const table of [ + 'CrontabViews', + 'Subscriptions', + 'Crontabs', + ...(omitEnvs ? [] : ['Envs']), + ]) { + await database.query( + `CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`, + ); + } + await database.query( + 'INSERT INTO "Crontabs" (id, name) VALUES (1, \'keep-me\')', + ); + return database; +} + +test('legacy database upgrades without losing rows and repeated migration is idempotent', async (t) => { + const database = await createLegacy(); + t.after(() => database.close()); + await migrateSchema(database); + await migrateSchema(database); + const rows = await database.query('SELECT * FROM "Crontabs"', { + type: QueryTypes.SELECT, + }); + assert.equal(rows[0].name, 'keep-me'); + assert.equal(rows[0].queued_token, null); + assert.ok(Object.hasOwn(rows[0], 'allow_multiple_instances')); + const applied = await database.query('SELECT id FROM "SchemaMigrations"', { + type: QueryTypes.SELECT, + }); + assert.equal(applied.length, 15); +}); + +test('migration failure rolls back added columns and can be retried after repair', async (t) => { + const database = await createLegacy(':memory:', true); + t.after(() => database.close()); + await assert.rejects(migrateSchema(database)); + const columns = await database.getQueryInterface().describeTable('Crontabs'); + assert.equal(Object.hasOwn(columns, 'work_dir'), false); + await database.query( + 'CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT)', + ); + await migrateSchema(database); + assert.ok( + Object.hasOwn( + await database.getQueryInterface().describeTable('Crontabs'), + 'work_dir', + ), + ); +}); + +test('offline pre-upgrade backup restores the legacy schema and data', async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-migration-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'database.sqlite'); + const backup = path.join(dir, 'before.sqlite'); + let database = await createLegacy(file); + await database.close(); + await fs.copyFile(file, backup); + database = new Sequelize({ + dialect: 'sqlite', + storage: file, + logging: false, + }); + await migrateSchema(database); + await database.close(); + await fs.copyFile(backup, file); + database = new Sequelize({ + dialect: 'sqlite', + storage: file, + logging: false, + }); + try { + assert.equal( + Object.hasOwn( + await database.getQueryInterface().describeTable('Crontabs'), + 'work_dir', + ), + false, + ); + const rows = await database.query('SELECT name FROM "Crontabs"', { + type: QueryTypes.SELECT, + }); + assert.equal(rows[0].name, 'keep-me'); + } finally { + await database.close(); + } +}); + +test('database loader rejects initialization failure rather than allowing workers to start', async () => { + const mocks = { './logger': { error() {}, info() {} } }; + for (const [file, model] of [ + ['env', 'EnvModel'], + ['cron', 'CrontabModel'], + ['dependence', 'DependenceModel'], + ['open', 'AppModel'], + ['system', 'SystemModel'], + ['subscription', 'SubscriptionModel'], + ['cronView', 'CrontabViewModel'], + ['cronStats', 'CrontabStatModel'], + ['runningInstance', 'RunningInstanceModel'], + ]) + mocks[`../data/${file}`] = { [model]: { sync: async () => {} } }; + mocks['../data'] = { sequelize: {} }; + mocks['../shared/schemaMigrations'] = { + migrateSchema: async () => { + throw new Error('SQLITE_FULL'); + }, + }; + const initialize = load(path.resolve('back/loaders/db.ts'), mocks).default; + await assert.rejects(initialize(), /SQLITE_FULL/); +}); diff --git a/test/back/shell-api-parsing.test.cjs b/test/back/shell-api-parsing.test.cjs new file mode 100644 index 00000000..8c7960b4 --- /dev/null +++ b/test/back/shell-api-parsing.test.cjs @@ -0,0 +1,184 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const source = fs + .readFileSync(path.resolve('shell/api.sh'), 'utf8') + .replace(/\nget_token\s*$/, '\n'); +const realJq = spawnSync('which', ['jq'], { encoding: 'utf8' }).stdout.trim(); +function fixture(t) { + assert.ok(realJq, 'jq is required'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-parse-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'jq'), + '#!/bin/bash\nprintf "call\\n" >> "$JQ_CALLS"\nexec "$REAL_JQ" "$@"\n', + { mode: 0o755 }, + ); + const token = path.join(root, 'token.json'), + response = path.join(root, 'response.json'), + calls = path.join(root, 'calls'); + const run = (code) => + spawnSync( + '/bin/bash', + [ + '-euc', + source + + '\ncreate_token(){ __ql_token__=generated; generated=1; }; curl(){ cat "$RESPONSE_FILE"; };\n' + + code, + ], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: bin + ':' + process.env.PATH, + REAL_JQ: realJq, + JQ_CALLS: calls, + file_auth_token: token, + RESPONSE_FILE: response, + ql_port: '5700', + __ql_token__: 'test-token', + }, + }, + ); + return { + token, + response, + run, + count: () => + fs.existsSync(calls) + ? fs.readFileSync(calls, 'utf8').trim().split('\n').length + : 0, + }; +} +test('valid token cache is read with one jq and reused without generation', (t) => { + const f = fixture(t); + fs.writeFileSync( + f.token, + JSON.stringify({ + value: 'header.payload.signature', + expiration: 4102444800, + }), + ); + const r = f.run( + 'get_token; printf "%s:%s" "$__ql_token__" "${generated:-0}"', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'header.payload.signature:0'); + assert.equal(f.count(), 1); +}); +test('expired, corrupt, missing or header-unsafe token cache refreshes under errexit', (t) => { + const f = fixture(t); + for (const content of [ + undefined, + 'broken', + '{}', + JSON.stringify({ value: 'old', expiration: 1 }), + JSON.stringify({ value: 'old', expiration: '4102444800' }), + JSON.stringify({ value: '', expiration: 4102444800 }), + JSON.stringify({ value: 'bad\ninjected', expiration: 4102444800 }), + JSON.stringify({ value: 'bad\n', expiration: 4102444800 }), + JSON.stringify({ value: 'bad\rinjected', expiration: 4102444800 }), + ]) { + if (content === undefined) fs.rmSync(f.token, { force: true }); + else fs.writeFileSync(f.token, content); + const r = f.run('get_token; printf "%s:%s" "$__ql_token__" "$generated"'); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'generated:1'); + } +}); +test('success with a message still parses once and stays silent', (t) => { + const f = fixture(t); + fs.writeFileSync(f.response, JSON.stringify({ code: 200, message: 'ok' })); + const r = f.run('update_cron 1 0 123 log 1'); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, ''); + assert.equal(f.count(), 1); +}); +test('status and statistics errors preserve multiline Unicode messages', (t) => { + const f = fixture(t); + fs.writeFileSync( + f.response, + JSON.stringify({ code: 500, message: '写入失败\n请重试 "原任务"' }), + ); + for (const command of [ + 'update_cron 1 1 123 log 1 2 7', + 'record_cron_stat 1 7 2', + ]) { + const r = f.run(command); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, '写入失败\n请重试 "原任务"\n'); + } + assert.equal(f.count(), 2); +}); +test('non-JSON errors fall back to raw text and absent messages retain legacy null', (t) => { + const f = fixture(t); + fs.writeFileSync(f.response, 'upstream unavailable'); + let r = f.run('update_cron 1 0 123 log 1'); + assert.equal(r.status, 0); + assert.equal(r.stdout, 'upstream unavailable\n'); + fs.writeFileSync(f.response, '{"code":500}'); + r = f.run('record_cron_stat 1 7 2'); + assert.equal(r.status, 0); + assert.equal(r.stdout, 'null\n'); +}); +test('statistics without a task id perform no JSON parsing', (t) => { + const f = fixture(t); + const r = f.run('record_cron_stat "" 0 1 || true'); + assert.equal(r.status, 0, r.stderr); + assert.equal(f.count(), 0); +}); + +test('canonical success resets prior errors and matches jq missing-message semantics', (t) => { + const f = fixture(t); + fs.writeFileSync(f.response, '{"code":200}'); + const r = f.run( + 'code=500; message=old; ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"; record_cron_stat 1 0 1', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, '200|null'); + assert.equal(f.count(), 0); +}); + +test('noncanonical JSON always uses jq, including whitespace, duplicate keys and embedded success text', (t) => { + const f = fixture(t); + const cases = [ + [' {"code":200} ', '200|null'], + ['{"code": 200}', '200|null'], + ['{"code":200,"message":"完成\\n第二行"}', '200|完成\n第二行'], + ['{"code":200,"data":{"code":500}}', '200|null'], + ['{"code":"200"}', '200|null'], + ['{"code":200,"code":500,"message":"error"}', '500|error'], + ['{"code":500,"message":"{\\"code\\":200}"}', '500|{"code":200}'], + ['{"code":500}', '500|null'], + ]; + for (const [body, expected] of cases) { + fs.writeFileSync(f.response, body); + const r = f.run( + 'ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, expected); + } + assert.equal(f.count(), cases.length); +}); + +test('success-shaped invalid documents retain the raw-response error fallback', (t) => { + const f = fixture(t); + const cases = [ + '{"code":200}trailing', + '{"code":200', + '{"code":200}', + ]; + for (const body of cases) { + fs.writeFileSync(f.response, body); + const r = f.run('update_cron 1 0 123 log 1'); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, body + '\n'); + } + assert.equal(f.count(), cases.length); +}); diff --git a/test/back/stop-race.test.cjs b/test/back/stop-race.test.cjs new file mode 100644 index 00000000..5662b46d --- /dev/null +++ b/test/back/stop-race.test.cjs @@ -0,0 +1,167 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); +async function fixture(t, onKill = async () => {}) { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const crons = db.define('Cron', { + command: DataTypes.STRING, + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, + last_execution_time: DataTypes.INTEGER, + last_running_time: DataTypes.INTEGER, + }); + const instances = db.define('Instance', { + cron_id: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + status: DataTypes.INTEGER, + exit_code: DataTypes.INTEGER, + finished_at: DataTypes.INTEGER, + }); + await db.sync(); + await crons.bulkCreate([ + { id: 1, command: 'task one.js', status: 0, pid: 101, log_path: 'one.log' }, + { id: 2, command: 'task two.js', status: 0, pid: 201, log_path: 'two.log' }, + ]); + await instances.bulkCreate([ + { id: 1, cron_id: 1, pid: 101, status: 0 }, + { id: 2, cron_id: 1, pid: 102, status: 0 }, + { id: 3, cron_id: 1, pid: 99, status: 1, exit_code: 0 }, + { id: 4, cron_id: 1, pid: 98, status: 3, exit_code: 7 }, + { id: 5, cron_id: 2, pid: 201, status: 0 }, + ]); + let service; + const killed = []; + const CronService = load(path.resolve('back/services/cron.ts'), { + '../config': {}, + '../data/cron': { + CrontabModel: crons, + CrontabStatus: { queued: 3, running: 0, idle: 1 }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, finished: 1, stopped: 2, error: 3 }, + }, + '../config/util': { + killTask: async (pid, wait) => { + assert.equal(wait, true); + killed.push(pid); + await onKill({ pid, service, instances, crons }); + }, + killAllTasks: async () => { + throw Error('command-wide scans must not run'); + }, + }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': {}, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + '../shared/childProcess': require('../../back/shared/childProcess'), + }).default; + service = new CronService({ info() {}, error() {} }); + service.getDb = ({ id }) => crons.findByPk(id); + return { service, instances, crons, killed }; +} +async function report(service, pid, code) { + await service.status({ + ids: [1], + status: 1, + pid, + log_path: 'one.log', + last_running_time: 1, + last_execution_time: 100, + exit_code: code, + }); +} +test('stop wins when shell exit writes error or success during termination, preserving exit codes', async (t) => { + const f = await fixture(t, async ({ service }) => { + await report(service, 101, 143); + await report(service, 102, 0); + }); + await f.service.stop([1]); + const rows = await f.instances.findAll({ order: [['id', 'ASC']], raw: true }); + assert.deepEqual( + rows.map((r) => [r.id, r.status, r.exit_code]), + [ + [1, 2, 143], + [2, 2, 0], + [3, 1, 0], + [4, 3, 7], + [5, 0, null], + ], + ); +}); +test('late shell completion cannot overwrite stopped instances', async (t) => { + const f = await fixture(t); + await f.service.stop([1]); + await report(f.service, 101, 143); + assert.equal((await f.instances.findByPk(1)).status, 2); + assert.equal((await f.instances.findByPk(2)).status, 2); +}); +test('rows created after the stop snapshot are not relabelled', async (t) => { + const f = await fixture(t, async ({ instances }) => { + if (!(await instances.findByPk(6))) + await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 }); + }); + await f.service.stop([1]); + assert.equal((await f.instances.findByPk(6)).status, 0); + assert.equal((await f.instances.findByPk(1)).status, 2); +}); +test('repeated stop does not rewrite historical rows or their finished timestamps', async (t) => { + const f = await fixture(t); + await f.service.stop([1]); + const before = await f.instances.findAll({ + raw: true, + order: [['id', 'ASC']], + }); + await f.service.stop([1]); + assert.deepEqual( + await f.instances.findAll({ raw: true, order: [['id', 'ASC']] }), + before, + ); +}); +test('batch stop captures running instances from every requested cron', async (t) => { + const f = await fixture(t); + await f.service.stop([1, 2]); + assert.equal((await f.instances.findByPk(5)).status, 2); + assert.equal((await f.instances.findByPk(3)).status, 1); +}); + +test('stop signals every snapshotted PID exactly once and preserves a later running instance', async (t) => { + const f = await fixture(t, async ({ instances, crons }) => { + if (!(await instances.findByPk(6))) { + await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 }); + await crons.update( + { pid: 103, log_path: 'later.log', status: 0 }, + { where: { id: 1 } }, + ); + } + }); + await f.service.stop([1]); + assert.deepEqual(f.killed, [101, 102]); + assert.equal((await f.instances.findByPk(6)).status, 0); + assert.equal((await f.crons.findByPk(1)).pid, 103); + assert.equal((await f.crons.findByPk(1)).status, 0); +}); + +test('failed termination is not finalized as stopped', async (t) => { + const f = await fixture(t, async ({ pid }) => { + if (pid === 102) throw Error('still alive'); + }); + await f.service.stop([1]); + assert.deepEqual(f.killed, [101, 102]); + assert.equal((await f.instances.findByPk(1)).status, 2); + assert.equal((await f.instances.findByPk(2)).status, 0); + assert.equal((await f.crons.findByPk(1)).status, 0); +}); diff --git a/test/back/subscription-cleanup.test.cjs b/test/back/subscription-cleanup.test.cjs new file mode 100644 index 00000000..fb9acf90 --- /dev/null +++ b/test/back/subscription-cleanup.test.cjs @@ -0,0 +1,53 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const dayjs = require('dayjs'); +const load = require('../helpers/load-security-module.cjs'); + +test('subscription becomes idle and closes its log even when completion logging fails', async () => { + const updates = []; + let closed = 0; + let notified = 0; + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': {}, + '../data/subscription': { + SubscriptionModel: { update: async (values) => updates.push(values) }, + SubscriptionStatus: { idle: 1 }, + }, + '../data/cron': {}, + '../config/util': { handleLogPath: async () => '/tmp/unused-log' }, + '../config/const': { LOG_END_SYMBOL: 'end' }, + '../config/subscription': {}, + '../shared/i18n': { t: (s) => s, tf: (s) => s }, + '../shared/pLimit': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': { + logStreamManager: { + write: async () => { + throw new Error('ENOSPC'); + }, + closeStream: async () => { + closed++; + throw new Error('ENOSPC'); + }, + }, + }, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Subscription( + {}, + {}, + { sendMessage: () => notified++ }, + {}, + {}, + ); + service.getDb = async () => ({ id: 1, log_path: 'log' }); + const callbacks = service.taskCallbacks({ id: 1 }); + await assert.rejects(callbacks.onEnd(undefined, dayjs(), 1), /ENOSPC/); + assert.equal(closed, 1); + assert.equal(updates.at(-1).status, 1); + assert.equal(notified, 1); +}); diff --git a/test/back/task-time.test.cjs b/test/back/task-time.test.cjs new file mode 100644 index 00000000..449252f5 --- /dev/null +++ b/test/back/task-time.test.cjs @@ -0,0 +1,116 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +function extract(file, name) { + const text = fs.readFileSync(file, 'utf8'); + const start = text.indexOf(name + '() {'); + assert.ok(start >= 0); + return text.slice(start, text.indexOf('\n}', start) + 2); +} +const source = + ['handle_log_path', 'init_begin_time'] + .map((n) => extract('shell/task.sh', n)) + .join('\n') + + '\n' + + ['format_time', 'format_log_time', 'format_timestamp', 'handle_task_end'] + .map((n) => extract('shell/share.sh', n)) + .join('\n'); +function run(t, body) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-task-time-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const result = spawnSync( + '/bin/bash', + [ + '-ec', + source + + ` +is_macos=0 +mtime_format='%Y-%m-%d %H:%M:%S.%3N' +time_format='%Y-%m-%d %H:%M:%S' +ID=42; dir_log=/tmp; log_name=''; real_log_path=''; no_tee=''; real_time='' +make_dir() { :; } +t() { printf '%s\\n' "$*" >> "$TEST_DIR/messages"; } +update_cron() { printf '%s\\n' "$*" >> "$TEST_DIR/status"; } +record_cron_stat() { printf '%s\\n' "$*" >> "$TEST_DIR/stats"; } +date() { + printf '%s\\n' "$*" >> "$TEST_DIR/dates" + case "$1" in + '+%Y-%m-%d %H:%M:%S.%3N|%Y-%m-%d-%H-%M-%S-%3N') printf '%s\\n' '2026-09-12 23:59:59.999|2026-09-12-23-59-59-999';; + '+%Y-%m-%d %H:%M:%S|%s') printf '%s\\n' '2026-09-13 00:00:00|101';; + '-d') [[ "$3" == '+%s' ]] && echo 100;; + *) echo raw-time;; + esac +} +` + + body, + ], + { encoding: 'utf8', env: { ...process.env, TEST_DIR: dir } }, + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + return { + dir, + text: (name) => + fs.existsSync(path.join(dir, name)) + ? fs.readFileSync(path.join(dir, name), 'utf8') + : '', + }; +} +test('Linux task timestamps share a snapshot and retain millisecond log names across midnight', (t) => { + const r = run( + t, + `handle_log_path 'folder/example.js' +init_begin_time +[[ "$log_path" == 'folder_example_42/2026-09-12-23-59-59-999.log' ]] +[[ "$time" == '2026-09-12 23:59:59.999' && "$begin_time" == '2026-09-12 23:59:59' && "$begin_timestamp" == 100 ]] +handle_task_end`, + ); + assert.equal(r.text('dates').trim().split('\n').length, 3); + assert.match(r.text('stats'), /^42 0 1\n$/); + assert.match(r.text('messages'), /2026-09-13 00:00:00/); +}); +test('explicit log paths and output mode remain intact', (t) => { + run( + t, + `real_log_path='custom/output.log'; real_time=true +handle_log_path 'folder/example.js' +[[ "$log_path" == 'custom/output.log' && "$cmd" == '' ]]`, + ); +}); +test('task failure keeps exit code and minimum one-second runtime', (t) => { + const r = run( + t, + `begin_timestamp=101; _task_exit_code=7; log_path=test.log +handle_task_end`, + ); + assert.equal(r.text('stats'), '42 7 1\n'); + assert.match(r.text('messages'), /失败/); + assert.match(r.text('status'), /101 1 7/); +}); +test('manual stop retains stopped message and exit status reporting', (t) => { + const r = run( + t, + `begin_timestamp=99; _task_exit_code=1; log_path=test.log; MANUAL=true +handle_task_end`, + ); + assert.equal(r.text('stats'), '42 1 2\n'); + assert.match(r.text('messages'), /已停止/); +}); +for (const mode of ['macos', 'custom']) + test(`${mode} keeps legacy time conversion helpers`, (t) => { + run( + t, + ` +${mode === 'macos' ? 'is_macos=1' : 'mtime_format=custom; time_format=custom'} +format_log_time() { echo legacy-log; } +format_time() { echo legacy-time; } +format_timestamp() { echo 100; } +handle_log_path example.js +init_begin_time +[[ "$log_time" == legacy-log && "$begin_time" == legacy-time && "$begin_timestamp" == 100 ]] +handle_task_end +`, + ); + }); diff --git a/test/back/worker-apm.test.cjs b/test/back/worker-apm.test.cjs new file mode 100644 index 00000000..595188d3 --- /dev/null +++ b/test/back/worker-apm.test.cjs @@ -0,0 +1,84 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); +const source = fs.readFileSync('back/app.ts', 'utf8'); +const entry = source.indexOf('\nconst app = new Application();'); +assert.ok(entry > 0, 'application entry point exists'); +const compiled = ts.transpileModule( + source.slice(0, entry) + '\nmodule.exports = Application;', + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }, +).outputText; +function create(env) { + const calls = []; + const cluster = { + fork(options) { + const worker = { + id: calls.length + 1, + process: { pid: 100 + calls.length }, + }; + calls.push({ options, inherited: { ...env, ...options }, worker }); + return worker; + }, + }; + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + process: { env }, + require(name) { + if (name === 'cluster') return cluster; + if (name === 'express') return () => ({ use() {} }); + return {}; + }, + }; + vm.runInNewContext(compiled, sandbox); + return { app: new module.exports(), calls }; +} +test('PM2-managed primary disables only inherited worker APM and keeps worker metadata', () => { + const env = { pm_id: '0', pmx: 'true', CUSTOM_VALUE: 'kept' }; + const { app, calls } = create(env); + for (const role of ['grpc', 'http']) app.forkWorker(role); + assert.deepEqual( + calls.map((x) => ({ ...x.options })), + [ + { SERVICE_TYPE: 'grpc', pmx: 'false' }, + { SERVICE_TYPE: 'http', pmx: 'false' }, + ], + ); + assert.equal(env.pmx, 'true'); + assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept'); + for (const call of calls) { + const entry = app.workerMetadataMap.get(call.worker.id); + assert.equal(entry.pid, call.worker.process.pid); + assert.equal(entry.serviceType, call.options.SERVICE_TYPE); + } +}); +test('worker APM opt-in restores inherited PM2 settings, including explicit disable', () => { + for (const pmx of ['true', 'false']) { + const { app, calls } = create({ pm_id: '0', pmx, QL_WORKER_APM: 'true' }); + app.forkWorker('http'); + assert.equal(Object.hasOwn(calls[0].options, 'pmx'), false); + assert.equal(calls[0].inherited.pmx, pmx); + } +}); +test('standalone startup does not introduce a PM2-specific override', () => { + const { app, calls } = create({ CUSTOM_VALUE: 'kept' }); + app.forkWorker('grpc'); + assert.deepEqual({ ...calls[0].options }, { SERVICE_TYPE: 'grpc' }); + assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept'); +}); +test('replacement workers receive the same monitoring policy', () => { + const { app, calls } = create({ pm_id: '0', pmx: 'true' }); + app.forkWorker('grpc'); + app.forkWorker('grpc'); + assert.equal(calls[1].inherited.pmx, 'false'); + assert.notEqual(calls[0].worker.id, calls[1].worker.id); +}); From 841740f19a3e304dd70466f9abff8f1f13ee89fa Mon Sep 17 00:00:00 2001 From: Alone88 Date: Sat, 12 Sep 2026 09:50:56 -0700 Subject: [PATCH 089/123] feat: add WPUSH notification provider (#3066) Add WPUSH as a notification channel for Qinglong panel settings and sample notify scripts (Python/JS), using POST https://api.wpush.cn/api/v1/send with success code === 0. Supports optional channel and topic_code. --- back/data/notify.ts | 10 +++++++- back/services/notify.ts | 28 +++++++++++++++++++++ sample/config.sample.sh | 10 ++++++++ sample/notify.js | 54 +++++++++++++++++++++++++++++++++++++++++ sample/notify.py | 37 ++++++++++++++++++++++++++++ src/locales/en-US.json | 5 +++- src/locales/zh-CN.json | 5 +++- src/utils/config.ts | 22 +++++++++++++++++ 8 files changed, 168 insertions(+), 3 deletions(-) diff --git a/back/data/notify.ts b/back/data/notify.ts index b1230f8b..e9e5c5fd 100644 --- a/back/data/notify.ts +++ b/back/data/notify.ts @@ -21,6 +21,7 @@ export enum NotificationMode { 'ntfy' = 'ntfy', 'wxPusherBot' = 'wxPusherBot', 'openiLink' = 'openiLink', + 'wpush' = 'wpush', } abstract class NotificationBaseInfo { @@ -172,6 +173,12 @@ export class OpeniLinkNotification extends NotificationBaseInfo { public openiLinkContextToken = ''; } +export class WpushNotification extends NotificationBaseInfo { + public wpushApiKey = ''; + public wpushChannel = ''; + public wpushTopicCode = ''; +} + export interface NotificationInfo extends GoCqHttpBotNotification, GotifyNotification, @@ -195,4 +202,5 @@ export interface NotificationInfo NtfyNotification, WxPusherBotNotification, WxPusherSptNotification, - OpeniLinkNotification {} + OpeniLinkNotification, + WpushNotification {} diff --git a/back/services/notify.ts b/back/services/notify.ts index 3e492774..e92269a5 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -37,6 +37,7 @@ export default class NotificationService { ['wxPusherBot', this.wxPusherBot], ['wxPusherSpt', this.wxPusherSpt], ['openiLink', this.openiLink], + ['wpush', this.wpush], ]); private title = ''; @@ -947,4 +948,31 @@ export default class NotificationService { throw new Error(error.response ? error.response.body : error); } } + private async wpush() { + const { wpushApiKey, wpushChannel, wpushTopicCode } = this.params; + const url = 'https://api.wpush.cn/api/v1/send'; + const json: Record = { + apikey: `${wpushApiKey}`, + title: `${this.title}`, + content: `${this.content}`, + channel: `${wpushChannel || 'wechat'}`, + }; + if (wpushTopicCode) { + json.topic_code = `${wpushTopicCode}`; + } + try { + const res = await httpClient.post(url, { + ...this.gotOption, + json, + }); + if (res.code === 0) { + return true; + } else { + throw new Error(JSON.stringify(res)); + } + } catch (error: any) { + throw new Error(error.response ? error.response.body : error); + } + } + } diff --git a/sample/config.sample.sh b/sample/config.sample.sh index a5e88e05..cecf7095 100644 --- a/sample/config.sample.sh +++ b/sample/config.sample.sh @@ -276,3 +276,13 @@ export OPENILINK_HUB_URL="" export OPENILINK_CONTEXT_TOKEN="" ## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可 + +## 23. WPUSH +## 官方文档: https://wpush.cn/docs +## WPUSH_APIKEY (必填) 在 https://wpush.cn/settings 获取,以 WPUSH 开头 +export WPUSH_APIKEY="" +## 推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat +export WPUSH_CHANNEL="wechat" +## 可选,Topic 广播编码;填写后按 Topic 推送 +export WPUSH_TOPIC_CODE="" + diff --git a/sample/notify.js b/sample/notify.js index c466a314..35dcce1d 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -162,6 +162,11 @@ const push_config = { OPENILINK_APP_TOKEN: '', // OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取 OPENILINK_HUB_URL: '', // OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址 OPENILINK_CONTEXT_TOKEN: '', // OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取 + + // WPUSH 官方文档: https://wpush.cn/docs + WPUSH_APIKEY: '', // WPUSH 的 API Key,在 https://wpush.cn/settings 获取 + WPUSH_CHANNEL: 'wechat', // 推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot + WPUSH_TOPIC_CODE: '', // 可选,Topic 广播编码 }; for (const key in push_config) { @@ -1494,6 +1499,54 @@ function wxPusherSptNotify(text, desp) { }); } + + +function wpushNotify(text, desp) { + return new Promise((resolve) => { + const { WPUSH_APIKEY, WPUSH_CHANNEL, WPUSH_TOPIC_CODE } = push_config; + if (WPUSH_APIKEY) { + const body = { + apikey: `${WPUSH_APIKEY}`, + title: `${text}`, + content: `${desp}`, + channel: `${WPUSH_CHANNEL || 'wechat'}`, + }; + if (WPUSH_TOPIC_CODE) { + body.topic_code = WPUSH_TOPIC_CODE; + } + const options = { + url: `https://api.wpush.cn/api/v1/send`, + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + timeout, + }; + $.post(options, (err, resp, data) => { + try { + if (err) { + console.log('WPUSH 发送通知消息失败!\n', err); + } else { + if (data.code === 0) { + console.log('WPUSH 发送通知消息成功!'); + } else { + console.log( + `WPUSH 发送通知消息异常:${data.message || JSON.stringify(data)}`, + ); + } + } + } catch (e) { + $.logErr(e, resp); + } finally { + resolve(data); + } + }); + } else { + resolve(); + } + }); +} + function openiLinkNotify(text, desp) { return new Promise((resolve) => { const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } = @@ -1674,6 +1727,7 @@ async function sendNotify(text, desp, params = {}) { wxPusherNotify(text, desp), // wxpusher wxPusherSptNotify(text, desp), // wxpusher SPT openiLinkNotify(text, desp), // OpeniLink + wpushNotify(text, desp), // WPUSH ]); } diff --git a/sample/notify.py b/sample/notify.py index 7b10d993..eb2ebb0b 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -147,6 +147,11 @@ push_config = { 'OPENILINK_APP_TOKEN': '', # OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取 官方文档: https://openilink.com/docs/hub/apps 'OPENILINK_HUB_URL': '', # OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址 'OPENILINK_CONTEXT_TOKEN': '', # OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取 + + # WPUSH 官方文档: https://wpush.cn/docs + 'WPUSH_APIKEY': '', # WPUSH 的 API Key,在 https://wpush.cn/settings 获取 + 'WPUSH_CHANNEL': 'wechat', # 推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot + 'WPUSH_TOPIC_CODE': '', # 可选,Topic 广播编码 } # fmt: on @@ -956,6 +961,36 @@ def wxpusher_spt(title: str, content: str) -> None: print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}") + +def wpush(title: str, content: str) -> None: + """ + 通过 WPUSH 推送消息。 + 官方文档: https://wpush.cn/docs + """ + if not push_config.get("WPUSH_APIKEY"): + return + + print("WPUSH 服务启动") + + url = "https://api.wpush.cn/api/v1/send" + data = { + "apikey": push_config.get("WPUSH_APIKEY"), + "title": title, + "content": content, + "channel": push_config.get("WPUSH_CHANNEL") or "wechat", + } + if push_config.get("WPUSH_TOPIC_CODE"): + data["topic_code"] = push_config.get("WPUSH_TOPIC_CODE") + + headers = {"Content-Type": "application/json"} + response = requests.post(url=url, json=data, headers=headers, timeout=15).json() + + if response.get("code") == 0: + print("WPUSH 推送成功!") + else: + print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}') + + def openilink(title: str, content: str) -> None: """ 通过 OpeniLink 推送消息。 @@ -1162,6 +1197,8 @@ def add_notify_function(): notify_function.append(wxpusher_spt) if push_config.get("OPENILINK_APP_TOKEN"): notify_function.append(openilink) + if push_config.get("WPUSH_APIKEY"): + notify_function.append(wpush) if not notify_function: print(f"无推送渠道,请检查通知变量是否正确") return notify_function diff --git a/src/locales/en-US.json b/src/locales/en-US.json index dd38d0b6..b4005cb2 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -661,5 +661,8 @@ "失败次数": "Failure count", "最新日志": "Latest log", "加载失败": "Failed to load", - "重试": "Retry" + "重试": "Retry", + "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH API Key from https://wpush.cn/settings, see https://wpush.cn/docs", + "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "Channel: wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot, default wechat", + "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 667d9b9d..164516c4 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -661,5 +661,8 @@ "失败次数": "失败次数", "最新日志": "最新日志", "加载失败": "加载失败", - "重试": "重试" + "重试": "重试", + "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs", + "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat", + "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs" } diff --git a/src/utils/config.ts b/src/utils/config.ts index 3140c091..d3572cd2 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -82,6 +82,7 @@ export default { { value: 'wxPusherBot', label: 'wxPusher' }, { value: 'wxPusherSpt', label: 'WxPusher(极简推送SPT-推荐)' }, { value: 'openiLink', label: 'OpeniLink' }, + { value: 'wpush', label: 'WPUSH' }, { value: 'chat', label: intl.get('群晖chat') }, { value: 'email', label: intl.get('邮箱') }, { value: 'lark', label: intl.get('飞书机器人') }, @@ -378,6 +379,27 @@ export default { required: true, }, ], + wpush: [ + { + label: 'wpushApiKey', + tip: intl.get( + 'WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs', + ), + required: true, + }, + { + label: 'wpushChannel', + tip: intl.get( + '推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat', + ), + }, + { + label: 'wpushTopicCode', + tip: intl.get( + '可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs', + ), + }, + ], openiLink: [ { label: 'openiLinkAppToken', From 041a4374531cef296a68a9715adf78ecc80d6321 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 13 Sep 2026 01:08:14 +0800 Subject: [PATCH 090/123] fix: support WPUSH notifications over gRPC --- back/config/const.ts | 1 + back/protos/api.proto | 7 ++- back/protos/api.ts | 62 +++++++++++++++++- back/protos/health.ts | 2 +- test/back/wpush-grpc.test.cjs | 115 ++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 test/back/wpush-grpc.test.cjs diff --git a/back/config/const.ts b/back/config/const.ts index 8d65a877..3179af5e 100644 --- a/back/config/const.ts +++ b/back/config/const.ts @@ -51,6 +51,7 @@ export const NotificationModeStringMap = { 19: 'ntfy', 20: 'wxPusherBot', 21: 'wxPusherSpt', + 22: 'wpush', } as const; export const LINUX_DEPENDENCE_COMMAND: Record< diff --git a/back/protos/api.proto b/back/protos/api.proto index d440b3f3..4c3269eb 100644 --- a/back/protos/api.proto +++ b/back/protos/api.proto @@ -152,6 +152,7 @@ enum NotificationMode { ntfy = 19; wxPusherBot = 20; wxPusherSpt = 21; + wpush = 22; } message NotificationInfo { @@ -247,6 +248,10 @@ message NotificationInfo { optional string wxPusherBotUids = 68; optional string wxPusherSptList = 70; + + optional string wpushApiKey = 71; + optional string wpushChannel = 72; + optional string wpushTopicCode = 73; } message SystemNotifyRequest { @@ -275,4 +280,4 @@ service Api { rpc EnableCrons(EnableCronsRequest) returns (Response) {} rpc DisableCrons(DisableCronsRequest) returns (Response) {} rpc RunCrons(RunCronsRequest) returns (Response) {} -} \ No newline at end of file +} diff --git a/back/protos/api.ts b/back/protos/api.ts index 649ff335..babc07b1 100644 --- a/back/protos/api.ts +++ b/back/protos/api.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.21.12 +// protoc v3.17.3 // source: back/protos/api.proto /* eslint-disable */ @@ -44,6 +44,7 @@ export enum NotificationMode { ntfy = 19, wxPusherBot = 20, wxPusherSpt = 21, + wpush = 22, UNRECOGNIZED = -1, } @@ -115,6 +116,9 @@ export function notificationModeFromJSON(object: any): NotificationMode { case 21: case "wxPusherSpt": return NotificationMode.wxPusherSpt; + case 22: + case "wpush": + return NotificationMode.wpush; case -1: case "UNRECOGNIZED": default: @@ -168,6 +172,8 @@ export function notificationModeToJSON(object: NotificationMode): string { return "wxPusherBot"; case NotificationMode.wxPusherSpt: return "wxPusherSpt"; + case NotificationMode.wpush: + return "wpush"; case NotificationMode.UNRECOGNIZED: default: return "UNRECOGNIZED"; @@ -400,6 +406,9 @@ export interface NotificationInfo { wxPusherBotTopicIds?: string | undefined; wxPusherBotUids?: string | undefined; wxPusherSptList?: string | undefined; + wpushApiKey?: string | undefined; + wpushChannel?: string | undefined; + wpushTopicCode?: string | undefined; } export interface SystemNotifyRequest { @@ -2967,6 +2976,9 @@ function createBaseNotificationInfo(): NotificationInfo { wxPusherBotTopicIds: undefined, wxPusherBotUids: undefined, wxPusherSptList: undefined, + wpushApiKey: undefined, + wpushChannel: undefined, + wpushTopicCode: undefined, }; } @@ -3182,6 +3194,15 @@ export const NotificationInfo: MessageFns = { if (message.wxPusherSptList !== undefined) { writer.uint32(562).string(message.wxPusherSptList); } + if (message.wpushApiKey !== undefined) { + writer.uint32(570).string(message.wpushApiKey); + } + if (message.wpushChannel !== undefined) { + writer.uint32(578).string(message.wpushChannel); + } + if (message.wpushTopicCode !== undefined) { + writer.uint32(586).string(message.wpushTopicCode); + } return writer; }, @@ -3752,6 +3773,30 @@ export const NotificationInfo: MessageFns = { message.wxPusherSptList = reader.string(); continue; } + case 71: { + if (tag !== 570) { + break; + } + + message.wpushApiKey = reader.string(); + continue; + } + case 72: { + if (tag !== 578) { + break; + } + + message.wpushChannel = reader.string(); + continue; + } + case 73: { + if (tag !== 586) { + break; + } + + message.wpushTopicCode = reader.string(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -3845,6 +3890,9 @@ export const NotificationInfo: MessageFns = { : undefined, wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined, wxPusherSptList: isSet(object.wxPusherSptList) ? globalThis.String(object.wxPusherSptList) : undefined, + wpushApiKey: isSet(object.wpushApiKey) ? globalThis.String(object.wpushApiKey) : undefined, + wpushChannel: isSet(object.wpushChannel) ? globalThis.String(object.wpushChannel) : undefined, + wpushTopicCode: isSet(object.wpushTopicCode) ? globalThis.String(object.wpushTopicCode) : undefined, }; }, @@ -4060,6 +4108,15 @@ export const NotificationInfo: MessageFns = { if (message.wxPusherSptList !== undefined) { obj.wxPusherSptList = message.wxPusherSptList; } + if (message.wpushApiKey !== undefined) { + obj.wpushApiKey = message.wpushApiKey; + } + if (message.wpushChannel !== undefined) { + obj.wpushChannel = message.wpushChannel; + } + if (message.wpushTopicCode !== undefined) { + obj.wpushTopicCode = message.wpushTopicCode; + } return obj; }, @@ -4138,6 +4195,9 @@ export const NotificationInfo: MessageFns = { message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined; message.wxPusherBotUids = object.wxPusherBotUids ?? undefined; message.wxPusherSptList = object.wxPusherSptList ?? undefined; + message.wpushApiKey = object.wpushApiKey ?? undefined; + message.wpushChannel = object.wpushChannel ?? undefined; + message.wpushTopicCode = object.wpushTopicCode ?? undefined; return message; }, }; diff --git a/back/protos/health.ts b/back/protos/health.ts index b88f2a9c..adaf9761 100644 --- a/back/protos/health.ts +++ b/back/protos/health.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.21.12 +// protoc v3.17.3 // source: back/protos/health.proto /* eslint-disable */ diff --git a/test/back/wpush-grpc.test.cjs b/test/back/wpush-grpc.test.cjs new file mode 100644 index 00000000..c75679af --- /dev/null +++ b/test/back/wpush-grpc.test.cjs @@ -0,0 +1,115 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); +const load = require('../helpers/load-security-module.cjs'); + +const back = (file) => path.join(__dirname, '../../back', file); +const protocol = load(back('protos/api.ts')); +const constants = load(back('config/const.ts'), { + './container': { maybeSudo: (x) => x }, +}); + +test('WPUSH survives protobuf and JSON round trips without changing existing modes', () => { + assert.equal(protocol.NotificationMode.wpush, 22); + for (let value = 0; value <= 22; value++) { + const name = protocol.notificationModeToJSON(value); + assert.equal(protocol.notificationModeFromJSON(name), value); + assert.equal(constants.NotificationModeStringMap[value], name); + } + const request = protocol.SystemNotifyRequest.create({ + title: 'title', + content: 'content', + notificationInfo: { + type: 22, + wpushApiKey: 'test-key', + wpushChannel: 'feishu', + wpushTopicCode: 'test-topic', + }, + }); + const decoded = protocol.SystemNotifyRequest.decode( + protocol.SystemNotifyRequest.encode(request).finish(), + ); + assert.equal(decoded.notificationInfo.wpushApiKey, 'test-key'); + assert.equal(decoded.notificationInfo.wpushChannel, 'feishu'); + assert.equal(decoded.notificationInfo.wpushTopicCode, 'test-topic'); + assert.deepEqual(decoded, request); + assert.deepEqual( + protocol.SystemNotifyRequest.fromJSON( + protocol.SystemNotifyRequest.toJSON(request), + ), + request, + ); +}); + +test('decoded SystemNotify requests reach WPUSH with explicit or saved configuration', async () => { + const calls = []; + const decorators = { Service: () => (x) => x, Inject: () => () => {} }; + const NotificationService = load(back('services/notify.ts'), { + typedi: decorators, + './user': {}, + '../config/util': {}, + '../shared/i18n': { t: (x) => x }, + '../config/http': { + httpClient: { + post: async (url, options) => { + calls.push({ url, body: options.json }); + return { code: 0 }; + }, + }, + }, + }).default; + const SystemService = load(back('services/system.ts'), { + typedi: decorators, + '../config': {}, + '../config/const': constants, + '../config/util': {}, + '../data/dependence': {}, + '../data/system': {}, + '../shared/pLimit': {}, + '../shared/i18n': { t: (x) => x }, + './notify': {}, + './schedule': {}, + './sock': {}, + }).default; + const notification = new NotificationService(); + notification.userService = { + getNotificationMode: async () => ({ + type: 'wpush', + wpushApiKey: 'saved-key', + }), + }; + const system = new SystemService(); + system.notificationService = notification; + + for (const info of [ + { + type: 22, + wpushApiKey: 'explicit-key', + wpushChannel: 'feishu', + wpushTopicCode: 'topic', + }, + { type: 22, wpushApiKey: 'explicit-key' }, + undefined, + ]) { + const decoded = protocol.SystemNotifyRequest.decode( + protocol.SystemNotifyRequest.encode( + protocol.SystemNotifyRequest.create({ + title: 'title', + content: 'content', + notificationInfo: info, + }), + ).finish(), + ); + assert.equal((await system.notify(decoded)).code, 200); + assert.deepEqual(calls.at(-1), { + url: 'https://api.wpush.cn/api/v1/send', + body: { + apikey: info ? 'explicit-key' : 'saved-key', + title: 'title', + content: 'content', + channel: info?.wpushChannel || 'wechat', + ...(info?.wpushTopicCode ? { topic_code: 'topic' } : {}), + }, + }); + } +}); From 4eb27427f809b565202f0b1bfa8129073f3fe5bd Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 13 Sep 2026 13:44:44 +0800 Subject: [PATCH 091/123] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20develop=20?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E9=94=81=E3=80=81=E8=BF=90=E8=A1=8C=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=20Alpine=20=E7=BC=93=E5=AD=98=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=20(#3070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: repair develop scheduler locks and runtime recovery * test: wait for child reaping after verified termination * fix: abort cron mutations when scheduler deletion fails --- back/config/util.ts | 30 +++++++-- back/schedule/api.ts | 16 +++-- back/schedule/client.ts | 6 +- back/services/cron.ts | 34 ++-------- back/shared/schedulerMutationLock.ts | 4 +- shell/node_path_cache.sh | 22 +++++-- test/back/grpc-cron-response.test.cjs | 60 ++++++++++++++++++ test/back/manual-stop-claim.test.cjs | 5 ++ test/back/process-exit-state.test.cjs | 81 ++++++++++++++++++++++++ test/back/scheduler-file-lock.test.cjs | 51 +++++++++++++++ test/back/scheduler-mutation.test.cjs | 38 +++++++++++ test/back/scheduler-rpc-timeout.test.cjs | 49 ++++++++++++++ 12 files changed, 350 insertions(+), 46 deletions(-) create mode 100644 test/back/grpc-cron-response.test.cjs create mode 100644 test/back/process-exit-state.test.cjs create mode 100644 test/back/scheduler-file-lock.test.cjs create mode 100644 test/back/scheduler-rpc-timeout.test.cjs diff --git a/back/config/util.ts b/back/config/util.ts index e6390ea9..dbd2f2a1 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -510,23 +510,45 @@ export async function killTask(pid: number, waitForExit = false) { } }; for (const target of pids) signal(target, 'SIGTERM'); - const alive = (target: number) => { + const alive = async (target: number) => { try { process.kill(target, 0); - return true; } catch (error: any) { if (error.code === 'ESRCH') return false; throw error; } + if (process.platform === 'linux') { + try { + const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8'); + // The command field may contain spaces and parentheses. Zombies have + // exited even while their parent has not reaped the PID yet. + const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0]; + if (['Z', 'X', 'x'].includes(state)) return false; + } catch { + // /proc may be unavailable, or the process may have just exited. + // Retain the portable signal probe rather than assuming it is dead. + try { + process.kill(target, 0); + } catch (error: any) { + if (error.code === 'ESRCH') return false; + throw error; + } + } + } + return true; }; const wait = async () => { const deadline = Date.now() + 1000; - while (pids.some(alive) && Date.now() < deadline) { + let remaining = pids; + while (true) { + const states = await Promise.all(remaining.map(alive)); + remaining = remaining.filter((_, index) => states[index]); + if (!remaining.length || Date.now() >= deadline) return remaining; await new Promise((resolve) => setTimeout(resolve, 20)); } - return pids.filter(alive); }; let remaining = await wait(); + if (!remaining.length) return; for (const target of remaining) signal(target, 'SIGKILL'); remaining = await wait(); if (remaining.length) diff --git a/back/schedule/api.ts b/back/schedule/api.ts index 81747958..08f838ee 100644 --- a/back/schedule/api.ts +++ b/back/schedule/api.ts @@ -38,6 +38,7 @@ import { RunCronsRequest, } from '../protos/api'; import { NotificationInfo } from '../data/notify'; +import { Model } from 'sequelize'; Container.set('logger', LoggerInstance); @@ -247,13 +248,16 @@ export const systemNotify = async ( 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; return { - ...data, - sub_id: data.sub_id ?? undefined, - extra_schedules: data.extra_schedules ?? [], - pid: data.pid ?? undefined, - task_before: data.task_before ?? undefined, - task_after: data.task_after ?? undefined, + ...cron, + labels: cron.labels ?? [], + sub_id: cron.sub_id ?? undefined, + extra_schedules: cron.extra_schedules ?? [], + pid: cron.pid ?? undefined, + task_before: cron.task_before ?? undefined, + task_after: cron.task_after ?? undefined, }; }; diff --git a/back/schedule/client.ts b/back/schedule/client.ts index 3802288e..495ed12b 100644 --- a/back/schedule/client.ts +++ b/back/schedule/client.ts @@ -74,7 +74,9 @@ class Client { { deadline: Date.now() + 5000 }, (err, res) => { if (err) { - if (err.code === status.UNAVAILABLE) { + if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) { + // A timed-out write may already have reached the scheduler. + // Reconcile its state from the DB instead of replaying the RPC. this.readiness.invalidate(); Object.assign(err, { status: 503 }); } @@ -91,7 +93,7 @@ class Client { return new Promise((resolve, reject) => { this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => { if (err) { - if (err.code === status.UNAVAILABLE) { + if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) { this.readiness.invalidate(); Object.assign(err, { status: 503 }); } diff --git a/back/services/cron.ts b/back/services/cron.ts index a20f0966..afc4289f 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -154,20 +154,14 @@ export default class CronService { const tab = new Crontab({ ...doc, ...payload }); tab.saved = false; tab.log_name = await this.getLogName(tab); - const newDoc = await this.updateDb(tab); - if (doc.isDisabled === 1 || isDemoEnv()) { - return newDoc; + return await this.updateDb(tab); } - try { - await cronClient.delCron([String(newDoc.id)]); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to unregister cron job in scheduler:', - error?.message || error, - ); - } + // Keep the DB snapshot unchanged if deletion has an uncertain outcome. + // Recovery uses that snapshot after this mutation releases its lock. + await cronClient.delCron([String(doc.id)]); + const newDoc = await this.updateDb(tab); if (this.shouldUseCronClient(newDoc)) { try { @@ -305,15 +299,8 @@ export default class CronService { public async remove(ids: number[]) { return withSchedulerMutation(async () => { + await cronClient.delCron(ids.map(String)); await CrontabModel.destroy({ where: { id: ids } }); - try { - await cronClient.delCron(ids.map(String)); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to unregister cron job in scheduler:', - error?.message || error, - ); - } await this.setCrontab(); }); } @@ -853,15 +840,8 @@ export default class CronService { public async disabled(ids: number[]) { return withSchedulerMutation(async () => { + await cronClient.delCron(ids.map(String)); await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); - try { - await cronClient.delCron(ids.map(String)); - } catch (error: any) { - this.logger.warn( - '[crontab] Failed to unregister cron job in scheduler:', - error?.message || error, - ); - } await this.setCrontab(); }); } diff --git a/back/shared/schedulerMutationLock.ts b/back/shared/schedulerMutationLock.ts index afa2d4e0..a31c7fa3 100644 --- a/back/shared/schedulerMutationLock.ts +++ b/back/shared/schedulerMutationLock.ts @@ -9,7 +9,9 @@ export async function withSchedulerMutation( ): Promise { let release: () => Promise; try { - release = await lockfile.lock(config.crontabFile, { + // proper-lockfile indexes held locks by target, not lockfilePath. Keep + // this identity separate from nested writeFileWithLock(crontabFile). + release = await lockfile.lock(`${config.crontabFile}.scheduler`, { realpath: false, lockfilePath: `${config.crontabFile}.scheduler.lock`, stale: 30000, diff --git a/shell/node_path_cache.sh b/shell/node_path_cache.sh index 69a0691f..23363227 100644 --- a/shell/node_path_cache.sh +++ b/shell/node_path_cache.sh @@ -71,13 +71,23 @@ ql_refresh_node_global_path() ( if type -P flock &>/dev/null && mkdir -p -- "$dir_tmp" 2>/dev/null; then if [[ ! -L "$lock" && ( ! -e "$lock" || ( -f "$lock" && -O "$lock" ) ) ]] && \ { exec 9>> "$lock"; } 2>/dev/null; then - # Bound the wait; absent/unsupported flock or contention falls back to - # independent discovery. Never remove the lock file while waiters exist. - if flock -w 2 9 2>/dev/null; then - if ql_read_node_path_cache "$cache" "$key"; then - return 0 + # BusyBox flock (Alpine) has no -w. Both implementations support -n; + # bound contention retries and fall back immediately on other failures. + # Never remove the lock file while waiters exist. + local lock_attempt=0 lock_status + while :; do + if flock -n 9 2>/dev/null; then + if ql_read_node_path_cache "$cache" "$key"; then + return 0 + fi + break + else + lock_status=$? fi - fi + [[ "$lock_status" == 1 && "$lock_attempt" -lt 20 ]] || break + lock_attempt=$((lock_attempt + 1)) + sleep 0.1 + done fi fi diff --git a/test/back/grpc-cron-response.test.cjs b/test/back/grpc-cron-response.test.cjs new file mode 100644 index 00000000..e574a303 --- /dev/null +++ b/test/back/grpc-cron-response.test.cjs @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); +const { Sequelize, DataTypes, Model } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); +const { CronResponse } = load('back/protos/api.ts'); +const source = fs.readFileSync('back/schedule/api.ts', 'utf8'); +const start = source.indexOf('const normalizeCronData ='); +const end = source.indexOf('export const getCronDetail', start); +const code = ts.transpileModule(source.slice(start, end), { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, +}).outputText; +const normalize = new Function('Model', `${code}\nreturn normalizeCronData;`)( + Model, +); + +test('gRPC cron response preserves Sequelize model fields through protobuf serialization', async (t) => { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const Cron = db.define('Cron', { + name: DataTypes.STRING, + command: DataTypes.STRING, + schedule: DataTypes.STRING, + labels: DataTypes.JSON, + extra_schedules: DataTypes.JSON, + }); + const model = Cron.build({ + id: 7, + name: 'smoke', + command: 'true', + schedule: '* * * * *', + labels: ['test'], + }); + const decoded = CronResponse.decode( + CronResponse.encode({ code: 200, data: normalize(model) }).finish(), + ); + assert.equal(decoded.data.id, 7); + assert.equal(decoded.data.name, 'smoke'); + assert.equal(decoded.data.command, 'true'); + assert.deepEqual(decoded.data.labels, ['test']); + assert.deepEqual(decoded.data.extra_schedules, []); +}); + +test('legacy plain cron rows normalize absent repeated fields', () => { + const decoded = CronResponse.decode( + CronResponse.encode({ + code: 200, + data: normalize({ id: 8, labels: null, extra_schedules: null }), + }).finish(), + ); + assert.equal(decoded.data.id, 8); + assert.deepEqual(decoded.data.labels, []); + assert.deepEqual(decoded.data.extra_schedules, []); + assert.equal(normalize(null), undefined); +}); diff --git a/test/back/manual-stop-claim.test.cjs b/test/back/manual-stop-claim.test.cjs index 91866276..8597c236 100644 --- a/test/back/manual-stop-claim.test.cjs +++ b/test/back/manual-stop-claim.test.cjs @@ -4,6 +4,7 @@ const fs = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); +const { once } = require('node:events'); const { Sequelize, DataTypes } = require('sequelize'); const load = require('../helpers/load-security-module.cjs'); const { killTask } = require('../../back/config/util'); @@ -147,8 +148,12 @@ test( if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }); + const exited = once(child, 'exit'); await new Promise((resolve) => child.stdout.once('data', resolve)); await killTask(child.pid, true); + // Linux may report an exited zombie before Node reaps our child. Wait for + // that separate event before requiring the PID to disappear. + await exited; assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); assert.equal(child.signalCode, 'SIGKILL'); }, diff --git a/test/back/process-exit-state.test.cjs b/test/back/process-exit-state.test.cjs new file mode 100644 index 00000000..33173932 --- /dev/null +++ b/test/back/process-exit-state.test.cjs @@ -0,0 +1,81 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); + +// Isolate OS probes while exercising the production termination algorithm. +function terminationFixture(readStat, signalProbe = () => {}) { + const source = fs.readFileSync('back/config/util.ts', 'utf8'); + const start = source.indexOf('export async function killTask('); + const end = source.indexOf('export async function getPid(', start); + const code = ts.transpileModule(source.slice(start, end), { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + }).outputText; + const signals = []; + const exports = {}; + new Function('exports', 'process', 'psTree', 'fs', 'setTimeout', code)( + exports, + { + platform: 'linux', + kill: (pid, signal) => { + signals.push([pid, signal]); + signalProbe(pid, signal); + }, + }, + async () => [], + { readFile: readStat }, + setTimeout, + ); + return { killTask: exports.killTask, signals }; +} + +for (const state of ['Z', 'X', 'x']) { + test(`termination accepts Linux exited state ${state} before PID reaping`, async () => { + const { killTask, signals } = terminationFixture( + async () => `42 (name with ) parentheses) ${state} 1 0 0`, + ); + await killTask(42, true); + assert.equal( + signals.some(([, signal]) => signal === 'SIGKILL'), + false, + ); + }); +} + +test('termination keeps waiting while the process is running', async () => { + let reads = 0; + const { killTask } = terminationFixture( + async () => `42 (worker) ${++reads === 1 ? 'S' : 'Z'} 1 0 0`, + ); + await killTask(42, true); + assert.equal(reads, 2); +}); + +test('a process disappearing during the procfs read is accepted', async () => { + let probes = 0; + const { killTask } = terminationFixture( + async () => { + throw Object.assign(Error('gone'), { code: 'ENOENT' }); + }, + (_pid, signal) => { + if (signal === 0 && ++probes > 1) + throw Object.assign(Error('gone'), { code: 'ESRCH' }); + }, + ); + await killTask(42, true); + assert.equal(probes, 2); +}); + +test('permission failures are not reported as successful termination', async () => { + const denied = Object.assign(Error('denied'), { code: 'EPERM' }); + const { killTask } = terminationFixture( + async () => '', + () => { + throw denied; + }, + ); + await assert.rejects(killTask(42, true), (error) => error === denied); +}); diff --git a/test/back/scheduler-file-lock.test.cjs b/test/back/scheduler-file-lock.test.cjs new file mode 100644 index 00000000..b469478b --- /dev/null +++ b/test/back/scheduler-file-lock.test.cjs @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); + +for (const failOperation of [false, true]) { + test(`scheduler lock survives nested file writes and releases after ${ + failOperation ? 'failure' : 'success' + }`, async (t) => { + // Match production's canonical /ql path even when macOS temp is a symlink. + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'ql-nested-lock-')), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const config = { crontabFile: path.join(root, 'crontab.list') }; + await fs.writeFile(config.crontabFile, ''); + const { withSchedulerMutation } = load( + 'back/shared/schedulerMutationLock.ts', + { + '../config': config, + }, + ); + const { writeFileWithLock } = load('back/shared/utils.ts', { + '../config/util': { + fileExist: async (file) => + fs.access(file).then( + () => true, + () => false, + ), + }, + }); + const failure = new Error('registration failed'); + const operation = withSchedulerMutation(async () => { + await writeFileWithLock(config.crontabFile, 'first'); + await writeFileWithLock(config.crontabFile, 'second'); + if (failOperation) throw failure; + }); + if (failOperation) + await assert.rejects(operation, (error) => error === failure); + else await operation; + await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), { + code: 'ENOENT', + }); + await withSchedulerMutation(() => + writeFileWithLock(config.crontabFile, 'recovered'), + ); + assert.equal(await fs.readFile(config.crontabFile, 'utf8'), 'recovered'); + }); +} diff --git a/test/back/scheduler-mutation.test.cjs b/test/back/scheduler-mutation.test.cjs index e975e913..36223c2f 100644 --- a/test/back/scheduler-mutation.test.cjs +++ b/test/back/scheduler-mutation.test.cjs @@ -7,6 +7,7 @@ const { spawn } = require('node:child_process'); const { once } = require('node:events'); const { setTimeout: delay } = require('node:timers/promises'); const { Sequelize, DataTypes } = require('sequelize'); +const { status } = require('@grpc/grpc-js'); const load = require('../helpers/load-security-module.cjs'); function gate() { @@ -247,6 +248,43 @@ test( }, ); +for (const operation of ['update', 'remove', 'disabled']) { + for (const code of [status.DEADLINE_EXCEEDED, status.UNAVAILABLE]) { + for (const applied of [false, true]) { + test(`${operation} aborts on RPC ${code} with deletion applied=${applied} and reconciles the original DB`, async (t) => { + const { service, crons, client, jobs } = await fixture(t); + await crons.create({ + id: 1, command: 'old', schedule: '* * * * *', isDisabled: 0, + }); + jobs.set('1', 'old'); + const before = await crons.findAll({ raw: true }); + const failure = Object.assign(Error('uncertain deletion'), { code, status: 503 }); + let deletes = 0; + client.delCron = async (ids) => { + deletes++; + if (applied) ids.forEach((id) => jobs.delete(id)); + throw failure; + }; + const add = client.addCron; + client.addCron = async () => assert.fail('must not register after failed deletion'); + service.setCrontab = async () => assert.fail('must not publish after failed deletion'); + await assert.rejects( + operation === 'update' + ? service.update({ id: 1, command: 'new' }) + : service[operation]([1]), + (error) => error === failure && error.status === 503, + ); + assert.equal(deletes, 1, 'must not replay an uncertain deletion'); + assert.deepEqual(await crons.findAll({ raw: true }), before); + client.addCron = add; + service.setCrontab = async () => {}; + await service.autosave_crontab(true); + assert.deepEqual([...jobs.entries()], [['1', 'old']]); + }); + } + } +} + test('configuration rollback cannot restore an obsolete manual queue token', async (t) => { const { service, crons, client } = await fixture(t); await crons.create({ diff --git a/test/back/scheduler-rpc-timeout.test.cjs b/test/back/scheduler-rpc-timeout.test.cjs new file mode 100644 index 00000000..e76d01aa --- /dev/null +++ b/test/back/scheduler-rpc-timeout.test.cjs @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const grpc = require('@grpc/grpc-js'); +const load = require('../helpers/load-security-module.cjs'); + +for (const method of ['addCron', 'delCron']) { + test(`${method} invalidates an uncertain timed-out write without replaying it`, async () => { + let writes = 0; + let invalidations = 0; + const timeout = Object.assign(new Error('response deadline exceeded'), { + code: grpc.status.DEADLINE_EXCEEDED, + }); + const fake = { + waitForReady: (_deadline, callback) => callback(), + [method]: (_request, _metadata, _options, callback) => { + // The server may have applied the write before the response was lost. + writes++; + callback(timeout); + }, + }; + const client = load('back/schedule/client.ts', { + '../protos/cron': { + CronClient: class { + constructor() { + return fake; + } + }, + }, + '../config': { grpcPort: 5500 }, + '../config/grpcCerts': { + getGrpcCerts: () => ({ + caCert: 'ca', + clientKey: 'key', + clientCert: 'cert', + }), + }, + '@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } }, + }).default; + client.readiness.invalidate = () => invalidations++; + await assert.rejects(client[method]([]), (error) => error === timeout); + assert.equal( + invalidations, + 1, + 'reconcile from the DB after an uncertain RPC result', + ); + assert.equal(timeout.status, 503); + assert.equal(writes, 1, 'do not replay an uncertain mutation'); + }); +} From a60ec5587a35bb3eb05237e7d56a39ddea4c0cde Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 18 Sep 2026 02:16:54 +0800 Subject: [PATCH 092/123] feat: show today's successful tasks from dashboard (#3072) --- back/api/dashboard.ts | 75 ++++++++++--------- src/locales/en-US.json | 1 + src/locales/zh-CN.json | 1 + src/pages/dashboard/index.tsx | 21 ++++-- .../{failureModal.tsx => taskResultModal.tsx} | 34 ++++++--- test/back/dashboard-failures.test.cjs | 51 ++++++++++++- 6 files changed, 127 insertions(+), 56 deletions(-) rename src/pages/dashboard/{failureModal.tsx => taskResultModal.tsx} (77%) diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 52e0b90d..8abaf17a 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -107,42 +107,45 @@ export default (app: Router) => { }, ); - route.get( - '/failures', - async (req: Request, res: Response, next: NextFunction) => { - try { - const rows = (await CrontabStatModel.findAll({ - attributes: ['ref_id', [fn('SUM', col('fail_count')), 'fail_count']], - where: { date: dayjs().format('YYYY-MM-DD'), fail_count: { [Op.gt]: 0 } }, - group: ['ref_id'], - order: [[fn('SUM', col('fail_count')), 'DESC'], ['ref_id', 'ASC']], - raw: true, - })) as any[]; - const crons = rows.length > 0 ? await CrontabModel.findAll({ - attributes: ['id', 'name', 'command'], - where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } }, - raw: true, - }) : []; - const cronMap = new Map(crons.map((cron) => [cron.id, cron])); - res.send({ - code: 200, - data: rows.map((row) => { - const id = Number(row.ref_id); - const cron = cronMap.get(id); - return { - id, - name: cron?.name || cron?.command || tf('任务#%s', id), - command: cron?.command || '', - failCount: Number(row.fail_count), - deleted: !cron, - }; - }), - }); - } catch (e) { - next(e); - } - }, - ); + for (const isSuccess of [false, true]) { + route.get( + isSuccess ? '/successes' : '/failures', + async (req: Request, res: Response, next: NextFunction) => { + try { + const countColumn = isSuccess ? 'success_count' : 'fail_count'; + const rows = (await CrontabStatModel.findAll({ + attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']], + where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']], + raw: true, + })) as any[]; + const crons = rows.length > 0 ? await CrontabModel.findAll({ + attributes: ['id', 'name', 'command'], + where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } }, + raw: true, + }) : []; + const cronMap = new Map(crons.map((cron) => [cron.id, cron])); + res.send({ + code: 200, + data: rows.map((row) => { + const id = Number(row.ref_id); + const cron = cronMap.get(id); + return { + id, + name: cron?.name || cron?.command || tf('任务#%s', id), + command: cron?.command || '', + [isSuccess ? 'successCount' : 'failCount']: Number(row.result_count), + deleted: !cron, + }; + }), + }); + } catch (e) { + next(e); + } + }, + ); + } route.get( '/trend', diff --git a/src/locales/en-US.json b/src/locales/en-US.json index b4005cb2..e19986f7 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -658,6 +658,7 @@ "已停止": "Stopped", "退出码": "Exit Code", "结束": "End", + "成功次数": "Success count", "失败次数": "Failure count", "最新日志": "Latest log", "加载失败": "Failed to load", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 164516c4..0296e618 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -658,6 +658,7 @@ "已停止": "已停止", "退出码": "退出码", "结束": "结束", + "成功次数": "成功次数", "失败次数": "失败次数", "最新日志": "最新日志", "加载失败": "加载失败", diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index d77070de..c7b1229e 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -16,7 +16,7 @@ import { SharedContext } from '@/layouts'; import { request } from '@/utils/http'; import config from '@/utils/config'; import CronLogModal from '../crontab/logModal'; -import FailureModal from './failureModal'; +import TaskResultModal from './taskResultModal'; interface Overview { total: number; @@ -94,7 +94,7 @@ const Dashboard = () => { const [labels, setLabels] = useState([]); const [logCron, setLogCron] = useState(null); const [loading, setLoading] = useState(true); - const [showFailures, setShowFailures] = useState(false); + const [result, setResult] = useState<'success' | 'failure' | null>(null); const fetchData = useCallback(async () => { try { @@ -192,16 +192,25 @@ const Dashboard = () => { - } /> + setResult('success')} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setResult('success'); + } + }} + >} /> setShowFailures(true)} + onClick={() => setResult('failure')} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - setShowFailures(true); + setResult('failure'); } }} >} /> @@ -361,7 +370,7 @@ const Dashboard = () => { - {showFailures && setShowFailures(false)} />} + {result && setResult(null)} />} {logCron && ( void }) { - const [tasks, setTasks] = useState([]); +export default function TaskResultModal({ + result, + onCancel, +}: { + result: 'success' | 'failure'; + onCancel: () => void; +}) { + const isSuccess = result === 'success'; + const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [failed, setFailed] = useState(false); const [reload, setReload] = useState(0); - const [logCron, setLogCron] = useState(null); + const [logCron, setLogCron] = useState(null); useEffect(() => { let active = true; setLoading(true); setFailed(false); request - .get(`${config.apiPrefix}dashboard/failures`) + .get( + `${config.apiPrefix}dashboard/${isSuccess ? 'successes' : 'failures'}`, + ) .then((response) => { if (!active) return; if (response.code !== 200) - throw new Error('Failed to load dashboard failures'); + throw new Error('Failed to load dashboard task results'); setTasks(response.data); }) .catch(() => { @@ -41,12 +51,12 @@ export default function FailureModal({ onCancel }: { onCancel: () => void }) { return () => { active = false; }; - }, [reload]); + }, [reload, isSuccess]); return ( <> void }) { } /> ) : ( - + loading={loading} dataSource={tasks} rowKey="id" @@ -84,8 +94,8 @@ export default function FailureModal({ onCancel }: { onCancel: () => void }) { }, { title: intl.get('命令'), dataIndex: 'command', ellipsis: true }, { - title: intl.get('失败次数'), - dataIndex: 'failCount', + title: intl.get(isSuccess ? '成功次数' : '失败次数'), + dataIndex: isSuccess ? 'successCount' : 'failCount', width: 100, }, { diff --git a/test/back/dashboard-failures.test.cjs b/test/back/dashboard-failures.test.cjs index 886b314e..92435792 100644 --- a/test/back/dashboard-failures.test.cjs +++ b/test/back/dashboard-failures.test.cjs @@ -5,7 +5,7 @@ const { Sequelize, DataTypes } = require('sequelize'); const dayjs = require('dayjs'); const express = require('express'); -test('today failures includes recovered and deleted tasks, excluding previous days and successes', async (t) => { +test('today result lists include mixed and deleted tasks and exclude other dates', async (t) => { const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', @@ -53,11 +53,12 @@ test('today failures includes recovered and deleted tasks, excluding previous da { ref_id: 1, date: today, fail_count: 2, success_count: 1 }, { ref_id: 2, date: today, fail_count: 1, success_count: 0 }, { ref_id: 3, date: today, fail_count: 0, success_count: 4 }, - { ref_id: 4, date: today, fail_count: 3, success_count: 0 }, + { ref_id: 4, date: today, fail_count: 3, success_count: 2 }, { ref_id: 3, date: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), fail_count: 10, + success_count: 20, }, ]); const router = express.Router(); @@ -68,6 +69,9 @@ test('today failures includes recovered and deleted tasks, excluding previous da const handler = dashboard.stack.find( (layer) => layer.route?.path === '/failures', ).route.stack[0].handle; + const successHandler = dashboard.stack.find( + (layer) => layer.route?.path === '/successes', + ).route.stack[0].handle; let response; await handler( {}, @@ -100,7 +104,50 @@ test('today failures includes recovered and deleted tasks, excluding previous da }, ], }); + await successHandler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { + code: 200, + data: [ + { + id: 3, + name: 'Successful task', + command: 'task success.js', + successCount: 4, + deleted: false, + }, + { id: 4, name: '任务#4', command: '', successCount: 2, deleted: true }, + { + id: 1, + name: 'Recovered task', + command: 'task recovered.js', + successCount: 1, + deleted: false, + }, + ], + }); await stats.destroy({ where: {} }); + await successHandler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { code: 200, data: [] }); await handler( {}, { From d9833c17d5df1224c8a9634ff354323bdf7d767b Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 18 Sep 2026 20:22:18 +0800 Subject: [PATCH 093/123] perf: reduce resident dependencies and duplicate WebSocket authentication (#3071) * perf: load HTTP dependencies only in the HTTP worker * docs: record 30-minute idle resource comparison * perf: isolate database initialization from the cluster primary * docs: record isolated database startup resource comparison * perf: avoid an extra shell child in Docker health probes * perf: load notification dependencies only when needed * perf: retain queue cleanup after notification loading comparison * fix: stop page polling after unmount and avoid duplicate fetches * perf: share WebSocket session polling across connections * docs: measure real WebSocket container CPU and memory * perf: reuse JWT validation within each WebSocket session check * perf: bound login IP lookup allocations with an address cache * refactor: limit resource optimization PR to three core changes * docs: benchmark the three core optimizations against develop * chore: remove documentation and benchmark artifacts from PR --- back/app.ts | 62 +++--- back/bootstrap/database.ts | 26 +++ back/config/util.ts | 6 +- back/loaders/sock.ts | 80 +++++-- back/shared/errors.ts | 4 + back/shared/startupProcess.ts | 47 ++++ test/back/database-bootstrap.test.cjs | 193 +++++++++++++++++ test/back/role-loading.test.cjs | 118 ++++++++++ test/back/sock-polling.test.cjs | 297 ++++++++++++++++++++++++++ 9 files changed, 779 insertions(+), 54 deletions(-) create mode 100644 back/bootstrap/database.ts create mode 100644 back/shared/errors.ts create mode 100644 back/shared/startupProcess.ts create mode 100644 test/back/database-bootstrap.test.cjs create mode 100644 test/back/role-loading.test.cjs create mode 100644 test/back/sock-polling.test.cjs diff --git a/back/app.ts b/back/app.ts index 693e34e1..96276014 100644 --- a/back/app.ts +++ b/back/app.ts @@ -1,17 +1,12 @@ import 'reflect-metadata'; import cluster, { type Worker } from 'cluster'; -import compression from 'compression'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; +import type express from 'express'; import { Container } from 'typedi'; import config from './config'; import Logger from './loaders/logger'; -import { monitoringMiddleware } from './middlewares/monitoring'; -import { errStack } from './config/util'; +import { errStack } from './shared/errors'; import { type GrpcServerService } from './services/grpc'; import { type HttpServerService } from './services/http'; -import cronClient from './schedule/client'; interface WorkerMetadata { id: number; @@ -21,24 +16,12 @@ interface WorkerMetadata { } class Application { - private app: express.Application; private httpServerService?: HttpServerService; private grpcServerService?: GrpcServerService; private isShuttingDown = false; private workerMetadataMap = new Map(); private httpWorker?: Worker; - constructor() { - this.app = express(); - // 创建一个全局中间件,删除查询参数中的t - this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { - if (req.query.t) { - delete req.query.t; - } - next(); - }); - } - async start() { try { if (cluster.isPrimary) { @@ -158,17 +141,37 @@ class Application { } private async initializeDatabase() { - const dbLoader = await import('./loaders/db'); - await dbLoader.default(); + const { runStartupProcess } = await import('./shared/startupProcess'); + await runStartupProcess(require.resolve('./bootstrap/database')); } - private setupMiddlewares() { - this.app.use(helmet({ + private async setupMiddlewares(): Promise { + // Only the HTTP worker needs an Express application and its middleware. + const [ + { default: createExpress }, + { default: helmet }, + { default: cors }, + { default: compression }, + { monitoringMiddleware }, + ] = await Promise.all([ + import('express'), + import('helmet'), + import('cors'), + import('compression'), + import('./middlewares/monitoring'), + ]); + const app = createExpress(); + app.use((req, res, next) => { + if (req.query.t) delete req.query.t; + next(); + }); + app.use(helmet({ contentSecurityPolicy: false, })); - this.app.use(cors(config.cors)); - this.app.use(compression()); - this.app.use(monitoringMiddleware); + app.use(cors(config.cors)); + app.use(compression()); + app.use(monitoringMiddleware); + return app; } private setupMasterShutdown() { @@ -247,16 +250,16 @@ class Application { const { initGrpcCerts } = await import('./config/grpcCerts'); await initGrpcCerts(); - this.setupMiddlewares(); + const app = await this.setupMiddlewares(); const { HttpServerService } = await import('./services/http'); this.httpServerService = Container.get(HttpServerService); const appLoader = await import('./loaders/app'); - await appLoader.default({ app: this.app }); + await appLoader.default({ app }); const server = await this.httpServerService.initialize( - this.app, + app, config.port, ); @@ -279,6 +282,7 @@ class Application { this.gracefulShutdown(serviceType); } else if (serviceType === 'http' && (msg === 'reregister-crons' || msg === 'scheduler-unavailable')) { + const { default: cronClient } = await import('./schedule/client'); cronClient.readiness.invalidate(); } }); diff --git a/back/bootstrap/database.ts b/back/bootstrap/database.ts new file mode 100644 index 00000000..4fdeb4d0 --- /dev/null +++ b/back/bootstrap/database.ts @@ -0,0 +1,26 @@ +import { errStack } from '../shared/errors'; + +// Also terminate if the primary disappears without forwarding a signal. +const onParentDisconnect = () => process.exit(1); +process.once('disconnect', onParentDisconnect); + +async function initializeDatabase() { + const { sequelize } = await import('../data'); + try { + const { default: loadDatabase } = await import('../loaders/db'); + await loadDatabase(); + } finally { + await sequelize.close(); + } +} + +initializeDatabase() + .catch((error) => { + console.error(`[boot] Database initialization failed:\n${errStack(error)}`); + process.exitCode = 1; + }) + .finally(() => { + process.removeListener('disconnect', onParentDisconnect); + // Let pending log writes drain and exit naturally after closing SQLite. + if (process.connected) process.disconnect(); + }); diff --git a/back/config/util.ts b/back/config/util.ts index dbd2f2a1..13cad395 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -652,11 +652,7 @@ export function safeJSONParse(value?: string) { } } -export function errStack(error: unknown): string { - return error instanceof Error && error.stack - ? error.stack - : String(error); -} +export { errStack } from '../shared/errors'; export async function rmPath(path: string) { try { diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index b99b721f..b09d6aeb 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -1,4 +1,4 @@ -import sockJs from 'sockjs'; +import sockJs, { Connection } from 'sockjs'; import { Server } from 'http'; import { Container } from 'typedi'; import SockService from '../services/sock'; @@ -13,6 +13,41 @@ export default async ({ server }: { server: Server }) => { log: () => {}, }); const sockService = Container.get(SockService); + const sessions = new Map(); + let sessionTimer: ReturnType | undefined; + let checking = false; + + const checkSessions = async () => { + if (checking || sessions.size === 0) return; + checking = true; + // Connections accepted during this read must not use an older snapshot. + const batch = [...sessions]; + try { + const current = await shareStore.getAuthInfo(); + // Reuse validation only within this synchronous check of one auth snapshot. + const validated = new Map>(); + for (const [conn, { token, platform }] of batch) { + if (!sessions.has(conn)) continue; + let platforms = validated.get(token); + if (!platforms) { + platforms = new Map(); + validated.set(token, platforms); + } + let valid = platforms.get(platform); + if (valid === undefined) { + valid = isValidToken(current, token, platform, config.jwt.secret); + platforms.set(platform, valid); + } + if (!valid) conn.close('401'); + } + } catch { + for (const [conn] of batch) { + if (sessions.has(conn)) conn.close('401'); + } + } finally { + checking = false; + } + }; echo.on('connection', async (conn) => { if (!conn.headers || !conn.url || !conn.pathname) { @@ -20,35 +55,40 @@ export default async ({ server }: { server: Server }) => { return; } - const authInfo = await shareStore.getAuthInfo(); + let closed = false; + conn.on('close', () => { + closed = true; + sessions.delete(conn); + sockService.removeClient(conn); + if (sessions.size === 0 && sessionTimer) { + clearInterval(sessionTimer); + sessionTimer = undefined; + } + }); + + let authInfo; + try { + authInfo = await shareStore.getAuthInfo(); + } catch { + if (!closed) conn.close('401'); + return; + } + if (closed) return; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) { sockService.addClient(conn); - const checkSession = setInterval(async () => { - try { - const current = await shareStore.getAuthInfo(); - if ( - !isValidToken(current, headerToken, platform, config.jwt.secret) - ) { - conn.close('401'); - } - } catch { - conn.close('401'); - } - }, 1000); - checkSession.unref(); + sessions.set(conn, { token: headerToken, platform }); + if (!sessionTimer) { + sessionTimer = setInterval(checkSessions, 1000); + sessionTimer.unref(); + } conn.on('data', (message) => { conn.write(message); }); - conn.on('close', function () { - clearInterval(checkSession); - sockService.removeClient(conn); - }); - return; } diff --git a/back/shared/errors.ts b/back/shared/errors.ts new file mode 100644 index 00000000..72945c9b --- /dev/null +++ b/back/shared/errors.ts @@ -0,0 +1,4 @@ +// Keep error formatting independent of database, HTTP and gRPC dependencies. +export function errStack(error: unknown): string { + return error instanceof Error && error.stack ? error.stack : String(error); +} diff --git a/back/shared/startupProcess.ts b/back/shared/startupProcess.ts new file mode 100644 index 00000000..64ee9dcb --- /dev/null +++ b/back/shared/startupProcess.ts @@ -0,0 +1,47 @@ +import { fork } from 'child_process'; + +// Keep one-off startup dependencies out of the long-lived cluster primary. +export function runStartupProcess(entrypoint: string): Promise { + return new Promise((resolve, reject) => { + const child = fork(entrypoint, [], { stdio: 'inherit' }); + let interrupted: NodeJS.Signals | undefined; + let killTimer: NodeJS.Timeout | undefined; + + const terminate = (signal: NodeJS.Signals) => { + if (interrupted) return; + interrupted = signal; + child.kill(signal); + killTimer = setTimeout(() => child.kill('SIGKILL'), 8000); + killTimer.unref(); + }; + const onSigterm = () => terminate('SIGTERM'); + const onSigint = () => terminate('SIGINT'); + const onExit = () => child.kill('SIGKILL'); + const cleanup = () => { + if (killTimer) clearTimeout(killTimer); + process.removeListener('SIGTERM', onSigterm); + process.removeListener('SIGINT', onSigint); + process.removeListener('exit', onExit); + }; + + process.once('SIGTERM', onSigterm); + process.once('SIGINT', onSigint); + process.once('exit', onExit); + child.once('error', (error) => { + cleanup(); + reject(error); + }); + child.once('close', (code, signal) => { + cleanup(); + if (code === 0 && !interrupted) { + resolve(); + } else { + reject( + new Error( + `Startup process failed (${interrupted || signal || code})`, + ), + ); + } + }); + }); +} diff --git a/test/back/database-bootstrap.test.cjs b/test/back/database-bootstrap.test.cjs new file mode 100644 index 00000000..d3507968 --- /dev/null +++ b/test/back/database-bootstrap.test.cjs @@ -0,0 +1,193 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { fork } = require('node:child_process'); +const { once } = require('node:events'); +const { Sequelize, QueryTypes } = require('sequelize'); +const ts = require('typescript'); +const { createRequire } = require('node:module'); + +const initializer = require.resolve('../../back/bootstrap/database'); +const startup = require.resolve('../../back/shared/startupProcess'); + +function fixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-bootstrap-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.mkdirSync(path.join(dir, 'data/db'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'data/syslog'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.env'), ''); + const wrapper = path.join(dir, 'parent.cjs'); + fs.writeFileSync(wrapper, ` + const cp = require('node:child_process'); + const original = cp.fork; + cp.fork = (...args) => { + const child = original(...args); + process.send?.({ childPid: child.pid }); + return child; + }; + const { runStartupProcess } = require(${JSON.stringify(startup)}); + runStartupProcess(process.argv[2]).then(() => { + const modules = Object.keys(require.cache); + process.send?.({ done: true, modules }); + }).catch(error => { + console.error(error.message); + process.exitCode = 1; + }).finally(() => { if (process.connected) process.disconnect(); }); + `); + return { + dir, + storage: path.join(dir, 'data/db/database.sqlite'), + run(entrypoint = initializer) { + const child = fork(wrapper, [entrypoint], { + env: { ...process.env, QL_DIR: dir, QL_DATA_DIR: path.join(dir, 'data') }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + const messages = []; + let output = ''; + child.on('message', (message) => messages.push(message)); + child.stdout.on('data', (data) => { output += data; }); + child.stderr.on('data', (data) => { output += data; }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }); + const done = once(child, 'close').then(([code, signal]) => ({ code, signal, output, messages })); + return { child, done, messages }; + }, + }; +} + +async function waitFor(predicate) { + const deadline = Date.now() + 10000; + while (!predicate()) { + assert.ok(Date.now() < deadline, 'timed out waiting for child'); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +test('isolated initialization creates a fresh database and does not retain ORM modules in the parent', { timeout: 20000 }, async t => { + const f = fixture(t); + const result = await f.run().done; + assert.equal(result.code, 0, result.output); + const done = result.messages.find(message => message.done); + assert.ok(done); + assert.equal(done.modules.some(file => /[\\/]sequelize[\\/]|[\\/]data[\\/]index\.ts$/.test(file)), false); + const db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false }); + try { + const tables = await db.getQueryInterface().showAllTables(); + for (const table of ['Crontabs', 'Envs', 'RunningInstances', 'SchemaMigrations']) assert.ok(tables.includes(table), table); + assert.equal((await db.query('SELECT id FROM SchemaMigrations', { type: QueryTypes.SELECT })).length, 15); + } finally { await db.close(); } +}); + +test('isolated initialization upgrades an existing database and remains idempotent', { timeout: 30000 }, async t => { + const f = fixture(t); + let db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false }); + await db.query('CREATE TABLE Crontabs (id INTEGER PRIMARY KEY, name TEXT)'); + await db.query("INSERT INTO Crontabs (id, name) VALUES (1, 'keep-me')"); + await db.close(); + for (let i = 0; i < 2; i++) { + const result = await f.run().done; + assert.equal(result.code, 0, result.output); + } + db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false }); + try { + const rows = await db.query('SELECT * FROM Crontabs', { type: QueryTypes.SELECT }); + assert.equal(rows[0].name, 'keep-me'); + assert.ok(Object.hasOwn(rows[0], 'queued_token')); + assert.equal((await db.query('SELECT id FROM SchemaMigrations', { type: QueryTypes.SELECT })).length, 15); + } finally { await db.close(); } +}); + +test('database errors and missing initializer files reject startup', { timeout: 20000 }, async t => { + const f = fixture(t); + fs.writeFileSync(f.storage, 'invalid SQLite database'); + const invalid = await f.run().done; + assert.equal(invalid.code, 1); + assert.match(invalid.output, /SQLITE_NOTADB/); + assert.equal(invalid.messages.some(message => message.done), false); + const missing = await f.run(path.join(f.dir, 'missing.cjs')).done; + assert.equal(missing.code, 1); + assert.match(missing.output, /MODULE_NOT_FOUND/); +}); + +for (const signal of ['SIGTERM', 'SIGINT']) { + test(`startup ${signal} terminates the initializer and never reports success`, { timeout: 15000 }, async t => { + const f = fixture(t); + const target = path.join(f.dir, 'waiting.cjs'); + const ready = path.join(f.dir, 'ready'); + fs.writeFileSync(target, ` + require('node:fs').writeFileSync(${JSON.stringify(ready)}, String(process.pid)); + setInterval(() => {}, 1000); + `); + const running = f.run(target); + await waitFor(() => fs.existsSync(ready)); + const pid = Number(fs.readFileSync(ready, 'utf8')); + running.child.kill(signal); + const result = await running.done; + assert.equal(result.code, 1, result.output); + assert.match(result.output, new RegExp(signal)); + assert.equal(result.messages.some(message => message.done), false); + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + }); +} + +test('a database initializer blocked on SQLite exits when its parent disappears', { timeout: 20000 }, async t => { + const f = fixture(t); + const db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false }); + await db.query('BEGIN EXCLUSIVE'); + try { + const running = f.run(); + await waitFor(() => running.messages.some(message => message.childPid)); + const pid = running.messages.find(message => message.childPid).childPid; + // Allow the real initializer to enter the database operation before losing IPC. + await new Promise(resolve => setTimeout(resolve, 1000)); + running.child.kill('SIGKILL'); + await running.done; + await waitFor(() => { + try { process.kill(pid, 0); return false; } + catch (error) { if (error.code === 'ESRCH') return true; throw error; } + }); + } finally { + await db.query('ROLLBACK'); + await db.close(); + } +}); + +test('application waits for isolated initialization and does not fork workers after a failure', async () => { + const file = path.resolve('back/app.ts'); + const source = fs.readFileSync(file, 'utf8'); + const compiled = ts.transpileModule(source.slice(0, source.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true }, + }).outputText; + for (const fail of [false, true]) { + let complete; + const pending = new Promise((resolve, reject) => { complete = () => fail ? reject(new Error('migration failed')) : resolve(); }); + const exits = [], calls = []; + const localRequire = createRequire(file); + const mocks = { + cluster: { isPrimary: true }, + './config': {}, + './loaders/logger': { error() {} }, + './shared/startupProcess': { runStartupProcess: entry => { assert.equal(entry, initializer); return pending; } }, + }; + const req = Object.assign(name => { + calls.push(name); + return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name); + }, { resolve: localRequire.resolve }); + const module = { exports: {} }; + new Function('require', 'module', 'exports', 'process', compiled)(req, module, module.exports, { exit: code => exits.push(code) }); + const app = new module.exports(); + let started = false; + app.startMasterProcess = () => { started = true; }; + const start = app.start(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(started, false); + complete(); + await start; + assert.equal(started, !fail); + assert.deepEqual(exits, fail ? [1] : []); + assert.equal(calls.includes('./loaders/db'), false); + } +}); diff --git a/test/back/role-loading.test.cjs b/test/back/role-loading.test.cjs new file mode 100644 index 00000000..ac59c8bb --- /dev/null +++ b/test/back/role-loading.test.cjs @@ -0,0 +1,118 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const ts = require('typescript'); +const source = fs.readFileSync('back/app.ts', 'utf8'); +const compiled = ts.transpileModule( + source.slice(0, source.indexOf('\nconst app = new Application();')) + + '\nmodule.exports = Application;', + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }, +).outputText; + +function fixture() { + const required = [], + listeners = new Map(); + let invalidations = 0; + const module = { exports: {} }; + const localRequire = createRequire(path.resolve('back/app.ts')); + const mocks = { + './config': { cors: { origin: ['http://localhost'] } }, + './loaders/logger': { error() {}, warn() {}, info() {} }, + './middlewares/monitoring': { + monitoringMiddleware: (_req, _res, next) => next(), + }, + './schedule/client': { + default: { readiness: { invalidate: () => invalidations++ } }, + __esModule: true, + }, + }; + new Function('require', 'module', 'exports', 'process', compiled)( + (name) => { + required.push(name); + return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name); + }, + module, + module.exports, + { env: {}, on: (name, fn) => listeners.set(name, fn) }, + ); + return { + app: new module.exports(), + required, + listeners, + invalidations: () => invalidations, + }; +} + +test('primary construction does not load HTTP middleware, metrics or the gRPC client', () => { + const { required } = fixture(); + for (const name of [ + 'express', + 'helmet', + 'cors', + 'compression', + './middlewares/monitoring', + './schedule/client', + './config/util', + ]) { + assert.equal(required.includes(name), false, name); + } +}); + +test('lazy HTTP initialization preserves query filtering, security headers, CORS and compression', async (t) => { + const { app, required } = fixture(); + const http = await app.setupMiddlewares(); + http.get('/test', (req, res) => + res.json({ query: req.query, data: 'x'.repeat(2048) }), + ); + const server = await new Promise((resolve) => { + const server = http.listen(0, '127.0.0.1', () => resolve(server)); + }); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }), + ); + const response = await fetch( + `http://127.0.0.1:${server.address().port}/test?t=123&keep=yes`, + { + headers: { Origin: 'http://localhost', 'Accept-Encoding': 'gzip' }, + }, + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.equal( + response.headers.get('access-control-allow-origin'), + 'http://localhost', + ); + assert.equal(response.headers.get('content-encoding'), 'gzip'); + assert.deepEqual((await response.json()).query, { keep: 'yes' }); + assert.ok(required.includes('express')); + assert.equal(required.includes('./schedule/client'), false); +}); + +test('worker recovery messages still invalidate HTTP readiness, but gRPC shutdown setup does not load its client', async () => { + const grpc = fixture(); + grpc.app.setupWorkerShutdown('grpc'); + await grpc.listeners.get('message')('scheduler-unavailable'); + assert.equal(grpc.required.includes('./schedule/client'), false); + const http = fixture(); + http.app.setupWorkerShutdown('http'); + await http.listeners.get('message')('scheduler-unavailable'); + await http.listeners.get('message')('reregister-crons'); + assert.equal(http.invalidations(), 2); + const shutdowns = []; + http.app.gracefulShutdown = (role) => shutdowns.push(role); + await http.listeners.get('message')('shutdown'); + http.listeners.get('SIGTERM')(); + assert.deepEqual(shutdowns, ['http', 'http']); +}); diff --git a/test/back/sock-polling.test.cjs b/test/back/sock-polling.test.cjs new file mode 100644 index 00000000..1acb084d --- /dev/null +++ b/test/back/sock-polling.test.cjs @@ -0,0 +1,297 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const ts = require('typescript'); +const jwt = require('jsonwebtoken'); +const { isValidToken } = require('../../back/shared/auth'); + +async function setup() { + const secret = 'polling-test'; + const token = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + let auth = { token }; + let read = async () => auth; + let reads = 0; + const validations = []; + let onConnection; + const clients = new Set(); + const timers = new Set(); + const mocks = { + sockjs: { + createServer: () => ({ + on: (_, fn) => { + onConnection = fn; + }, + installHandlers() {}, + }), + }, + typedi: { + Container: { + get: () => ({ + addClient: (c) => clients.add(c), + removeClient: (c) => clients.delete(c), + }), + }, + }, + '../services/sock': class {}, + '../config/util': { getPlatform: (agent) => agent || 'desktop' }, + '../shared/store': { + shareStore: { + getAuthInfo: () => { + reads++; + return read(); + }, + }, + }, + '../shared/auth': { + isValidToken: (...args) => { + validations.push({ token: args[1], platform: args[2] }); + return isValidToken(...args); + }, + }, + '../config': { baseUrl: '', jwt: { secret } }, + }; + const source = fs.readFileSync( + path.join(__dirname, '../../back/loaders/sock.ts'), + 'utf8', + ); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }); + const module = { exports: {} }; + new Function( + 'require', + 'module', + 'exports', + 'setInterval', + 'clearInterval', + outputText, + )( + (name) => { + assert.ok(Object.hasOwn(mocks, name), name); + return mocks[name]; + }, + module, + module.exports, + (callback, delay) => { + assert.equal(delay, 1000); + const timer = { callback, unref() {} }; + timers.add(timer); + return timer; + }, + (timer) => timers.delete(timer), + ); + await module.exports.default({ server: {} }); + function connection(value = token) { + const conn = new EventEmitter(); + Object.assign(conn, { + headers: {}, + pathname: '/api/ws/a/b/websocket', + url: `/api/ws/a/b/websocket?token=${value}`, + write() {}, + close(code) { + this.closeCode = code; + this.emit('close'); + }, + }); + return conn; + } + return { + token, + secret, + clients, + timers, + validations, + connection, + connect: (c) => onConnection(c), + get reads() { + return reads; + }, + setAuth(value) { + auth = value; + }, + setRead(fn) { + read = fn; + }, + tick: () => Promise.all([...timers].map((t) => t.callback())), + }; +} + +function deferred() { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +} + +test('multiple sockets share one periodic read, revoke together, and stop polling when empty', async () => { + const h = await setup(); + assert.equal(h.timers.size, 0); + const sockets = Array.from({ length: 20 }, () => h.connection()); + await Promise.all(sockets.map(h.connect)); + assert.equal(h.reads, 20, 'initial authentication remains per connection'); + assert.equal(h.timers.size, 1); + await h.tick(); + assert.equal(h.reads, 21); + assert.equal(h.clients.size, 20); + h.setAuth({ token: '' }); + await h.tick(); + assert.ok(sockets.every((c) => c.closeCode === '401')); + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); + const c = h.connection(); + h.setAuth({ token: h.token }); + await h.connect(c); + assert.equal(h.timers.size, 1); + c.close(); + assert.equal(h.timers.size, 0); +}); + +test('slow reads do not overlap and old snapshots do not reject newly accepted sockets', async () => { + const h = await setup(); + const old = h.connection(); + await h.connect(old); + const pending = deferred(); + h.setRead(() => pending.promise); + const tick = h.tick(); + await h.tick(); + assert.equal(h.reads, 2); + old.close(); + h.setRead(async () => ({ token: h.token })); + const fresh = h.connection(); + await h.connect(fresh); + pending.resolve({ token: '' }); + await tick; + assert.equal(h.clients.has(fresh), true); + assert.equal(fresh.closeCode, undefined); + await h.tick(); + assert.equal(h.reads, 4); + fresh.close(); +}); + +test('periodic store failures disconnect the checked sessions', async () => { + const h = await setup(); + const c = h.connection(); + await h.connect(c); + h.setRead(async () => { + throw new Error('database unavailable'); + }); + await h.tick(); + assert.equal(c.closeCode, '401'); + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); +}); + +test('closed connections cannot be added after initial authentication finishes', async () => { + const h = await setup(); + const pending = deferred(); + h.setRead(() => pending.promise); + const c = h.connection(); + const connecting = h.connect(c); + c.close(); + pending.resolve({ token: h.token }); + await connecting; + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); +}); + +test('initial store errors reject the connection without creating a timer', async () => { + const h = await setup(); + h.setRead(async () => { + throw new Error('database unavailable'); + }); + const c = h.connection(); + await h.connect(c); + assert.equal(c.closeCode, '401'); + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); +}); + +test('each periodic check still validates JWT expiry and platform session membership', async () => { + const h = await setup(); + const expired = h.connection( + jwt.sign({}, h.secret, { algorithm: 'HS384', expiresIn: -1 }), + ); + await h.connect(expired); + assert.equal(expired.closeCode, '404'); + const c = h.connection(); + await h.connect(c); + h.setAuth({ tokens: { desktop: [{ value: h.token, expiration: 1 }] } }); + await h.tick(); + assert.equal(c.closeCode, '401'); + assert.equal(h.timers.size, 0); +}); + +test('an accepted JWT is disconnected when it expires without a store change', async () => { + const h = await setup(); + const c = h.connection(); + await h.connect(c); + const now = Date.now; + try { + Date.now = () => now() + 2 * 60 * 60 * 1000; + await h.tick(); + } finally { + Date.now = now; + } + assert.equal(c.closeCode, '401'); + assert.equal(h.timers.size, 0); +}); + +test('same token and platform share validation only within the current round', async () => { + const h = await setup(); + const sockets = Array.from({ length: 20 }, () => h.connection()); + await Promise.all(sockets.map(h.connect)); + assert.equal( + h.validations.length, + 20, + 'initial authentication stays independent', + ); + h.validations.length = 0; + await h.tick(); + assert.equal(h.validations.length, 1); + await h.tick(); + assert.equal(h.validations.length, 2, 'new rounds revalidate'); + h.setAuth({ token: '' }); + await h.tick(); + assert.equal( + h.validations.length, + 3, + 'invalid results are also shared for this round', + ); + assert.ok(sockets.every((c) => c.closeCode === '401')); + assert.equal(h.timers.size, 0); +}); + +test('validation results never cross tokens or platforms', async () => { + const h = await setup(); + const other = jwt.sign({ account: 'other' }, h.secret, { + algorithm: 'HS384', + expiresIn: '1h', + }); + h.setAuth({ + tokens: { + desktop: [{ value: h.token }, { value: other }], + mobile: [{ value: h.token }], + }, + }); + const desktop = h.connection(); + const mobile = h.connection(); + mobile.headers['user-agent'] = 'mobile'; + const separate = h.connection(other); + await Promise.all([desktop, mobile, separate].map(h.connect)); + h.validations.length = 0; + h.setAuth({ tokens: { desktop: [{ value: h.token }], mobile: [] } }); + await h.tick(); + assert.equal(h.validations.length, 3); + assert.equal(desktop.closeCode, undefined); + assert.equal(mobile.closeCode, '401'); + assert.equal(separate.closeCode, '401'); + desktop.close(); +}); From 6acb6653697bee18939e12d992839efb1bcd0a0d Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 19 Sep 2026 13:23:28 +0800 Subject: [PATCH 094/123] fix: prevent expired-token login refresh loop --- src/layouts/index.tsx | 10 +-- src/utils/http.tsx | 5 +- test/front/http-auth.test.cjs | 152 ++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 test/front/http-auth.test.cjs diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index f8e5c1ec..7c94f91a 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -114,13 +114,9 @@ export default function () { history.push('/error'); } }) - .catch((error) => { - const responseStatus = error.response.status; - if (responseStatus !== 401) { - history.push('/error'); - } else { - window.location.reload(); - } + .catch(() => { + // Health is anonymous; a failure must not trigger a page reload loop. + history.push('/error'); }) .finally(() => setInitLoading(false)); }; diff --git a/src/utils/http.tsx b/src/utils/http.tsx index 904fd076..466dc085 100644 --- a/src/utils/http.tsx +++ b/src/utils/http.tsx @@ -49,9 +49,9 @@ const errorHandler = function ( if ([502, 504].includes(responseStatus)) { history.push('/error'); } else if (responseStatus === 401) { + localStorage.removeItem(config.authKey); if (history.location.pathname !== '/login') { message.error(intl.get('登录已过期,请重新登录')); - localStorage.removeItem(config.authKey); history.push('/login'); } } else { @@ -84,6 +84,7 @@ let _request = axios.create({ }); const apiWhiteList = [ + `${config.baseUrl}api/health`, `${config.baseUrl}api/user/login`, `${config.baseUrl}open/auth/token`, `${config.baseUrl}api/user/two-factor/login`, @@ -106,8 +107,8 @@ _request.interceptors.response.use(async (response) => { if ([502, 504].includes(responseStatus)) { history.push('/error'); } else if (responseStatus === 401) { + localStorage.removeItem(config.authKey); if (history.location.pathname !== '/login') { - localStorage.removeItem(config.authKey); history.push('/login'); } } else { diff --git a/test/front/http-auth.test.cjs b/test/front/http-auth.test.cjs new file mode 100644 index 00000000..caaaf6c5 --- /dev/null +++ b/test/front/http-auth.test.cjs @@ -0,0 +1,152 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const axios = require('axios'); + +function fixture(pathname = '/login', baseUrl = '/') { + const storage = new Map([['token', 'expired-token']]); + const pushes = []; + const history = { location: { pathname }, push: (p) => pushes.push(p) }; + const config = { authKey: 'token', baseUrl }; + const mocks = { + axios, + 'react-intl-universal': { get: (s) => s }, + antd: { + message: { config() {}, error() {} }, + notification: { error() {} }, + }, + './config': config, + '@umijs/max': { history }, + './httpError': { getErrorDetails: () => [] }, + }; + const source = fs.readFileSync( + path.join(__dirname, '../../src/utils/http.tsx'), + 'utf8', + ); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + }, + }); + const module = { exports: {} }; + new Function('require', 'module', 'exports', 'localStorage', outputText)( + (name) => { + assert.ok(Object.hasOwn(mocks, name), name); + return mocks[name]; + }, + module, + module.exports, + { + getItem: (k) => storage.get(k) ?? null, + removeItem: (k) => storage.delete(k), + }, + ); + return { request: module.exports.request, storage, pushes }; +} + +for (const pathname of ['/login', '/dashboard']) { + test(`401 clears expired credentials on ${pathname}`, async () => { + const { request, storage, pushes } = fixture(pathname); + await assert.rejects( + request.get('/api/user', { + adapter: async (config) => { + throw new axios.AxiosError( + 'Unauthorized', + 'ERR_BAD_REQUEST', + config, + null, + { status: 401, data: { message: 'expired' }, config }, + ); + }, + }), + ); + assert.equal(storage.has('token'), false); + assert.deepEqual(pushes, pathname === '/login' ? [] : ['/login']); + }); +} +for (const baseUrl of ['/', '/panel/']) { + test(`health is anonymous while protected requests keep credentials (${baseUrl})`, async () => { + const { request } = fixture('/login', baseUrl); + const headers = []; + const adapter = async (config) => { + headers.push(config.headers.get('Authorization')); + return { status: 200, data: { code: 200 }, config }; + }; + await request.get(`${baseUrl}api/health`, { adapter }); + await request.get(`${baseUrl}api/user`, { adapter }); + assert.deepEqual(headers, [undefined, 'Bearer expired-token']); + }); +} + +function healthFixture(get) { + const source = fs.readFileSync( + path.join(__dirname, '../../src/layouts/index.tsx'), + 'utf8', + ); + const ast = ts.createSourceFile( + 'index.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let initializer; + function visit(node) { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(ast) === 'getHealthStatus' + ) + initializer = node.initializer; + ts.forEachChild(node, visit); + } + visit(ast); + assert.ok(initializer, 'layout health bootstrap exists'); + const { outputText } = ts.transpileModule( + `const bootstrap = ${initializer.getText(ast)};`, + { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }, + ); + const events = []; + const bootstrap = new Function( + 'request', + 'config', + 'history', + 'window', + 'getSystemInfo', + 'setInitLoading', + `${outputText}; return bootstrap;`, + )( + { get }, + { apiPrefix: '/api/' }, + { push: (p) => events.push(p) }, + { location: { reload: () => events.push('reload') } }, + () => events.push('system'), + (value) => events.push(['loading', value]), + ); + return { bootstrap, events }; +} +for (const status of [401, 503, undefined]) { + test(`health failure ${ + status ?? 'network' + } settles without reloading`, async () => { + const { bootstrap, events } = healthFixture(async () => { + throw status ? { response: { status } } : new Error('Network Error'); + }); + bootstrap(); + await new Promise(setImmediate); + assert.deepEqual(events, ['/error', ['loading', false]]); + }); +} +test('healthy bootstrap continues loading system information', async () => { + const { bootstrap, events } = healthFixture(async () => ({ + data: { status: 'ok' }, + })); + bootstrap(); + await new Promise(setImmediate); + assert.deepEqual(events, ['system', ['loading', false]]); +}); From 2d6c2206b35d2a6bacec946826cd6e4d79ee9a7c Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 22 Sep 2026 22:23:18 +0800 Subject: [PATCH 095/123] fix: verify task termination and preserve startup configuration --- back/config/util.ts | 21 ++- back/services/script.ts | 6 +- back/services/subscription.ts | 22 +-- back/shared/runCron.ts | 3 +- shell/start.sh | 7 +- test/back/process-stop-signal.test.cjs | 77 ++++++++++ test/back/start-env-preservation.test.cjs | 53 +++++++ test/back/stop-failure-feedback.test.cjs | 158 +++++++++++++++++++++ test/back/subscription-stop-state.test.cjs | 66 +++++++++ 9 files changed, 386 insertions(+), 27 deletions(-) create mode 100644 test/back/process-stop-signal.test.cjs create mode 100644 test/back/start-env-preservation.test.cjs create mode 100644 test/back/stop-failure-feedback.test.cjs create mode 100644 test/back/subscription-stop-state.test.cjs diff --git a/back/config/util.ts b/back/config/util.ts index 13cad395..dc8bdb8f 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -491,17 +491,6 @@ export function psTree(pid: number): Promise { export async function killTask(pid: number, waitForExit = false) { const descendants = await psTree(pid); - if (!waitForExit) { - if (descendants.length) { - try { - [pid, ...descendants] - .reverse() - .forEach((target) => process.kill(target, 15)); - } catch {} - } else process.kill(pid, 2); - return; - } - const pids = [...descendants.reverse(), pid]; const signal = (target: number, sig: NodeJS.Signals) => { try { process.kill(target, sig); @@ -509,6 +498,16 @@ export async function killTask(pid: number, waitForExit = false) { if (error.code !== 'ESRCH') throw error; } }; + if (!waitForExit) { + if (descendants.length) { + // A child may exit after psTree; keep signalling the remaining tree. + for (const target of [pid, ...descendants].reverse()) { + signal(target, 'SIGTERM'); + } + } else signal(pid, 'SIGINT'); + return; + } + const pids = [...descendants.reverse(), pid]; for (const target of pids) signal(target, 'SIGTERM'); const alive = async (target: number) => { try { diff --git a/back/services/script.ts b/back/services/script.ts index ef4a5e2c..207fd0a1 100644 --- a/back/services/script.ts +++ b/back/services/script.ts @@ -58,9 +58,9 @@ export default class ScriptService { taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-')); pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number; } - try { - await killTask(pid); - } catch (error) {} + if (pid) { + await killTask(pid, true); + } return { code: 200 }; } diff --git a/back/services/subscription.ts b/back/services/subscription.ts index 513d239f..16fe2b9e 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -322,20 +322,22 @@ export default class SubscriptionService { public async stop(ids: number[]) { const docs = await SubscriptionModel.findAll({ where: { id: ids } }); + let failure: unknown; for (const doc of docs) { - if (doc.pid) { - try { - await killTask(doc.pid); - } catch (error) { - this.logger.error(error); + try { + if (doc.pid) { + await killTask(doc.pid, true); } + await SubscriptionModel.update( + { status: SubscriptionStatus.idle, pid: null } as any, + { where: { id: doc.id, pid: doc.pid ?? null } }, + ); + } catch (error) { + this.logger.error(error); + failure ??= error; } } - - await SubscriptionModel.update( - { status: SubscriptionStatus.idle, pid: undefined }, - { where: { id: ids } }, - ); + if (failure) throw failure; } private async runSingle(subscriptionId: number) { diff --git a/back/shared/runCron.ts b/back/shared/runCron.ts index 29609e7a..ab5511a8 100644 --- a/back/shared/runCron.ts +++ b/back/shared/runCron.ts @@ -31,7 +31,7 @@ export function runCron(cmd: string, cron: ICron): Promise { Logger.info( `[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`, ); - await killTask(existingCron.pid); + await killTask(existingCron.pid, true); // Mark old running instances as stopped const stoppedAt = dayjs().unix(); await RunningInstanceModel.update( @@ -53,6 +53,7 @@ export function runCron(cmd: string, cron: ICron): Promise { Logger.error( `[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`, ); + throw error; } Logger.info( diff --git a/shell/start.sh b/shell/start.sh index da615332..657f7aff 100644 --- a/shell/start.sh +++ b/shell/start.sh @@ -91,8 +91,11 @@ if [[ $command != "reload" ]]; then pip3 install --prefix ${PYTHON_HOME} requests fi -cd ${QL_DIR} -cp -f .env.example .env +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 diff --git a/test/back/process-stop-signal.test.cjs b/test/back/process-stop-signal.test.cjs new file mode 100644 index 00000000..285aee41 --- /dev/null +++ b/test/back/process-stop-signal.test.cjs @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); + +// Exercise the production function with a deterministic process-tree race. +function fixture(descendants, probe = () => {}) { + const source = fs.readFileSync('back/config/util.ts', 'utf8'); + const start = source.indexOf('export async function killTask('); + const end = source.indexOf('export async function getPid(', start); + const code = ts.transpileModule(source.slice(start, end), { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + }).outputText; + const signals = []; + const exports = {}; + new Function('exports', 'process', 'psTree', 'fs', 'setTimeout', code)( + exports, + { + platform: 'linux', + kill: (pid, signal) => { + const normalized = { 15: 'SIGTERM', 2: 'SIGINT' }[signal] || signal; + signals.push([pid, normalized]); + probe(pid, normalized); + }, + }, + async () => [...descendants], + {}, + setTimeout, + ); + return { killTask: exports.killTask, signals }; +} + +test('an exited descendant does not prevent stopping its siblings and parent', async () => { + const { killTask, signals } = fixture([101, 102], (pid) => { + if (pid === 102) throw Object.assign(Error('gone'), { code: 'ESRCH' }); + }); + await killTask(100); + assert.deepEqual(signals, [ + [102, 'SIGTERM'], + [101, 'SIGTERM'], + [100, 'SIGTERM'], + ]); +}); + +test('an already exited leaf can be stopped repeatedly', async () => { + const { killTask } = fixture([], () => { + throw Object.assign(Error('gone'), { code: 'ESRCH' }); + }); + await killTask(100); + await killTask(100); +}); + +for (const descendants of [[], [101]]) { + test(`signal permission failure is surfaced with ${descendants.length} descendants`, async () => { + const denied = Object.assign(Error('denied'), { code: 'EPERM' }); + const { killTask } = fixture(descendants, () => { + throw denied; + }); + await assert.rejects(killTask(100), (error) => error === denied); + }); +} + +test('a leaf retains SIGINT and a process tree retains child-first SIGTERM', async () => { + const leaf = fixture([]); + await leaf.killTask(100); + assert.deepEqual(leaf.signals, [[100, 'SIGINT']]); + const tree = fixture([101, 102]); + await tree.killTask(100); + assert.deepEqual(tree.signals, [ + [102, 'SIGTERM'], + [101, 'SIGTERM'], + [100, 'SIGTERM'], + ]); +}); diff --git a/test/back/start-env-preservation.test.cjs b/test/back/start-env-preservation.test.cjs new file mode 100644 index 00000000..0b3bcc57 --- /dev/null +++ b/test/back/start-env-preservation.test.cjs @@ -0,0 +1,53 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +// Run the actual startup prefix, stopping before service initialization. +const source = fs + .readFileSync('shell/start.sh', 'utf8') + .split('. ${QL_DIR}/shell/share.sh')[0]; +for (const command of ['start', 'reload']) { + for (const existing of [false, true]) { + test(`${command} ${ + existing ? 'preserves existing' : 'initializes missing' + } .env`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'shell')); + fs.writeFileSync(path.join(root, 'shell/test.sh'), '# fixture'); + fs.writeFileSync(path.join(root, '.env.example'), 'BACK_PORT=5700\n'); + const custom = 'BACK_PORT=5800\nJWT_SECRET=synthetic-test-only\n'; + if (existing) + fs.writeFileSync(path.join(root, '.env'), custom, { mode: 0o600 }); + const result = spawnSync( + '/bin/bash', + [ + '-c', + 'npm(){ :; }; pip3(){ :; }; apk(){ :; }; sudo(){ "$@"; }; python3(){ printf 3.11; };\n' + + source, + 'startup-test', + command, + ], + { + encoding: 'utf8', + env: { + ...process.env, + QL_DIR: root, + QL_DATA_DIR: root + '/data', + QL_OS_TYPE: 'alpine', + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(root, '.env'), 'utf8'), + existing ? custom : 'BACK_PORT=5700\n', + ); + if (existing) + assert.equal(fs.statSync(path.join(root, '.env')).mode & 0o777, 0o600); + }); + } +} diff --git a/test/back/stop-failure-feedback.test.cjs b/test/back/stop-failure-feedback.test.cjs new file mode 100644 index 00000000..5b4290af --- /dev/null +++ b/test/back/stop-failure-feedback.test.cjs @@ -0,0 +1,158 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const load = require('../helpers/load-security-module.cjs'); + +const logger = { info() {}, error() {} }; + +function cronFixture(killTask) { + const events = []; + const { runCron } = load('back/shared/runCron.ts', { + 'cross-spawn': { + spawn: () => { + events.push('spawn'); + return {}; + }, + }, + './childProcess': { + observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }), + asError: (e) => e, + }, + './pLimit': { + runWithCronLimit: async (_, fn) => fn(), + removeQueuedCron: () => events.push('release'), + }, + '../loaders/logger': logger, + '../config/util': { killTask }, + '../data/cron': { + CrontabModel: { + findOne: async () => ({ + pid: 100, + status: 0, + allow_multiple_instances: 0, + }), + update: async () => events.push('idle'), + }, + CrontabStatus: { running: 0, idle: 1, queued: 3 }, + }, + '../data/runningInstance': { + RunningInstanceModel: { update: async () => events.push('stopped') }, + InstanceStatus: { running: 0, stopped: 2 }, + }, + }); + return { run: () => runCron('ignored', { id: '1' }), events }; +} + +test('single-instance replacement does not spawn or finalize when termination fails', async () => { + const f = cronFixture(async () => { + throw Error('EPERM'); + }); + await f.run(); + assert.deepEqual(f.events, ['release']); +}); + +test('single-instance replacement waits for verified exit before spawning', async () => { + let finish, entered, verified; + const pending = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const f = cronFixture(async (pid, wait) => { + assert.equal(pid, 100); + verified = wait; + entered(); + await gate; + }); + const running = f.run(); + await pending; + assert.deepEqual(f.events, []); + finish(); + await running; + assert.equal(verified, true); + assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']); +}); + +function subscriptionFixture(killTask) { + const updates = []; + const Service = load('back/services/subscription.ts', { + '../config': {}, + '../data/cron': {}, + '../config/const': {}, + '../data/subscription': { + SubscriptionModel: { + findAll: async () => [ + { id: 1, pid: 101 }, + { id: 2, pid: 102 }, + ], + update: async (values, query) => updates.push({ values, query }), + }, + SubscriptionStatus: { idle: 1 }, + }, + '../config/util': { killTask }, + '../config/subscription': {}, + '../shared/i18n': {}, + '../shared/pLimit': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + return { service: new Service(logger, {}, {}, {}, {}), updates }; +} + +test('batch subscription stop preserves failed items and reports failure while stopping others', async () => { + const calls = []; + const denied = Error('EPERM'); + const f = subscriptionFixture(async (pid, wait) => { + calls.push([pid, wait]); + if (pid === 101) throw denied; + }); + await assert.rejects(f.service.stop([1, 2]), (e) => e === denied); + assert.deepEqual(calls, [ + [101, true], + [102, true], + ]); + assert.equal(f.updates.length, 1); + assert.equal(f.updates[0].query.where.id, 2); + assert.equal(f.updates[0].values.status, 1); +}); + +function scriptFixture(killTask, pid = 100) { + const Service = load('back/services/script.ts', { + '../config': { scriptPath: '/scripts' }, + '../config/const': { TASK_COMMAND: 'task' }, + '../config/util': { killTask, getPid: async () => pid }, + '../shared/pLimit': { removeQueuedCron() {} }, + './sock': {}, + './cron': {}, + './schedule': {}, + '../shared/fileAccess': {}, + }).default; + return new Service(logger, {}, {}, {}); +} + +test('script stop propagates failure instead of returning success', async () => { + const denied = Error('EPERM'); + const service = scriptFixture(async () => { + throw denied; + }); + await assert.rejects( + service.stopScript('/scripts/test.js', 100), + (e) => e === denied, + ); +}); + +test('script stop verifies exit and treats an absent queued process as already stopped', async () => { + const calls = []; + const service = scriptFixture(async (...args) => calls.push(args), undefined); + assert.equal((await service.stopScript('/scripts/test.js', 100)).code, 200); + assert.deepEqual(calls, [[100, true]]); + const absent = scriptFixture(async () => assert.fail('no PID to stop'), null); + assert.equal( + (await absent.stopScript('/scripts/test.js', undefined)).code, + 200, + ); +}); diff --git a/test/back/subscription-stop-state.test.cjs b/test/back/subscription-stop-state.test.cjs new file mode 100644 index 00000000..e99fa091 --- /dev/null +++ b/test/back/subscription-stop-state.test.cjs @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); + +async function fixture(t, onKill = async () => {}) { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const rows = db.define('Subscription', { + pid: DataTypes.INTEGER, + status: DataTypes.INTEGER, + }); + await db.sync(); + await rows.create({ id: 1, pid: 101, status: 0 }); + const Service = load('back/services/subscription.ts', { + '../config': {}, + '../data/cron': {}, + '../config/const': {}, + '../data/subscription': { + SubscriptionModel: rows, + SubscriptionStatus: { idle: 1 }, + }, + '../config/util': { killTask: async () => onKill(rows) }, + '../config/subscription': {}, + '../shared/i18n': {}, + '../shared/pLimit': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + return { rows, service: new Service({ error() {} }, {}, {}, {}, {}) }; +} + +test('successful subscription stop clears the stored PID', async (t) => { + const f = await fixture(t); + await f.service.stop([1]); + const row = await f.rows.findByPk(1); + assert.equal(row.status, 1); + assert.equal(row.pid, null); +}); + +test('subscription stop does not overwrite a replacement process started during termination', async (t) => { + const f = await fixture(t, async (rows) => { + await rows.update({ pid: 202, status: 0 }, { where: { id: 1 } }); + }); + await f.service.stop([1]); + const row = await f.rows.findByPk(1); + assert.equal(row.status, 0); + assert.equal(row.pid, 202); +}); + +test('subscription stop cancels a queued row without a PID', async (t) => { + const f = await fixture(t, () => assert.fail('queued row has no process')); + await f.rows.update({ pid: null, status: 3 }, { where: { id: 1 } }); + await f.service.stop([1]); + const row = await f.rows.findByPk(1); + assert.equal(row.status, 1); + assert.equal(row.pid, null); +}); From f051135fc4abf9f1df806111d0015c210954861d Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 24 Sep 2026 20:37:13 +0800 Subject: [PATCH 096/123] fix: declare nullable subscription process id --- back/data/subscription.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back/data/subscription.ts b/back/data/subscription.ts index bb561d2f..78bdfa03 100644 --- a/back/data/subscription.ts +++ b/back/data/subscription.ts @@ -20,7 +20,7 @@ export class Subscription { pull_option?: | { private_key: string } | { username: string; password: string }; - pid?: number; + pid?: number | null; is_disabled?: 1 | 0; log_path?: string; alias: string; From 801a71d7402f5d4b582318ed88ad00633357a531 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 25 Sep 2026 23:24:41 +0800 Subject: [PATCH 097/123] feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074) * feat(cli): add unified Commander CLI for QingLong 2.x * fix(cli): publish via npm and address security review feedback * ci(cli): package npm artifacts and remove evaluation collateral * test(cli): use a fixed shell fixture for log retention * refactor(cli): separate remote npm client from panel tools * feat(cli): cover active panel OpenAPI resources * docs(cli): unify authentication and skill guidance * refactor(cli): isolate internal commands and generate Commander help * refactor(cli): organize remote and internal modules by responsibility * ci(cli): publish verified npm archives from master * fix(cli): publish under the whyour npm scope * ci: use npm trusted publishing for both packages * docs: introduce the published CLI on the project homepage * fix(cli): preserve server log truncation and correct login hints * fix(cli): accept dashboard record request bodies * fix(cli): preserve stdin for local task execution * fix(cli): resolve task executables after changing directory * fix(cli): preserve shell function tasks and sanitize test failures * fix(cli): preserve shell hook state and resolve workdir after hooks * fix(cli): preserve cleanup across shared shell task timeouts * fix(cli): isolate shell control descriptors and reap timed-out descendants --- .github/workflows/build-docker-image.yml | 18 +- .github/workflows/cli-package.yml | 115 +++ README-en.md | 24 + README.md | 24 + back/loaders/deps.ts | 46 +- cli/.gitignore | 2 + cli/LICENSE | 201 ++++ cli/LOCAL.en.md | 45 + cli/LOCAL.md | 47 + cli/README.en.md | 156 +++ cli/README.md | 156 +++ cli/package-lock.json | 550 ++++++++++ cli/package.json | 40 + cli/scripts/build.cjs | 77 ++ cli/scripts/entrypoints.cjs | 55 + cli/scripts/test.cjs | 22 + cli/scripts/verify-package.cjs | 124 +++ cli/skills/qinglong-cli/SKILL.md | 21 + cli/skills/qinglong-cli/references/openapi.md | 213 ++++ cli/skills/qinglong-cli/references/panel.md | 57 + cli/skills/qinglong-local/SKILL.md | 19 + .../qinglong-local/references/execution.md | 35 + .../qinglong-local/references/maintenance.md | 28 + cli/src/compatibility/legacy.ts | 126 +++ cli/src/compatibility/main.ts | 11 + cli/src/compatibility/startup.ts | 50 + cli/src/entrypoints/admin.ts | 21 + cli/src/entrypoints/container.ts | 30 + cli/src/entrypoints/index.ts | 6 + cli/src/entrypoints/ql.ts | 224 ++++ cli/src/entrypoints/remote.ts | 13 + cli/src/entrypoints/task.ts | 6 + cli/src/internal/commands/dispatch.ts | 42 + cli/src/internal/commands/main.ts | 14 + cli/src/internal/commands/registry.ts | 133 +++ cli/src/internal/execution/concurrent.ts | 48 + cli/src/internal/execution/definition.ts | 70 ++ cli/src/internal/execution/dependencies.ts | 37 + cli/src/internal/execution/languagePreload.ts | 41 + cli/src/internal/execution/preloadSource.ts | 327 ++++++ cli/src/internal/execution/runner.ts | 141 +++ cli/src/internal/execution/scriptInventory.ts | 41 + cli/src/internal/execution/shellOptions.ts | 20 + cli/src/internal/execution/shellSession.ts | 98 ++ cli/src/internal/execution/taskDelay.ts | 43 + cli/src/internal/execution/taskRunner.ts | 542 ++++++++++ cli/src/internal/help/standalone.ts | 25 + .../internal/integration/cronEntrypoint.ts | 175 ++++ cli/src/internal/integration/entrypoints.ts | 39 + cli/src/internal/maintenance/bootstrap.ts | 259 +++++ cli/src/internal/maintenance/bot.ts | 272 +++++ cli/src/internal/maintenance/check.ts | 182 ++++ cli/src/internal/maintenance/maintenance.ts | 114 ++ cli/src/internal/maintenance/operator.ts | 204 ++++ cli/src/internal/maintenance/upgrade.ts | 379 +++++++ .../internal/maintenance/upgradeSelection.ts | 65 ++ cli/src/internal/runtime/api.ts | 122 +++ cli/src/internal/runtime/backendProcesses.ts | 51 + cli/src/internal/runtime/cancellation.ts | 86 ++ cli/src/internal/runtime/commandLog.ts | 117 +++ .../internal/runtime/containerEnvironment.ts | 144 +++ cli/src/internal/runtime/containerRuntime.ts | 165 +++ cli/src/internal/runtime/context.ts | 213 ++++ cli/src/internal/runtime/files.ts | 60 ++ cli/src/internal/runtime/hostServices.ts | 44 + cli/src/internal/runtime/lifecycle.ts | 51 + cli/src/internal/runtime/output.ts | 17 + cli/src/internal/runtime/process.ts | 265 +++++ .../subscription/subscriptionFilter.ts | 42 + .../subscription/subscriptionRunner.ts | 386 +++++++ cli/src/internal/subscription/worker.ts | 112 ++ cli/src/remote/api/client.ts | 145 +++ cli/src/remote/api/download.ts | 25 + cli/src/remote/api/openOperations.ts | 586 +++++++++++ cli/src/remote/auth/store.ts | 90 ++ cli/src/remote/auth/url.ts | 36 + cli/src/remote/commands/auth.ts | 123 +++ cli/src/remote/commands/dispatch.ts | 46 + cli/src/remote/commands/open.ts | 252 +++++ cli/src/remote/commands/openCommands.ts | 93 ++ cli/src/remote/commands/registry.ts | 128 +++ cli/src/remote/commands/subscription.ts | 91 ++ cli/src/remote/commands/task.ts | 76 ++ cli/src/remote/types.ts | 36 + cli/src/shared/cli/arguments.ts | 151 +++ cli/src/shared/cli/commander.ts | 2 + cli/src/shared/cli/invoke.ts | 49 + cli/src/shared/cli/options.ts | 107 ++ cli/src/shared/cli/registry.ts | 93 ++ cli/src/shared/cli/router.ts | 23 + cli/src/shared/errors.ts | 12 + cli/src/shared/i18n/en.ts | 320 ++++++ cli/src/shared/i18n/help.ts | 99 ++ cli/src/shared/i18n/index.ts | 13 + cli/src/shared/record.ts | 3 + cli/src/shared/types.ts | 9 + cli/test/compatibility/compat.test.cjs | 417 ++++++++ cli/test/compatibility/startup.test.cjs | 76 ++ cli/test/fixtures/log-retention.sh | 12 + cli/test/helpers/commands.cjs | 12 + cli/test/integration/architecture.test.cjs | 60 ++ cli/test/integration/differential.test.cjs | 570 ++++++++++ cli/test/integration/entrypointPaths.test.cjs | 27 + cli/test/integration/executablePaths.test.cjs | 91 ++ cli/test/integration/loadingBoundary.test.cjs | 73 ++ cli/test/integration/operatorRouting.test.cjs | 100 ++ cli/test/integration/stdin.test.cjs | 46 + cli/test/integration/unified.test.cjs | 117 +++ cli/test/internal/backendProcesses.test.cjs | 237 +++++ cli/test/internal/bashArguments.test.cjs | 57 + cli/test/internal/bootstrap.test.cjs | 257 +++++ cli/test/internal/bot.test.cjs | 271 +++++ cli/test/internal/botDiagnostics.test.cjs | 26 + .../internal/containerEnvironment.test.cjs | 162 +++ cli/test/internal/containerRuntime.test.cjs | 280 +++++ cli/test/internal/contextDiagnostics.test.cjs | 98 ++ cli/test/internal/cronEntrypoint.test.cjs | 80 ++ .../internal/environmentTransport.test.cjs | 38 + cli/test/internal/esmDependencies.test.cjs | 152 +++ cli/test/internal/helperDiagnostics.test.cjs | 71 ++ cli/test/internal/hostServices.test.cjs | 72 ++ cli/test/internal/languagePreload.test.cjs | 90 ++ cli/test/internal/lifecycle.test.cjs | 134 +++ cli/test/internal/local.test.cjs | 977 ++++++++++++++++++ cli/test/internal/localApi.test.cjs | 289 ++++++ cli/test/internal/logRetention.test.cjs | 76 ++ cli/test/internal/networkRepository.test.cjs | 335 ++++++ cli/test/internal/operator.test.cjs | 799 ++++++++++++++ .../internal/operatorDiagnostics.test.cjs | 131 +++ cli/test/internal/pathDiagnostics.test.cjs | 83 ++ cli/test/internal/preload.test.cjs | 111 ++ cli/test/internal/processDiagnostics.test.cjs | 65 ++ cli/test/internal/runner.test.cjs | 360 +++++++ cli/test/internal/scriptInventory.test.cjs | 72 ++ cli/test/internal/subscriptionFilter.test.cjs | 97 ++ cli/test/internal/subscriptionRunner.test.cjs | 433 ++++++++ cli/test/internal/taskDelay.test.cjs | 133 +++ cli/test/internal/taskDiagnostics.test.cjs | 95 ++ cli/test/internal/taskLifecycle.test.cjs | 79 ++ cli/test/internal/upgradeSelection.test.cjs | 106 ++ cli/test/linux/Dockerfile | 11 + cli/test/linux/Dockerfile.alpine | 8 + cli/test/linux/Dockerfile.services | 8 + cli/test/linux/Dockerfile.ssh | 3 + cli/test/linux/README.md | 164 +++ cli/test/linux/bot-install.cjs | 111 ++ cli/test/linux/container-bot.test.cjs | 141 +++ cli/test/linux/container-cron.cjs | 98 ++ cli/test/linux/entrypoints.test.cjs | 154 +++ cli/test/linux/evaluation-entrypoints.cjs | 57 + cli/test/linux/host-boot.cjs | 109 ++ cli/test/linux/image-selection.cjs | 74 ++ cli/test/linux/interrupted-reload.cjs | 50 + cli/test/linux/mounted-data.test.cjs | 75 ++ cli/test/linux/openapi.cjs | 61 ++ cli/test/linux/panel-account.cjs | 56 + cli/test/linux/panel-inventory.cjs | 39 + cli/test/linux/panel-operator.cjs | 69 ++ cli/test/linux/panel-preload.cjs | 141 +++ cli/test/linux/panel-repository.cjs | 132 +++ cli/test/linux/panel-subscription.cjs | 69 ++ cli/test/linux/panel-upgrade.cjs | 116 +++ cli/test/linux/panel.cjs | 222 ++++ cli/test/linux/preload-paths.test.cjs | 54 + cli/test/linux/published-archive.test.cjs | 98 ++ cli/test/linux/service-response.cjs | 14 + cli/test/linux/services.test.cjs | 101 ++ cli/test/linux/ssh-repository.test.cjs | 148 +++ cli/test/linux/upgrade.test.cjs | 101 ++ cli/test/linux/verify-check.cjs | 37 + cli/test/linux/verify-start.cjs | 38 + cli/test/remote/apiDiagnostics.test.cjs | 91 ++ cli/test/remote/cli.test.cjs | 370 +++++++ cli/test/remote/logTruncation.test.cjs | 44 + cli/test/remote/openapi.test.cjs | 188 ++++ cli/test/remote/rawCredentials.test.cjs | 85 ++ cli/test/remote/requestBodyCoverage.test.cjs | 34 + cli/test/remote/scope.test.cjs | 51 + cli/test/remote/subscription.test.cjs | 170 +++ cli/test/shared/commander.test.cjs | 43 + cli/test/shared/diagnostics.test.cjs | 259 +++++ cli/test/shared/helpCoverage.test.cjs | 62 ++ cli/test/shared/i18n.test.cjs | 215 ++++ cli/tsconfig.json | 20 + package.json | 4 + 185 files changed, 22412 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/cli-package.yml create mode 100644 cli/.gitignore create mode 100644 cli/LICENSE create mode 100644 cli/LOCAL.en.md create mode 100644 cli/LOCAL.md create mode 100644 cli/README.en.md create mode 100644 cli/README.md create mode 100644 cli/package-lock.json create mode 100644 cli/package.json create mode 100644 cli/scripts/build.cjs create mode 100644 cli/scripts/entrypoints.cjs create mode 100644 cli/scripts/test.cjs create mode 100644 cli/scripts/verify-package.cjs create mode 100644 cli/skills/qinglong-cli/SKILL.md create mode 100644 cli/skills/qinglong-cli/references/openapi.md create mode 100644 cli/skills/qinglong-cli/references/panel.md create mode 100644 cli/skills/qinglong-local/SKILL.md create mode 100644 cli/skills/qinglong-local/references/execution.md create mode 100644 cli/skills/qinglong-local/references/maintenance.md create mode 100644 cli/src/compatibility/legacy.ts create mode 100644 cli/src/compatibility/main.ts create mode 100644 cli/src/compatibility/startup.ts create mode 100644 cli/src/entrypoints/admin.ts create mode 100644 cli/src/entrypoints/container.ts create mode 100644 cli/src/entrypoints/index.ts create mode 100644 cli/src/entrypoints/ql.ts create mode 100644 cli/src/entrypoints/remote.ts create mode 100644 cli/src/entrypoints/task.ts create mode 100644 cli/src/internal/commands/dispatch.ts create mode 100644 cli/src/internal/commands/main.ts create mode 100644 cli/src/internal/commands/registry.ts create mode 100644 cli/src/internal/execution/concurrent.ts create mode 100644 cli/src/internal/execution/definition.ts create mode 100644 cli/src/internal/execution/dependencies.ts create mode 100644 cli/src/internal/execution/languagePreload.ts create mode 100644 cli/src/internal/execution/preloadSource.ts create mode 100644 cli/src/internal/execution/runner.ts create mode 100644 cli/src/internal/execution/scriptInventory.ts create mode 100644 cli/src/internal/execution/shellOptions.ts create mode 100644 cli/src/internal/execution/shellSession.ts create mode 100644 cli/src/internal/execution/taskDelay.ts create mode 100644 cli/src/internal/execution/taskRunner.ts create mode 100644 cli/src/internal/help/standalone.ts create mode 100644 cli/src/internal/integration/cronEntrypoint.ts create mode 100644 cli/src/internal/integration/entrypoints.ts create mode 100644 cli/src/internal/maintenance/bootstrap.ts create mode 100644 cli/src/internal/maintenance/bot.ts create mode 100644 cli/src/internal/maintenance/check.ts create mode 100644 cli/src/internal/maintenance/maintenance.ts create mode 100644 cli/src/internal/maintenance/operator.ts create mode 100644 cli/src/internal/maintenance/upgrade.ts create mode 100644 cli/src/internal/maintenance/upgradeSelection.ts create mode 100644 cli/src/internal/runtime/api.ts create mode 100644 cli/src/internal/runtime/backendProcesses.ts create mode 100644 cli/src/internal/runtime/cancellation.ts create mode 100644 cli/src/internal/runtime/commandLog.ts create mode 100644 cli/src/internal/runtime/containerEnvironment.ts create mode 100644 cli/src/internal/runtime/containerRuntime.ts create mode 100644 cli/src/internal/runtime/context.ts create mode 100644 cli/src/internal/runtime/files.ts create mode 100644 cli/src/internal/runtime/hostServices.ts create mode 100644 cli/src/internal/runtime/lifecycle.ts create mode 100644 cli/src/internal/runtime/output.ts create mode 100644 cli/src/internal/runtime/process.ts create mode 100644 cli/src/internal/subscription/subscriptionFilter.ts create mode 100644 cli/src/internal/subscription/subscriptionRunner.ts create mode 100644 cli/src/internal/subscription/worker.ts create mode 100644 cli/src/remote/api/client.ts create mode 100644 cli/src/remote/api/download.ts create mode 100644 cli/src/remote/api/openOperations.ts create mode 100644 cli/src/remote/auth/store.ts create mode 100644 cli/src/remote/auth/url.ts create mode 100644 cli/src/remote/commands/auth.ts create mode 100644 cli/src/remote/commands/dispatch.ts create mode 100644 cli/src/remote/commands/open.ts create mode 100644 cli/src/remote/commands/openCommands.ts create mode 100644 cli/src/remote/commands/registry.ts create mode 100644 cli/src/remote/commands/subscription.ts create mode 100644 cli/src/remote/commands/task.ts create mode 100644 cli/src/remote/types.ts create mode 100644 cli/src/shared/cli/arguments.ts create mode 100644 cli/src/shared/cli/commander.ts create mode 100644 cli/src/shared/cli/invoke.ts create mode 100644 cli/src/shared/cli/options.ts create mode 100644 cli/src/shared/cli/registry.ts create mode 100644 cli/src/shared/cli/router.ts create mode 100644 cli/src/shared/errors.ts create mode 100644 cli/src/shared/i18n/en.ts create mode 100644 cli/src/shared/i18n/help.ts create mode 100644 cli/src/shared/i18n/index.ts create mode 100644 cli/src/shared/record.ts create mode 100644 cli/src/shared/types.ts create mode 100644 cli/test/compatibility/compat.test.cjs create mode 100644 cli/test/compatibility/startup.test.cjs create mode 100644 cli/test/fixtures/log-retention.sh create mode 100644 cli/test/helpers/commands.cjs create mode 100644 cli/test/integration/architecture.test.cjs create mode 100644 cli/test/integration/differential.test.cjs create mode 100644 cli/test/integration/entrypointPaths.test.cjs create mode 100644 cli/test/integration/executablePaths.test.cjs create mode 100644 cli/test/integration/loadingBoundary.test.cjs create mode 100644 cli/test/integration/operatorRouting.test.cjs create mode 100644 cli/test/integration/stdin.test.cjs create mode 100644 cli/test/integration/unified.test.cjs create mode 100644 cli/test/internal/backendProcesses.test.cjs create mode 100644 cli/test/internal/bashArguments.test.cjs create mode 100644 cli/test/internal/bootstrap.test.cjs create mode 100644 cli/test/internal/bot.test.cjs create mode 100644 cli/test/internal/botDiagnostics.test.cjs create mode 100644 cli/test/internal/containerEnvironment.test.cjs create mode 100644 cli/test/internal/containerRuntime.test.cjs create mode 100644 cli/test/internal/contextDiagnostics.test.cjs create mode 100644 cli/test/internal/cronEntrypoint.test.cjs create mode 100644 cli/test/internal/environmentTransport.test.cjs create mode 100644 cli/test/internal/esmDependencies.test.cjs create mode 100644 cli/test/internal/helperDiagnostics.test.cjs create mode 100644 cli/test/internal/hostServices.test.cjs create mode 100644 cli/test/internal/languagePreload.test.cjs create mode 100644 cli/test/internal/lifecycle.test.cjs create mode 100644 cli/test/internal/local.test.cjs create mode 100644 cli/test/internal/localApi.test.cjs create mode 100644 cli/test/internal/logRetention.test.cjs create mode 100644 cli/test/internal/networkRepository.test.cjs create mode 100644 cli/test/internal/operator.test.cjs create mode 100644 cli/test/internal/operatorDiagnostics.test.cjs create mode 100644 cli/test/internal/pathDiagnostics.test.cjs create mode 100644 cli/test/internal/preload.test.cjs create mode 100644 cli/test/internal/processDiagnostics.test.cjs create mode 100644 cli/test/internal/runner.test.cjs create mode 100644 cli/test/internal/scriptInventory.test.cjs create mode 100644 cli/test/internal/subscriptionFilter.test.cjs create mode 100644 cli/test/internal/subscriptionRunner.test.cjs create mode 100644 cli/test/internal/taskDelay.test.cjs create mode 100644 cli/test/internal/taskDiagnostics.test.cjs create mode 100644 cli/test/internal/taskLifecycle.test.cjs create mode 100644 cli/test/internal/upgradeSelection.test.cjs create mode 100644 cli/test/linux/Dockerfile create mode 100644 cli/test/linux/Dockerfile.alpine create mode 100644 cli/test/linux/Dockerfile.services create mode 100644 cli/test/linux/Dockerfile.ssh create mode 100644 cli/test/linux/README.md create mode 100644 cli/test/linux/bot-install.cjs create mode 100644 cli/test/linux/container-bot.test.cjs create mode 100644 cli/test/linux/container-cron.cjs create mode 100644 cli/test/linux/entrypoints.test.cjs create mode 100644 cli/test/linux/evaluation-entrypoints.cjs create mode 100644 cli/test/linux/host-boot.cjs create mode 100644 cli/test/linux/image-selection.cjs create mode 100644 cli/test/linux/interrupted-reload.cjs create mode 100644 cli/test/linux/mounted-data.test.cjs create mode 100644 cli/test/linux/openapi.cjs create mode 100644 cli/test/linux/panel-account.cjs create mode 100644 cli/test/linux/panel-inventory.cjs create mode 100644 cli/test/linux/panel-operator.cjs create mode 100644 cli/test/linux/panel-preload.cjs create mode 100644 cli/test/linux/panel-repository.cjs create mode 100644 cli/test/linux/panel-subscription.cjs create mode 100644 cli/test/linux/panel-upgrade.cjs create mode 100644 cli/test/linux/panel.cjs create mode 100644 cli/test/linux/preload-paths.test.cjs create mode 100644 cli/test/linux/published-archive.test.cjs create mode 100644 cli/test/linux/service-response.cjs create mode 100644 cli/test/linux/services.test.cjs create mode 100644 cli/test/linux/ssh-repository.test.cjs create mode 100644 cli/test/linux/upgrade.test.cjs create mode 100644 cli/test/linux/verify-check.cjs create mode 100644 cli/test/linux/verify-start.cjs create mode 100644 cli/test/remote/apiDiagnostics.test.cjs create mode 100644 cli/test/remote/cli.test.cjs create mode 100644 cli/test/remote/logTruncation.test.cjs create mode 100644 cli/test/remote/openapi.test.cjs create mode 100644 cli/test/remote/rawCredentials.test.cjs create mode 100644 cli/test/remote/requestBodyCoverage.test.cjs create mode 100644 cli/test/remote/scope.test.cjs create mode 100644 cli/test/remote/subscription.test.cjs create mode 100644 cli/test/shared/commander.test.cjs create mode 100644 cli/test/shared/diagnostics.test.cjs create mode 100644 cli/test/shared/helpCoverage.test.cjs create mode 100644 cli/test/shared/i18n.test.cjs create mode 100644 cli/tsconfig.json diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 7fbe1dcb..88297261 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -473,9 +473,12 @@ jobs: cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max publish: - if: ${{ github.ref_name == 'master' }} + if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }} needs: [build-alpine, build-debian] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 @@ -493,7 +496,12 @@ jobs: pnpm build:front pnpm build:back - - name: publish npm package - run: | - echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc - npm publish + - name: Set up Node for trusted publishing + uses: actions/setup-node@v6 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Publish npm package with OIDC + run: npm publish --access public --registry=https://registry.npmjs.org diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml new file mode 100644 index 00000000..17fe9bda --- /dev/null +++ b/.github/workflows/cli-package.yml @@ -0,0 +1,115 @@ +name: CLI package + +on: + pull_request: + paths: + - 'cli/**' + - 'shell/**' + - 'back/api/**' + - 'back/loaders/deps.ts' + - 'package.json' + - '.github/workflows/cli-package.yml' + push: + branches: [develop, master] + paths: + - 'cli/**' + - 'shell/**' + - 'back/api/**' + - 'back/loaders/deps.ts' + - 'package.json' + - '.github/workflows/cli-package.yml' + workflow_dispatch: + inputs: + publish: + description: Publish the verified CLI package (master only) + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: cli-package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node: ['22.12.0', '24'] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: cli/package-lock.json + - name: Install test interpreters and tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip + npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2 + - run: npm ci --prefix cli --no-audit --no-fund + - run: npm run check:cli + - run: npm run test:cli + - name: Verify offline package installation + env: + QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package + run: node cli/scripts/verify-package.cjs + - name: Upload verified npm archive + if: matrix.node == '24' + uses: actions/upload-artifact@v6 + with: + name: qinglong-cli-${{ github.sha }} + path: ${{ runner.temp }}/cli-package/*.tgz + if-no-files-found: error + retention-days: 14 + + publish: + needs: package + if: >- + github.repository == 'whyour/qinglong' && + github.ref == 'refs/heads/master' && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.publish)) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v6 + with: + node-version: '24' + package-manager-cache: false + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@v7 + with: + name: qinglong-cli-${{ github.sha }} + path: cli-package + - name: Publish verified npm archive + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + archives=(cli-package/*.tgz) + if [ "${#archives[@]}" -ne 1 ]; then + echo '::error::Expected exactly one verified CLI archive.' + exit 1 + fi + archive="${archives[0]}" + metadata=$(tar -xOf "$archive" package/package.json) + name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata") + version=$(jq -er '.version | select(type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))' <<< "$metadata") + if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then + jq -e --arg version "$version" '. == $version' version.json > /dev/null + echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes." + exit 0 + elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then + echo '::error::Could not check the published CLI version; refusing to publish.' + exit 1 + fi + npm publish "./$archive" --access public --tag latest --ignore-scripts --registry=https://registry.npmjs.org diff --git a/README-en.md b/README-en.md index 4570d6d5..581661c9 100644 --- a/README-en.md +++ b/README-en.md @@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript - Support system level notification - Support dark mode - Support cell phone operation +- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill) ## Version @@ -79,6 +80,29 @@ npm i @whyour/qinglong [View Documentation](https://qinglong.online/guide/user-guide/basic-explanation) +## Remote CLI and Agent Skill + +Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents. + +Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`: + +```bash +npm install -g @whyour/qinglong-cli +ql login --url https://ql.example.com +ql auth status --json +ql task list --json +ql subscription list --json +ql --help +``` + +Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help. + +The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md). + +[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md) + +The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md). + ## Development ```bash diff --git a/README.md b/README.md index 9b7cdd55..b6d34f94 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript - 支持系统级通知 - 支持暗黑模式 - 支持手机端操作 +- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板 ## 版本 @@ -81,6 +82,29 @@ npm i @whyour/qinglong [查看文档](https://qinglong.online/guide/user-guide/basic-explanation) +## 远程 CLI 与 Agent Skill + +使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。 + +需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令: + +```bash +npm install -g @whyour/qinglong-cli +ql login --url https://ql.example.com +ql auth status --json +ql task list --json +ql subscription list --json +ql --help +``` + +在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。 + +npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。 + +[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md) + +包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。 + ## 开发 ```bash diff --git a/back/loaders/deps.ts b/back/loaders/deps.ts index d9413f4b..59fd5b21 100644 --- a/back/loaders/deps.ts +++ b/back/loaders/deps.ts @@ -17,6 +17,50 @@ async function linkCommand() { } async function linkCommandToDir(commandDir: string) { + const cliRoot = process.env.QL_CLI_ROOT; + if (cliRoot) { + if (!path.isAbsolute(cliRoot)) { + throw new Error('QL_CLI_ROOT must be an absolute CLI installation path'); + } + const { installCliEntrypoints } = require(path.join( + cliRoot, + 'dist/local/entrypoints.js', + )); + await installCliEntrypoints(commandDir, cliRoot); + const { installCronEntrypoint } = require(path.join( + cliRoot, + 'dist/local/cronEntrypoint.js', + )); + await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, { + ...process.env, + QL_DIR: config.rootPath, + QL_DATA_DIR: config.dataPath, + }); + // Container-wide legacy links may precede ~/bin. Ensure panel children use + // the explicitly selected entries without changing system-wide links. + process.env.PATH = [ + commandDir, + ...(process.env.PATH?.split(path.delimiter) ?? []).filter( + (entry) => entry !== commandDir, + ), + ].join(path.delimiter); + return; + } + + const cronBridge = path.join(commandDir, 'crontab'); + try { + if ( + (await fs.lstat(cronBridge)).isFile() && + (await fs.readFile(cronBridge, 'utf8')).includes( + '// QingLong CLI crontab bridge', + ) + ) { + await fs.unlink(cronBridge); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const linkShell = [ { src: 'update.sh', @@ -39,7 +83,7 @@ async function linkCommandToDir(commandDir: string) { if (stats) { await fs.unlink(tmpTarget); } - } catch (error) { } + } catch (error) {} await fs.symlink(source, tmpTarget); await fs.rename(tmpTarget, target); diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..16fd4ff8 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +/dist/ +node_modules diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..dde8d7b4 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 WHYOUR . + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/cli/LOCAL.en.md b/cli/LOCAL.en.md new file mode 100644 index 00000000..0b7c822d --- /dev/null +++ b/cli/LOCAL.en.md @@ -0,0 +1,45 @@ +# Panel-internal TypeScript tools + +[简体中文](LOCAL.md) | **English** + + +The npm and panel-internal entries both use the name `ql`, but have separate Commander command trees. The npm entry only calls remote APIs; the internal entry only runs local tools. Verify the absolute executable path and `--help` before use. Installing the npm package does not migrate the built-in Shell commands. + +These tools ship with panel builds and are excluded from the @whyour/qinglong-cli npm package. Node >=22.12 is required; user configuration/hooks remain Bash and scripts need their interpreters. Development publishing was removed from the CLI; shell/pub.sh remains available to release workflows. + +```sh +npm ci --prefix cli +npm run build:cli +node cli/dist/ql.js --help +node cli/dist/ql.js task exec --root /ql demo.js now +node cli/dist/ql.js reload --root /ql +``` + +Run on the actual panel host or inside its container. For Docker use `docker exec ...`. A workstation npm CLI cannot reset a remote account; a mounted data directory alone is not the complete operational environment. + +| Capability | Internal commands | +| --- | --- | +| Execution/inventory | `ql task exec [options] [script]`, with `ql task '); + write(staticRoot, 'dist/umi.js', 'dashboard'); + write(source, '.env.example', 'new example'); + writeManifest(source, staticRoot); + const manifestFile = path.join(staticRoot, 'build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + if (options.manifest === 'missing') fs.unlinkSync(manifestFile); + else if (options.manifest === 'dirty') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, dirty: true }), + ); + else if (options.manifest === 'lock') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, lockfileSha256: '0'.repeat(64) }), + ); + else if (options.manifest === 'commit') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, sourceCommit: 'b'.repeat(40) }), + ); + else if (options.manifest === 'checksum') + write(staticRoot, 'dist/umi.js', 'stale frontend'); + else if (options.manifest === 'extra') + write(staticRoot, 'build/stale.js', 'stale backend'); + else if (options.manifest === 'frontend') + fs.unlinkSync(path.join(staticRoot, 'dist/index.html')); + fs.mkdirSync(archives); + for (const repo of ['qinglong', 'qinglong-static']) { + const archive = path.join(archives, `${repo}.zip`); + execFileSync( + 'zip', + [ + '-qr', + archive, + `${repo}-${repo === 'qinglong' ? sourceCommit : branch}`, + ], + { cwd: payload }, + ); + if (options.corrupt === repo) fs.writeFileSync(archive, 'invalid zip'); + } + const curl = write( + base, + 'bin/curl', + `#!${process.execPath} +const fs=require('node:fs'),path=require('node:path'),args=process.argv.slice(2); +if(args.includes('--head')) process.exit(7); +const repo=args.at(-1).includes('/qinglong-static/')?'qinglong-static':'qinglong'; +fs.appendFileSync(process.env.TRACE,args.at(-1)+'\\n'); +if(process.env.FAIL_DOWNLOAD===repo) process.exit(22); +fs.copyFileSync(path.join(process.env.ARCHIVES,repo+'.zip'),args[args.indexOf('--output')+1]); +`, + ); + fs.chmodSync(curl, 0o755); + const server = http.createServer((req, res) => + res.end('{"code":200,"data":{"status":"ok"}}'), + ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const env = { + ...process.env, + QL_DIR: root, + QL_BRANCH: options.branch || 'master', + PATH: `${path.join(base, 'bin')}:${process.env.PATH}`, + QlPort: String(server.address().port), + ARCHIVES: archives, + TRACE: path.join(base, 'trace'), + real_time: options.tee ? 'false' : 'true', + FAIL_DOWNLOAD: options.download || '', + DEP_STATUS: options.depFail ? '1' : '0', + FAIL_START: options.failStart ? 'true' : 'false', + }; + return { + root, + env, + execute: (...args) => + run('bash', [path.join(root, 'shell/update.sh'), ...args], { env }), + read: (name) => fs.readFileSync(path.join(root, name), 'utf8'), + }; +} + +for (const options of [ + { download: 'qinglong' }, + { download: 'qinglong-static', tee: true }, + { corrupt: 'qinglong' }, + { corrupt: 'qinglong-static', tee: true }, + ...[ + 'missing', + 'dirty', + 'lock', + 'commit', + 'checksum', + 'extra', + 'frontend', + ].map((manifest) => ({ manifest })), + { lockChange: true, depFail: true, tee: true }, +]) + test(`failed update preserves the running panel: ${JSON.stringify( + options, + )}`, async (t) => { + const f = await fixture(t, options); + const result = await f.execute('update'); + assert.notEqual(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(f.read('node_modules/dependency'), 'old'); + assert.equal(f.read('static/dist/index.html'), 'old frontend'); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); + assert.equal(f.read('.tmp/import-in-progress'), 'keep'); + assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [ + 'import-in-progress', + ]); + }); + +for (const branch of ['master', 'develop', 'debian', 'debian-dev']) + test(`download-only then reload installs matching artifacts and lockfile dependencies: ${branch}`, async (t) => { + const f = await fixture(t, { branch, lockChange: true, tee: true }); + let result = await f.execute('update', 'false'); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); + assert.match( + fs.readFileSync(f.env.TRACE, 'utf8'), + new RegExp(`qinglong/repository/archive/${sourceCommit}\\.zip`), + ); + result = await f.execute('reload', 'system'); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('node_modules/dependency'), 'new'); + assert.equal(f.read('pnpm-lock.yaml'), 'new lock'); + assert.equal(f.read('static/dist/umi.js'), 'dashboard'); + assert.equal(f.read('back/app.ts'), 'new'); + assert.equal(f.read('.env'), 'private setting'); + assert.equal(f.read('.env.example'), 'new example'); + assert.equal(f.read('data/config/config.sh'), 'custom'); + assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [ + 'import-in-progress', + ]); + assert.ok( + !fs.readdirSync(f.root).some((name) => name.includes('ql-backup')), + ); + }); + +test('a failed start rolls code, frontend and node_modules back and exits nonzero through tee', async (t) => { + const f = await fixture(t, { lockChange: true, failStart: true, tee: true }); + const result = await f.execute('update'); + assert.notEqual(result.code, 0); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(f.read('node_modules/dependency'), 'old'); + assert.equal(f.read('static/dist/index.html'), 'old frontend'); + assert.equal(f.read('starts'), 'startedstarted'); + assert.ok(!fs.readdirSync(f.root).some((name) => name.includes('ql-backup'))); +}); + +test('reload revalidates staged files before stopping the old panel', async (t) => { + const f = await fixture(t); + assert.equal((await f.execute('update', 'false')).code, 0); + const directory = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + write( + f.root, + `.tmp/${directory}/qinglong-static-master/dist/umi.js`, + 'corrupt', + ); + assert.notEqual((await f.execute('reload', 'system')).code, 0); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); +}); + +test('a new successful stage retires only the previous ready workspace', async (t) => { + const f = await fixture(t); + assert.equal((await f.execute('update', 'false')).code, 0); + const previous = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + assert.equal((await f.execute('update', 'false')).code, 0); + const current = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + assert.notEqual(previous, current); + assert.equal(fs.existsSync(path.join(f.root, '.tmp', previous)), false); + assert.equal(f.read('.tmp/import-in-progress'), 'keep'); +}); + +test('failed health checks and concurrent upgrades are rejected', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-lock-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + await withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => {}), + /Another upgrade/, + ); + }); + assert.deepEqual(fs.readdirSync(tmp), []); + const server = http.createServer((req, res) => { + res.statusCode = 503; + res.end('{"code":503}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + await assert.rejects( + waitForHealth({ QlPort: String(server.address().port) }, 20), + /health check/, + ); +}); diff --git a/test/back/static-mirror.test.cjs b/test/back/static-mirror.test.cjs new file mode 100644 index 00000000..6934d96b --- /dev/null +++ b/test/back/static-mirror.test.cjs @@ -0,0 +1,179 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { writeManifest } = require('../helpers/upgrade-fixture.cjs'); + +test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'), + target = path.join(tmp, 'target.git'); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', source); + git('init', '--bare', target); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + const script = path.resolve('scripts/sync-static.sh'); + const env = { ...process.env, QL_STATIC_SOURCE: source }; + for (const branch of ['master', 'develop']) { + git('-C', source, 'checkout', '--orphan', branch); + fs.writeFileSync(path.join(source, 'build-info.json'), branch); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', branch); + execFileSync('bash', [script, target, branch], { env, stdio: 'pipe' }); + assert.equal( + git('-C', target, 'rev-parse', branch), + git('-C', source, 'rev-parse', branch), + ); + assert.equal( + git('-C', target, 'rev-parse', '--is-shallow-repository'), + 'false', + ); + } + fs.writeFileSync( + path.join(target, 'hooks/pre-receive'), + '#!/bin/sh\nexit 1\n', + { mode: 0o755 }, + ); + fs.writeFileSync(path.join(source, 'build-info.json'), 'next'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'next'); + assert.notEqual( + spawnSync('bash', [script, target, 'develop'], { env }).status, + 0, + ); +}); + +test('mirror jobs select the built branch, retain hidden artifacts and propagate failures', () => { + const yaml = require('js-yaml'); + const workflow = yaml.load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + for (const name of ['static_gitee', 'static_gitlab']) { + const job = workflow.jobs[name], + step = job.steps.find((step) => step.run); + assert.equal(job.needs, 'build-static'); + assert.equal(step.env.STATIC_BRANCH, '${{ github.ref_name }}'); + assert.equal(step.env.STATIC_REF_TYPE, '${{ github.ref_type }}'); + assert.match(step.run, /set -euo pipefail/); + assert.match(step.run, /bash scripts\/sync-static.sh.*"\$STATIC_BRANCH"/); + assert.doesNotMatch(step.run, /warning|set \+e|--depth/); + } + const publish = workflow.jobs['build-static'].steps.find( + (step) => step.name === 'copy to static repo', + ); + assert.equal(publish.env.GITHUB_REF_TYPE, '${{ github.ref_type }}'); + assert.match(publish.run, /bash scripts\/publish-static.sh/); + assert.match( + fs.readFileSync('scripts/publish-static.sh', 'utf8'), + /cp -a static\/\./, + ); +}); + +test('version static snapshots retain a matching branch and Git tag on all three remotes', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-version-static-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', source); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + fs.writeFileSync(path.join(source, 'version.yaml'), 'version: 2.22.0\n'); + fs.writeFileSync(path.join(source, '.gitignore'), 'static/\n'); + fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'fixture lock\n'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'source'); + const staticRoot = path.join(source, 'static'); + for (const filename of [ + 'build/app.js', + 'dist/index.html', + 'dist/.well-known/config', + ]) { + fs.mkdirSync(path.dirname(path.join(staticRoot, filename)), { + recursive: true, + }); + fs.writeFileSync(path.join(staticRoot, filename), filename); + } + writeManifest(source, staticRoot); + const manifestFile = path.join(staticRoot, 'build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + manifest.sourceCommit = git('-C', source, 'rev-parse', 'HEAD'); + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + const remotes = ['github', 'gitlab', 'gitee'].map((name) => + path.join(tmp, `${name}.git`), + ); + for (const remote of remotes) git('init', '--bare', remote); + const publish = path.resolve('scripts/publish-static.sh'), + sync = path.resolve('scripts/sync-static.sh'); + const options = { cwd: source, stdio: 'pipe', env: process.env }; + execFileSync('bash', [publish, remotes[0], 'master', 'branch'], options); + execFileSync('bash', [publish, remotes[0], 'v2.22.0', 'tag'], options); + for (const remote of remotes.slice(1)) + execFileSync('bash', [sync, remote, 'v2.22.0', 'tag'], { + ...options, + env: { ...process.env, QL_STATIC_SOURCE: remotes[0] }, + }); + const snapshot = git('-C', remotes[0], 'rev-parse', 'refs/heads/v2.22.0'); + for (const remote of remotes) { + assert.equal( + git('-C', remote, 'rev-parse', 'refs/heads/v2.22.0'), + snapshot, + ); + assert.equal(git('-C', remote, 'rev-parse', 'refs/tags/v2.22.0'), snapshot); + assert.equal( + git('-C', remote, 'show', 'v2.22.0:dist/.well-known/config'), + 'dist/.well-known/config', + ); + } + assert.notEqual( + spawnSync('bash', [publish, remotes[0], 'v2.23.0', 'tag'], options).status, + 0, + ); + assert.equal( + spawnSync('git', [ + '-C', + remotes[0], + 'rev-parse', + '--verify', + 'refs/tags/v2.23.0', + ]).status, + 128, + ); +}); + +test('static backfill uses existing snapshots and skips all source, build and image publication jobs', () => { + const workflow = require('js-yaml').load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + assert.equal( + workflow.on.workflow_dispatch.inputs.static_sync_only.default, + false, + ); + const job = workflow.jobs['sync-existing-static']; + assert.match(job.if, /workflow_dispatch.*inputs.static_sync_only/); + assert.deepEqual(job.strategy.matrix.mirror, ['gitlab', 'gitee']); + const sync = job.steps.find((step) => step.run); + assert.equal(sync.env.STATIC_REF, '${{ inputs.static_ref }}'); + assert.equal(sync.env.STATIC_REF_TYPE, '${{ inputs.static_ref_type }}'); + assert.match(sync.run, /bash scripts\/sync-static.sh/); + assert.doesNotMatch(sync.run, /publish-static|docker|pub.sh/); + for (const name of ['validate', 'code_gitlab', 'code_gitee', 'build-static']) + assert.match(workflow.jobs[name].if, /!inputs.static_sync_only/); + // Downstream jobs require the skipped build; none bypass dependency failures. + for (const name of [ + 'static_gitlab', + 'static_gitee', + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) { + assert.equal(workflow.jobs[name].needs, 'build-static'); + assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/); + } +}); diff --git a/test/back/upgrade-guards.test.cjs b/test/back/upgrade-guards.test.cjs new file mode 100644 index 00000000..247368c8 --- /dev/null +++ b/test/back/upgrade-guards.test.cjs @@ -0,0 +1,285 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { randomUUID } = require('node:crypto'); +const { spawn } = require('node:child_process'); +const { withUpgradeLock, waitForHealth } = require('../../shell/upgrade.cjs'); + +async function temporary(t) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-upgrade-guards-')); + t.after(() => fs.rm(tmp, { recursive: true, force: true })); + return tmp; +} + +for (const legacy of [true, false]) + test( + `concurrent reclamation of a ${ + legacy ? 'legacy lock' : 'unique owner lock' + } cannot remove a new owner`, + { timeout: 5000 }, + async (t) => { + const tmp = await temporary(t); + const lock = path.join(tmp, 'upgrade.lock'); + const owner = legacy ? 'pid' : `owner-99999999-${randomUUID()}`; + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, owner), legacy ? '99999999' : ''); + const original = { + readFile: fs.readFile, + readdir: fs.readdir, + rm: fs.rm, + unlink: fs.unlink, + }; + let readCount = 0, + removeCount = 0, + active = 0, + maximum = 0; + let readyReaders, firstEntered, release; + const readers = new Promise((resolve) => { + readyReaders = resolve; + }); + const entered = new Promise((resolve) => { + firstEntered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const synchronize = async () => { + if (++readCount === 2) readyReaders(); + await readers; + }; + t.mock.method(fs, 'readFile', async (...args) => { + const value = await original.readFile(...args); + if (legacy && args[0] === path.join(lock, owner) && readCount < 2) + await synchronize(); + return value; + }); + t.mock.method(fs, 'readdir', async (...args) => { + const value = await original.readdir(...args); + if (!legacy && args[0] === lock && readCount < 2) await synchronize(); + return value; + }); + t.mock.method(fs, 'rm', async (...args) => { + if (args[0] === lock && ++removeCount === 2) await entered; + return original.rm(...args); + }); + t.mock.method(fs, 'unlink', async (...args) => { + if (args[0] === path.join(lock, owner) && ++removeCount === 2) + await entered; + return original.unlink(...args); + }); + const operation = async () => { + maximum = Math.max(maximum, ++active); + firstEntered(); + if (active === 1) await held; + active--; + }; + const attempts = [ + withUpgradeLock(tmp, operation), + withUpgradeLock(tmp, operation), + ].map((attempt) => + attempt.then( + () => ({ ok: true }), + (error) => ({ error }), + ), + ); + try { + await entered; + const contender = await Promise.race(attempts); + assert.match(contender.error?.message || '', /Another upgrade/); + assert.equal(maximum, 1); + assert.equal((await original.readdir(lock)).length, 1); + } finally { + release(); + await Promise.all(attempts); + } + assert.deepEqual(await original.readdir(tmp), []); + }, + ); + +test('an empty abandoned lock is acquired atomically and a failed operation releases its owner', async (t) => { + const tmp = await temporary(t); + await fs.mkdir(path.join(tmp, 'upgrade.lock')); + await assert.rejects( + withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => assert.fail('concurrent operation')), + /Another upgrade/, + ); + throw new Error('operation failed'); + }), + /operation failed/, + ); + await withUpgradeLock(tmp, async () => {}); + assert.deepEqual(await fs.readdir(tmp), []); +}); + +test('release never deletes another owner after the original lock was replaced', async (t) => { + const tmp = await temporary(t); + const lock = path.join(tmp, 'upgrade.lock'); + const replacement = `owner-${process.pid}-${randomUUID()}`; + await withUpgradeLock(tmp, async () => { + await fs.rm(lock, { recursive: true }); + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, replacement), ''); + }); + assert.deepEqual(await fs.readdir(lock), [replacement]); +}); + +test( + 'a lock left by a killed process can be reclaimed by a new process', + { timeout: 5000 }, + async (t) => { + const tmp = await temporary(t); + const worker = path.join(tmp, 'worker.cjs'); + await fs.writeFile( + worker, + ` +const { withUpgradeLock } = require(${JSON.stringify( + path.resolve('shell/upgrade.cjs'), + )}); +withUpgradeLock(process.argv[2], async () => { + process.stdout.write('owned\\n'); + await new Promise(() => setInterval(() => {}, 1000)); +}).catch(error => { process.stderr.write(error.message); process.exitCode = 1; }); +`, + ); + const child = spawn(process.execPath, [worker, tmp], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + await new Promise((resolve, reject) => { + child.stdout.once('data', resolve); + child.once('error', reject); + child.once('exit', () => + reject(new Error('worker exited before acquiring its lock')), + ); + }); + child.kill('SIGKILL'); + await closed; + await withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => {}), + /Another upgrade/, + ); + }); + assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']); + }, +); + +async function healthServer(t, handler) { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }), + ); + return String(server.address().port); +} + +test('health checks normalize supported URL prefixes exactly like the backend', async (t) => { + const requests = []; + const port = await healthServer(t, (req, res) => { + requests.push(req.url); + if ( + ['/api/health', '/panel/api/health', '/panel//api/health'].includes( + req.url, + ) + ) + res.end('{"code":200,"data":{"status":"ok"}}'); + else { + res.statusCode = 404; + res.end('{}'); + } + }); + for (const prefix of [ + '', + '/', + 'panel', + '/panel', + 'panel/', + '/panel/', + 'panel//', + '/panel//', + ]) + await waitForHealth({ QlPort: port, QlBaseUrl: prefix }, 100); + assert.deepEqual(requests, [ + '/api/health', + '/api/health', + ...Array(4).fill('/panel/api/health'), + ...Array(2).fill('/panel//api/health'), + ]); +}); + +test( + 'health timeout bounds a continuously streaming response', + { timeout: 3000 }, + async (t) => { + const port = await healthServer(t, (req, res) => { + const timer = setInterval(() => res.write(' '), 10); + res.once('close', () => clearInterval(timer)); + }); + const began = Date.now(); + await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/); + assert.ok( + Date.now() - began < 1000, + 'deadline must not depend on socket inactivity', + ); + }, +); + +for (const streaming of [false, true]) + test( + `cancellation interrupts ${ + streaming ? 'an active response' : 'the retry delay' + } and releases the upgrade lock`, + { timeout: 3000 }, + async (t) => { + const tmp = await temporary(t); + const controller = new AbortController(); + const reason = new Error('cancelled health check'); + const port = await healthServer(t, (req, res) => { + if (streaming) res.write('{'); + else { + res.statusCode = 503; + res.end('{}'); + } + setTimeout(() => controller.abort(reason), 30); + }); + const began = Date.now(); + await assert.rejects( + withUpgradeLock(tmp, () => + waitForHealth({ QlPort: port }, 5000, controller.signal), + ), + (error) => error === reason, + ); + assert.ok( + Date.now() - began < 400, + 'cancel must interrupt the current wait', + ); + await withUpgradeLock(tmp, async () => {}); + assert.deepEqual(await fs.readdir(tmp), []); + }, + ); + +test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => { + for (const body of ['{}', '{', ' '.repeat(65537)]) { + const port = await healthServer(t, (req, res) => res.end(body)); + await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/); + } +}); diff --git a/test/back/upgrade-lifecycle.test.cjs b/test/back/upgrade-lifecycle.test.cjs new file mode 100644 index 00000000..2fe9bc1d --- /dev/null +++ b/test/back/upgrade-lifecycle.test.cjs @@ -0,0 +1,288 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const { + runProcess, + withLifecycleHooks, + withUpgradeLock, +} = require('../../shell/upgrade.cjs'); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function assertStopped(pid) { + for (let attempt = 0; attempt < 50; attempt++) { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === 'ESRCH') return; + throw error; + } + const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { + encoding: 'utf8', + }); + // Linux PID 1 can retain a dead orphan until it is reaped. It can also + // disappear between kill(0) and ps; neither state is a running installer. + if ( + (state.status === 1 && !state.stdout.trim()) || + /^Z/.test(state.stdout.trim()) + ) + return; + assert.equal(state.status, 0, state.stderr); + await delay(20); + } + assert.fail(`test process ${pid} is still running`); +} + +async function fixture(t, directory) { + const base = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-upgrade-lifecycle-'), + ); + t.after(() => fs.rm(base, { recursive: true, force: true })); + const root = directory ? path.join(base, directory) : base; + const tmp = path.join(root, '.tmp'); + await fs.mkdir(tmp, { recursive: true }); + await fs.mkdir(path.join(root, 'shell/lang'), { recursive: true }); + await fs.writeFile(path.join(root, 'shell/lang/zh.sh'), ''); + await fs.writeFile( + path.join(root, 'shell/env.sh'), + await fs.readFile('shell/env.sh'), + ); + return { root, tmp, env: { ...process.env, QL_DIR: root } }; +} + +test('installed stop/start hooks do not match their own pkill and survive shell replacement', async (t) => { + const f = await fixture(t); + // Only the test's random path can match; never signal a running panel. + const marker = `review/${randomUUID()}/app.js`; + const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll( + 'static/build/app.js', + marker, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `${helpers} +pm2() { printf '%s\\n' "$*" >> "$QL_DIR/pm2-calls"; } +${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''} +`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => { + await fs.writeFile(path.join(f.root, 'shell/share.sh'), 'exit 99'); + await hook('stop'); + await hook('start'); + assert.match( + await fs.readFile(path.join(f.root, 'pm2-calls'), 'utf8'), + /startOrGracefulReload/, + ); + }), + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); + +test( + 'the installed Node fallback starts successfully and its stop hook kills only the test backend', + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + const marker = `review/${randomUUID()}/app.js`; + let pid; + t.after(() => { + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }); + const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll( + 'static/build/app.js', + marker, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `${helpers} +pm2() { return 1; } +${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''} +`, + ); + await fs.mkdir(path.dirname(path.join(f.root, marker)), { + recursive: true, + }); + await fs.mkdir(path.join(f.root, 'data/log'), { recursive: true }); + await fs.writeFile( + path.join(f.root, marker), + `require('node:fs').writeFileSync(process.env.QL_DIR + '/backend-pid', String(process.pid)); setInterval(() => {}, 1000);`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => { + await hook('start'); + for (let attempt = 0; attempt < 150; attempt++) { + try { + pid = Number( + await fs.readFile(path.join(f.root, 'backend-pid'), 'utf8'), + ); + break; + } catch { + await delay(20); + } + } + assert.ok(pid, 'fallback backend started'); + process.kill(pid, 0); + await hook('stop'); + await assertStopped(pid); + }), + ); + }, +); + +for (const { redirect, ignoreShell } of [ + { redirect: false, ignoreShell: false }, + { redirect: true, ignoreShell: false }, + { redirect: false, ignoreShell: true }, +]) + test( + `cancelled installation stops descendants before releasing its lock (${JSON.stringify( + { redirect, ignoreShell }, + )})`, + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + let pid; + t.after(() => { + controller.abort(); + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }); + const worker = path.join(f.root, 'installer.cjs'); + await fs.writeFile( + worker, + ` +const fs = require('node:fs'), path = require('node:path'); +process.on('SIGTERM', () => {}); +fs.writeFileSync(path.join(process.env.QL_DIR, 'installer-pid'), String(process.pid)); +setInterval(() => fs.appendFileSync(path.join(process.env.QL_DIR, 'heartbeat'), 'x'), 20); +`, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `import_config() { :; } +npm_install_2() { + ${ignoreShell ? "trap '' TERM" : ':'} + "$TEST_NODE" "$1"${redirect ? ' >/dev/null 2>&1' : ''} + exit_status=$? +} +`, + ); + const operation = withUpgradeLock(f.tmp, () => + withLifecycleHooks( + f.root, + f.tmp, + { ...f.env, TEST_NODE: process.execPath }, + (hook) => hook('install', [worker], controller.signal), + ), + ); + // Attach rejection handling before aborting the in-flight operation. + const rejection = assert.rejects(operation, /cancelled installation/); + for (let attempt = 0; attempt < 150; attempt++) { + try { + pid = Number( + await fs.readFile(path.join(f.root, 'installer-pid'), 'utf8'), + ); + await fs.access(path.join(f.root, 'heartbeat')); + break; + } catch { + await delay(20); + } + } + assert.ok(pid, 'installer started'); + await fs.access(path.join(f.tmp, 'upgrade.lock')); + controller.abort(new Error('cancelled installation')); + await rejection; + assert.deepEqual(await fs.readdir(f.tmp), []); + const before = await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'); + await delay(100); + assert.equal( + await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'), + before, + ); + await assertStopped(pid); + }, + ); + +test('an already cancelled command does not spawn and a spawn failure releases the upgrade lock', async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + controller.abort(new Error('cancelled before spawn')); + const script = path.join(f.root, 'never-run.sh'); + await fs.writeFile(script, 'touch "$QL_DIR/spawned"'); + await assert.rejects( + runProcess(f.env, 'bash', ['--', script], false, controller.signal), + /cancelled before spawn/, + ); + await assert.rejects(fs.access(path.join(f.root, 'spawned')), { + code: 'ENOENT', + }); + await assert.rejects( + withUpgradeLock(f.tmp, () => + runProcess(f.env, path.join(f.root, 'missing-program'), []), + ), + { code: 'ENOENT' }, + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); + +test('fixed lifecycle dispatch treats spaces, quotes and command substitutions in paths as data', async (t) => { + const special = + 'panel spaces \u0027"; $(touch "$TEST_MARKER"); `touch "$TEST_MARKER"`'; + const f = await fixture(t, special); + const marker = path.join(f.root, 'injected'); + const source = path.join(f.root, 'source ' + special); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + ` +import_config() { :; } +delete_pm2() { printf stopped > "$QL_DIR/stopped"; } +reload_pm2() { printf started > "$QL_DIR/started"; } +npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; } +`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks( + f.root, + f.tmp, + { ...f.env, TEST_MARKER: marker }, + async (hook) => { + await hook('stop'); + await hook('start'); + await hook('install', [source]); + assert.equal( + await fs.readFile(path.join(f.root, 'stopped'), 'utf8'), + 'stopped', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'started'), 'utf8'), + 'started', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'installed'), 'utf8'), + source, + ); + await assert.rejects(fs.access(marker), { code: 'ENOENT' }); + await assert.rejects(hook('touch "$TEST_MARKER"'), /exit 2/); + await assert.rejects(fs.access(marker), { code: 'ENOENT' }); + }, + ), + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); diff --git a/test/back/upgrade-transaction.test.cjs b/test/back/upgrade-transaction.test.cjs new file mode 100644 index 00000000..3df18f2f --- /dev/null +++ b/test/back/upgrade-transaction.test.cjs @@ -0,0 +1,133 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { replaceAndReload } = require('../../shell/upgrade.cjs'); + +async function fixture(t) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-upgrade-transaction-'), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const source = path.join(root, 'source'), + target = path.join(root, 'target'); + for (const directory of [source, target]) await fs.mkdir(directory); + await fs.writeFile(path.join(source, 'version'), 'new'); + await fs.writeFile(path.join(target, 'version'), 'old'); + return { root, source, target }; +} + +for (const failure of ['backup', 'remove', 'replacement', 'start']) + test(`Bash transaction restores the full original after an overlay ${failure} failure`, async (t) => { + const f = await fixture(t); + const rename = fs.rename, + cp = fs.cp, + rm = fs.rm; + t.mock.method(fs, 'rename', async (source, target) => { + if (source === f.target || source.includes('.ql-backup-')) { + const error = new Error('overlay rename'); + error.code = 'EXDEV'; + throw error; + } + return rename(source, target); + }); + let injected = false; + t.mock.method(fs, 'cp', async (source, target, options) => { + if ( + !injected && + ((failure === 'backup' && source === f.target) || + (failure === 'replacement' && source === f.source)) + ) { + injected = true; + await fs.mkdir(target, { recursive: true }); + await fs.writeFile(path.join(target, 'partial'), 'partial'); + throw new Error('copy failed'); + } + return cp(source, target, options); + }); + t.mock.method(fs, 'rm', async (target, options) => { + if (!injected && failure === 'remove' && target === f.target) { + injected = true; + await fs.unlink(path.join(target, 'version')); + throw new Error('remove failed'); + } + return rm(target, options); + }); + let starts = 0; + await assert.rejects( + replaceAndReload([f], { + stop: async () => {}, + start: async () => { + if (++starts === 1 && failure === 'start') + throw new Error('start failed'); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'old', + ); + }, + }), + /failed/, + ); + assert.deepEqual(await fs.readdir(f.target), ['version']); + assert.ok( + !(await fs.readdir(f.root)).some((name) => name.includes('.ql-backup-')), + ); + }); + +test('interrupted upgrade recovers the previous service outside cancellation', async (t) => { + const f = await fixture(t), + controller = new AbortController(); + let starts = 0; + await assert.rejects( + replaceAndReload([f], { + signal: controller.signal, + stop: async () => {}, + start: async (recover) => { + if (++starts === 1) { + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'new', + ); + controller.abort(new Error('interrupted')); + } else { + assert.equal(recover, true); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'old', + ); + } + }, + }), + /interrupted/, + ); + assert.equal(starts, 2); +}); + +test('failed stop during rollback retains backups without overwriting running files', async (t) => { + const f = await fixture(t); + let stops = 0; + await assert.rejects( + replaceAndReload([f], { + stop: async () => { + if (++stops === 2) throw new Error('stop failed'); + }, + start: async () => { + throw new Error('start failed'); + }, + }), + /stop failed/, + ); + const backup = (await fs.readdir(f.root)).find((name) => + name.includes('.ql-backup-'), + ); + assert.ok(backup); + assert.equal( + await fs.readFile(path.join(f.root, backup, 'version'), 'utf8'), + 'old', + ); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'new', + ); +}); diff --git a/test/helpers/upgrade-fixture.cjs b/test/helpers/upgrade-fixture.cjs new file mode 100644 index 00000000..251174fd --- /dev/null +++ b/test/helpers/upgrade-fixture.cjs @@ -0,0 +1,30 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { collectBuildFiles } = require('../../docker/build-manifest.cjs'); +const sourceCommit = 'a'.repeat(40); + +function writeManifest(source, staticRoot) { + const lockfile = path.join(source, 'pnpm-lock.yaml'); + if (!fs.existsSync(lockfile)) fs.writeFileSync(lockfile, 'fixture lock\n'); + fs.mkdirSync(path.join(staticRoot, 'dist'), { recursive: true }); + if (!fs.existsSync(path.join(staticRoot, 'dist/index.html'))) + fs.writeFileSync( + path.join(staticRoot, 'dist/index.html'), + '
', + ); + fs.writeFileSync( + path.join(staticRoot, 'build-info.json'), + JSON.stringify({ + version: 1, + dirty: false, + sourceCommit, + lockfileSha256: createHash('sha256') + .update(fs.readFileSync(lockfile)) + .digest('hex'), + files: collectBuildFiles(staticRoot), + }), + ); +} + +module.exports = { writeManifest, sourceCommit }; From ef1c54a620466dc1a244c9dcf07bf2cc51ca7839 Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 7 Oct 2026 20:35:59 +0800 Subject: [PATCH 117/123] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=BC=BA=E5=A4=B1=E7=9A=84=E4=B8=AD=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en-US.json | 6 ++++++ src/locales/zh-CN.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 95d90d7a..0c769c51 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -19,6 +19,9 @@ "Minimum is 4": "Minimum is 4", "NPM 镜像源": "NPM Mirror Source", "Node 软件包镜像源": "Node Software Package Mirror Source", + "OpeniLink Hub地址,默认为 https://hub.openilink.com,自建Hub时填写自己的地址": "OpeniLink Hub URL, defaults to https://hub.openilink.com. For a self-hosted Hub, enter your own URL.", + "OpeniLink的app_token,在OpeniLink Hub后台安装App后获取,参考 https://openilink.com/docs/hub/apps": "OpeniLink app_token. Install an app in the OpeniLink Hub dashboard to obtain it. See https://openilink.com/docs/hub/apps", + "OpeniLink的context_token,用于标识消息会话上下文,可从消息事件中获取": "OpeniLink context_token identifies the message conversation context and can be obtained from message events.", "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer Key, https://github.com/easychen/pushdeer", "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer's self-hosted API endpoint, default is https://api2.pushdeer.com/message/push", "PushMe的Key,https://push.i-i.me/": "PushMe key, https://push.i-i.me/", @@ -35,6 +38,7 @@ "alpine linux 镜像源": "Alpine Linux Mirror Source", "chat的token码": "Chat token code", "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid, corpsecret, touser (note: separate multiple member IDs with |), agentid, message type (optional, defaults to text message type) – separated by commas (`,`), e.g., wwcfrs, B-76WERQ, qinglong, 1000001, 2COat", + "debian linux 镜像源": "Debian Linux Mirror Source", "docker安装在持久化config目录下的chronocat.yml文件可找到": "The docker installation can be found in the persistence config directory in the chronocat.yml file", "gotify的url地址,例如 https://push.example.de:8080": "gotify URL address, e.g., https://push.example.de:8080", "gotify的消息应用token码": "gotify message application token code", @@ -351,6 +355,7 @@ "查看日志": "View Log", "标签": "Tags", "标签统计": "Label Stats", + "核心": "cores", "检查更新": "Check for Updates", "检查更新中...": "Checking for updates...", "次数": "Runs", @@ -584,6 +589,7 @@ "邮箱": "Email", "邮箱地址": "Email Address", "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", + "邮箱认证地址": "Email address for SMTP authentication", "配置文件": "Configuration Files", "重启": "Restart", "重命名": "Rename", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index daafeb5b..6cfdcd5d 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -18,6 +18,9 @@ "Linux 软件包镜像源": "Linux 软件包镜像源", "NPM 镜像源": "NPM 镜像源", "Node 软件包镜像源": "Node 软件包镜像源", + "OpeniLink Hub地址,默认为 https://hub.openilink.com,自建Hub时填写自己的地址": "OpeniLink Hub地址,默认为 https://hub.openilink.com,自建Hub时填写自己的地址", + "OpeniLink的app_token,在OpeniLink Hub后台安装App后获取,参考 https://openilink.com/docs/hub/apps": "OpeniLink的app_token,在OpeniLink Hub后台安装App后获取,参考 https://openilink.com/docs/hub/apps", + "OpeniLink的context_token,用于标识消息会话上下文,可从消息事件中获取": "OpeniLink的context_token,用于标识消息会话上下文,可从消息事件中获取", "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer的Key,https://github.com/easychen/pushdeer", "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push", "PushMe的Key,https://push.i-i.me/": "PushMe的Key,https://push.i-i.me/", @@ -34,6 +37,7 @@ "alpine linux 镜像源": "alpine linux 镜像源", "chat的token码": "chat的token码", "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid,corpsecret,touser(注:多个成员ID使用|隔开),agentid,消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat", + "debian linux 镜像源": "debian linux 镜像源", "docker安装在持久化config目录下的chronocat.yml文件可找到": "docker安装在持久化config目录下的chronocat.yml文件可找到", "gotify的url地址,例如 https://push.example.de:8080": "gotify的url地址,例如 https://push.example.de:8080", "gotify的消息应用token码": "gotify的消息应用token码", @@ -351,6 +355,7 @@ "查看日志": "查看日志", "标签": "标签", "标签统计": "标签统计", + "核心": "核心", "检查更新": "检查更新", "检查更新中...": "检查更新中...", "次数": "次数", @@ -584,6 +589,7 @@ "邮箱": "邮箱", "邮箱地址": "邮箱地址", "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", + "邮箱认证地址": "邮箱认证地址", "配置文件": "配置文件", "重启": "重启", "重命名": "重命名", From 9d5f58ef99b4d0fbfe7173472623bdea6199f316 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 8 Oct 2026 00:14:22 +0800 Subject: [PATCH 118/123] ci: verify Gitee Pull mirrors with resilient GitHub notifications Use native Gitee Pull mirrors for code and static artifacts, and verify branch and tag SHAs alongside image builds. Trigger stale mirrors through small GitHub REST ref updates, retry transient errors with backoff, and keep polling when a notification fails. --- .github/workflows/build-docker-image.yml | 95 ++--- scripts/wait-for-mirror.sh | 214 +++++++++++ test/back/mirror-status.test.cjs | 258 ++++++++++++++ test/back/static-mirror-trigger.test.cjs | 436 +++++++++++++++++++++++ test/back/static-mirror.test.cjs | 8 +- 5 files changed, 960 insertions(+), 51 deletions(-) create mode 100644 scripts/wait-for-mirror.sh create mode 100644 test/back/mirror-status.test.cjs create mode 100644 test/back/static-mirror-trigger.test.cjs diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 07068893..75941c8c 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -17,7 +17,7 @@ on: workflow_dispatch: inputs: static_sync_only: - description: Only synchronize an existing GitHub static snapshot + description: Synchronize an existing static snapshot to GitLab and verify Gitee type: boolean default: false static_ref: @@ -56,37 +56,6 @@ jobs: git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed" git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed" - code_gitee: - if: ${{ !inputs.static_sync_only }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Push to Gitee - env: - GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} - run: | - set +e - mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null - git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null - - if git push --force --all gitee 2>&1; then - echo "::notice::Gitee push --all succeeded" - else - echo "::warning::Push failed, trying to create repo via API..." - curl -sS --connect-timeout 30 --max-time 60 \ - -X POST "https://gitee.com/api/v5/user/repos" \ - -H "Content-Type: application/json" \ - -d '{"name":"qinglong","private":"false"}' \ - "?access_token=$GITEE_TOKEN" 2>/dev/null - git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry" - fi - git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed" - build-static: if: ${{ !inputs.static_sync_only }} needs: validate @@ -146,26 +115,42 @@ jobs: ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE" - static_gitee: + verify-gitee-mirrors: needs: build-static runs-on: ubuntu-latest - concurrency: - group: static-gitee-${{ github.ref_name }} - cancel-in-progress: false + timeout-minutes: 14 + permissions: + contents: write + strategy: + fail-fast: false + matrix: + repository: [qinglong, qinglong-static] steps: - uses: actions/checkout@v6 - - name: Push qinglong-static to Gitee + # Gitee Pull mirrors and GitHub push webhooks own synchronization. + # Verification runs alongside Docker builds and needs no Gitee credentials. + - name: Wait for Gitee Pull mirror + if: ${{ matrix.repository == 'qinglong' }} env: - STATIC_BRANCH: ${{ github.ref_name }} - STATIC_REF_TYPE: ${{ github.ref_type }} - MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }} + GH_TOKEN: ${{ github.token }} + QL_MIRROR_NOTIFY_REPO: whyour/qinglong + MIRROR_REPOSITORY: ${{ matrix.repository }} + MIRROR_REF: ${{ github.ref_name }} + MIRROR_REF_TYPE: ${{ github.ref_type }} + QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }} + QL_MIRROR_NOTIFY_ON_START: 'false' run: | - set -euo pipefail - mkdir -p ~/.ssh - printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null - bash scripts/sync-static.sh git@gitee.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE" + bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE" + - name: Notify and wait for Gitee static mirror + if: ${{ matrix.repository == 'qinglong-static' }} + env: + GH_TOKEN: ${{ secrets.API_TOKEN }} + QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static + MIRROR_REF: ${{ github.ref_name }} + MIRROR_REF_TYPE: ${{ github.ref_type }} + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true' + run: | + bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE" sync-existing-static: if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }} @@ -173,7 +158,7 @@ jobs: strategy: fail-fast: false matrix: - mirror: [gitlab, gitee] + mirror: [gitlab] concurrency: group: static-${{ matrix.mirror }}-${{ inputs.static_ref }} cancel-in-progress: false @@ -193,6 +178,22 @@ jobs: ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE" + verify-existing-static-gitee: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }} + runs-on: ubuntu-latest + timeout-minutes: 14 + steps: + - uses: actions/checkout@v6 + - name: Wait for existing Gitee static snapshot + env: + GH_TOKEN: ${{ secrets.API_TOKEN }} + QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static + MIRROR_REF: ${{ inputs.static_ref }} + MIRROR_REF_TYPE: ${{ inputs.static_ref_type }} + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true' + run: | + bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE" + build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static diff --git a/scripts/wait-for-mirror.sh b/scripts/wait-for-mirror.sh new file mode 100644 index 00000000..25f9befa --- /dev/null +++ b/scripts/wait-for-mirror.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Observe Pull mirrors; optionally notify them through a small GitHub push. +set -euo pipefail + +if [[ $# -lt 3 || $# -gt 4 ]]; then + echo 'Usage: wait-for-mirror.sh SOURCE MIRROR REF [branch|tag]' >&2 + exit 2 +fi +source=$1 +mirror=$2 +ref=$3 +ref_type=${4:-branch} +wait_seconds=${QL_MIRROR_WAIT_SECONDS:-600} +poll_interval=${QL_MIRROR_POLL_INTERVAL:-30} +query_timeout=${QL_MIRROR_QUERY_TIMEOUT:-20} +include_version_branch=${QL_MIRROR_INCLUDE_VERSION_BRANCH:-false} +notify_repo=${QL_MIRROR_NOTIFY_REPO:-} +retry_after=${QL_MIRROR_RETRY_AFTER_SECONDS:-310} +notify_on_start=${QL_MIRROR_NOTIFY_ON_START:-true} + +timings=("$wait_seconds" "$poll_interval" "$query_timeout") +if [[ -n "$notify_repo" ]]; then timings+=("$retry_after"); fi +for value in "${timings[@]}"; do + if [[ ! "$value" =~ ^[1-9][0-9]{0,4}$ ]] || (( value > 86400 )); then + echo 'Mirror timing values must be integers between 1 and 86400 seconds' >&2 + exit 2 + fi +done +case "$ref_type" in + branch) refs=("refs/heads/$ref") ;; + tag) + refs=("refs/tags/$ref") + if [[ "$include_version_branch" == true ]]; then + refs+=("refs/heads/$ref") + fi + ;; + *) echo 'Mirror ref type must be branch or tag' >&2; exit 2 ;; +esac +if [[ "$include_version_branch" != true && "$include_version_branch" != false ]]; then + echo 'QL_MIRROR_INCLUDE_VERSION_BRANCH must be true or false' >&2 + exit 2 +fi +if [[ "$notify_on_start" != true && "$notify_on_start" != false ]]; then + echo 'QL_MIRROR_NOTIFY_ON_START must be true or false' >&2 + exit 2 +fi +for requested_ref in "${refs[@]}"; do + git check-ref-format "$requested_ref" +done +command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; } +if [[ -n "$notify_repo" ]]; then + if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2 + exit 2 + fi + command -v gh >/dev/null || { echo 'GitHub CLI is required for notifications' >&2; exit 2; } +fi +export GIT_TERMINAL_PROMPT=0 +polling=false + +notification_command() { + local budget=$((notification_deadline - SECONDS)) + if (( budget <= 0 )); then return 1; fi + if (( budget > 10 )); then budget=10; fi + timeout --signal=KILL "${budget}s" "$@" +} + +notify_static_mirror() { + local notification_deadline=$((SECONDS + 30)) + if [[ "$polling" == true ]] && (( notification_deadline > wait_seconds )); then + notification_deadline=$wait_seconds + fi + local trigger_ref=refs/heads/gitee-sync-trigger + local attempt remote_refs source_sha parent tree commit delay remaining + local commit_args + if ! remote_refs=$(notification_command git ls-remote --refs "$source" "${refs[0]}"); then + echo '::warning::Could not read the published source; continuing mirror verification' + return 1 + fi + source_sha=${remote_refs%%$'\t'*} + if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Published source ref ${refs[0]} does not exist" + return 2 + fi + for attempt in 1 2 3; do + if (( attempt > 1 )); then + delay=$((attempt * 2 - 2)) + remaining=$((notification_deadline - SECONDS)) + if (( remaining <= delay )); then break; fi + sleep "$delay" + fi + # Refresh the parent on every attempt. REST avoids the Git push endpoint's + # transient 500s, and non-forced updates preserve concurrent notifications. + if parent=$(notification_command gh api "repos/$notify_repo/git/ref/heads/gitee-sync-trigger" --jq '.object.sha' 2>&1); then + if ! tree=$(notification_command gh api "repos/$notify_repo/git/commits/$parent" --jq '.tree.sha'); then + echo "::warning::Could not read notification history (attempt $attempt)" + continue + fi + elif [[ "$parent" == *'(HTTP 404)'* ]]; then + parent='' + # A new notification branch starts with one tiny marker, never artifacts. + # Later commits reuse this tree and have no changed files in the webhook. + if ! tree=$(printf '%s' '{"tree":[{"path":".gitee-sync-trigger","mode":"100644","type":"blob","content":"Gitee Pull mirror notifications.\n"}]}' | + notification_command gh api --method POST "repos/$notify_repo/git/trees" --input - --jq '.sha'); then + echo "::warning::Could not create notification tree (attempt $attempt)" + continue + fi + else + echo "::warning::Could not read notification ref (attempt $attempt)" + continue + fi + commit_args=(-f "tree=$tree" -f "message=Trigger Gitee mirror for ${refs[0]} at $source_sha") + if [[ -n "$parent" ]]; then commit_args+=(-f "parents[]=$parent"); fi + if ! commit=$(notification_command gh api --method POST "repos/$notify_repo/git/commits" "${commit_args[@]}" --jq '.sha'); then + echo "::warning::Could not create notification commit (attempt $attempt)" + continue + fi + if [[ -n "$parent" ]]; then + if notification_command gh api --method PATCH "repos/$notify_repo/git/refs/heads/gitee-sync-trigger" -f "sha=$commit" -F force=false --silent; then + echo '::notice::Sent a small GitHub push event via REST to trigger the Gitee Pull mirror' + return 0 + fi + elif notification_command gh api --method POST "repos/$notify_repo/git/refs" -f "ref=$trigger_ref" -f "sha=$commit" --silent; then + echo '::notice::Sent a small GitHub push event via REST to trigger the Gitee Pull mirror' + return 0 + fi + echo "::warning::Mirror notification update failed (attempt $attempt)" + done + echo '::warning::Could not send the mirror notification; continuing mirror verification' + return 1 +} + +if [[ -n "$notify_repo" && "$notify_on_start" == true ]]; then + if notify_static_mirror; then :; elif [[ $? -eq 2 ]]; then exit 1; fi +fi + +SECONDS=0 +polling=true +attempt=0 +retried=false +last_source='(not read)' +last_mirror='(not read)' +while (( SECONDS < wait_seconds )); do + attempt=$((attempt + 1)) + outputs=() + ready=true + # Re-read the source after the mirror so a moving source cannot pass with + # the previous SHA. --refs compares annotated tag objects as well as branches. + urls=("$source" "$mirror" "$source") + labels=('source' 'mirror' 'source confirmation') + for index in 0 1 2; do + remaining=$((wait_seconds - SECONDS)) + if (( remaining <= 0 )); then + ready=false + break + fi + budget=$query_timeout + if (( budget > remaining )); then budget=$remaining; fi + if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \ + "${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then + echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying" + ready=false + break + fi + if (( index == 0 )); then last_source=$output; fi + if (( index == 1 )); then last_mirror=$output; fi + for requested_ref in "${refs[@]}"; do + found=false + while IFS=$'\t' read -r sha returned_ref; do + if [[ "$returned_ref" == "$requested_ref" && "$sha" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; then + found=true + fi + done <<< "$output" + if [[ "$found" == false ]]; then + echo "::notice::${labels[$index]} is missing $requested_ref; waiting" + ready=false + break + fi + done + if [[ "$ready" == false ]]; then break; fi + outputs+=("$output") + # Only confirm a source when the initial source and mirror reads match. + if (( index == 1 )) && [[ "${outputs[0]}" != "${outputs[1]}" ]]; then + ready=false + break + fi + done + if [[ "$ready" == true && "${outputs[0]}" == "${outputs[2]}" ]]; then + echo "::notice::Mirror matches the latest source refs after ${SECONDS}s" + printf '%s\n' "${outputs[2]}" + exit 0 + fi + echo "Waiting for mirror (attempt $attempt, ${SECONDS}/${wait_seconds}s)" + remaining=$((wait_seconds - SECONDS)) + if (( remaining <= 0 )); then break; fi + # Gitee requires five minutes between sync requests. Retry once after that + # interval without restarting the polling deadline. + if [[ -n "$notify_repo" && "$retried" == false ]] && (( SECONDS >= retry_after )); then + echo '::notice::Retrying the GitHub notification after the Gitee sync interval' + if notify_static_mirror; then :; elif [[ $? -eq 2 ]]; then exit 1; fi + retried=true + continue + fi + delay=$poll_interval + if (( delay > remaining )); then delay=$remaining; fi + if [[ -n "$notify_repo" && "$retried" == false ]] && (( SECONDS < retry_after && delay > retry_after - SECONDS )); then + delay=$((retry_after - SECONDS)) + fi + sleep "$delay" +done + +echo "::error::Mirror did not match the latest source refs within ${wait_seconds}s" +printf 'Last source refs:\n%s\nLast mirror refs:\n%s\n' "$last_source" "$last_mirror" +exit 1 diff --git a/test/back/mirror-status.test.cjs b/test/back/mirror-status.test.cjs new file mode 100644 index 00000000..bfb8209e --- /dev/null +++ b/test/back/mirror-status.test.cjs @@ -0,0 +1,258 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawn, spawnSync } = require('node:child_process'); + +const script = path.resolve('scripts/wait-for-mirror.sh'); +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); +const options = (env = {}) => ({ + encoding: 'utf8', + env: { + ...process.env, + QL_MIRROR_WAIT_SECONDS: '3', + QL_MIRROR_POLL_INTERVAL: '1', + QL_MIRROR_QUERY_TIMEOUT: '1', + ...env, + }, +}); + +function fixture(t) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-status-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'); + const mirror = path.join(tmp, 'mirror.git'); + git('init', source); + git('init', '--bare', mirror); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + git('-C', source, 'checkout', '-b', 'develop'); + fs.writeFileSync(path.join(source, 'snapshot'), 'first'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'first'); + return { tmp, source, mirror }; +} + +test('mirror verification reads matching refs without changing either repository', (t) => { + const { source, mirror } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + const before = git('ls-remote', '--refs', mirror); + const result = spawnSync( + 'bash', + [script, source, mirror, 'develop'], + options(), + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /Mirror matches the latest source refs/); + assert.equal(git('ls-remote', '--refs', mirror), before); + assert.equal(git('-C', source, 'status', '--porcelain'), ''); +}); + +test('mirror polling follows a source that advances while waiting', async (t) => { + const { source, mirror } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + fs.writeFileSync(path.join(source, 'snapshot'), 'second'); + git('-C', source, 'commit', '-am', 'second'); + const child = spawn( + 'bash', + [script, source, mirror, 'develop'], + options({ + QL_MIRROR_WAIT_SECONDS: '6', + }), + ); + let output = ''; + let error = ''; + let moved = false; + child.stderr.on('data', (data) => { + error += data; + }); + child.stdout.on('data', (data) => { + output += data; + if (!moved && output.includes('Waiting for mirror')) { + moved = true; + fs.writeFileSync(path.join(source, 'snapshot'), 'third'); + git('-C', source, 'commit', '-am', 'third'); + git('-C', source, 'push', mirror, 'develop'); + } + }); + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + assert.equal(status, 0, error + output); + assert.ok(moved); + assert.match(output, new RegExp(git('-C', source, 'rev-parse', 'develop'))); +}); + +test('empty or missing refs never count as a synchronized mirror', (t) => { + const { source, mirror } = fixture(t); + for (const ref of ['develop', 'missing']) { + const result = spawnSync( + 'bash', + [script, source, mirror, ref], + options({ + QL_MIRROR_WAIT_SECONDS: '1', + }), + ); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.match(result.stdout, /is missing refs\/heads\//); + assert.match(result.stdout, /::error::Mirror did not match/); + } +}); + +test('static releases require both the version branch and annotated tag object', (t) => { + const { source, mirror } = fixture(t); + git('-C', source, 'branch', 'v2.22.0'); + git('-C', source, 'tag', '-a', 'v2.22.0', '-m', 'release'); + git('-C', source, 'push', mirror, 'refs/tags/v2.22.0'); + const args = [script, source, mirror, 'v2.22.0', 'tag']; + const env = { + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true', + QL_MIRROR_WAIT_SECONDS: '1', + }; + const partial = spawnSync('bash', args, options(env)); + assert.equal(partial.status, 1, partial.stderr + partial.stdout); + assert.match(partial.stdout, /mirror is missing refs\/heads\/v2.22.0/); + git('-C', source, 'push', mirror, 'refs/heads/v2.22.0'); + const complete = spawnSync('bash', args, options(env)); + assert.equal(complete.status, 0, complete.stderr + complete.stdout); + assert.match( + complete.stdout, + new RegExp(git('-C', source, 'rev-parse', 'refs/tags/v2.22.0')), + ); + // A tag pointing to the same commit with different annotation is still stale. + git('-C', source, 'tag', '-fa', 'v2.22.0', '-m', 'updated annotation'); + const stale = spawnSync('bash', args, options(env)); + assert.equal(stale.status, 1, stale.stderr + stale.stdout); +}); + +test('network failures and hung ref queries stop within the configured deadline', (t) => { + const { tmp, source, mirror } = fixture(t); + const failure = spawnSync( + 'bash', + [script, source, path.join(tmp, 'absent'), 'develop'], + options({ + QL_MIRROR_WAIT_SECONDS: '1', + }), + ); + assert.equal(failure.status, 1, failure.stderr + failure.stdout); + assert.match(failure.stdout, /Could not read mirror refs/); + + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim(); + fs.writeFileSync( + path.join(bin, 'git'), + '#!/bin/sh\nif [ "$1" = ls-remote ]; then sleep 30; fi\nexec "$QL_TEST_GIT" "$@"\n', + { mode: 0o755 }, + ); + const started = Date.now(); + const hung = spawnSync('bash', [script, source, mirror, 'develop'], { + ...options({ + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + QL_TEST_GIT: realGit, + QL_MIRROR_WAIT_SECONDS: '2', + QL_MIRROR_QUERY_TIMEOUT: '20', + }), + timeout: 5000, + }); + assert.equal(hung.status, 1, hung.stderr + hung.stdout); + assert.ok(Date.now() - started < 4500); + assert.match(hung.stdout, /within 2s/); +}); + +test('invalid mirror parameters fail before polling', (t) => { + const { source, mirror } = fixture(t); + for (const args of [ + [], + [source, mirror, 'develop', 'other'], + [source, mirror, 'bad ref'], + ]) { + assert.notEqual(spawnSync('bash', [script, ...args], options()).status, 0); + } + for (const value of ['0', '-1', '1.5', '1+2', '9999999999999']) { + const result = spawnSync( + 'bash', + [script, source, mirror, 'develop'], + options({ + QL_MIRROR_WAIT_SECONDS: value, + }), + ); + assert.equal(result.status, 2, result.stderr + result.stdout); + } +}); + +test('Gitee workflows verify both mirrors and do not gate image publication', () => { + const workflow = require('js-yaml').load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + assert.equal(workflow.jobs.code_gitee, undefined); + assert.equal(workflow.jobs.static_gitee, undefined); + const job = workflow.jobs['verify-gitee-mirrors']; + assert.equal(job.needs, 'build-static'); + assert.equal(job['timeout-minutes'], 14); + assert.deepEqual(job.permissions, { contents: 'write' }); + assert.deepEqual(job.strategy.matrix.repository, [ + 'qinglong', + 'qinglong-static', + ]); + const verify = job.steps.find((step) => step.run); + assert.equal(verify.env.MIRROR_REF, '${{ github.ref_name }}'); + assert.equal(verify.env.MIRROR_REF_TYPE, '${{ github.ref_type }}'); + assert.equal( + verify.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, + "${{ matrix.repository == 'qinglong-static' }}", + ); + assert.match(verify.run, /bash scripts\/wait-for-mirror.sh/); + assert.equal(verify.if, "${{ matrix.repository == 'qinglong' }}"); + assert.equal(verify.env.GH_TOKEN, '${{ github.token }}'); + assert.equal(verify.env.QL_MIRROR_NOTIFY_ON_START, 'false'); + assert.equal(verify.env.QL_MIRROR_NOTIFY_REPO, 'whyour/qinglong'); + assert.doesNotMatch( + JSON.stringify(verify), + /secrets\.|git push|sync-static|ssh-keyscan/, + ); + const notify = job.steps.find((step) => + /Notify and wait/.test(step.name || ''), + ); + assert.equal(notify.if, "${{ matrix.repository == 'qinglong-static' }}"); + assert.equal(notify.env.GH_TOKEN, '${{ secrets.API_TOKEN }}'); + assert.match(notify.run, /bash scripts\/wait-for-mirror.sh/); + assert.equal(notify.env.QL_MIRROR_NOTIFY_REPO, 'whyour/qinglong-static'); + assert.equal(notify.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, 'true'); + assert.doesNotMatch( + JSON.stringify(job), + /GITEE_TOKEN|git@gitee|sync-static|ssh-keyscan/, + ); + const backfill = workflow.jobs['verify-existing-static-gitee']; + assert.match(backfill.if, /workflow_dispatch.*inputs.static_sync_only/); + assert.equal(backfill.needs, undefined); + const verifyExisting = backfill.steps.find((step) => step.run); + assert.equal(verifyExisting.env.MIRROR_REF, '${{ inputs.static_ref }}'); + assert.equal( + verifyExisting.env.MIRROR_REF_TYPE, + '${{ inputs.static_ref_type }}', + ); + assert.equal(verifyExisting.env.GH_TOKEN, '${{ secrets.API_TOKEN }}'); + assert.match(verifyExisting.run, /wait-for-mirror.sh/); + assert.equal( + verifyExisting.env.QL_MIRROR_NOTIFY_REPO, + 'whyour/qinglong-static', + ); + assert.equal(verifyExisting.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, 'true'); + assert.deepEqual(workflow.on.push.branches, ['master', 'develop']); + assert.doesNotMatch( + JSON.stringify(backfill), + /GITEE_TOKEN|git@gitee|sync-static|ssh-keyscan/, + ); + for (const name of [ + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) { + assert.equal(workflow.jobs[name].needs, 'build-static'); + } +}); diff --git a/test/back/static-mirror-trigger.test.cjs b/test/back/static-mirror-trigger.test.cjs new file mode 100644 index 00000000..c50bbfc6 --- /dev/null +++ b/test/back/static-mirror-trigger.test.cjs @@ -0,0 +1,436 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawn, spawnSync } = require('node:child_process'); +const notify = path.resolve('scripts/wait-for-mirror.sh'); +const verify = notify; +const triggerRef = 'refs/heads/gitee-sync-trigger'; +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + +function fixture(t) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-trigger-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const work = path.join(tmp, 'work'); + const source = path.join(tmp, 'source.git'); + const mirror = path.join(tmp, 'mirror.git'); + for (const repo of [source, mirror]) git('init', '--bare', repo); + git('init', work); + for (const repo of [source, work]) { + git('-C', repo, 'config', 'user.name', 'Fixture'); + git('-C', repo, 'config', 'user.email', 'fixture@example.invalid'); + } + git('-C', work, 'checkout', '-b', 'develop'); + fs.writeFileSync(path.join(work, 'snapshot'), 'static artifact'); + git('-C', work, 'add', '.'); + git('-C', work, 'commit', '-m', 'snapshot'); + git('-C', work, 'push', source, 'develop'); + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + // Model GitHub's Git Data API with real Git objects and non-forced ref updates. + // The fixture's post-receive hook simulates Gitee receiving the push event. + fs.writeFileSync( + path.join(bin, 'gh'), + `#!${process.execPath} +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const args = process.argv.slice(2); +const endpoint = args.find((arg) => arg.startsWith('repos/')); +const method = args.includes('--method') ? args[args.indexOf('--method') + 1] : 'GET'; +const fields = {}; +for (let i = 0; i < args.length; i++) { + if (args[i] === '-f' || args[i] === '-F') { + const field = args[++i]; + const separator = field.indexOf('='); + fields[field.slice(0, separator)] = field.slice(separator + 1); + } +} +fs.appendFileSync(process.env.QL_TEST_API_LOG, JSON.stringify({method, endpoint, fields, time: Date.now()}) + '\\n'); +const source = process.env.QL_TEST_SOURCE; +const git = (...args) => execFileSync('git', ['-C', source, ...args], {encoding: 'utf8', stdio: 'pipe'}).trim(); +const fail = (status) => { console.error('gh: API failure (HTTP ' + status + ')'); process.exit(1); }; +const ref = 'refs/heads/gitee-sync-trigger'; +const isUpdate = method === 'PATCH' || endpoint.endsWith('/git/refs'); +if (process.env.QL_TEST_API_HANG) spawnSync('sleep', ['30'], {stdio: 'ignore'}); +if (process.env.QL_TEST_API_REJECT) fail(500); +if (isUpdate && process.env.QL_TEST_COLLISION && !fs.existsSync(process.env.QL_TEST_COLLISION)) { + const current = git('rev-parse', ref); + const tree = git('rev-parse', current + '^{tree}'); + const peer = git('commit-tree', tree, '-p', current, '-m', 'Concurrent notifier'); + git('update-ref', ref, peer); + fs.writeFileSync(process.env.QL_TEST_COLLISION, peer); +} +if (isUpdate && process.env.QL_TEST_UPDATE_FAILURE && !fs.existsSync(process.env.QL_TEST_UPDATE_FAILURE)) { + fs.writeFileSync(process.env.QL_TEST_UPDATE_FAILURE, 'failed'); + fail(500); +} +try { + if (method === 'GET' && endpoint.includes('/git/ref/')) { + console.log(git('rev-parse', '--verify', 'refs/' + endpoint.split('/git/ref/')[1])); + } else if (method === 'GET' && endpoint.includes('/git/commits/')) { + console.log(git('rev-parse', endpoint.split('/git/commits/')[1] + '^{tree}')); + } else if (endpoint.endsWith('/git/trees')) { + const body = JSON.parse(fs.readFileSync(0, 'utf8')); + const entries = body.tree.map((entry) => { + const blob = execFileSync('git', ['-C', source, 'hash-object', '-w', '--stdin'], {encoding: 'utf8', input: entry.content}).trim(); + return entry.mode + ' blob ' + blob + '\\t' + entry.path + '\\n'; + }).join(''); + console.log(execFileSync('git', ['-C', source, 'mktree'], {encoding: 'utf8', input: entries}).trim()); + } else if (endpoint.endsWith('/git/commits')) { + const parents = fields['parents[]'] ? ['-p', fields['parents[]']] : []; + console.log(git('commit-tree', fields.tree, ...parents, '-m', fields.message)); + } else if (isUpdate) { + const current = spawnSync('git', ['-C', source, 'rev-parse', '--verify', ref], {encoding: 'utf8'}); + const old = current.status === 0 ? current.stdout.trim() : '0'.repeat(40); + if (method === 'PATCH') { + if (fields.force !== 'false' || current.status !== 0 || spawnSync('git', ['-C', source, 'merge-base', '--is-ancestor', old, fields.sha]).status !== 0) fail(422); + } else if (current.status === 0 || fields.ref !== ref) fail(422); + git('update-ref', ref, fields.sha, old); + const hook = path.join(source, 'hooks/post-receive'); + if (fs.existsSync(hook)) execFileSync(hook, {cwd: source, input: old + ' ' + fields.sha + ' ' + ref + '\\n', env: {...process.env, GIT_DIR: source}, stdio: ['pipe', 'pipe', 'pipe']}); + } else fail(400); +} catch (error) { fail(method === 'GET' ? 404 : 422); } +`, + { mode: 0o755 }, + ); + const env = { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + QL_TEST_SOURCE: source, + QL_TEST_API_LOG: path.join(tmp, 'api.log'), + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true', + QL_MIRROR_WAIT_SECONDS: '4', + QL_MIRROR_RETRY_AFTER_SECONDS: '1', + QL_MIRROR_POLL_INTERVAL: '1', + QL_MIRROR_QUERY_TIMEOUT: '1', + }; + return { tmp, work, source, mirror, env }; +} + +test('static notifications use a tiny REST branch with unchanged trees and preserve published refs', (t) => { + const { source, env } = fixture(t); + const snapshot = git('-C', source, 'rev-parse', 'develop'); + execFileSync('bash', [notify, source, source, 'develop', 'branch'], { + stdio: 'pipe', + env, + }); + const first = git('-C', source, 'rev-parse', triggerRef); + execFileSync('bash', [notify, source, source, 'develop', 'branch'], { + stdio: 'pipe', + env, + }); + const second = git('-C', source, 'rev-parse', triggerRef); + assert.notEqual(first, second); + assert.equal(git('-C', source, 'rev-parse', `${second}^`), first); + assert.equal( + git('-C', source, 'ls-tree', '-r', '--name-only', second), + '.gitee-sync-trigger', + ); + assert.equal(git('-C', source, 'diff', '--name-only', first, second), ''); + assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot); + assert.match( + git('-C', source, 'log', '-1', '--format=%s', second), + new RegExp(snapshot), + ); +}); + +test('REST notifications retry a newer parent and GitHub 500s with backoff', (t) => { + const { tmp, source, env } = fixture(t); + execFileSync('bash', [notify, source, source, 'develop'], { + stdio: 'pipe', + env, + }); + const parent = git('-C', source, 'rev-parse', triggerRef); + const flag = path.join(tmp, 'collision'); + const collision = spawnSync('bash', [notify, source, source, 'develop'], { + encoding: 'utf8', + env: { ...env, QL_TEST_COLLISION: flag }, + }); + assert.equal(collision.status, 0, collision.stderr + collision.stdout); + assert.match(collision.stdout, /notification update failed \(attempt 1\)/); + const peer = fs.readFileSync(flag, 'utf8'); + assert.equal(git('-C', source, 'rev-parse', `${peer}^`), parent); + assert.equal(git('-C', source, 'rev-parse', `${triggerRef}^`), peer); + const failed = path.join(tmp, 'failed'); + const recovery = spawnSync('bash', [notify, source, source, 'develop'], { + encoding: 'utf8', + env: { ...env, QL_TEST_UPDATE_FAILURE: failed }, + }); + assert.equal(recovery.status, 0, recovery.stderr + recovery.stdout); + assert.match(recovery.stderr, /HTTP 500/); + const calls = fs + .readFileSync(env.QL_TEST_API_LOG, 'utf8') + .trim() + .split('\n') + .map(JSON.parse); + const updates = calls.filter((call) => call.method === 'PATCH'); + assert.ok(updates.every((call) => call.fields.force === 'false')); + const last = updates.slice(-2); + assert.ok( + last[1].time - last[0].time >= 1900, + 'failed API updates must back off', + ); +}); + +test('missing snapshots fail before sending any notification', (t) => { + const { source, env } = fixture(t); + const missing = spawnSync('bash', [notify, source, source, 'missing'], { + encoding: 'utf8', + env, + }); + assert.equal(missing.status, 1, missing.stderr + missing.stdout); + assert.match(missing.stdout, /Published source ref.*does not exist/); + assert.equal(fs.existsSync(env.QL_TEST_API_LOG), false); +}); + +test('notification failure does not prevent an already synchronized mirror from passing', (t) => { + const { source, mirror, env } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + const result = spawnSync('bash', [verify, source, mirror, 'develop'], { + encoding: 'utf8', + env: { ...env, QL_TEST_API_REJECT: 'true' }, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match( + result.stdout, + /Could not send the mirror notification; continuing/, + ); + assert.match(result.stdout, /Mirror matches the latest source refs/); +}); + +test('a failed delayed notification keeps polling until the mirror updates', async (t) => { + const { source, mirror, env } = fixture(t); + const child = spawn('bash', [verify, source, mirror, 'develop'], { + env: { + ...env, + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_WAIT_SECONDS: '10', + QL_TEST_API_REJECT: 'true', + }, + }); + let output = ''; + let error = ''; + let synchronized = false; + child.stderr.on('data', (data) => { + error += data; + }); + child.stdout.on('data', (data) => { + output += data; + if ( + !synchronized && + output.includes('Could not read notification ref (attempt 1)') + ) { + synchronized = true; + git('-C', source, 'push', mirror, 'develop'); + } + }); + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + assert.ok(synchronized); + assert.equal(status, 0, error + output); + assert.match(output, /Mirror matches the latest source refs/); +}); + +test('failed or hung notifications preserve the mirror polling deadline', (t) => { + const { source, mirror, env } = fixture(t); + for (const failure of ['QL_TEST_API_REJECT', 'QL_TEST_API_HANG']) { + const started = Date.now(); + const result = spawnSync('bash', [verify, source, mirror, 'develop'], { + encoding: 'utf8', + timeout: 6000, + env: { + ...env, + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_WAIT_SECONDS: '3', + [failure]: 'true', + }, + }); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.ok(Date.now() - started < 5000); + assert.match(result.stdout, /::error::Mirror did not match.*within 3s/); + } +}); + +test('static verification retries a small GitHub notification after a missed sync interval', (t) => { + const { tmp, source, mirror, env } = fixture(t); + const snapshot = git('-C', source, 'rev-parse', 'develop'); + git('-C', source, 'update-ref', 'refs/heads/v2.22.0', snapshot); + git('-C', source, 'update-ref', 'refs/tags/v2.22.0', snapshot); + const countFile = path.join(tmp, 'requests'); + fs.writeFileSync( + path.join(source, 'hooks/post-receive'), + `#!/bin/sh +while read before after ref; do + if [ "$ref" = refs/heads/gitee-sync-trigger ]; then + count=0 + if [ -f "$QL_TEST_COUNT" ]; then count=$(cat "$QL_TEST_COUNT"); fi + count=$((count + 1)) + printf '%s' "$count" > "$QL_TEST_COUNT" + if [ "$count" -ge 2 ]; then + git push --quiet "$QL_TEST_MIRROR" refs/heads/v2.22.0:refs/heads/v2.22.0 refs/tags/v2.22.0:refs/tags/v2.22.0 + fi + fi +done +`, + { mode: 0o755 }, + ); + const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], { + encoding: 'utf8', + env: { + ...env, + QL_TEST_COUNT: countFile, + QL_TEST_MIRROR: mirror, + }, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.equal(fs.readFileSync(countFile, 'utf8'), '2'); + assert.match(result.stdout, /Retrying the GitHub notification/); + assert.match(result.stdout, /Mirror matches the latest source refs/); + assert.doesNotMatch(result.stdout, /::error::/); + for (const ref of ['refs/heads/v2.22.0', 'refs/tags/v2.22.0']) { + assert.equal(git('-C', mirror, 'rev-parse', ref), snapshot); + } +}); + +test('static verification still fails when fresh notifications never synchronize the mirror', (t) => { + const { source, mirror, env } = fixture(t); + const result = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { + ...env, + QL_MIRROR_WAIT_SECONDS: '2', + }, + }, + ); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '2'); + assert.match(result.stdout, /::error::Mirror did not match/); + const invalid = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { ...env, QL_MIRROR_WAIT_SECONDS: '0' }, + }, + ); + assert.equal(invalid.status, 2); +}); + +test('code mirrors that already match do not send another notification', (t) => { + const { source, mirror, env } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + const codeEnv = { + ...env, + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false', + }; + const result = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: codeEnv, + }, + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /Mirror matches the latest source refs/); + assert.doesNotMatch(result.stdout, /Sent a small GitHub push event via REST/); + assert.notEqual( + spawnSync('git', ['-C', source, 'rev-parse', '--verify', triggerRef]) + .status, + 0, + ); + const invalid = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { ...codeEnv, QL_MIRROR_NOTIFY_ON_START: 'later' }, + }, + ); + assert.equal(invalid.status, 2); +}); + +test('code mirrors notify after the sync interval and verify tags without version branches', (t) => { + const { tmp, work, source, mirror, env } = fixture(t); + const snapshot = git('-C', source, 'rev-parse', 'develop'); + git('-C', work, 'tag', '-a', 'v2.22.0', '-m', 'release'); + git('-C', work, 'push', source, 'refs/tags/v2.22.0'); + const tag = git('-C', source, 'rev-parse', 'refs/tags/v2.22.0'); + const countFile = path.join(tmp, 'requests'); + fs.writeFileSync( + path.join(source, 'hooks/post-receive'), + `#!/bin/sh +while read before after ref; do + if [ "$ref" = refs/heads/gitee-sync-trigger ]; then + printf 'notification\n' >> "$QL_TEST_COUNT" + git push --quiet "$QL_TEST_MIRROR" refs/tags/v2.22.0:refs/tags/v2.22.0 + fi +done +`, + { mode: 0o755 }, + ); + const started = Date.now(); + const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], { + encoding: 'utf8', + env: { + ...env, + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false', + QL_MIRROR_RETRY_AFTER_SECONDS: '1', + QL_TEST_COUNT: countFile, + QL_TEST_MIRROR: mirror, + }, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.ok( + Date.now() - started >= 900, + 'notification should wait for the sync interval', + ); + assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n'); + assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1'); + assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot); + assert.equal(git('-C', mirror, 'rev-parse', 'refs/tags/v2.22.0'), tag); + assert.notEqual( + spawnSync('git', [ + '-C', + mirror, + 'rev-parse', + '--verify', + 'refs/heads/v2.22.0', + ]).status, + 0, + ); +}); + +test('code mirrors that never synchronize fail after one delayed notification', (t) => { + const { source, mirror, env } = fixture(t); + const result = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { + ...env, + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_WAIT_SECONDS: '3', + QL_MIRROR_RETRY_AFTER_SECONDS: '1', + }, + }, + ); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1'); + assert.match(result.stdout, /::error::Mirror did not match/); +}); diff --git a/test/back/static-mirror.test.cjs b/test/back/static-mirror.test.cjs index 6934d96b..47af860d 100644 --- a/test/back/static-mirror.test.cjs +++ b/test/back/static-mirror.test.cjs @@ -53,7 +53,7 @@ test('mirror jobs select the built branch, retain hidden artifacts and propagate const workflow = yaml.load( fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), ); - for (const name of ['static_gitee', 'static_gitlab']) { + for (const name of ['static_gitlab']) { const job = workflow.jobs[name], step = job.steps.find((step) => step.run); assert.equal(job.needs, 'build-static'); @@ -156,18 +156,18 @@ test('static backfill uses existing snapshots and skips all source, build and im ); const job = workflow.jobs['sync-existing-static']; assert.match(job.if, /workflow_dispatch.*inputs.static_sync_only/); - assert.deepEqual(job.strategy.matrix.mirror, ['gitlab', 'gitee']); + assert.deepEqual(job.strategy.matrix.mirror, ['gitlab']); const sync = job.steps.find((step) => step.run); assert.equal(sync.env.STATIC_REF, '${{ inputs.static_ref }}'); assert.equal(sync.env.STATIC_REF_TYPE, '${{ inputs.static_ref_type }}'); assert.match(sync.run, /bash scripts\/sync-static.sh/); assert.doesNotMatch(sync.run, /publish-static|docker|pub.sh/); - for (const name of ['validate', 'code_gitlab', 'code_gitee', 'build-static']) + for (const name of ['validate', 'code_gitlab', 'build-static']) assert.match(workflow.jobs[name].if, /!inputs.static_sync_only/); // Downstream jobs require the skipped build; none bypass dependency failures. for (const name of [ 'static_gitlab', - 'static_gitee', + 'verify-gitee-mirrors', 'build-alpine', 'build-debian', 'build-alpine310', From 68ffa106f80a2080ddd9b48affe0d7a7489e4d05 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 9 Oct 2026 01:41:29 +0800 Subject: [PATCH 119/123] fix: resolve Debian regression findings (#3087) * fix: resolve Debian regression findings * fix: reject failed APT index refreshes and simplify SSH home lookup --- back/config/util.ts | 10 +- back/loaders/initData.ts | 6 +- back/services/sshKey.ts | 14 +- back/services/system.ts | 7 +- test/back/linux-mirror-result.test.cjs | 176 +++++++++++++++++++ test/back/mirror-status.test.cjs | 9 +- test/back/restart-instance-recovery.test.cjs | 115 ++++++++++++ test/back/ssh-config-home.test.cjs | 113 ++++++++++++ test/back/static-mirror-trigger.test.cjs | 7 +- 9 files changed, 441 insertions(+), 16 deletions(-) create mode 100644 test/back/linux-mirror-result.test.cjs create mode 100644 test/back/restart-instance-recovery.test.cjs create mode 100644 test/back/ssh-config-home.test.cjs diff --git a/back/config/util.ts b/back/config/util.ts index dc8bdb8f..352e8712 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -846,23 +846,25 @@ async function _updateLinuxMirror( mirrorDomainWithScheme: string, ): Promise { 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}) && ${S}apt-get update`; + 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} && ${S}apt-get update`; + 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}) && ${S}apt-get update`; + 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} && ${S}apt-get update`; + 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'; diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 2620f58e..bfe3e61b 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -144,7 +144,11 @@ export default async () => { // 清空所有运行中的实例记录(服务重启后进程已不存在) await RunningInstanceModel.update( - { status: InstanceStatus.stopped }, + { + status: InstanceStatus.stopped, + finished_at: Math.floor(Date.now() / 1000), + // The previous process's exit code cannot be recovered after a restart. + }, { where: { status: InstanceStatus.running } }, ); diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index 521d5b08..f140df92 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -15,7 +15,15 @@ import { @Service() export default class SshKeyService { - private homedir = os.homedir(); + // OpenSSH uses the effective user's passwd entry, not the HOME override. + private get homedir(): string { + try { + return os.userInfo().homedir; + } catch { + // Custom container UIDs may have no passwd entry. Keep startup working. + return os.homedir(); + } + } private sshPath = config.sshdPath; private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; @@ -25,6 +33,10 @@ export default class SshKeyService { } private async initSshConfigFile() { + await fs.mkdir(path.dirname(this.sshConfigFilePath), { + recursive: true, + mode: 0o700, + }); let config = ''; const _exist = await fileExist(this.sshConfigFilePath); if (_exist) { diff --git a/back/services/system.ts b/back/services/system.ts index b5725d78..7567a3ee 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -239,7 +239,6 @@ export default class SystemService { return; } const command = await updateLinuxMirrorFile(info.linuxMirror || ''); - let hasError = false; this.scheduleService.runTask( command, { @@ -247,14 +246,15 @@ export default class SystemService { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, - onEnd: async () => { + onEnd: async (cp) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'update linux mirror end', status: 'completed', }); onEnd?.(); - if (!hasError) { + // Tools such as sudo and apt may write warnings to stderr on success. + if (cp?.exitCode === 0 && cp.signalCode === null) { await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, @@ -262,7 +262,6 @@ export default class SystemService { } }, onError: async (message: string) => { - hasError = true; this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, onLog: async (message: string) => { diff --git a/test/back/linux-mirror-result.test.cjs b/test/back/linux-mirror-result.test.cjs new file mode 100644 index 00000000..4945fb7d --- /dev/null +++ b/test/back/linux-mirror-result.test.cjs @@ -0,0 +1,176 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { spawn, spawnSync } = require('node:child_process'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); + +for (const scenario of [ + { + name: 'stderr warning with exit zero', + code: 'process.stderr.write("sudo: warning\\n")', + saved: true, + }, + { + name: 'nonzero exit without stderr', + code: 'process.exitCode = 7', + saved: false, + }, + { + name: 'signal termination', + code: 'process.kill(process.pid, "SIGTERM")', + saved: false, + }, + { name: 'spawn failure', shell: '/nonexistent-ql-test-shell', saved: false }, +]) { + test(`Linux mirror persistence follows the process result: ${scenario.name}`, async () => { + const logger = { info() {}, error() {} }; + const ScheduleService = load('back/services/schedule.ts', { + '../shared/cronScheduler': {}, + '../shared/pLimit': { runWithSystemLimit: async (_params, fn) => fn() }, + 'cross-spawn': { + spawn: () => + spawn( + process.execPath, + ['-e', scenario.code || ''], + scenario.shell ? { shell: scenario.shell } : {}, + ), + }, + }).default; + const schedule = new ScheduleService(logger); + let completion; + const runTask = schedule.runTask.bind(schedule); + schedule.runTask = (...args) => (completion = runTask(...args)); + const messages = [], + writes = []; + const SystemService = load('back/services/system.ts', { + os: { platform: () => 'linux' }, + '../config': {}, + '../config/const': {}, + '../config/util': { updateLinuxMirrorFile: async () => 'fixture' }, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': {}, + '../schedule/client': {}, + '../shared/pLimit': {}, + '../shared/i18n': {}, + '../shared/schedulerMutationLock': {}, + './notify': class {}, + './schedule': ScheduleService, + './sock': class {}, + }).default; + const service = new SystemService(logger, schedule, { + sendMessage: (message) => messages.push(message), + }); + service.getSystemConfig = async () => ({ id: 1, info: { lang: 'en-US' } }); + service.updateAuthDb = async (doc) => writes.push(doc); + let ended = 0; + await service.updateLinuxMirror( + { linuxMirror: 'https://mirror.example' }, + undefined, + () => ended++, + ); + const result = await completion; + assert.equal(ended, 1); + assert.equal(writes.length, scenario.saved ? 1 : 0); + assert.ok(messages.some((message) => message.status === 'completed')); + if (scenario.saved) { + assert.equal(result.code, 0); + assert.ok( + messages.some((message) => message.message.includes('sudo: warning')), + ); + assert.deepEqual(writes[0].info, { + lang: 'en-US', + linuxMirror: 'https://mirror.example', + }); + } + }); +} + +const hasApt = spawnSync('apt-get', ['--version']).status === 0; +for (const distribution of ['Debian', 'Ubuntu']) { + for (const existingMirror of [false, true]) { + test( + `${distribution} mirror update rejects failed index downloads with ${ + existingMirror ? 'an existing' : 'an empty' + } source`, + { skip: !hasApt }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-apt-result-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const parts = path.join(root, 'parts'); + await fs.mkdir(parts); + await fs.mkdir(path.join(root, 'lists', 'partial'), { + recursive: true, + }); + const source = path.join(root, 'sources.list'); + await fs.writeFile( + source, + 'deb http://127.0.0.1:1/debian bookworm main\n', + ); + // Load only fixture configuration, including all writable APT paths. + const aptConfig = path.join(root, 'apt.conf'); + await fs.writeFile( + aptConfig, + `Dir::Etc::main "/dev/null"; +Dir::Etc::parts "${parts}"; +Dir::Etc::sourcelist "${source}"; +Dir::Etc::sourceparts "${parts}"; +Dir::State::lists "${path.join(root, 'lists')}"; +Dir::Cache "${path.join(root, 'cache')}"; +Dir::Log "${path.join(root, 'logs')}"; +Acquire::Retries "0"; +Acquire::http::Proxy "DIRECT"; +Acquire::http::Timeout "1"; +`, + ); + const util = load('back/config/util.ts', { + 'fs/promises': { + readFile: async (file) => + file === '/etc/os-release' + ? distribution + : existingMirror + ? 'URIs: http://old.example/debian\n' + : '', + }, + os: { platform: () => 'linux' }, + './index': {}, + './const': {}, + './share': {}, + './container': { isInContainer: () => false }, + '../loaders/logger': {}, + '../shared/utils': {}, + '../shared/fileAccess': {}, + '../data/dependence': {}, + }); + const previousOS = process.env.QL_OS_TYPE; + let command; + try { + process.env.QL_OS_TYPE = distribution.toLowerCase(); + command = await util.updateLinuxMirrorFile('http://127.0.0.1:1'); + } finally { + if (previousOS === undefined) delete process.env.QL_OS_TYPE; + else process.env.QL_OS_TYPE = previousOS; + } + // Exercise the generated updater without modifying host source files. + const [program, ...args] = command + .slice(command.lastIndexOf('&&') + 2) + .trim() + .split(/\s+/); + assert.equal(program, 'apt-get'); + const options = { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, APT_CONFIG: aptConfig }, + }; + const baseline = spawnSync('apt-get', ['update'], options); + assert.equal(baseline.status, 0, baseline.stderr); + assert.match(baseline.stderr, /Failed to fetch/); + const result = spawnSync(program, args, options); + assert.equal(result.status, 100, result.stderr); + assert.match(result.stderr, /Failed to fetch/); + }, + ); + } +} diff --git a/test/back/mirror-status.test.cjs b/test/back/mirror-status.test.cjs index bfb8209e..e0eec3fe 100644 --- a/test/back/mirror-status.test.cjs +++ b/test/back/mirror-status.test.cjs @@ -92,9 +92,8 @@ test('empty or missing refs never count as a synchronized mirror', (t) => { const result = spawnSync( 'bash', [script, source, mirror, ref], - options({ - QL_MIRROR_WAIT_SECONDS: '1', - }), + // Leave time to read both refs before checking the missing-ref diagnostic. + options(), ); assert.equal(result.status, 1, result.stderr + result.stdout); assert.match(result.stdout, /is missing refs\/heads\//); @@ -110,7 +109,7 @@ test('static releases require both the version branch and annotated tag object', const args = [script, source, mirror, 'v2.22.0', 'tag']; const env = { QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true', - QL_MIRROR_WAIT_SECONDS: '1', + QL_MIRROR_WAIT_SECONDS: '3', }; const partial = spawnSync('bash', args, options(env)); assert.equal(partial.status, 1, partial.stderr + partial.stdout); @@ -134,7 +133,7 @@ test('network failures and hung ref queries stop within the configured deadline' 'bash', [script, source, path.join(tmp, 'absent'), 'develop'], options({ - QL_MIRROR_WAIT_SECONDS: '1', + QL_MIRROR_WAIT_SECONDS: '3', }), ); assert.equal(failure.status, 1, failure.stderr + failure.stdout); diff --git a/test/back/restart-instance-recovery.test.cjs b/test/back/restart-instance-recovery.test.cjs new file mode 100644 index 00000000..a0e3c03d --- /dev/null +++ b/test/back/restart-instance-recovery.test.cjs @@ -0,0 +1,115 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); + +test('startup closes interrupted instances without inventing exit codes or rewriting history', async (t) => { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const instances = db.define('Instance', { + status: DataTypes.INTEGER, + started_at: DataTypes.INTEGER, + finished_at: DataTypes.INTEGER, + exit_code: DataTypes.INTEGER, + }); + await db.sync(); + await instances.bulkCreate([ + { id: 1, status: 0, started_at: 100 }, + { id: 2, status: 1, started_at: 100, finished_at: 110, exit_code: 0 }, + { id: 3, status: 2, started_at: 100, finished_at: 120, exit_code: 143 }, + { id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 }, + ]); + const systemConfig = { info: { lang: 'en-US' } }; + const service = { + set_envs: async () => {}, + getAuthInfo: async () => ({}), + findApps: async () => [], + }; + const initData = load('back/loaders/initData.ts', { + typedi: { Container: { get: () => service } }, + '../services/dependence': class {}, + '../services/cron': class {}, + '../services/env': class {}, + '../services/system': class {}, + '../services/user': class {}, + '../services/open': class {}, + '../data/cron': { + CrontabModel: { update: async () => {}, findAll: async () => [] }, + CrontabStatus: { idle: 1 }, + }, + '../data/dependence': { + DependenceModel: { findAll: async () => [], update: async () => {} }, + DependenceStatus: { queued: 0 }, + }, + '../data/cronView': { + CrontabViewModel: { findAll: async () => [{}] }, + CronViewType: { 系统: 0 }, + }, + '../data/env': {}, + '../data/system': { + SystemModel: { findOrCreate: async () => [systemConfig] }, + AuthDataType: {}, + }, + '../data/open': { + AppModel: { + findOne: async () => ({ get: () => ({ scopes: ['dashboard'] }) }), + }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, stopped: 2 }, + }, + '../config': {}, + '../config/util': { fileExist: async () => true, isDemoEnv: () => false }, + '../shared/store': { shareStore: { updateAuthInfo: async () => {} } }, + '../shared/i18n': { setLang() {} }, + './logger': {}, + '../schedule/client': { + readiness: { configure() {}, recover: async () => {} }, + }, + }).default; + // Dependency installation is unrelated to startup reconciliation. + const originalTimeout = global.setTimeout; + global.setTimeout = () => 0; + const before = Math.floor(Date.now() / 1000); + try { + await initData(); + } finally { + global.setTimeout = originalTimeout; + } + const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true }); + assert.equal(rows[0].status, 2); + assert.ok( + rows[0].finished_at >= before && + rows[0].finished_at <= Math.floor(Date.now() / 1000), + ); + assert.equal(rows[0].exit_code, null); + assert.deepEqual( + rows + .slice(1) + .map(({ status, finished_at, exit_code }) => [ + status, + finished_at, + exit_code, + ]), + [ + [1, 110, 0], + [2, 120, 143], + [3, 130, 7], + ], + ); + global.setTimeout = () => 0; + try { + await initData(); + } finally { + global.setTimeout = originalTimeout; + } + assert.deepEqual( + await instances.findAll({ order: [['id', 'ASC']], raw: true }), + rows, + ); +}); diff --git a/test/back/ssh-config-home.test.cjs b/test/back/ssh-config-home.test.cjs new file mode 100644 index 00000000..bcc9d146 --- /dev/null +++ b/test/back/ssh-config-home.test.cjs @@ -0,0 +1,113 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const load = require('../helpers/load-security-module.cjs'); + +async function fixture(t, sameHome = false, missingUser = false) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-ssh-home-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const userHome = path.join(directory, 'user'); + const envHome = sameHome ? userHome : path.join(directory, 'env'); + const sshdPath = path.join(directory, 'ssh.d'); + await fs.mkdir(sshdPath); + const util = { + fileExist: async (file) => + fs.stat(file).then( + () => true, + () => false, + ), + rmPath: async (file) => fs.rm(file, { force: true }), + }; + const { writeFileWithLock } = load('back/shared/utils.ts', { + '../config/util': util, + }); + let initialized; + const ready = new Promise((resolve) => (initialized = resolve)); + const SshKeyService = load('back/services/sshKey.ts', { + os: { + ...os, + homedir: () => envHome, + userInfo: () => { + if (missingUser) throw Error('No passwd entry'); + return { homedir: userHome }; + }, + }, + '../config': { sshdPath }, + '../config/util': util, + '../shared/utils': { + writeFileWithLock: async (file, content, options) => { + await writeFileWithLock(file, content, options); + if (content.includes('Include ')) { + initialized(); + } + }, + }, + }).default; + const service = new SshKeyService({ error: assert.fail, warn: assert.fail }); + await ready; + return { service, userHome, envHome, sshdPath }; +} + +for (const sameHome of [false, true]) { + test(`SSH configuration uses the effective user home with ${ + sameHome ? 'matching' : 'overridden' + } HOME`, async (t) => { + const { service, userHome, envHome, sshdPath } = await fixture(t, sameHome); + const configFile = path.join(userHome, '.ssh/config'); + assert.equal( + await fs.readFile(configFile, 'utf8'), + `Include ${sshdPath}/*.config\n\n`, + ); + assert.equal((await fs.stat(path.dirname(configFile))).mode & 0o777, 0o700); + assert.equal((await fs.stat(configFile)).mode & 0o777, 0o600); + if (!sameHome) + await assert.rejects(fs.stat(path.join(envHome, '.ssh/config')), { + code: 'ENOENT', + }); + await fs.appendFile( + configFile, + 'Host existing\n Hostname example.com\n', + ); + await service.initSshConfigFile(); + const content = await fs.readFile(configFile, 'utf8'); + assert.equal(content.split('Include ').length, 2); + assert.match(content, /Host existing/); + }); +} + +test('SSH initialization still works for a custom UID without a passwd entry', async (t) => { + const { envHome, sshdPath } = await fixture(t, true, true); + assert.equal( + await fs.readFile(path.join(envHome, '.ssh/config'), 'utf8'), + `Include ${sshdPath}/*.config\n\n`, + ); +}); + +test('SSH alias changes retain private key permissions and remove old credentials', async (t) => { + const { service, sshdPath } = await fixture(t); + const { + getSubscriptionSshAlias, + } = require('../../back/shared/subscriptionPath'); + const original = 'private/中文@v1'; + const renamed = 'renamed/中文@v2'; + await service.addSSHKey('fixture-private-key', original, '127.0.0.1'); + const first = getSubscriptionSshAlias(original); + assert.equal((await fs.stat(path.join(sshdPath, first))).mode & 0o777, 0o400); + assert.match( + await fs.readFile(path.join(sshdPath, `${first}.config`), 'utf8'), + /Hostname 127\.0\.0\.1/, + ); + await service.removeSSHKey(original, '127.0.0.1'); + await service.addSSHKey('fixture-private-key', renamed, '127.0.0.1'); + await assert.rejects(fs.stat(path.join(sshdPath, first)), { code: 'ENOENT' }); + await assert.rejects(fs.stat(path.join(sshdPath, `${first}.config`)), { + code: 'ENOENT', + }); + assert.equal( + (await fs.stat(path.join(sshdPath, getSubscriptionSshAlias(renamed)))) + .mode & 0o777, + 0o400, + ); +}); diff --git a/test/back/static-mirror-trigger.test.cjs b/test/back/static-mirror-trigger.test.cjs index c50bbfc6..fb4a8936 100644 --- a/test/back/static-mirror-trigger.test.cjs +++ b/test/back/static-mirror-trigger.test.cjs @@ -312,7 +312,12 @@ test('static verification still fails when fresh notifications never synchronize }, ); assert.equal(result.status, 1, result.stderr + result.stdout); - assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '2'); + // The retry contract is checked above. A short polling deadline can expire + // while the REST fixture is still sending its second notification. + const notifications = Number( + git('-C', source, 'rev-list', '--count', triggerRef), + ); + assert.ok(notifications >= 1 && notifications <= 2); assert.match(result.stdout, /::error::Mirror did not match/); const invalid = spawnSync( 'bash', From 89c6355635112574a7e1c368a527ac49e6582cd4 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 11 Oct 2026 01:51:04 +0800 Subject: [PATCH 120/123] fix: renew system tokens and serialize authentication cache updates --- back/loaders/initData.ts | 27 +- back/loaders/initTask.ts | 1 + back/services/open.ts | 150 ++++-- back/token.ts | 21 +- shell/api.sh | 126 +++-- test/back/restart-instance-recovery.test.cjs | 3 +- test/back/shell-api-parsing.test.cjs | 8 +- test/back/shellApiToken.test.cjs | 151 ++++++ test/back/systemTokenRenewal.test.cjs | 497 +++++++++++++++++++ 9 files changed, 835 insertions(+), 149 deletions(-) create mode 100644 test/back/shellApiToken.test.cjs create mode 100644 test/back/systemTokenRenewal.test.cjs diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index bfe3e61b..7e36171b 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -13,12 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system'; import SystemService from '../services/system'; import UserService from '../services/user'; import { writeFile, readFile } from 'fs/promises'; -import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util'; +import { fileExist, isDemoEnv, safeJSONParse } from '../config/util'; import OpenService from '../services/open'; import { shareStore } from '../shared/store'; import Logger from './logger'; import cronClient from '../schedule/client'; -import { AppModel } from '../data/open'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; import { setLang, systemLang } from '../shared/i18n'; @@ -31,24 +30,7 @@ export default async () => { const openService = Container.get(OpenService); // 初始化增加系统配置 - let systemApp = ( - await AppModel.findOne({ - where: { name: 'system' }, - }) - )?.get({ plain: true }); - if (!systemApp) { - systemApp = await AppModel.create({ - name: 'system', - scopes: ['crons', 'system', 'dashboard'], - client_id: createRandomString(12, 12), - client_secret: createRandomString(24, 24), - }); - } else if (!systemApp.scopes.includes('dashboard')) { - await AppModel.update( - { scopes: [...systemApp.scopes, 'dashboard'] }, - { where: { name: 'system' } }, - ); - } + await openService.initializeSystemApp(); const [systemConfig] = await SystemModel.findOrCreate({ where: { type: AuthDataType.systemConfig }, }); @@ -250,9 +232,6 @@ export default async () => { await envService.set_envs(); const authInfo = await userService.getAuthInfo(); - const apps = await openService.findApps(); await shareStore.updateAuthInfo(authInfo); - if (apps?.length) { - await shareStore.updateApps(apps); - } + await openService.refreshApps(); }; diff --git a/back/loaders/initTask.ts b/back/loaders/initTask.ts index 608f00e9..7424a6b5 100644 --- a/back/loaders/initTask.ts +++ b/back/loaders/initTask.ts @@ -26,6 +26,7 @@ export default async () => { if (await fileExist(tokenFile)) { tokenCommand = `node ${tokenFile}`; } + tokenCommand += ' --renew'; const cron = { id: NaN, name: t('生成token'), diff --git a/back/services/open.ts b/back/services/open.ts index 19d846c0..e39486a6 100644 --- a/back/services/open.ts +++ b/back/services/open.ts @@ -6,27 +6,77 @@ import { v4 as uuidV4 } from 'uuid'; import sequelize, { Op } from 'sequelize'; import { shareStore } from '../shared/store'; import { t } from '../shared/i18n'; +import { lock } from 'proper-lockfile'; +import path from 'path'; +import os from 'os'; +import { writeFile, rename, rm, stat, chmod } from 'fs/promises'; +import config from '../config'; @Service() export default class OpenService { constructor(@Inject('logger') private logger: winston.Logger) {} + // Every database mutation and full authentication-cache publication shares + // this cross-process lock. Lock a separate marker, not token.json itself. + private async withAppsLock(operation: () => Promise): Promise { + const target = path.join(config.configPath, '.apps-state'); + const release = await lock(target, { + realpath: false, + stale: 10_000, + retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 }, + }); + try { + return await operation(); + } finally { + await release(); + } + } + + public async initializeSystemApp(): Promise { + await this.withAppsLock(async () => { + const doc = await AppModel.findOne({ where: { name: 'system' } }); + const app = doc?.get({ plain: true }); + if (!app) { + await this.insert({ + name: 'system', + scopes: ['crons', 'system', 'dashboard'], + client_id: createRandomString(12, 12), + client_secret: createRandomString(24, 24), + }); + } else if (!app.scopes.includes('dashboard')) { + await AppModel.update( + { scopes: [...app.scopes, 'dashboard'] }, + { where: { id: app.id } }, + ); + } + await shareStore.updateApps(await this.find({})); + }); + } + + public async refreshApps(): Promise { + await this.withAppsLock(async () => { + await shareStore.updateApps(await this.find({})); + }); + } + public async findApps(): Promise { const docs = await this.find({}); return docs; } public async create(payload: App): Promise { - const tab = { ...payload }; - tab.client_id = createRandomString(12, 12); - tab.client_secret = createRandomString(24, 24); - const doc = await this.insert(tab); - const apps = await this.find({}); - await shareStore.updateApps(apps); - return { ...doc, tokens: [] }; + return this.withAppsLock(async () => { + const tab = { ...payload }; + tab.client_id = createRandomString(12, 12); + tab.client_secret = createRandomString(24, 24); + const doc = await this.insert(tab); + const apps = await this.find({}); + await shareStore.updateApps(apps); + return { ...doc, tokens: [] }; + }); } - public async insert(payload: App): Promise { + private async insert(payload: App): Promise { const doc = await AppModel.create(payload, { returning: true }); return doc.get({ plain: true }); } @@ -41,10 +91,12 @@ export default class OpenService { } private async updateDb(payload: Partial): Promise { - await AppModel.update(payload, { where: { id: payload.id } }); - const apps = await this.find({}); - await shareStore.updateApps(apps); - return apps?.find((x) => x.id === payload.id) as App; + return this.withAppsLock(async () => { + await AppModel.update(payload, { where: { id: payload.id } }); + const apps = await this.find({}); + await shareStore.updateApps(apps); + return apps?.find((x) => x.id === payload.id) as App; + }); } public async getDb(query: Record): Promise { @@ -56,9 +108,11 @@ export default class OpenService { } public async remove(ids: number[]) { - await AppModel.destroy({ where: { id: ids } }); - const apps = await this.find({}); - await shareStore.updateApps(apps); + return this.withAppsLock(async () => { + await AppModel.destroy({ where: { id: ids } }); + const apps = await this.find({}); + await shareStore.updateApps(apps); + }); } public async resetSecret(id: number): Promise { @@ -112,7 +166,15 @@ export default class OpenService { return docs.map((x) => x.get({ plain: true })); } - public async authToken({ + public async authToken(credentials: { + client_id: string; + client_secret: string; + }): Promise { + return this.withAppsLock(() => this.issueToken(credentials)); + } + + // Caller holds withAppsLock through database update and cache publication. + private async issueToken({ client_id, client_secret, }: { @@ -156,27 +218,47 @@ export default class OpenService { } } - public async generateSystemToken(): Promise<{ + public async generateSystemToken(renew = false): Promise<{ value: string; expiration: number; }> { - const apps = await shareStore.getApps(); - const systemApp = apps?.find((x) => x.name === 'system'); - if (!systemApp) { - throw new Error('system app not found'); - } - const now = Math.round(Date.now() / 1000); - const currentToken = systemApp.tokens?.find((x) => x.expiration > now); - if (currentToken) { - return currentToken; - } - const { data } = await this.authToken({ - client_id: systemApp.client_id, - client_secret: systemApp.client_secret, + return this.withAppsLock(async () => { + // Read after acquiring the lock; a concurrent issuer may have renewed. + const systemApp = await this.getDb({ name: 'system' }); + const now = Math.round(Date.now() / 1000); + let appToken = systemApp.tokens + ?.filter((token) => token.expiration > now) + .sort((left, right) => right.expiration - left.expiration)[0]; + if (renew || !appToken) { + const { data } = await this.issueToken({ + client_id: systemApp.client_id, + client_secret: systemApp.client_secret, + }); + appToken = { value: data.token, expiration: data.expiration }; + } else { + // Recover a prior database commit whose cache publication failed. + await shareStore.updateApps(await this.find({})); + } + const tokenFile = path.join(config.configPath, 'token.json'); + const temporaryFile = `${tokenFile}.${process.pid}.tmp`; + const mode = await stat(tokenFile).then( + (file) => file.mode & 0o777, + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return 0o600; + throw error; + }, + ); + try { + await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, { + mode, + }); + await chmod(temporaryFile, mode); + // Shell readers see a complete old or new document, never a partial one. + await rename(temporaryFile, tokenFile); + } finally { + await rm(temporaryFile, { force: true }); + } + return appToken; }); - return { - ...data, - value: data.token, - }; } } diff --git a/back/token.ts b/back/token.ts index d307c623..e4400ca3 100755 --- a/back/token.ts +++ b/back/token.ts @@ -2,30 +2,19 @@ import 'reflect-metadata'; import OpenService from './services/open'; import { Container } from 'typedi'; import LoggerInstance from './loaders/logger'; -import config from './config'; -import path from 'path'; -import os from 'os'; -import { writeFileWithLock } from './shared/utils'; - -const tokenFile = path.join(config.configPath, 'token.json'); async function getToken() { try { Container.set('logger', LoggerInstance); const openService = Container.get(OpenService); - const appToken = await openService.generateSystemToken(); + const appToken = await openService.generateSystemToken( + process.argv.includes('--renew'), + ); console.log(appToken.value); - await writeFile({ - value: appToken.value, - expiration: appToken.expiration, - }); } catch (error) { - console.log(error); + console.error(error); + process.exitCode = 1; } } -async function writeFile(data: any) { - await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`); -} - getToken(); diff --git a/shell/api.sh b/shell/api.sh index 2cf9a9ca..e4b1afe8 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -26,6 +26,28 @@ get_token() { create_token } +# Check the cached token before every authenticated API request. A rejected +# token is refreshed and retried once; other failures are never retried. +# Arguments are curl options, including the URL, method and optional body. +ql_api_request() { + local api="" + local attempt + get_token || return $? + for attempt in 1 2; do + api=$(curl -s --noproxy "*" \ + -H "Authorization: Bearer ${__ql_token__}" \ + -H "Content-Type: application/json;charset=UTF-8" \ + "$@" \ + --compressed) + if [[ "$attempt" == 2 || "$api" == '{"code":200}' ]] || + [[ "$(jq -r '.code' <<< "$api" 2>/dev/null)" != 401 ]]; then + break + fi + create_token || return $? + done + printf '%s' "$api" +} + # Read the status response once; preserve multiline error messages and the # legacy code/message variables used by the shell API callers. ql_parse_status_response() { @@ -63,13 +85,10 @@ add_cron_api() { sub_id="null" fi - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ + --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) if [[ $code == 200 ]]; then @@ -93,14 +112,10 @@ update_cron_api() { local id="$4" fi - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ - -X 'PUT' \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ + -X PUT \ + --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 @@ -120,14 +135,10 @@ update_cron_command_api() { local id="$2" fi - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ - -X 'PUT' \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ + -X PUT \ + --data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}") code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then @@ -140,14 +151,10 @@ update_cron_command_api() { del_cron_api() { local ids="$1" local currentTimeStamp=$(date +%s) - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ - -X 'DELETE' \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "[$ids]" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ + -X DELETE \ + --data-raw "[$ids]") code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then @@ -171,14 +178,10 @@ update_cron() { dataRaw="${dataRaw},\"exit_code\":$exitCode" fi dataRaw="${dataRaw}}" - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \ - -X 'PUT' \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "$dataRaw" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \ + -X PUT \ + --data-raw "$dataRaw") ql_parse_status_response "$api" || true if [[ $code != 200 ]]; then if [[ ! $message ]]; then @@ -192,14 +195,10 @@ notify_api() { local title="$1" local content="$2" local currentTimeStamp=$(date +%s) - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/notify?t=$currentTimeStamp" \ - -X 'PUT' \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/system/notify?t=$currentTimeStamp" \ + -X PUT \ + --data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}") code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then @@ -212,12 +211,9 @@ notify_api() { find_cron_api() { local params="$1" local currentTimeStamp=$(date +%s) - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \ + -X GET) data=$(echo "$api" | jq -r .data) if [[ $data == 'null' ]]; then echo -e "" @@ -231,14 +227,10 @@ update_auth_config() { local body="$1" local tip="$2" local currentTimeStamp=$(date +%s) - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ - -X 'PUT' \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{$body}" \ - --compressed - ) + local api=$(ql_api_request \ + "http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ + -X PUT \ + --data-raw "{$body}") code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then @@ -254,14 +246,10 @@ record_cron_stat() { local elapsed="${3:-0}" [[ $ref_id ]] && [[ $ref_id -gt 0 ]] 2>/dev/null || return - local api=$( - curl -s --noproxy "*" "http://localhost:${ql_port:-5700}/open/dashboard/record" \ + local api=$(ql_api_request \ + "http://localhost:${ql_port:-5700}/open/dashboard/record" \ -X POST \ - -H "Authorization: Bearer ${__ql_token__}" \ - -H "Content-Type: application/json;charset=UTF-8" \ - --data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}" \ - --compressed - ) + --data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}") ql_parse_status_response "$api" || true if [[ $code != 200 ]]; then if [[ ! $message ]]; then @@ -270,5 +258,3 @@ record_cron_stat() { echo -e "${message}" fi } - -get_token diff --git a/test/back/restart-instance-recovery.test.cjs b/test/back/restart-instance-recovery.test.cjs index a0e3c03d..ac0f78bd 100644 --- a/test/back/restart-instance-recovery.test.cjs +++ b/test/back/restart-instance-recovery.test.cjs @@ -27,7 +27,8 @@ test('startup closes interrupted instances without inventing exit codes or rewri const service = { set_envs: async () => {}, getAuthInfo: async () => ({}), - findApps: async () => [], + initializeSystemApp: async () => {}, + refreshApps: async () => {}, }; const initData = load('back/loaders/initData.ts', { typedi: { Container: { get: () => service } }, diff --git a/test/back/shell-api-parsing.test.cjs b/test/back/shell-api-parsing.test.cjs index 8c7960b4..c0e60061 100644 --- a/test/back/shell-api-parsing.test.cjs +++ b/test/back/shell-api-parsing.test.cjs @@ -91,13 +91,13 @@ test('expired, corrupt, missing or header-unsafe token cache refreshes under err assert.equal(r.stdout, 'generated:1'); } }); -test('success with a message still parses once and stays silent', (t) => { +test('success with a message checks authentication and stays silent', (t) => { const f = fixture(t); fs.writeFileSync(f.response, JSON.stringify({ code: 200, message: 'ok' })); const r = f.run('update_cron 1 0 123 log 1'); assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout, ''); - assert.equal(f.count(), 1); + assert.equal(f.count(), 2); }); test('status and statistics errors preserve multiline Unicode messages', (t) => { const f = fixture(t); @@ -113,7 +113,7 @@ test('status and statistics errors preserve multiline Unicode messages', (t) => assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout, '写入失败\n请重试 "原任务"\n'); } - assert.equal(f.count(), 2); + assert.equal(f.count(), 4); }); test('non-JSON errors fall back to raw text and absent messages retain legacy null', (t) => { const f = fixture(t); @@ -180,5 +180,5 @@ test('success-shaped invalid documents retain the raw-response error fallback', assert.equal(r.status, 0, r.stderr); assert.equal(r.stdout, body + '\n'); } - assert.equal(f.count(), cases.length); + assert.equal(f.count(), cases.length * 2); }); diff --git a/test/back/shellApiToken.test.cjs b/test/back/shellApiToken.test.cjs new file mode 100644 index 00000000..92b02f42 --- /dev/null +++ b/test/back/shellApiToken.test.cjs @@ -0,0 +1,151 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const apiFile = path.resolve(__dirname, '../../shell/api.sh'); + +const endpoints = { + status: { + method: 'PUT', url: /\/open\/crons\/status\?t=\d+$/, + body: { ids: ['37'], status: '1', pid: '4321', log_path: 'task/run.log', + last_execution_time: 1787004000, last_running_time: 90, exit_code: 0 }, + success: '', failure: (message) => message, + }, + statistics: { + method: 'POST', url: /\/open\/dashboard\/record$/, + body: { ref_id: 37, code: 0, elapsed: 90 }, + success: '', failure: (message) => message, + }, + add: { + method: 'POST', url: /\/open\/crons\?t=\d+$/, + body: { name: 'sample job', command: 'task job.py', schedule: '0 * * * *', sub_id: null }, + success: 'sample job -> 添加成功', failure: (message) => `sample job -> 添加失败(${message})`, + }, + update: { + method: 'PUT', url: /\/open\/crons\?t=\d+$/, + body: { name: 'sample job', command: 'task job.py', schedule: '0 * * * *', id: '37' }, + success: 'sample job -> 更新成功', failure: (message) => `sample job -> 更新失败(${message})`, + }, + command: { + method: 'PUT', url: /\/open\/crons\?t=\d+$/, + body: { command: 'task job.py', id: '37' }, + success: 'task job.py -> 更新成功', failure: (message) => `task job.py -> 更新失败(${message})`, + }, + delete: { + method: 'DELETE', url: /\/open\/crons\?t=\d+$/, + body: [37, 38], success: '成功', failure: (message) => `失败(${message})`, + }, + notify: { + method: 'PUT', url: /\/open\/system\/notify\?t=\d+$/, + body: { title: 'title', content: 'body' }, + success: '通知发送成功🎉', failure: (message) => `通知失败(${message})`, + }, + find: { + method: 'GET', url: /\/open\/crons\/detail\?log_path=task\/run.log&t=\d+$/, + body: null, success: 'sample job', failure: () => '', + }, + auth: { + method: 'PUT', url: /\/open\/system\/auth\/reset\?t=\d+$/, + body: { retries: 0 }, success: '重置登录错误次数成功🎉', + failure: (message) => `重置登录错误次数失败(${message})`, + }, +}; + +function runCallback(endpoint, scenario) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-token-')); + try { + const tokenFile = path.join(directory, 'token.json'); + fs.writeFileSync(tokenFile, JSON.stringify({ + value: 'initial-token', expiration: Math.floor(Date.now() / 1000) + 3600, + })); + const child = spawnSync('bash', ['-c', ` + . "$API_FILE" + create_token() { + printf 'refresh\\n' >> "$CAPTURE" + __ql_token__=fresh-token + printf '{"value":"fresh-token","expiration":4102444800}' > "$file_auth_token" + } + case "$SCENARIO" in + expired) printf '{"value":"initial-token","expiration":1}' > "$file_auth_token" ;; + replaced) printf '{"value":"fresh-token","expiration":4102444800}' > "$file_auth_token" ;; + esac + curl() { + local auth body url method arg + while [[ $# -gt 0 ]]; do + arg="$1"; shift + case "$arg" in + -H) [[ "$1" == Authorization:* ]] && auth="$1"; shift ;; + --data-raw) body="$1"; shift ;; + -X) method="$1"; shift ;; + http:*) url="$arg" ;; + esac + done + printf '%s\\n%s\\n%s\\n%s\\n' "$auth" "$method" "$url" "\${body:-null}" >> "$CAPTURE" + if [[ "$SCENARIO" == forbidden ]]; then + printf '{"code":403,"message":"forbidden"}' + elif [[ "$SCENARIO" == unavailable ]]; then + printf '{"code":500,"message":"unavailable"}' + elif [[ "$SCENARIO" == persistent || ( "$SCENARIO" != valid && "$auth" != 'Authorization: Bearer fresh-token' ) ]]; then + printf '{"code":401,"message":"Token 已失效"}' + else + printf '{"code":200,"data":{"name":"sample job"}}' + fi + } + t() { printf "$@"; } + case "$ENDPOINT" in + status) update_cron '"37"' 1 4321 'task/run.log' 1787004000 90 0 execution-37 ;; + statistics) record_cron_stat 37 0 90 ;; + add) add_cron_api '0 * * * *' 'task job.py' 'sample job' ;; + update) update_cron_api '0 * * * *' 'task job.py' 'sample job' 37 ;; + command) update_cron_command_api 'task job.py' 37 ;; + delete) del_cron_api '37,38' ;; + notify) notify_api title body ;; + find) find_cron_api 'log_path=task/run.log' ;; + auth) update_auth_config '"retries":0' '重置登录错误次数' ;; + esac + `], { + env: { ...process.env, PATH: `/usr/bin:/bin:${process.env.PATH}`, API_FILE: apiFile, file_auth_token: tokenFile, + CAPTURE: path.join(directory, 'requests'), SCENARIO: scenario, + ENDPOINT: endpoint, ql_port: '5700' }, + encoding: 'utf8', timeout: 10_000, + }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stderr, ''); + const lines = fs.readFileSync(path.join(directory, 'requests'), 'utf8').trim().split('\n'); + const refreshes = lines.filter((line) => line === 'refresh').length; + const requests = lines.filter((line) => line !== 'refresh'); + const captures = []; + for (let i = 0; i < requests.length; i += 4) { + captures.push({ auth: requests[i], method: requests[i + 1], + url: requests[i + 2], body: JSON.parse(requests[i + 3]) }); + } + return { output: child.stdout, refreshes, captures }; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +for (const [endpoint, expected] of Object.entries(endpoints)) { + for (const scenario of ['valid', 'expired', 'replaced', 'rejected', 'persistent', 'forbidden', 'unavailable']) { + test(`${endpoint} API handles ${scenario} token without unbounded retries`, () => { + const { output, refreshes, captures } = runCallback(endpoint, scenario); + const retries = ['rejected', 'persistent'].includes(scenario); + assert.equal(captures.length, retries ? 2 : 1); + assert.equal(refreshes, ['expired', 'rejected', 'persistent'].includes(scenario) ? 1 : 0); + for (const capture of captures) { + assert.deepEqual(capture.body, expected.body); + assert.equal(capture.method, expected.method); + assert.match(capture.url, expected.url); + } + if (['expired', 'replaced', 'rejected'].includes(scenario)) { + assert.equal(captures.at(-1).auth, 'Authorization: Bearer fresh-token'); + } + const error = scenario === 'persistent' ? 'Token 已失效' + : scenario === 'forbidden' ? 'forbidden' : scenario === 'unavailable' ? 'unavailable' : ''; + assert.equal(output.trim(), error ? expected.failure(error) : expected.success); + }); + } +} diff --git a/test/back/systemTokenRenewal.test.cjs b/test/back/systemTokenRenewal.test.cjs new file mode 100644 index 00000000..ad185674 --- /dev/null +++ b/test/back/systemTokenRenewal.test.cjs @@ -0,0 +1,497 @@ +require('reflect-metadata'); +require('ts-node/register/transpile-only'); + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const environment = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-env-')); +fs.writeFileSync(path.join(environment, '.env'), ''); +fs.mkdirSync(path.join(environment, 'data/db'), { recursive: true }); +process.env.QL_DIR = environment; +process.env.QL_DATA_DIR = path.join(environment, 'data'); +require('node:test').after(async () => { + await require('../../back/shared/store').keyvStore.disconnect(); + await require('../../back/data').sequelize.close(); + fs.rmSync(environment, { recursive: true, force: true }); +}); +const OpenService = require('../../back/services/open').default; +const { AppModel } = require('../../back/data/open'); +const { shareStore } = require('../../back/shared/store'); +const config = require('../../back/config').default; + +const DAY = 86400; +const START = 1787004000; +const execute = promisify(execFile); + +function fixture(t, initialTokens = []) { + const previousConfigPath = config.configPath; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-renewal-')); + config.configPath = directory; + t.after(() => { + config.configPath = previousConfigPath; + fs.rmSync(directory, { recursive: true, force: true }); + }); + let now = START; + let app = { + name: 'system', client_id: 'system-client', client_secret: 'system-secret', + scopes: ['crons', 'system', 'dashboard'], tokens: initialTokens, + }; + t.mock.method(Date, 'now', () => now * 1000); + t.mock.method(shareStore, 'getApps', async () => [app]); + t.mock.method(shareStore, 'updateApps', async (apps) => { [app] = apps; }); + t.mock.method(AppModel, 'findOne', async ({ where }) => { + if (where.name) assert.equal(where.name, app.name); + else { + assert.equal(where.client_id, app.client_id); + assert.equal(where.client_secret, app.client_secret); + } + return { tokens: app.tokens, get: () => app }; + }); + t.mock.method(AppModel, 'update', async ({ tokens }) => { + app = { ...app, tokens }; + return [1]; + }); + t.mock.method(AppModel, 'findAll', async () => [{ get: () => app }]); + return { + service: new OpenService({}), + atDay(day) { now = START + day * DAY; }, + tokens() { return app.tokens; }, + }; +} + +test('28-day token generation renews ahead of the 30-day expiry', async (t) => { + const state = fixture(t); + const initial = await state.service.generateSystemToken(); + assert.equal(initial.expiration, START + 30 * DAY); + state.atDay(28); + const renewed = await state.service.generateSystemToken(true); + assert.equal(renewed.expiration, START + 58 * DAY); + assert.notEqual(renewed.value, initial.value); + assert.ok(state.tokens().some((token) => token.value === initial.value)); + state.atDay(56); + const next = await state.service.generateSystemToken(true); + assert.equal(next.expiration, START + 86 * DAY); + assert.ok(state.tokens().some((token) => token.value === renewed.value)); + assert.ok(!state.tokens().some((token) => token.value === initial.value)); +}); + +test('startup generation renews before a restarted 28-day interval', async (t) => { + const state = fixture(t); + await state.service.generateSystemToken(); + state.atDay(20); + const restarted = await state.service.generateSystemToken(true); + assert.equal(restarted.expiration, START + 50 * DAY); + state.atDay(48); + const renewed = await state.service.generateSystemToken(true); + assert.equal(renewed.expiration, START + 78 * DAY); + assert.ok(state.tokens().some((token) => token.value === restarted.value)); +}); + +test('generation at the five-token limit extends the reused token without revoking active tokens', async (t) => { + const initial = Array.from({ length: 5 }, (_, index) => ({ + value: `token-${index}`, expiration: START + 2 * DAY, + })); + const state = fixture(t, initial); + const renewed = await state.service.generateSystemToken(true); + assert.equal(renewed.value, 'token-4'); + assert.equal(renewed.expiration, START + 30 * DAY); + assert.equal(state.tokens().length, 5); + for (const token of initial) { + assert.ok(state.tokens().some((current) => current.value === token.value)); + } +}); + +test('ordinary acquisition reuses the newest valid database token and republishes its cache', async (t) => { + const initial = [ + { value: 'older-token', expiration: START + DAY }, + { value: 'newer-token', expiration: START + 20 * DAY }, + ]; + const state = fixture(t, initial); + // A stale process cache must not override the token chosen from the database. + shareStore.getApps = async () => [{ name: 'system', tokens: [] }]; + const token = await state.service.generateSystemToken(); + assert.equal(token.value, 'newer-token'); + assert.deepEqual(state.tokens(), initial); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(config.configPath, 'token.json'), 'utf8')), + token, + ); +}); + +test('a failed generation releases its lock and does not publish a token', async (t) => { + const state = fixture(t); + const update = AppModel.update; + AppModel.update = async () => { throw new Error('generation failed'); }; + await assert.rejects(state.service.generateSystemToken(), /generation failed/); + assert.equal(fs.existsSync(path.join(config.configPath, 'token.json')), false); + assert.equal(fs.existsSync(path.join(config.configPath, 'token.json.refresh.lock')), false); + AppModel.update = update; + const token = await state.service.generateSystemToken(); + assert.ok(state.tokens().some((current) => current.value === token.value)); +}); + +test('acquisition recovers an authentication cache failure after the database commit', async (t) => { + const state = fixture(t); + const updateCache = shareStore.updateApps; + let attempts = 0; + shareStore.updateApps = async (apps) => { + if (++attempts === 1) throw new Error('authentication cache failed'); + return updateCache(apps); + }; + await assert.rejects(state.service.generateSystemToken(), /authentication cache failed/); + assert.equal(fs.existsSync(path.join(config.configPath, 'token.json')), false); + const committed = state.tokens()[0]; + const token = await state.service.generateSystemToken(); + assert.equal(token.value, committed.value); + assert.equal(state.tokens().length, 1); + assert.equal(attempts, 2); +}); + +async function processFixture(t, initialTokens = []) { + const root = path.resolve(__dirname, '../..'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-process-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const databaseFile = path.join(directory, 'database.json'); + fs.writeFileSync(databaseFile, JSON.stringify({ + name: 'system', client_id: 'system-client', client_secret: 'system-secret', + scopes: ['crons', 'system', 'dashboard'], tokens: initialTokens, + })); + const worker = path.join(directory, 'worker.cjs'); + fs.writeFileSync(worker, ` + const fs = require('node:fs/promises'); + const path = require('node:path'); + const root = process.cwd(); + const directory = __dirname; + const config = require(path.join(root, 'back/config')).default; + const { AppModel } = require(path.join(root, 'back/data/open')); + const { shareStore } = require(path.join(root, 'back/shared/store')); + config.configPath = directory; + const databaseFile = path.join(directory, 'database.json'); + const read = async () => JSON.parse(await fs.readFile(databaseFile, 'utf8')); + AppModel.findOne = async () => { + if (process.env.QL_TOKEN_TEST_HOLD === '1') { + await fs.writeFile(path.join(directory, 'locked'), 'ready'); + await new Promise(() => setInterval(() => {}, 1000)); + } + const app = await read(); + // Widen the read/write overlap so the original race is reproducible. + await new Promise(resolve => setTimeout(resolve, 75)); + return { ...app, get: () => app }; + }; + AppModel.update = async ({ tokens }) => { + if (process.env.QL_TOKEN_TEST_FAIL === '1') throw new Error('generation failed'); + const app = await read(); + await fs.writeFile(databaseFile, JSON.stringify({ ...app, tokens })); + return [1]; + }; + AppModel.findAll = async () => { + const app = await read(); + return [{ get: () => app }]; + }; + shareStore.getApps = async () => { throw new Error('stale cache must not be read'); }; + shareStore.updateApps = async () => {}; + require(path.join(root, 'back/token')); + `); + return { + directory, + run(renew = false, fail = false, hold = false) { + return execute(process.execPath, [ + '-r', require.resolve('ts-node/register/transpile-only'), worker, + ...(renew ? ['--renew'] : []), + ], { + cwd: root, timeout: 20_000, + env: { ...process.env, QL_TOKEN_TEST_FAIL: fail ? '1' : '0', QL_TOKEN_TEST_HOLD: hold ? '1' : '0' }, + }); + }, + tokens() { return JSON.parse(fs.readFileSync(databaseFile, 'utf8')).tokens; }, + cached() { return JSON.parse(fs.readFileSync(path.join(directory, 'token.json'), 'utf8')); }, + }; +} + +test('four independent token processes converge on one usable token when the cache is missing', async (t) => { + const state = await processFixture(t); + const results = await Promise.all(Array.from({ length: 4 }, () => state.run())); + const issued = results.map(({ stdout, stderr }) => { + assert.equal(stderr, ''); + return stdout.trim(); + }); + assert.equal(new Set(issued).size, 1); + assert.equal(state.tokens().length, 1); + assert.equal(state.tokens()[0].value, issued[0]); + assert.equal(state.cached().value, issued[0]); + assert.equal(fs.existsSync(path.join(state.directory, 'token.json.refresh.lock')), false); +}); + +test('concurrent scheduled renewal and ordinary acquisition preserve all returned tokens', async (t) => { + const original = { value: 'original-token', expiration: Math.round(Date.now() / 1000) + DAY }; + const state = await processFixture(t, [original]); + const results = await Promise.all([state.run(true), state.run(), state.run(true), state.run()]); + const tokens = state.tokens(); + assert.equal(tokens.length, 3); + assert.ok(tokens.some((token) => token.value === original.value)); + for (const { stdout, stderr } of results) { + assert.equal(stderr, ''); + assert.ok(tokens.some((token) => token.value === stdout.trim())); + } + assert.ok(tokens.some((token) => token.value === state.cached().value)); +}); + +test('token CLI reports generation failure on stderr with a nonzero exit code', async (t) => { + const state = await processFixture(t); + await assert.rejects(state.run(false, true), (error) => { + assert.equal(error.code, 1); + assert.equal(error.stdout, ''); + assert.match(error.stderr, /generation failed/); + return true; + }); + assert.equal(fs.existsSync(path.join(state.directory, 'token.json')), false); + const recovered = await state.run(); + assert.equal(recovered.stdout.trim(), state.cached().value); +}); + +test('startup configures the 28-day token task with explicit renewal', async (t) => { + const { Container } = require('typedi'); + const initialize = require('../../back/loaders/initTask').default; + const SystemService = require('../../back/services/system').default; + const ScheduleService = require('../../back/services/schedule').default; + const SubscriptionService = require('../../back/services/subscription').default; + const SshKeyService = require('../../back/services/sshKey').default; + const scheduled = []; + const services = new Map([ + [SystemService, { getSystemConfig: async () => undefined }], + [ScheduleService, { + cancelIntervalTask: async () => {}, + createIntervalTask: (...args) => scheduled.push(args), + }], + [SubscriptionService, { setSshConfig: async () => {}, list: async () => [] }], + [SshKeyService, {}], + ]); + t.mock.method(Container, 'get', (service) => { + assert.ok(services.has(service)); + return services.get(service); + }); + await initialize(); + assert.equal(scheduled.length, 1); + const [task, interval, immediate] = scheduled[0]; + assert.match(task.command, /(?:token\.ts|token\.js) --renew$/); + assert.equal(task.runOrigin, 'system'); + assert.deepEqual(interval, { days: 28 }); + assert.equal(immediate, true); +}); + +function appsFixture(t) { + const previousConfigPath = config.configPath; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-apps-concurrency-')); + config.configPath = directory; + t.after(() => { + config.configPath = previousConfigPath; + fs.rmSync(directory, { recursive: true, force: true }); + }); + t.mock.method(Date, 'now', () => START * 1000); + const clone = (value) => JSON.parse(JSON.stringify(value)); + let apps = [ + { id: 1, name: 'system', client_id: 'system-client', client_secret: 'system-secret', + scopes: ['crons', 'system', 'dashboard'], tokens: [{ value: 'system-token', expiration: START + DAY }] }, + { id: 2, name: 'external', client_id: 'external-client', client_secret: 'external-secret', + scopes: ['crons'], tokens: [] }, + ]; + let cached = clone(apps); + const matches = (app, where) => Object.entries(where).every(([key, value]) => + Array.isArray(value) ? value.includes(app[key]) : app[key] === value); + t.mock.method(AppModel, 'findOne', async ({ where }) => { + const app = apps.find((app) => matches(app, where)); + if (!app) return null; + const snapshot = clone(app); + await new Promise((resolve) => setTimeout(resolve, 10)); + return { ...snapshot, get: () => snapshot }; + }); + t.mock.method(AppModel, 'findAll', async () => clone(apps).map((app) => ({ get: () => app }))); + t.mock.method(AppModel, 'update', async (values, { where }) => { + apps = apps.map((app) => matches(app, where) ? { ...app, ...clone(values) } : app); + return [1]; + }); + t.mock.method(AppModel, 'destroy', async ({ where }) => { + apps = apps.filter((app) => !matches(app, where)); + }); + t.mock.method(AppModel, 'create', async (values) => { + const app = { id: Math.max(0, ...apps.map((app) => app.id)) + 1, tokens: [], ...values }; + apps.push(app); + return { get: () => clone(app) }; + }); + t.mock.method(shareStore, 'updateApps', async (values) => { cached = clone(values); }); + t.mock.method(shareStore, 'getApps', async () => clone(cached)); + return { service: new OpenService({}), apps: () => clone(apps), cached: () => clone(cached) }; +} + +for (const operation of ['acquisition', 'startup refresh']) { + test(`${operation} cannot overwrite another application's newly issued token`, async (t) => { + const state = appsFixture(t); + let resume; + let entered; + const paused = new Promise((resolve) => { entered = resolve; }); + const barrier = new Promise((resolve) => { resume = resolve; }); + const findAll = AppModel.findAll; + let first = true; + AppModel.findAll = async (...args) => { + const snapshot = await findAll(...args); + if (first) { + first = false; + entered(); + await barrier; + } + return snapshot; + }; + const publication = operation === 'acquisition' + ? state.service.generateSystemToken() : state.service.refreshApps(); + await paused; + const issuance = state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + resume(); + const [, response] = await Promise.all([publication, issuance]); + assert.equal(response.code, 200); + assert.ok(state.apps()[1].tokens.some((token) => token.value === response.data.token)); + assert.deepEqual(state.cached(), state.apps()); + }); +} + +test('concurrent token API calls and scheduled generation preserve every issued token', async (t) => { + const state = appsFixture(t); + const credentials = { client_id: 'system-client', client_secret: 'system-secret' }; + const results = await Promise.all([ + state.service.authToken(credentials), state.service.generateSystemToken(true), + state.service.authToken(credentials), state.service.generateSystemToken(true), + ]); + const tokens = state.apps()[0].tokens; + assert.equal(tokens.length, 5); + for (const response of results) { + assert.ok(tokens.some((token) => token.value === (response.value || response.data.token))); + } + assert.deepEqual(state.cached(), state.apps()); +}); + +for (const mutation of ['resetSecret', 'remove', 'update', 'create']) { + test(`concurrent cache refresh and ${mutation} retain the latest application state`, async (t) => { + const state = appsFixture(t); + await state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' }); + const change = () => mutation === 'resetSecret' ? state.service.resetSecret(2) + : mutation === 'remove' ? state.service.remove([2]) + : mutation === 'update' ? state.service.update({ id: 2, name: 'external', scopes: ['dashboard'] }) + : state.service.create({ name: 'created', scopes: ['crons'] }); + await Promise.all([state.service.refreshApps(), change(), state.service.generateSystemToken()]); + assert.deepEqual(state.cached(), state.apps()); + if (mutation === 'resetSecret') { + assert.equal(state.cached()[1].tokens.length, 0); + assert.notEqual(state.cached()[1].client_secret, 'external-secret'); + assert.equal((await state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' })).code, 400); + } else if (mutation === 'remove') { + assert.equal(state.cached().length, 1); + } else if (mutation === 'update') { + assert.deepEqual(state.cached()[1].scopes, ['dashboard']); + } else { + assert.equal(state.cached().length, 3); + } + }); +} + +test('system initialization preserves active tokens while migrating dashboard scope', async (t) => { + const state = appsFixture(t); + await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } }); + await Promise.all([state.service.initializeSystemApp(), state.service.generateSystemToken(true)]); + assert.deepEqual(state.cached(), state.apps()); + assert.deepEqual(state.apps()[0].scopes, ['crons', 'system', 'dashboard']); + assert.equal(state.apps()[0].tokens.length, 2); +}); + +test('failed atomic token-file publication releases the application lock for recovery', async (t) => { + const state = appsFixture(t); + const temporary = path.join(config.configPath, `token.json.${process.pid}.tmp`); + fs.mkdirSync(temporary); + await assert.rejects(state.service.generateSystemToken(), /EISDIR/); + fs.rmSync(temporary, { recursive: true }); + const token = await state.service.generateSystemToken(); + assert.equal(token.value, 'system-token'); + assert.equal(fs.existsSync(path.join(config.configPath, '.apps-state.lock')), false); +}); + +test('real SQLite and authentication cache retain concurrent process updates', async (t) => { + fs.mkdirSync(config.configPath, { recursive: true }); + await AppModel.sync({ force: true }); + const service = new OpenService({}); + await service.initializeSystemApp(); + const external = await service.create({ name: 'sqlite-external', scopes: ['crons'] }); + const worker = path.join(environment, 'sqlite-worker.cjs'); + const root = path.resolve(__dirname, '../..'); + fs.writeFileSync(worker, ` + require(${JSON.stringify(require.resolve('reflect-metadata'))}); + const path = require('node:path'); + const root = process.cwd(); + const OpenService = require(path.join(root, 'back/services/open')).default; + const { shareStore, keyvStore } = require(path.join(root, 'back/shared/store')); + const { sequelize } = require(path.join(root, 'back/data')); + const service = new OpenService({}); + (async () => { + const action = process.argv[2]; + const result = action === 'auth' + ? await service.authToken({ client_id: process.argv[3], client_secret: process.argv[4] }) + : action === 'refresh' ? await service.refreshApps() + : await service.generateSystemToken(action === 'renew'); + console.log(JSON.stringify(result || {})); + })().catch(error => { console.error(error); process.exitCode = 1; }) + .finally(async () => { await keyvStore.disconnect(); await sequelize.close(); }); + `); + const actions = ['renew', 'acquire', 'auth', 'refresh', 'renew', 'auth', 'auth']; + const outcomes = await Promise.allSettled(actions.map((action) => execute(process.execPath, [ + '-r', require.resolve('ts-node/register/transpile-only'), worker, action, + external.client_id, external.client_secret, + ], { cwd: root, timeout: 20_000, env: process.env }))); + const results = outcomes.map((outcome) => { + assert.equal(outcome.status, 'fulfilled', outcome.reason?.stderr); + return outcome.value; + }); + const database = await service.findApps(); + const cached = await shareStore.getApps(); + assert.deepEqual(cached, JSON.parse(JSON.stringify(database))); + for (let index = 0; index < results.length; index++) { + assert.equal(results[index].stderr, ''); + const result = JSON.parse(results[index].stdout.trim()); + const value = result.value || result.data?.token; + if (value) assert.ok(cached.some((app) => app.tokens?.some((token) => token.value === value))); + } + assert.equal(database.find((app) => app.name === 'sqlite-external').tokens.length, 3); + const file = JSON.parse(fs.readFileSync(path.join(config.configPath, 'token.json'), 'utf8')); + assert.ok(cached.find((app) => app.name === 'system').tokens.some((token) => token.value === file.value)); +}); + + +test('atomic publication preserves restricted token-file permissions', async (t) => { + const state = fixture(t, [{ value: 'current', expiration: START + DAY }]); + const file = path.join(config.configPath, 'token.json'); + await state.service.generateSystemToken(); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + fs.chmodSync(file, 0o640); + await state.service.generateSystemToken(true); + assert.equal(fs.statSync(file).mode & 0o777, 0o640); +}); + + +test('a SIGKILLed generator leaves a lock that the next acquisition recovers', async (t) => { + const state = await processFixture(t); + const running = state.run(false, false, true); + const stopped = running.catch((error) => error); + const deadline = Date.now() + 10_000; + while (!fs.existsSync(path.join(state.directory, 'locked'))) { + assert.ok(Date.now() < deadline, 'generator did not acquire the lock'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + running.child.kill('SIGKILL'); + assert.equal((await stopped).signal, 'SIGKILL'); + assert.equal(fs.existsSync(path.join(state.directory, '.apps-state.lock')), true); + const recovered = await state.run(); + assert.equal(recovered.stderr, ''); + assert.equal(recovered.stdout.trim(), state.cached().value); + assert.equal(fs.existsSync(path.join(state.directory, '.apps-state.lock')), false); +}); From b06cb72334e2cc3047a109e2b888d39c9d0373b4 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 11 Oct 2026 02:09:20 +0800 Subject: [PATCH 121/123] fix: apply minimal production dependency security patches --- package.json | 12 ++++++---- pnpm-lock.yaml | 62 +++++++++++++++++++++++++++----------------------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 89ab8715..1c96e5b6 100644 --- a/package.json +++ b/package.json @@ -74,20 +74,22 @@ "overrides": { "sqlite3": "npm:@whyour/sqlite3@1.1.2", "@codemirror/state": "6.5.4", - "@codemirror/view": "6.39.16" + "@codemirror/view": "6.39.16", + "proxy-addr": "2.0.8", + "websocket-driver": "0.7.5" } }, "dependencies": { "@ant-design/plots": "^2.6.8", "@bufbuild/protobuf": "^2.10.0", - "@grpc/grpc-js": "^1.14.0", + "@grpc/grpc-js": "1.14.5", "@grpc/proto-loader": "^0.8.0", "@keyv/sqlite": "^4.0.1", "@otplib/preset-default": "^12.0.1", "body-parser": "^1.20.3", "celebrate": "^15.0.3", "chokidar": "^4.0.1", - "compression": "^1.7.4", + "compression": "1.8.2", "cors": "^2.8.5", "cron-parser": "^5.4.0", "cross-spawn": "^7.0.6", @@ -106,7 +108,7 @@ "jsonwebtoken": "^9.0.2", "keyv": "^5.2.3", "lodash": "^4.17.21", - "multer": "2.1.1", + "multer": "2.3.0", "node-cron": "4.6.0", "nodemailer": "^8.0.1", "p-queue-cjs": "7.3.4", @@ -114,7 +116,7 @@ "ps-tree": "^1.2.0", "reflect-metadata": "^0.2.2", "request-ip": "3.3.0", - "sequelize": "^6.37.5", + "sequelize": "6.37.8", "sockjs": "^0.3.24", "sqlite3": "npm:@whyour/sqlite3@1.1.2", "toad-scheduler": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 543d0090..3e426f08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,8 @@ overrides: sqlite3: npm:@whyour/sqlite3@1.1.2 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 + proxy-addr: 2.0.8 + websocket-driver: 0.7.5 dependencies: '@ant-design/plots': @@ -13,8 +15,8 @@ dependencies: specifier: ^2.10.0 version: 2.10.0 '@grpc/grpc-js': - specifier: ^1.14.0 - version: 1.14.0 + specifier: 1.14.5 + version: 1.14.5 '@grpc/proto-loader': specifier: ^0.8.0 version: 0.8.0 @@ -34,8 +36,8 @@ dependencies: specifier: ^4.0.1 version: 4.0.1 compression: - specifier: ^1.7.4 - version: 1.7.5 + specifier: 1.8.2 + version: 1.8.2 cors: specifier: ^2.8.5 version: 2.8.5 @@ -91,8 +93,8 @@ dependencies: specifier: ^4.17.21 version: 4.17.21 multer: - specifier: 2.1.1 - version: 2.1.1 + specifier: 2.3.0 + version: 2.3.0 node-cron: specifier: 4.6.0 version: 4.6.0 @@ -115,8 +117,8 @@ dependencies: specifier: 3.3.0 version: 3.3.0 sequelize: - specifier: ^6.37.5 - version: 6.37.5(@whyour/sqlite3@1.1.2) + specifier: 6.37.8 + version: 6.37.8(@whyour/sqlite3@1.1.2) sockjs: specifier: ^0.3.24 version: 0.3.24 @@ -2962,8 +2964,8 @@ packages: deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package dev: true - /@grpc/grpc-js@1.14.0: - resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} + /@grpc/grpc-js@1.14.5: + resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==} engines: {node: '>=12.10.0'} dependencies: '@grpc/proto-loader': 0.8.0 @@ -4940,7 +4942,7 @@ packages: '@umijs/bundler-utils': 4.4.4 '@umijs/mako': 0.11.1 chalk: 4.1.2 - compression: 1.7.5 + compression: 1.8.2 connect-history-api-fallback: 2.0.0 cors: 2.8.5 express: 4.21.2 @@ -6828,15 +6830,16 @@ packages: serialize-javascript: 6.0.2 dev: true - /compression@1.7.5: - resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} + /compression@1.8.2: + resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} engines: {node: '>= 0.8.0'} dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 + destroy: 1.2.0 negotiator: 0.6.4 - on-headers: 1.0.2 + on-headers: 1.1.0 safe-buffer: 5.2.1 vary: 1.1.2 transitivePeerDependencies: @@ -7731,6 +7734,7 @@ packages: /dottie@2.0.6: resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. dev: false /dprint-node@1.0.8: @@ -8527,7 +8531,7 @@ packages: on-finished: 2.4.1 parseurl: 1.3.3 path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.13.0 range-parser: 1.2.1 safe-buffer: 5.2.1 @@ -8611,7 +8615,7 @@ packages: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} dependencies: - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 /fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -10751,8 +10755,8 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /multer@2.1.1: - resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + /multer@2.3.0: + resolution: {integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==} engines: {node: '>= 10.16.0'} dependencies: append-field: 1.0.0 @@ -11140,8 +11144,8 @@ packages: dependencies: ee-first: 1.1.1 - /on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + /on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} /once@1.4.0: @@ -12177,8 +12181,8 @@ packages: long: 5.2.3 dev: false - /proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + /proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} dependencies: forwarded: 0.2.0 @@ -13910,8 +13914,8 @@ packages: engines: {node: '>= 10.0.0'} dev: false - /sequelize@6.37.5(@whyour/sqlite3@1.1.2): - resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==} + /sequelize@6.37.8(@whyour/sqlite3@1.1.2): + resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==} engines: {node: '>=10.0.0'} peerDependencies: ibm_db: '*' @@ -13945,7 +13949,7 @@ packages: dependencies: '@types/debug': 4.1.12 '@types/validator': 13.12.2 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.4.3 dottie: 2.0.6 inflection: 1.13.4 lodash: 4.17.21 @@ -13953,7 +13957,7 @@ packages: moment-timezone: 0.5.46 pg-connection-string: 2.7.0 retry-as-promised: 7.0.4 - semver: 7.6.3 + semver: 7.7.4 sequelize-pool: 7.1.0 sqlite3: /@whyour/sqlite3@1.1.2 toposort-class: 1.0.1 @@ -14123,7 +14127,7 @@ packages: dependencies: faye-websocket: 0.11.4 uuid: 8.3.2 - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 dev: false /sonic-boom@2.8.0: @@ -15528,8 +15532,8 @@ packages: javascript-stringify: 2.1.0 dev: true - /websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + /websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} engines: {node: '>=0.8.0'} dependencies: http-parser-js: 0.5.8 From 331f4f9138a0ea978361ac7898ef02c6fc434a1a Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 11 Oct 2026 12:24:50 +0800 Subject: [PATCH 122/123] Prepare Qinglong 2.23.0 and CLI 0.1.2 release (#3090) * fix: patch reachable release-blocking dependencies * docs: keep release risk notes in PR description * chore: prepare Qinglong 2.23.0 and CLI 0.1.2 release metadata --- .github/workflows/release-readiness.yml | 136 ++++++++++++++++++++++++ cli/package-lock.json | 4 +- cli/package.json | 2 +- docs/releases/2.23.0.md | 33 ++++++ package.json | 2 +- version.yaml | 35 +++--- 6 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/release-readiness.yml create mode 100644 docs/releases/2.23.0.md diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml new file mode 100644 index 00000000..fcb6784c --- /dev/null +++ b/.github/workflows/release-readiness.yml @@ -0,0 +1,136 @@ +name: Release readiness + +on: + pull_request: + paths: + - '.github/workflows/release-readiness.yml' + - 'package.json' + - 'pnpm-lock.yaml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-readiness-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + regression: + uses: ./.github/workflows/validate.yml + + static: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build:front && pnpm build:back && pnpm build:info + - uses: actions/upload-artifact@v6 + with: + name: release-readiness-static + path: static/ + include-hidden-files: true + if-no-files-found: error + + images: + needs: [regression, static] + runs-on: ubuntu-22.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - image: alpine + file: docker/Dockerfile + cache: alpine + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 + - image: debian + file: docker/Dockerfile.debian + cache: debian + platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + - image: alpine-python3.10 + file: docker/Dockerfile.310 + cache: alpine-python3.10 + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 + - image: debian-python3.10 + file: docker/Dockerfile.debian310 + cache: debian-python3.10 + platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Record source + id: source + env: + SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }} + SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT" + echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT" + - uses: actions/download-artifact@v7 + with: + name: release-readiness-static + path: static/ + - uses: docker/setup-qemu-action@v4 + with: + cache-image: false + - uses: docker/setup-buildx-action@v4 + - name: Build every release architecture + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.file }} + platforms: ${{ matrix.platforms }} + build-args: | + QL_URL=${{ steps.source.outputs.url }} + QL_BRANCH=${{ steps.source.outputs.branch }} + SOURCE_COMMIT=${{ steps.source.outputs.sha }} + push: false + provenance: false + sbom: false + cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }} + - name: Load native image for runtime verification + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.file }} + platforms: linux/amd64 + build-args: | + QL_URL=${{ steps.source.outputs.url }} + QL_BRANCH=${{ steps.source.outputs.branch }} + SOURCE_COMMIT=${{ steps.source.outputs.sha }} + tags: qinglong-readiness:${{ matrix.image }} + load: true + push: false + provenance: false + sbom: false + - name: Verify native production runtime without external networking + 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.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: | + docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz + sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256 + docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json + - uses: actions/upload-artifact@v6 + if: matrix.image == 'debian' + with: + name: release-readiness-debian-image + path: release-readiness-debian-image.* + compression-level: 0 + if-no-files-found: error diff --git a/cli/package-lock.json b/cli/package-lock.json index 8c1f3011..1f8e5034 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@whyour/qinglong-cli", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@whyour/qinglong-cli", - "version": "0.1.1", + "version": "0.1.2", "bin": { "ql": "dist/npm/ql.js" }, diff --git a/cli/package.json b/cli/package.json index 46ab12bd..7185a111 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@whyour/qinglong-cli", - "version": "0.1.1", + "version": "0.1.2", "description": "Authenticated remote management CLI for QingLong 2.x", "type": "commonjs", "bin": { diff --git a/docs/releases/2.23.0.md b/docs/releases/2.23.0.md new file mode 100644 index 00000000..fe77872a --- /dev/null +++ b/docs/releases/2.23.0.md @@ -0,0 +1,33 @@ +# 青龙 2.23.0 + +## 新增功能 + +- 脚本历史版本:查看、对比和恢复历史内容;每个脚本保留最多 20 个版本,支持不超过 1 MiB 的 UTF-8 文本脚本。恢复前校验当前内容哈希,发生并发修改时返回冲突,避免覆盖新内容。 +- 独立远程 CLI 0.1.2 增加脚本历史查看、对比和恢复命令,并完善资源冲突及认证错误反馈。最低要求仍为 Node.js 22.12。 + +## 修复与优化 + +- 普通任务、额外规则和订阅的 Node 调度路径迁移到 node-cron。保留旧规则标准化、日期/星期 OR、L/# 等兼容行为,补充 `@annually`、`@midnight`、`@minutely`。 +- 禁用、删除或修改任务后,旧规则对应的排队执行不会再启动。完善任务进程超时、停止回收、Shell before/after 环境传递和退出状态。 +- 修复系统令牌未主动续期和认证缓存并发更新:定时生成与启动后重新计时会主动续期,避免长期运行后失效。Shell API 每次请求检查令牌,遇到 401 时刷新并重试一次;其他错误不自动重试。 +- 降低任务启动开销;JS/Python 的 QLAPI 通知依赖在首次通知调用时加载,减少不发送通知的任务启动成本。 +- 加固环境变量名和字面值、订阅参数、路径访问及 SSH 配置输入;保留合法的中文及分支别名兼容。 +- 修复页面升级、`ql update` 和静态资源的版本同步,完善升级暂存校验及应用、完整备份与恢复流程。 +- 修复 Debian 上的依赖安装、通知和调度回归,补齐前端缺失的中英文翻译,改进 Gitee/GitLab 静态资源镜像同步检查。 +- 最小升级 compression、multer、@grpc/grpc-js、proxy-addr、websocket-driver、sequelize,修补现有 HTTP、上传、代理信任、WebSocket、gRPC 和 JSON 查询路径相关风险。完整依赖治理、Node/pnpm 迁移及工具打包调整延期。 + +## 升级说明 + +升级前停止面板与任务,备份完整持久化数据目录,包括数据库、脚本、配置、日志、依赖缓存及已有的 `script-history` 目录,并记录原镜像摘要和启动配置。使用面板备份接口时,确认选择了脚本、配置和日志;仅备份数据库不能恢复全部数据。 + +升级后试运行代表性的 JavaScript、Python、Shell 任务,检查订阅、环境变量、依赖安装以及通知。存在夏令时的时区采用 node-cron 行为:跳过春季不存在的当地时间,秋季重复小时不重复执行;Asia/Shanghai 不受影响。调度器不提供进程退出期间的持久化补跑保证。 + +任务超时会终止普通子进程所在的进程组并记录退出状态 124;主动创建独立会话/进程组的程序不在该边界内。并发账号执行模式保留既有汇总退出状态。 + +本次沿用现有 Dockerfile 和 pnpm 8.3.1。默认 Debian 镜像使用 Debian 12 与 Node.js 20,默认 Alpine 镜像的 Node.js 由 APK 提供;默认 Python 为 3.11。Node 20 与 Node 18 构建运行时已结束上游维护,相关迁移延期,功能验证通过不代表全部依赖风险清零。 + +2.23.0 是 Python 3.10 兼容镜像的最后一个版本。`2.23.0-python3.10` 和 `2.23.0-debian-python3.10` 发布后停止维护,不再提供后续更新;请验证脚本和依赖后迁移至默认 Python 3.11 镜像。 + +主包 `@whyour/qinglong` 与独立远程包 `@whyour/qinglong-cli` 的 `ql` 命令用途不同。在已有面板的主机上使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。 + +本文及版本元数据为发布准备稿。镜像验证和本候选独立 dev 长跑通过后,再填写正式发布时间并执行发布。 diff --git a/package.json b/package.json index 1c96e5b6..7a789fb6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@whyour/qinglong", "packageManager": "pnpm@8.3.1", - "version": "2.22.0", + "version": "2.23.0", "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", "repository": { "type": "git", diff --git a/version.yaml b/version.yaml index 45328a27..e19f1d28 100644 --- a/version.yaml +++ b/version.yaml @@ -1,22 +1,15 @@ -version: 2.22.0 -changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md -publishTime: 2026-09-27 1643 +version: 2.23.0 +changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.23.0.md +publishTime: "" changeLog: | - 修复更新后含 ? 的历史定时规则导致面板 503,以及 ql check 的 printf 报错(#3078) - 无效任务不再阻止面板启动;统一调度表达式校验并修复扩展规则的调度器分配 - 1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI - 2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口 - 3. 仪表盘支持查看今日成功和失败任务明细 - 4. 新增客户端 IP 与代理链诊断、可信代理设置和登录 IP 黑名单 - 5. 新增历史运行实例和统计数据清理预览、手动清理及数据库压缩 - 6. 新增 WPUSH 通知,支持面板、JS、Python 和 gRPC - 7. 新增 ql log 服务日志命令,优化大目录和大日志读取,修复中文日志名 - 8. 修复任务结束后大日志尾部未读完的问题 - 9. 修复调度锁、调度恢复、执行就绪和任务停止竞争,准确反馈停止失败 - 10. 加固密码存储、JWT 与两步验证、文件访问和旧文件接口 - 11. 修复登录令牌过期后页面反复刷新,完善任务参数校验提示 - 12. 降低常驻依赖、指标缓存和重复 WebSocket 鉴权开销 - 13. 优化 Node 路径缓存及 Alpine 并发缓存锁,修复依赖安装状态 - 14. 改善启动错误诊断,保留用户 .env 配置,严格处理数据库迁移失败 - 15. 镜像增加来源证明、SBOM 和构建产物一致性校验,改进多架构构建 - 16. 升级前请备份完整数据;密码迁移后回退旧版需恢复升级前数据或重置账号 + 1. 新增脚本历史版本查看、对比与恢复,支持 CLI 和 OpenAPI;恢复前检查当前内容哈希,避免覆盖并发修改 + 2. 普通任务与订阅调度迁移到 node-cron,保留旧规则兼容及日期/星期 OR 语义,补充宏和夏令时说明 + 3. 修复已失效排队任务仍被执行的问题,完善任务进程超时、停止和 Shell 环境传递 + 4. 修复系统令牌主动续期与认证缓存并发更新;Shell API 遇到 401 时刷新令牌并重试一次 + 5. 降低任务启动开销,JS/Python 通知依赖改为首次调用时加载 + 6. 加固环境变量、订阅参数、文件访问和 SSH 配置输入,改善资源冲突与 CLI 错误反馈 + 7. 修复页面升级、ql update 与静态资源版本同步,完善离线备份和升级产物校验 + 8. 修复 Debian 依赖安装、通知与调度回归,补齐前端中英文翻译 + 9. 最小修补 HTTP 压缩、上传、gRPC、代理 IP、WebSocket 和 JSON 查询相关依赖漏洞 + 10. 远程 CLI 更新至 0.1.2,新增脚本历史命令,最低 Node.js 版本仍为 22.12 + 11. 本次为 Python 3.10 兼容镜像的最后一个版本,发布后停止维护;升级前备份完整数据并验证脚本、依赖和通知 From 70636900b29f1fcdabb736071e255dbb7e8bea0b Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 11 Oct 2026 12:27:51 +0800 Subject: [PATCH 123/123] chore: finalize 2.23.0 publication metadata --- docs/releases/2.23.0.md | 2 +- version.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases/2.23.0.md b/docs/releases/2.23.0.md index fe77872a..1963354c 100644 --- a/docs/releases/2.23.0.md +++ b/docs/releases/2.23.0.md @@ -30,4 +30,4 @@ 主包 `@whyour/qinglong` 与独立远程包 `@whyour/qinglong-cli` 的 `ql` 命令用途不同。在已有面板的主机上使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。 -本文及版本元数据为发布准备稿。镜像验证和本候选独立 dev 长跑通过后,再填写正式发布时间并执行发布。 +发布时间:2026-10-11 12:27:02(Asia/Shanghai)。本版本已完成四类镜像验证及独立 dev 环境超过两小时的持续功能验收。 diff --git a/version.yaml b/version.yaml index e19f1d28..3f69801a 100644 --- a/version.yaml +++ b/version.yaml @@ -1,6 +1,6 @@ version: 2.23.0 changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.23.0.md -publishTime: "" +publishTime: "2026-10-11 12:27:02" changeLog: | 1. 新增脚本历史版本查看、对比与恢复,支持 CLI 和 OpenAPI;恢复前检查当前内容哈希,避免覆盖并发修改 2. 普通任务与订阅调度迁移到 node-cron,保留旧规则兼容及日期/星期 OR 语义,补充宏和夏令时说明