mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
fix: drain completed task logs and prepare 2.22.0 release (#3076)
This commit is contained in:
@@ -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`。
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user