fix: drain completed task logs and prepare 2.22.0 release (#3076)

This commit is contained in:
whyour
2026-09-26 01:17:28 +08:00
committed by GitHub
parent 801a71d740
commit 6c487018c2
6 changed files with 352 additions and 39 deletions
+49
View File
@@ -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`。
+54
View File
@@ -0,0 +1,54 @@
# 青龙 2.22.0
## 新增功能
- 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。
- 可选面板内部 TypeScript 工具:任务执行、订阅同步、启动检查、升级重载和账号恢复。默认继续使用原 Shell 入口。
- 仪表盘支持点击今日成功、今日失败查看任务明细。
- 客户端 IP 和代理链诊断、可信代理配置、登录 IP 黑名单。
- 历史运行实例和统计数据的保留参数、清理预览、手动清理、依赖缓存清理和数据库压缩。保存保留参数不等于开启自动清理。
- WPUSH 通知,支持面板、JavaScript、Python 和 gRPC。
- `ql log [--lines <n>] [--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`。通过单元测试或构建不代表完成所有平台和真实升级场景的验收。
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@whyour/qinglong", "name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1", "packageManager": "pnpm@8.3.1",
"version": "2.21.0-16", "version": "2.22.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": { "repository": {
"type": "git", "type": "git",
+15 -4
View File
@@ -56,12 +56,20 @@ const CronLogModal = ({
}`; }`;
request request
.get(`${baseUrl}${pagination}`) .get(`${baseUrl}${pagination}`)
.then(({ code, data, logStatus, nextOffset }) => { .then(({ code, data, logStatus, nextOffset, total }) => {
if (code !== 200 || localStorage.getItem("logCron") !== uniqPath) { if (code !== 200 || localStorage.getItem("logCron") !== uniqPath) {
return; 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) || ""; const chunk = (data as string) || "";
let log = isFirst ? chunk : `${valueRef.current}${chunk}`; let log = isFirst ? chunk : `${valueRef.current}${chunk}`;
if (!log && !hasNext) { if (!log && !hasNext) {
@@ -75,13 +83,16 @@ const CronLogModal = ({
if (typeof nextOffset === "number") { if (typeof nextOffset === "number") {
logOffsetRef.current = nextOffset; logOffsetRef.current = nextOffset;
} }
setExecuting(hasNext); setExecuting(isRunning);
if (chunk || !hasNext) { if (chunk || !hasNext) {
autoScroll(); autoScroll();
} }
if (hasNext) { if (hasNext) {
pollTimerRef.current = setTimeout(() => getCronLog(), 2000); pollTimerRef.current = setTimeout(
() => getCronLog(),
hasUnread ? 0 : 2000,
);
} }
}) })
.finally(() => { .finally(() => {
+214
View File
@@ -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);
}
});
+19 -34
View File
@@ -1,35 +1,20 @@
version: 2.21.0 version: 2.22.0
changeLogLink: https://t.me/jiao_long/436 changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md
publishTime: 2026-06-18 2300 publishTime: 2026-09-26 0000
changeLog: | changeLog: |
1. shell 增加国际化支持 1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI
2. 接口提示信息国际化,更新国际化文案 2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口
3. 统一 Alpine/Debian 分支,QL_SCHEDULER 参数化调度 3. 仪表盘支持查看今日成功和失败任务明细
4. grpc 服务增加证书校验 4. 新增客户端 IP 与代理链诊断、可信代理设置和登录 IP 黑名单
5. 定时任务增加 work_dir 设置 5. 新增历史运行实例和统计数据清理预览、手动清理及数据库压缩
6. 增加任务统计功能 6. 新增 WPUSH 通知,支持面板、JS、Python 和 gRPC
7. 新增 OpeniLink 通知渠道 7. 新增 ql log 服务日志命令,优化大目录和大日志读取,修复中文日志名
8. 支持自定义接收邮箱地址 8. 修复任务结束后大日志尾部未读完的问题
9. 增加运行实例功能 9. 修复调度锁、调度恢复、执行就绪和任务停止竞争,准确反馈停止失败
10. 增加 sudo 命令判断 10. 加固密码存储、JWT 与两步验证、文件访问和旧文件接口
11. 延迟增加运行时间提示 11. 修复登录令牌过期后页面反复刷新,完善任务参数校验提示
12. 开机运行任务支持同时开始运行 12. 降低常驻依赖、指标缓存和重复 WebSocket 鉴权开销
13. 增加 localhost 检测 13. 优化 Node 路径缓存及 Alpine 并发缓存锁,修复依赖安装状态
14. 增加环境变量标签功能 14. 改善启动错误诊断,保留用户 .env 配置,严格处理数据库迁移失败
15. 修复路径穿越安全漏洞 15. 镜像增加来源证明、SBOM 和构建产物一致性校验,改进多架构构建
16. 修复配置文件路径可能越权 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