From 6c487018c2e6b37ea1cfd5fabb383c8702e30426 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 26 Sep 2026 01:17:28 +0800 Subject: [PATCH] fix: drain completed task logs and prepare 2.22.0 release (#3076) --- docs/releases/2.22.0-validation.md | 49 ++++++ docs/releases/2.22.0.md | 54 ++++++ package.json | 2 +- src/pages/crontab/logModal.tsx | 19 ++- test/front/cron-log-pagination.test.cjs | 214 ++++++++++++++++++++++++ version.yaml | 53 +++--- 6 files changed, 352 insertions(+), 39 deletions(-) create mode 100644 docs/releases/2.22.0-validation.md create mode 100644 docs/releases/2.22.0.md create mode 100644 test/front/cron-log-pagination.test.cjs diff --git a/docs/releases/2.22.0-validation.md b/docs/releases/2.22.0-validation.md new file mode 100644 index 00000000..7d412b69 --- /dev/null +++ b/docs/releases/2.22.0-validation.md @@ -0,0 +1,49 @@ +# 2.22.0 发布准备验收 + +日期:2026-09-26。候选代码基于 `origin/develop@801a71d7402f5d4b582318ed88ad00633357a531`,主包和面板版本统一为 `2.22.0`。本文件记录验收证据,不代表已经发布。 + +## 自动化与构建 + +- 新增 5 项日志弹窗回归:大日志结束后读完分页、尾部截取不重复取历史、运行中空日志继续轮询、关闭取消定时器/丢弃在途响应、旧响应和不前进偏移不死循环。 +- 用真实日志读取器写入 600 KiB 后结束任务:原 develop 在新增回归中失败,修复后 5 项全通过。 +- macOS arm64、Node 20、按冻结锁文件独立安装依赖:228 项测试,225 通过、3 项平台相关跳过、0 失败。后端 TypeScript 与前端生产构建通过;前端有已有的大包体提示。 +- `npm pack --dry-run` 成功,包含前后端产物,主包版本为 2.22.0;npm registry 查询该版本返回 E404(尚未占用,发布时仍需重新核实)。 +- 上游基线 Linux CI 为 223 项回归全部通过;CLI Node 22.12/24 流水线通过,Node 24 的 291 项测试全部通过。该结果属于上游提交,不冒充本分支新的 CI 结果。 +- GitNexus 对 `getCronLog` 修改前影响分析:LOW,直接调用者 `CronLogModal`,1 条流程。最终差异分析涉及日志弹窗流程;行号移动也将 `autoScroll` 列出,但该函数实现未修改。 + +## 容器升级与回退 + +测试仅使用新建账号、新建任务和隔离容器数据,不接触真实用户数据。 + +- 已发布旧版镜像 `whyour/qinglong:2.21.0` digest:`sha256:e72f77d855a86cfabf774de76b030731c38540b251a0c888038810fbcf256c48`。镜像内源码为 `9b37f064d815baf49b1869445bbd60e4209bacc9`,与当前 master 提交不同;这是已发布镜像到候选版的实际升级验收。 +- 候选运行环境为 `whyour/qinglong:develop` digest:`sha256:35c5b9f2d0666ab7fcde954f175087c097e93fa852306360aa07b8aa0a5d0481`,revision 标签对应 `801a71d7402f5d4b582318ed88ad00633357a531`。装入本分支本地构建的前后端及版本文件。因此验证了候选代码的容器运行,不等同于最终发行镜像的完整构建验收。 +- 旧版创建账号及真实 Shell 脚本任务并验证独立输出,停机复制完整 `/ql/data` 作为一致的升级前备份。 +- 候选版读取该备份后原密码可登录、原任务仍存在并能真实执行,数据库已增加迁移表,测试账号密码已转为 scrypt(仅检查布尔值,未记录凭据)。 +- 长运行脚本启动后确认实际 PID;停止后确认该进程不存在;立即重跑并再次停止成功。状态字段恢复为空闲;不把历史 PID 字段当作进程存活判断。 +- 两步验证启用、密码阶段返回挑战、OTP 登录、同一验证码重放拒绝、关闭两步验证及重新登录均通过。 +- 每 5 秒调度的测试脚本产生持续输出;本容器 HTTP 夹具订阅下载 V1 文件成功。 +- 订阅夹具更新为 V2 后再次同步,落盘内容已变为 V2。 +- 容器重启前调度标记 34 行,重启后 41 行,HTTP/gRPC 健康且可重新登录,确认定时注册恢复。 +- 将升级前完整备份恢复到全新的 2.21.0 容器,原密码登录、原任务保留及真实脚本输出均通过。 + +## 生产依赖审计:仍需处理 + +在无共享 node_modules 的临时目录,使用候选 `package.json` 和 `pnpm-lock.yaml` 执行 `pnpm audit --prod --json`:414 个依赖,报告 3 Critical、38 High、49 Moderate、9 Low。数量是扫描公告命中,不等于独立漏洞数量或均能在面板中利用。审计时间与数据库变化会影响结果。 + +| Critical 命中 | 生产依赖路径 | 初步适用性与处理 | +| --- | --- | --- | +| protobufjs 7.5.4 | `@grpc/proto-loader → protobufjs` | 公告要求攻击者能控制加载的 schema/descriptor;当前主要使用仓库内生成的协议,尚未证实外部可控 schema 入口。仍应升级到修复版本并验证 gRPC。 | +| websocket-driver 0.7.4 | `sockjs → websocket-driver` / `faye-websocket` | 处理网络协议帧,不能用面板业务鉴权替代协议层修复;应升级到至少 0.7.5 并回归 WebSocket。 | +| tar 6.2.1 | `@whyour/sqlite3 → tar`、`@mapbox/node-pre-gyp → tar` | 主要涉及安装/构建解压链,不能直接等同于面板上传接口漏洞。修复跨 tar 主版本,需验证 SQLite 预编译下载和源码编译路径;另有 tar 7.5.20 的后续公告。 | + +参考:[protobufjs 公告](https://github.com/advisories/GHSA-xq3m-2v4x-88gg)、[websocket-driver 公告](https://github.com/advisories/GHSA-xv26-6w52-cph6)、[tar 公告](https://github.com/advisories/GHSA-23hp-3jrh-7fpw)。 + +High 命中还涉及 gRPC、multer、js-yaml、JWT 的 jws、undici 等运行时依赖。当前准备改动没有批量升级依赖;不能宣称安全扫描通过。正式发布仍需完成依赖升级与可达性评估、回归,或由维护者明确记录接受的风险。 + +## 尚未放行的发布步骤 + +- 本分支新的 Linux CI 尚待执行。 +- Python 3.10 镜像和全部架构的最终候选构建尚未验证;上游 develop 工作流跳过这些发布专用任务。 +- 主包及 CLI 的 npm Trusted Publisher 设置属于 npm 账户配置,未通过代码或只读公开 registry 验证;不得把 `id-token: write` 视作已完成授权。 +- 最终发版时确认 `publishTime` 与实际发布时间一致,并重新核对版本是否可发布。 +- 完成上述事项前不合并到 master、不打正式 tag、不执行 `shell/pub.sh`。 diff --git a/docs/releases/2.22.0.md b/docs/releases/2.22.0.md new file mode 100644 index 00000000..2bd67283 --- /dev/null +++ b/docs/releases/2.22.0.md @@ -0,0 +1,54 @@ +# 青龙 2.22.0 + +## 新增功能 + +- 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。 +- 可选面板内部 TypeScript 工具:任务执行、订阅同步、启动检查、升级重载和账号恢复。默认继续使用原 Shell 入口。 +- 仪表盘支持点击今日成功、今日失败查看任务明细。 +- 客户端 IP 和代理链诊断、可信代理配置、登录 IP 黑名单。 +- 历史运行实例和统计数据的保留参数、清理预览、手动清理、依赖缓存清理和数据库压缩。保存保留参数不等于开启自动清理。 +- WPUSH 通知,支持面板、JavaScript、Python 和 gRPC。 +- `ql log [--lines ] [--nostream]` 查看服务日志。 + +## 修复与优化 + +- 修复任务生命周期、调度锁、调度器就绪和恢复、排队与停止竞争、订阅清理,以及停止失败的反馈。 +- 日志改为分块读取;任务结束后继续读完剩余分页,确保最后的输出和退出信息可见。修复中文任务日志名。 +- 加固密码存储、JWT 有效性校验、两步验证、认证状态变更和文件路径访问。 +- 修复过期登录令牌引起的页面刷新循环,完善任务字段校验提示。 +- 减少常驻依赖和重复 WebSocket 鉴权,限制指标缓存和日志显示量,优化大目录枚举。 +- 优化 Node 路径缓存与 Alpine 并发缓存锁,修正依赖缓存清理后的安装状态。 +- 启动时保留 `.env` 配置;保留容器启动错误诊断;数据库迁移失败时中止启动。 +- 镜像增加 provenance、SBOM、源码/静态产物/依赖锁文件一致性校验,改进多架构构建。 + +## 升级兼容性 + +### 数据备份与回退 + +升级前停止面板和任务,备份完整持久化数据目录,并记录当前镜像的不可变 digest、启动参数、挂载和环境配置。不要只复制正在写入的 SQLite 主文件;必须保留完整离线数据备份。 + +新版兼容旧密码;首次成功登录或修改密码后,密码会迁移为 scrypt 哈希。旧版不识别这种格式,因此**不能只回退镜像并继续使用升级后的数据**。 + +需要回退时:停止新版,另外保存升级后的数据,恢复升级前的完整备份,再使用原镜像 digest 和原启动配置启动旧版。恢复备份会丢弃备份后产生的任务、配置和日志变更。如必须保留升级后数据,应单独验证旧版兼容性,并通过可信终端使用旧版账号恢复功能重新设置密码;不要手工把哈希当作密码使用。 + +数据库迁移新增 `SchemaMigrations` 和任务排队标识;迁移异常会阻止启动。应检查启动日志并修复数据或恢复备份,不要通过删除数据库来绕过错误。 + +### API 与日志 + +旧文件读取路由 `/configs/:file`、`/scripts/:file`、`/logs/:file` 返回业务码 `410`,请改用对应的 `/detail` 接口(适用于 `/api` 和 `/open` 前缀)。 + +日志接口默认返回尾部片段。客户端需要使用 `offset`、`nextOffset`、`total` 读取后续数据;任务已结束不代表所有日志已经读取完毕。面板仅保留有限长度的日志显示,完整文件仍在日志目录中。 + +修改账号或两步验证配置会使旧会话失效;客户端应重新登录。IP 黑名单针对登录;反向代理场景应先在客户端 IP 页面确认识别结果再配置黑名单。 + +### CLI + +远程 npm CLI 要求 Node >=22.12,推荐 Node 24。它与面板内置工具均使用 `ql` 名称;已有面板的机器应使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖内置命令。 + +远程 npm 包不包含本机执行器、升级、重载和账号恢复实现,也不会自动切换面板入口。使用内部工具需单独构建完整 `cli/dist`,在满足 Node 版本要求的环境中显式设置 `QL_CLI_ROOT`;清除该变量并重启恢复 Shell 入口。标准面板镜像并未默认启用内部 TypeScript 工具。 + +## 发布验收 + +正式发布前应完成:旧版离线数据升级、登录与两步验证、定时/手动任务、停止后立即重跑、订阅更新、容器重启调度恢复和备份回退。发布者还需检查 Python 3.10 镜像矩阵,以及主包和 CLI 的 npm Trusted Publisher 配置。 + +依赖审计与执行证据见同目录的 `2.22.0-validation.md`。通过单元测试或构建不代表完成所有平台和真实升级场景的验收。 diff --git a/package.json b/package.json index 009123ad..005fe177 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@whyour/qinglong", "packageManager": "pnpm@8.3.1", - "version": "2.21.0-16", + "version": "2.22.0", "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", "repository": { "type": "git", diff --git a/src/pages/crontab/logModal.tsx b/src/pages/crontab/logModal.tsx index 4fe5a5e1..1eaec07e 100644 --- a/src/pages/crontab/logModal.tsx +++ b/src/pages/crontab/logModal.tsx @@ -56,12 +56,20 @@ const CronLogModal = ({ }`; request .get(`${baseUrl}${pagination}`) - .then(({ code, data, logStatus, nextOffset }) => { + .then(({ code, data, logStatus, nextOffset, total }) => { if (code !== 200 || localStorage.getItem("logCron") !== uniqPath) { return; } - const hasNext = logStatus === "running"; + const isRunning = logStatus === "running"; + // A completed process can still have multiple unread chunks. Drain + // them before stopping, without presenting it as a running task. + const hasUnread = + typeof nextOffset === "number" && + typeof total === "number" && + nextOffset > (offset ?? 0) && + nextOffset < total; + const hasNext = isRunning || hasUnread; const chunk = (data as string) || ""; let log = isFirst ? chunk : `${valueRef.current}${chunk}`; if (!log && !hasNext) { @@ -75,13 +83,16 @@ const CronLogModal = ({ if (typeof nextOffset === "number") { logOffsetRef.current = nextOffset; } - setExecuting(hasNext); + setExecuting(isRunning); if (chunk || !hasNext) { autoScroll(); } if (hasNext) { - pollTimerRef.current = setTimeout(() => getCronLog(), 2000); + pollTimerRef.current = setTimeout( + () => getCronLog(), + hasUnread ? 0 : 2000, + ); } }) .finally(() => { diff --git a/test/front/cron-log-pagination.test.cjs b/test/front/cron-log-pagination.test.cjs new file mode 100644 index 00000000..5b1e78e8 --- /dev/null +++ b/test/front/cron-log-pagination.test.cjs @@ -0,0 +1,214 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { readLogChunk } = require('../../back/shared/logReader'); + +const source = fs.readFileSync( + path.join(__dirname, '../../src/pages/crontab/logModal.tsx'), + 'utf8', +); +const ast = ts.createSourceFile( + 'logModal.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const declarations = new Map(); +function visit(node) { + if ( + ts.isVariableDeclaration(node) && + ['getCronLog', 'cancel'].includes(node.name.getText(ast)) + ) + declarations.set(node.name.getText(ast), node.initializer.getText(ast)); + ts.forEachChild(node, visit); +} +visit(ast); + +function fixture(get) { + const timers = new Map(); + const storage = new Map([['logCron', '1']]); + const values = { content: '', executing: true }; + const pending = []; + let timerId = 0; + const context = { + logUrl: undefined, + config: { apiPrefix: '/api/' }, + cron: { id: 1 }, + uniqPath: '1', + LOG_CHUNK_BYTES: 256 * 1024, + MAX_LOG_VIEW_CHARS: 1024 * 1024, + logOffsetRef: { current: undefined }, + valueRef: { current: '' }, + pollTimerRef: {}, + request: { + get(url) { + const result = get(url); + pending.push(result); + return result; + }, + }, + localStorage: { + getItem: (key) => storage.get(key), + removeItem: (key) => storage.delete(key), + }, + intl: { get: (value) => value }, + setLoading() {}, + autoScroll() {}, + handleCancel() {}, + setValue(value) { + values.content = value; + }, + setExecuting(value) { + values.executing = value; + }, + setTimeout(fn, delay) { + timers.set(++timerId, { fn, delay }); + return timerId; + }, + clearTimeout(id) { + timers.delete(id); + }, + }; + vm.createContext(context); + for (const [name, initializer] of declarations) + vm.runInContext( + ts.transpile(`var ${name} = ${initializer}`, { + target: ts.ScriptTarget.ES2020, + }), + context, + ); + async function settle() { + await Promise.all(pending.splice(0)); + await new Promise((resolve) => setImmediate(resolve)); + } + return { + values, + timers, + start() { + context.getCronLog(true); + }, + settle, + async read() { + context.getCronLog(true); + await settle(); + }, + async tick() { + const [id, timer] = timers.entries().next().value; + timers.delete(id); + timer.fn(); + await settle(); + }, + close() { + context.cancel(); + }, + }; +} + +test('completed tasks drain unread chunks and display the final exit marker', async (t) => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ql-log-pagination-')); + t.after(() => fsp.rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'task.log'); + await fsp.writeFile(file, 'started\n'); + let running = true; + const offsets = []; + const view = fixture(async (url) => { + const query = new URL(url, 'http://localhost').searchParams; + const offset = query.has('offset') + ? Number(query.get('offset')) + : undefined; + offsets.push(offset); + const chunk = await readLogChunk(file, { + offset, + tail: query.get('tail') === 'true', + limit: Number(query.get('limit')), + }); + return { + code: 200, + data: chunk.content, + logStatus: running ? 'running' : 'completed', + ...chunk, + }; + }); + await view.read(); + assert.equal(view.timers.values().next().value.delay, 2000); + await fsp.appendFile(file, 'x'.repeat(600 * 1024) + '\nFINAL_EXIT_CODE=1\n'); + running = false; + await view.tick(); + assert.equal(view.values.executing, false); + assert.equal(view.timers.values().next().value.delay, 0); + await view.tick(); + await view.tick(); + assert.ok(view.values.content.endsWith('FINAL_EXIT_CODE=1\n')); + assert.equal(view.timers.size, 0); + assert.deepEqual(offsets, [undefined, 8, 8 + 256 * 1024, 8 + 512 * 1024]); +}); + +test('completed tail does not refetch discarded historical data', async () => { + const view = fixture(async () => ({ + code: 200, + data: 'tail', + logStatus: 'completed', + nextOffset: 900000, + total: 900000, + truncated: true, + })); + await view.read(); + assert.equal(view.values.content, 'tail'); + assert.equal(view.timers.size, 0); +}); + +test('running tasks poll without new bytes and closing cancels polling', async () => { + const view = fixture(async () => ({ + code: 200, + data: '', + logStatus: 'running', + nextOffset: 0, + total: 0, + })); + await view.read(); + assert.equal(view.values.executing, true); + assert.equal(view.timers.values().next().value.delay, 2000); + view.close(); + assert.equal(view.timers.size, 0); +}); + +test('closing ignores an in-flight response with unread data', async () => { + let resolve; + const view = fixture( + () => + new Promise((done) => { + resolve = done; + }), + ); + view.start(); + view.close(); + resolve({ + code: 200, + data: 'late', + logStatus: 'completed', + nextOffset: 4, + total: 100, + }); + await view.settle(); + assert.equal(view.values.content, ''); + assert.equal(view.timers.size, 0); +}); + +test('legacy and non-advancing completed responses do not loop', async () => { + for (const pagination of [{}, { nextOffset: 0, total: 100 }]) { + const view = fixture(async () => ({ + code: 200, + data: '', + logStatus: 'completed', + ...pagination, + })); + await view.read(); + assert.equal(view.timers.size, 0); + } +}); diff --git a/version.yaml b/version.yaml index ce86d2b4..64d24b9e 100644 --- a/version.yaml +++ b/version.yaml @@ -1,35 +1,20 @@ -version: 2.21.0 -changeLogLink: https://t.me/jiao_long/436 -publishTime: 2026-06-18 2300 +version: 2.22.0 +changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md +publishTime: 2026-09-26 0000 changeLog: | - 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 + 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. 升级前请备份完整数据;密码迁移后回退旧版需恢复升级前数据或重置账号