feat(ql3): bridge legacy panel to bounded run logs

This commit is contained in:
whyour
2026-09-04 01:55:44 +08:00
parent fa730da091
commit 09ef174522
11 changed files with 328 additions and 53 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
# QingLong 3.0 Architecture RFC
- D-428/ADR-0530(源码候选,等待可下载同源装配与双架构阶段实物):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health``GET /api/system``GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user``GET /api/system/config` 仍经过 Bearer、`task.read` Policy、durable audit 与 credential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timer、watcher 或后台进程;默认 headlessCluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。Local API 12-package closure 与 89/89、18-package 完整测试、package/import/dependency audit、旧面板 production build 均通过;Playwright 同源源码旅程验证了登录、只读列表和刷新清凭据,最终 0 console error。仍须把改造静态资源装入可下载 Console、在 exact Console + Local API + SQLite 上重跑真实 credential journey、通过远端 CI 和双架构 artifact,才能声明阶段页面产物可直接部署
- D-429/ADR-0531(源码候选,等待远端 CI 与 exact 双架构 Console 实物):现有面板的 Cron “日志”入口不恢复 2.x `/api/crons/:id/log` 或文件系统日志权威,而是复用规范 v3 Run/Attempt/Artifact 链。`BoundedRunListItem` 仅增加经 128 字节无控制字符验证的可选 `triggerId`;页面按 `triggerId + taskId` 在 Edge 64/Standalone 256 条 Run 总预算中定位最新 Run,再读取 detail 的 latest Attempt 与 16/32 KiB 首个日志片段,明确呈现 pending/retired/truncated。所有请求继续走 Bearer、Project Policy、durable audit、credential reconfirm 与 Artifact retention;不暴露路径、Artifact id、Secret 或其他 Run 私有字段。QL3 Action 列只开放日志,列表关闭旧 10 秒轮询,日志关闭旧 2 秒轮询,仅由初次进入、点击与手动刷新驱动;2.x 行为保持不变。没有新增 package、dependency、migration、schema、listener、连接或后台进程headlessCluster authority 不变。本地 runtime-core 594/594、兼容回归 4/4、Node 20 production build、240 files / 11,965,017 bytes 闭包审计和真实 Chromium journey 已通过;浏览器稳定 15 秒只有一次 Cron list,日志稳定 5 秒只有 Run list/detail/log 各一次
- D-428/ADR-0530exact 双架构阶段实物已交付):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health``GET /api/system``GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user``GET /api/system/config` 仍经过 Bearer、`task.read` Policy、durable audit 与 credential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timer、watcher 或后台进程;默认 headless、Cluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。提交 `fa730da0912d0e3c503697e7816aa0b14fa02165` 的普通主 CI run `33597913690` 为 42 success / 3 expected condition skip / 0 failConsole artifact run `33597938992` 为 43 success / 2 expected skip / 0 failamd64/arm64/milestone artifact `9834695619`/`9834657575`/`9834716653` 保留至 2026-10-02。下载后的 v7 milestone `SHA256SUMS` 与离线 auditor 均返回 `compatible=true`,D-428 因此已经是可直接试运行的阶段页面产物。
- D-427/ADR-0529(双架构阶段实物已交付):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential、`task.read` Policy、durable audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Triggercommand 只返回 `ql3:<kind>:<taskId>@<revision>` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size;搜索、排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timer 或 watcherheadless 与 Cluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过。提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功;headless amd64/arm64/milestone artifact `9827434236`/`9827259756`/`9827553099`Console `9828792297`/`9828714089`/`9828941802`,均未过期并保留至 2026-10-02。D-427 因此闭合为后端阶段实物;它仍不包含改造后的 2.x 静态面板,页面交付由 D-428 独立闭合。
@@ -58,4 +58,4 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络
提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功。headless amd64/arm64/milestone artifact 为 `9827434236`/`9827259756`/`9827553099`Console 为 `9828792297`/`9828714089`/`9828941802`;均绑定同一 source revision,保留至 2026-10-02。headless 继续不携带 Local API/旧面板;Console artifact 携带本 Adapter,但仍只证明后端兼容切片,不包含改造后的 2.x 静态面板。
后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。ADR-0530 已完成 capability/bootstrap 与旧面板源码的内存凭据、菜单降级、只读 Crontab 适配;仍须完成同源静态资源装配真实浏览器 journey,才能把该页面作为可下载阶段产物交付。完整 2.x 零改兼容不作为 3.0 目标。
后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。ADR-0530 已完成 capability/bootstrap同源静态装配真实浏览器 journey 和双架构阶段实物;ADR-0531 继续以规范 v3 API、caller-driven 读取和 Profile 预算开放 Cron 日志。完整 2.x 零改兼容不作为 3.0 目标。
@@ -1,6 +1,6 @@
# ADR-0530:有界 Local 面板能力发现与启动适配
- 状态:AcceptedD-428 本地同源装配候选已闭合,等待远端 CI 与双架构可下载实物门
- 状态:AcceptedD-428 exact Console 双架构阶段实物已交付
- 日期:2026-09-02
- 关联 RFCQL-RFC-0001 D-428、D-427、D-423、D-424
@@ -40,13 +40,14 @@ ADR-0529 已交付认证后的只读 `/api/crons` Adapter,但现有 2.x 面板
本地装配候选已经通过 12-package Console closure、719 files / 16,162,123 bytes(上限 768 files / 20 MiB)镜像审计,以及面板 240-file 哈希/磁盘闭包复核。最终 arm64 Edge 镜像 `sha256:27472cf1bdd66d9fa4e937622ef69d4e36f74918a759de62818b4e59d265714c` 已完成 fresh setup/replay、Owner provision/challenge/claim/presentation/ack、首个 Task 执行与日志标记、graceful stop、SQLite integrity 和 HTTP 200/401 边界,结果为 `compatible=true`
真实浏览器已验证现有页面使用内存中的 `ql3c_` credential 完成 `/login``/crontab`53 个同源静态请求全部为 200`/api/health``/api/system``/api/v3/capabilities``/api/user``/api/system/config``/api/crons` 均为 200;最终镜像同时验证 `/console` 原生管理台与 `/login` 旧页面共存。旧页面仍引用的外部图标/装饰图片被严格 CSP 阻止,页面功能可用但这些装饰会缺失;本阶段不为消除装饰错误而放宽网络或 CSP。远端 CI 和可下载双架构实物仍待闭合。
真实浏览器已验证现有页面使用内存中的 `ql3c_` credential 完成 `/login``/crontab`53 个同源静态请求全部为 200`/api/health``/api/system``/api/v3/capabilities``/api/user``/api/system/config``/api/crons` 均为 200;最终镜像同时验证 `/console` 原生管理台与 `/login` 旧页面共存。旧页面仍引用的外部图标/装饰图片被严格 CSP 阻止,页面功能可用但这些装饰会缺失;本阶段不为消除装饰错误而放宽网络或 CSP。
提交 `fa730da0912d0e3c503697e7816aa0b14fa02165` 的普通主 CI [run 33597913690](https://github.com/whyour/qinglong/actions/runs/33597913690) 为 42 success / 3 expected condition skip / 0 fail;显式 Console artifact [run 33597938992](https://github.com/whyour/qinglong/actions/runs/33597938992) 为 43 success / 2 expected skip / 0 fail。该 run 交付 amd64/arm64/milestone artifact `9834695619`/`9834657575`/`9834716653`,大小 `239011275`/`234410955`/`6797` bytes,保留至 2026-10-02;下载后的 milestone `SHA256SUMS` 与 v7 auditor 均通过并返回 `compatible=true`。D-428 因此闭合为可直接试运行的 exact 双架构阶段页面产物。
仍未完成:
1. 推送后通过远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone
2. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求
3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口;
4. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
1. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求
2. Run/Log 入口由 ADR-0531 以规范 v3 API 的 caller-driven 客户端桥接继续推进
3. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
在远端实物门完成前,本 ADR 声明的是“当前提交可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。
本 ADR 声明的是“已有 exact 双架构阶段产物可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。
@@ -0,0 +1,40 @@
# ADR-0531:现有面板到规范 Run 日志的有界桥接
- 状态:AcceptedD-429 源码候选;远端 CI 与双架构 Console 实物待 gate
- 日期:2026-09-04
- 关联 RFCQL-RFC-0001 D-429、D-428、D-415
## 背景
ADR-0530 已把现有 2.x 面板作为受限静态客户端装入 Local Console,并只开放 Cron 列表。用户仍无法从该页面观察一次定时执行的日志。恢复旧 `/api/crons/:id/log`、文件系统日志目录或 2 秒轮询,会复制已经存在的 3.0 Run/Attempt/Artifact 权威,并给 Edge 路由设备增加持续请求。
Run 聚合已经持久化可选 `triggerId`,规范 Local API 已提供有界 Run list、Run detail 与 Attempt log range read。缺口不是新的日志后端,而是安全地把这条规范链交给受限旧页面。
## 决策
- `BoundedRunListItem` 增加可选 `triggerId`。只有值存在且满足 128 字节无控制字符边界时才投影;其余 Run 私有字段仍不跨越 projection。
- 旧面板不新增 `/api/crons/:id/log` 兼容路由。用户点击某个 Cron 的“日志”后,页面按 capability budget 分页读取 `/api/v3/projects/:project/runs`,以 `triggerId + taskId` 精确选择最新 Run,再读取 Run detail 的 latest Attempt 和该 Attempt 的首个日志片段。
- 每次日志片段仍经过既有 Bearer authentication、`artifact.read` Policy、durable audit、credential reconfirm 与 Artifact retention 检查。页面不接触文件路径、Artifact id 或 Secret。
- Edge 最多扫描 64 条 Run、读取 16 KiBStandalone 最多扫描 256 条 Run、读取 32 KiB。每页最大 64 条。未找到、尚未就绪、已清理、运行中无 Artifact 和截断状态必须显式呈现。
- QL3 模式下 Cron 列表不再运行旧页面的 10 秒轮询,日志也不运行旧页面的 2 秒轮询。初始列表只读一次,后续列表由分页/页面动作触发;日志只由点击和“刷新”按钮触发。
- QL3 的 Action 列只保留“日志”。运行、停止、更多、创建、批量操作和写 Modal 继续隐藏;2.x 模式保持原行为。
## 部署与架构边界
- 不新增 workspace package、production dependency、migration、schema、数据库连接、listener、timer、watcher 或后台进程。
- 默认 headless 产物不含旧面板,资源占用不变。Console 仍使用同一个 loopback Local API 和同一个受限静态闭包。
- Cluster 不复用 Local 面板 shell`triggerId` 是 profile-neutral 的 Run 低敏感关联字段,因此规范 Run projection 可被 Local/Cluster 消费,但本 ADR 不新增 Cluster UI。
- 这是一条迁移期客户端桥接,不把 2.x 页面定义为 QingLong 3.0 的领域模型或长期管理面。新的 3.0 能力优先进入原生 `/console`
## 验证与剩余门禁
本地已完成 runtime-core 594/594、Local API 93/93、18-package clean build/test、后端 1685 项(1683 pass / 2 environment skip / 0 fail)、兼容闭包回归 4/4、Node 20 production panel build,以及 package boundary、Edge import、Cluster dependency 和 240 files / 11,965,017 bytes 离线闭包审计。真实 Chromium mock journey 验证了 QL3 credential 登录、只读 Action 列、Run list → Run detail → Attempt log 三个规范请求和 Base64/UTF-8 日志展示;页面稳定 15 秒只有一次 Cron list,日志打开后稳定 5 秒没有自动重读。
仍须完成 GitNexus staged/default-branch change detection、阶段提交、远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone,才可把 D-429 标记为双架构阶段实物已交付。
## 不做的方案
- 不恢复旧文件日志 API 或任意路径读取;
- 不把日志轮询速度做成配置项;低端设备默认仍会付出持续成本;
- 不在本切片开放 Run start/stop、Cron mutation 或完整 `/log` 页面;
- 不为复用旧页面而把 Local SQLite/POSIX authority 带入 Cluster。
+2 -1
View File
@@ -533,7 +533,8 @@
| [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Acceptedexact headless 双架构阶段实物已交付) |
| [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | AcceptedD-426c3 源码候选;双架构阶段实物待 gate) |
| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | AcceptedD-427 双架构阶段实物已交付) |
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | ProposedD-428 源码候选;可下载同源装配与双架构实物待 gate |
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | AcceptedD-428 exact Console 双架构阶段实物已交付 |
| [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | AcceptedD-429 源码候选;远端 CI 与双架构 Console 实物待 gate |
## 规则
@@ -1,9 +1,7 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
createLocalApiRunListRoute,
} = require('../dist/run/runListRoute.js');
const { createLocalApiRunListRoute } = require('../dist/run/runListRoute.js');
function run(id, createdAtMs, overrides = {}) {
return {
@@ -29,7 +27,7 @@ test('returns the shared bounded Project Run list projection', async () => {
const route = createLocalApiRunListRoute({
async listRunsByProject(query) {
calls.push(query);
return [run('run-b', 20), run('run-a', 10)];
return [run('run-b', 20, { triggerId: 'cron:task-b' }), run('run-a', 10)];
},
});
const response = await route.handle({
@@ -39,6 +37,7 @@ test('returns the shared bounded Project Run list projection', async () => {
assert.deepEqual(calls, [{ projectId: 'prj_default', limit: 2 }]);
assert.equal(response.statusCode, 200);
assert.equal(response.body.runs[0].id, 'run-b');
assert.equal(response.body.runs[0].triggerId, 'cron:task-b');
assert.equal(response.body.hasMore, true);
assert.deepEqual(response.body.next, { createdAtMs: 20, runId: 'run-b' });
assert.equal(JSON.stringify(response).includes('secret-adjacent'), false);
@@ -50,7 +49,9 @@ test('fails closed on cross-Project, malformed and unavailable pages', async ()
[run('run-a', 10, { status: 'invented' })],
]) {
const route = createLocalApiRunListRoute({
async listRunsByProject() { return rows; },
async listRunsByProject() {
return rows;
},
});
assert.deepEqual(
await route.handle({ projectId: 'prj_default', input: {} }),
@@ -58,7 +59,9 @@ test('fails closed on cross-Project, malformed and unavailable pages', async ()
);
}
const unavailable = createLocalApiRunListRoute({
async listRunsByProject() { throw new Error('offline'); },
async listRunsByProject() {
throw new Error('offline');
},
});
assert.deepEqual(
await unavailable.handle({ projectId: 'prj_default', input: {} }),
@@ -28,6 +28,7 @@ export interface BoundedRunListItem {
readonly id: string;
readonly taskId: string;
readonly taskRevision: string;
readonly triggerId?: string;
readonly status: RunStatus;
readonly version: number;
readonly eventSequence: number;
@@ -73,7 +74,11 @@ function boundedText(value: unknown, maximum: number): value is string {
);
}
function integer(value: unknown, minimum: number, maximum: number): value is number {
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
@@ -89,7 +94,11 @@ function cursor(value: unknown): value is Readonly<ProjectRunListCursor> {
Reflect.ownKeys(value).length === 2 &&
Object.hasOwn(value, 'createdAtMs') &&
Object.hasOwn(value, 'runId') &&
integer((value as ProjectRunListCursor).createdAtMs, 0, Number.MAX_SAFE_INTEGER) &&
integer(
(value as ProjectRunListCursor).createdAtMs,
0,
Number.MAX_SAFE_INTEGER,
) &&
boundedText((value as ProjectRunListCursor).runId, 128)
);
}
@@ -153,6 +162,7 @@ function projectRun(
(boundary !== undefined && !isBefore(rowCursor, boundary)) ||
!boundedText(value.taskId, 255) ||
!boundedText(value.taskRevision, 255) ||
(value.triggerId !== undefined && !boundedText(value.triggerId, 128)) ||
!RUN_STATUSES.includes(value.status) ||
!EXECUTION_ORIGINS.includes(value.executionOrigin) ||
(value.executionOwner !== 'legacy' && value.executionOwner !== 'runtime') ||
@@ -169,6 +179,7 @@ function projectRun(
id: value.id,
taskId: value.taskId,
taskRevision: value.taskRevision,
...(value.triggerId === undefined ? {} : { triggerId: value.triggerId }),
status: value.status,
version: value.version,
eventSequence: value.eventSequence,
@@ -177,8 +188,12 @@ function projectRun(
executionOwner: value.executionOwner,
createdAtMs: value.createdAtMs,
...(value.queuedAtMs === undefined ? {} : { queuedAtMs: value.queuedAtMs }),
...(value.startedAtMs === undefined ? {} : { startedAtMs: value.startedAtMs }),
...(value.finishedAtMs === undefined ? {} : { finishedAtMs: value.finishedAtMs }),
...(value.startedAtMs === undefined
? {}
: { startedAtMs: value.startedAtMs }),
...(value.finishedAtMs === undefined
? {}
: { finishedAtMs: value.finishedAtMs }),
});
}
@@ -37,7 +37,11 @@ test('projects one descending bounded page and a stable keyset cursor', async ()
{
async listRunsByProject(query) {
calls.push(query);
return [run('run-c', 30), run('run-b', 20), run('run-a', 10)];
return [
run('run-c', 30, { triggerId: 'cron:task-run-c' }),
run('run-b', 20),
run('run-a', 10),
];
},
},
'prj_default',
@@ -50,6 +54,7 @@ test('projects one descending bounded page and a stable keyset cursor', async ()
id: 'run-c',
taskId: 'task-run-c',
taskRevision: 'revision-1',
triggerId: 'cron:task-run-c',
status: 'succeeded',
version: 2,
eventSequence: 3,
@@ -109,7 +114,11 @@ test('uses default and maximum bounds and passes an exact cursor', async () => {
});
test('fails closed on malformed inputs, rows, ordering and repository failures', async () => {
const reader = { async listRunsByProject() { return []; } };
const reader = {
async listRunsByProject() {
return [];
},
};
for (const input of [
null,
{ limit: 0 },
@@ -131,7 +140,11 @@ test('fails closed on malformed inputs, rows, ordering and repository failures',
]) {
await assert.rejects(
executeBoundedRunListProjection(
{ async listRunsByProject() { return rows; } },
{
async listRunsByProject() {
return rows;
},
},
'prj_default',
{},
),
@@ -140,7 +153,11 @@ test('fails closed on malformed inputs, rows, ordering and repository failures',
}
await assert.rejects(
executeBoundedRunListProjection(
{ async listRunsByProject() { throw new Error('offline'); } },
{
async listRunsByProject() {
throw new Error('offline');
},
},
'prj_default',
{},
),
+10 -6
View File
@@ -305,7 +305,7 @@ const Crontab = () => {
const isPc = !isPhone;
return (
<Space size="middle">
{record.status === CrontabStatus.idle && (
{!qingLong3ReadOnly && record.status === CrontabStatus.idle && (
<a
onClick={(e) => {
e.stopPropagation();
@@ -315,7 +315,7 @@ const Crontab = () => {
{intl.get('运行')}
</a>
)}
{record.status !== CrontabStatus.idle && (
{!qingLong3ReadOnly && record.status !== CrontabStatus.idle && (
<a
onClick={(e) => {
e.stopPropagation();
@@ -333,7 +333,9 @@ const Crontab = () => {
>
{intl.get('日志')}
</a>
<MoreBtn key="more" record={record} index={index} />
{!qingLong3ReadOnly && (
<MoreBtn key="more" record={record} index={index} />
)}
</Space>
);
},
@@ -373,7 +375,7 @@ const Crontab = () => {
const visibleColumns = qingLong3ReadOnly
? columns
.filter((column) =>
['name', 'command', 'status', 'schedule'].includes(
['name', 'command', 'status', 'schedule', 'action'].includes(
String(column.key || ''),
),
)
@@ -465,6 +467,7 @@ const Crontab = () => {
};
useEffect(() => {
if (qingLong3ReadOnly) return;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
@@ -1132,13 +1135,14 @@ const Crontab = () => {
components={isPhone || pageConf.size < 50 ? undefined : vt}
/>
</div>
{!qingLong3ReadOnly && isLogModalVisible && (
{isLogModalVisible && (
<CronLogModal
handleCancel={() => {
getCronDetail(logCron);
if (!qingLong3ReadOnly) getCronDetail(logCron);
setIsLogModalVisible(false);
}}
cron={logCron}
qingLong3={qingLong3}
/>
)}
{!qingLong3ReadOnly && isModalVisible && (
+179 -23
View File
@@ -1,5 +1,5 @@
import intl from "react-intl-universal";
import React, { useEffect, useRef, useState } from "react";
import intl from 'react-intl-universal';
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
message,
@@ -8,32 +8,166 @@ import {
Statistic,
Button,
Typography,
} from "antd";
import { request } from "@/utils/http";
import config from "@/utils/config";
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import {
qingLong3Credential,
type QingLong3Capabilities,
} from '@/utils/qinglong3';
import {
Loading3QuartersOutlined,
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";
} 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 ACTIVE_RUN_STATUSES = new Set([
'created',
'queued',
'dispatching',
'running',
'waiting_approval',
'retry_wait',
'lost',
]);
async function qingLong3Get(path: string, acceptedStatuses: number[] = []) {
const credential = qingLong3Credential();
if (!credential) throw new Error('QL3_AUTHENTICATION_REQUIRED');
const response = await fetch(path, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
accept: 'application/json',
authorization: `Bearer ${credential}`,
},
});
const value = await response.json();
if (!response.ok && !acceptedStatuses.includes(response.status)) {
throw new Error(
typeof value?.code === 'string' ? value.code : 'QL3_REQUEST_UNAVAILABLE',
);
}
return { status: response.status, value };
}
function decodeQingLong3Log(content: unknown): string {
if (typeof content !== 'string') throw new Error('QL3_LOG_INVALID');
const binary = atob(content);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
async function readQingLong3CronLog(
cron: any,
capabilities: Readonly<QingLong3Capabilities>,
): Promise<Readonly<{ log: string; active: boolean }>> {
const projectId = cron?.ql3?.projectId;
const taskId = cron?.ql3?.taskId;
const triggerId = cron?.ql3?.triggerId;
if (
typeof projectId !== 'string' ||
typeof taskId !== 'string' ||
typeof triggerId !== 'string'
) {
throw new Error('QL3_CRON_IDENTITY_INVALID');
}
let remaining = capabilities.limits.cronRows;
let cursor: { createdAtMs: number; runId: string } | undefined;
let matchedRun: any;
while (remaining > 0) {
const limit = Math.min(capabilities.limits.cronPageSize, remaining);
const query = new URLSearchParams({ limit: String(limit) });
if (cursor) {
query.set('after_created_at_ms', String(cursor.createdAtMs));
query.set('after_run_id', cursor.runId);
}
const { value: page } = await qingLong3Get(
`/api/v3/projects/${projectId}/runs?${query.toString()}`,
);
if (!Array.isArray(page?.runs) || page.runs.length > limit) {
throw new Error('QL3_RUN_PAGE_INVALID');
}
matchedRun = page.runs.find(
(run: any) => run?.triggerId === triggerId && run?.taskId === taskId,
);
if (matchedRun) break;
remaining -= page.runs.length;
if (!page.hasMore || !page.next || page.runs.length === 0) break;
cursor = page.next;
}
if (!matchedRun) {
return { log: intl.get('暂无日志'), active: false };
}
const { value: detail } = await qingLong3Get(
`/api/v3/projects/${projectId}/runs/${matchedRun.id}`,
);
const attempt = detail?.run?.latestAttempt;
const status = detail?.run?.status;
const active = ACTIVE_RUN_STATUSES.has(status);
if (!attempt?.id || attempt.logAvailable !== true) {
return {
log: `${intl.get('暂无日志')}\n\nRun: ${matchedRun.id}\nStatus: ${
status || 'unknown'
}`,
active,
};
}
const length = capabilities.limits.logChunkBytes;
const { status: responseStatus, value: logView } = await qingLong3Get(
`/api/v3/projects/${projectId}/runs/${matchedRun.id}/attempts/${attempt.id}/log?offset=0&length=${length}`,
[202, 410],
);
if (responseStatus === 202 || logView?.status === 'pending') {
return { log: `日志尚未就绪\n\nRun: ${matchedRun.id}`, active };
}
if (responseStatus === 410 || logView?.status === 'retired') {
return {
log: `日志已按保留策略清理\n\nRun: ${matchedRun.id}`,
active: false,
};
}
if (logView?.status !== 'available' || logView?.encoding !== 'base64') {
throw new Error('QL3_LOG_INVALID');
}
const suffix =
logView.range?.nextOffset !== undefined
? `\n\n[仅显示前 ${length} 字节,可点击刷新重新读取当前片段]`
: logView.truncation?.truncated === true
? '\n\n[日志已按运行时保留上限截断]'
: '';
return {
log:
`${decodeQingLong3Log(logView.content)}${suffix}` || intl.get('暂无日志'),
active,
};
}
const CronLogModal = ({
cron,
handleCancel,
data,
logUrl,
qingLong3,
}: {
cron?: any;
handleCancel: () => void;
data?: string;
logUrl?: string;
qingLong3?: Readonly<QingLong3Capabilities> | null;
}) => {
const [value, setValue] = useState<string>(intl.get("启动中..."));
const [value, setValue] = useState<string>(intl.get('启动中...'));
const [loading, setLoading] = useState<any>(true);
const [executing, setExecuting] = useState<any>(true);
const [isPhone, setIsPhone] = useState(false);
@@ -44,18 +178,33 @@ const CronLogModal = ({
if (isFirst) {
setLoading(true);
}
if (qingLong3) {
readQingLong3CronLog(cron, qingLong3)
.then(({ log, active }) => {
if (localStorage.getItem('logCron') !== uniqPath) return;
setValue(log);
setExecuting(active);
setTimeout(() => autoScroll());
})
.catch(() => {
setValue('日志读取失败,请检查凭据或稍后重试');
setExecuting(false);
})
.finally(() => setLoading(false));
return;
}
request
.get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`)
.then(({ code, data, logStatus }) => {
if (
code === 200 &&
localStorage.getItem("logCron") === uniqPath &&
localStorage.getItem('logCron') === uniqPath &&
data !== value
) {
const log = (data as string) || intl.get("暂无日志");
const log = (data as string) || intl.get('暂无日志');
setValue(log);
const hasNext = logStatus === 'running';
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
if (!hasNext && !logEnded(value) && value !== intl.get('启动中...')) {
setTimeout(() => {
autoScroll();
});
@@ -83,13 +232,13 @@ const CronLogModal = ({
setTimeout(() => {
document
.querySelector("#log-flag")
?.scrollIntoView({ behavior: "smooth" });
.querySelector('#log-flag')
?.scrollIntoView({ behavior: 'smooth' });
}, 600);
};
const cancel = () => {
localStorage.removeItem("logCron");
localStorage.removeItem('logCron');
handleCancel();
};
@@ -105,7 +254,7 @@ const CronLogModal = ({
const titleElement = () => {
return (
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{(executing || loading) && <Loading3QuartersOutlined spin />}
{!executing && !loading && <CheckCircleOutlined />}
<Typography.Text ellipsis={true} style={{ marginLeft: 5 }}>
@@ -141,8 +290,15 @@ const CronLogModal = ({
onOk={() => cancel()}
onCancel={() => cancel()}
footer={[
<Button type="primary" onClick={() => cancel()}>
{intl.get("知道了")}
...(qingLong3
? [
<Button key="refresh" onClick={() => getCronLog(true)}>
</Button>,
]
: []),
<Button key="close" type="primary" onClick={() => cancel()}>
{intl.get('知道了')}
</Button>,
]}
>
@@ -154,9 +310,9 @@ const CronLogModal = ({
style={
isPhone
? {
fontFamily: "Source Code Pro",
zoom: 0.83,
}
fontFamily: 'Source Code Pro',
zoom: 0.83,
}
: {}
}
>
+36
View File
@@ -138,3 +138,39 @@ test('rejects mutable assets and external entrypoint drift', (t) => {
/retains an external origin/u,
);
});
test('keeps the QL3 legacy panel log journey canonical, bounded and caller-driven', () => {
const crontab = fs.readFileSync(
path.resolve(__dirname, '../../src/pages/crontab/index.tsx'),
'utf8',
);
const logModal = fs.readFileSync(
path.resolve(__dirname, '../../src/pages/crontab/logModal.tsx'),
'utf8',
);
assert.match(
crontab,
/\['name', 'command', 'status', 'schedule', 'action'\]/u,
);
assert.match(crontab, /!qingLong3ReadOnly && record\.status/u);
assert.match(crontab, /qingLong3=\{qingLong3\}/u);
assert.match(
crontab,
/useEffect\(\(\) => \{\s+if \(qingLong3ReadOnly\) return;[\s\S]+setTimeout\(poll, 10000\)/u,
);
assert.match(logModal, /qingLong3Credential\(\)/u);
assert.match(
logModal,
/Math\.min\(capabilities\.limits\.cronPageSize, remaining\)/u,
);
assert.match(logModal, /\/api\/v3\/projects\/\$\{projectId\}\/runs\?/u);
assert.match(logModal, /run\?\.triggerId === triggerId/u);
assert.match(logModal, /attempts\/\$\{attempt\.id\}\/log\?offset=0&length=/u);
assert.match(
logModal,
/if \(qingLong3\) \{[\s\S]+readQingLong3CronLog[\s\S]+return;\s+\}\s+request/u,
);
assert.match(logModal, />\s*刷新\s*<\/Button>/u);
});