mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): manage canonical task runs from the existing panel
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
# QingLong 3.0 Architecture RFC
|
||||
|
||||
- D-433/ADR-0535(现有面板执行管理源码候选):通过可选 `panel.runControl=task_run_v1` 开放逐项 Task start 和确切 Run cancellation,不恢复旧 Cron 写接口。确认前分别展示定时绑定版本与当前任务版本;每次项目 Run 窗口最多 64 条,只显示当前 Task 匹配项,支持显式选择、状态刷新和该次执行首片日志。确认绑定 mutation UUID,结果不明时只显式重试同一请求;关闭或重登后的响应不得继续操作。客户端 14 项含规范 body parser 与新旧 capability 回归,Node 20 面板构建与 240 files / 11,993,647 bytes 裁剪审计通过;全量前端类型诊断与基线同为 41 项,无新增。此切片仍待同源远端 CI/双架构实物,不代表旧面板已完整迁移,见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。
|
||||
|
||||
- D-432/ADR-0534(原生 Console 源码候选):修复断开/重连后的异步结果污染。每次连接使用独立内存代次,统一请求与编辑/执行链拒绝旧成功、失败和 finally;旧响应不得重开编辑器、恢复 Secret catalog、覆盖 proof 或在新连接下发出后续请求。16 项针对性回归、完整 Local API 130/130、后端 1683 pass/2 环境 skip/0 fail,静态资产合计 114,042 bytes。断开不撤销已提交的服务端写入,重连须核对 durable 状态;不新增包、后台服务或后端权限。此修复尚未通过同源远端 CI/阶段实物验收,见 [ADR-0534](./adr/ADR-0534-native-console-session-isolation.md)。
|
||||
|
||||
- D-431/ADR-0533(原生 Console 源码候选):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。远端 CI、浏览器证据和 exact 阶段实物分别核验,不借用 D-430 archive。
|
||||
- D-431/ADR-0533(exact 双架构 Console 阶段实物已交付):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。提交 `ce8c3a7d` 的主 CI `33796190607` 与 Console artifact `33797144811` 已成功;amd64/arm64/milestone 实际下载校验、bundle/milestone auditor 与 archive 页面源码逐字节复核均通过,不借用 D-430 archive。它不包含后续 D-432 会话修复或 D-433 执行管理。
|
||||
|
||||
- D-430/ADR-0532(exact Console 双架构阶段实物已交付):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。提交 `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5` 主 CI run `33793623200` 为 42 success/3 expected skip/0 fail,独立 Kubernetes run `33793623184` 成功;Console artifact run `33793687627` 为 43 success/2 expected skip/0 fail。amd64/arm64/milestone artifact `9909229721`/`9909131639`/`9909265138` 实际下载后的 checksum、bundle v8 与 milestone v7 auditor 均通过;两个 archive 内的原生 JS 都与该提交逐字节一致。该实物不包含后续 D-431 列表分页,不等同完整 3.0 或 Public Release。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0533:原生 Console 的任务、定时与运行列表分页
|
||||
|
||||
- 状态:Accepted(D-431 远端 CI 与产物构建通过;下载实物复核待完成)
|
||||
- 状态:Accepted(D-431 exact 双架构 Console 阶段实物已交付并复核)
|
||||
- 日期:2026-09-04
|
||||
- 关联:QL-RFC-0001 D-431、ADR-0532
|
||||
|
||||
@@ -29,4 +29,6 @@
|
||||
|
||||
真实 Chromium 使用当前原生静态资产与合成 HTTP 数据验证了 Task、Trigger、Run 的第 65 条可达,任务详情可从第二页打开,DOM 每次只保留当前页。请求记录包含三个规范 continuation URL,浏览期间没有自动列表轮询。该浏览器证据验证交互,不冒充真实数据库、Owner 写入或双架构镜像验收。
|
||||
|
||||
提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`(239,038,659 bytes)、arm64 `9910153237`(234,438,339 bytes)及 milestone `9910185111`(6,797 bytes);本记录仅确认远端成功与上传存在,下载后的 checksum、离线 auditor 和 archive 源码复核尚未完成。这些归档不包含后续 ADR-0534 的客户端连接代次修复。
|
||||
提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`(239,038,659 bytes)、arm64 `9910153237`(234,438,339 bytes)及 milestone `9910185111`(6,797 bytes)。三份已实际下载,SHA256SUMS、双 bundle v8 auditor、milestone v7 auditor 均通过,两份 bundle manifest digest 与 milestone 一致。Docker archive 内 application 最终层的 `console.js` 均为 80,336 bytes、SHA256 `febac949f0f72ebeda41fe743909074762d5add0804895ca6802b54d0d51a6a5`,与该提交逐字节一致。amd64 archive SHA256 为 `dffa8606ee30e6cbf45bd1166ad0c716450e15ac21c07e7e15e539db140d3281`,arm64 为 `25cad723858afea09f2787cf42160db4150e48690e03afdbb0ac396eb1bcb8c9`。
|
||||
|
||||
因本机空间不足,完成审计后已删除本轮两份临时 Docker archive,保留 manifest 等小文件;归档可从上述 run 重新下载。这些实物仍为 Alpha,不包含后续 ADR-0534 的客户端连接代次修复或 ADR-0535 的旧面板执行管理。
|
||||
|
||||
@@ -33,3 +33,5 @@
|
||||
真实 Chromium 使用当前静态资产和仅监听本机的合成 HTTP 服务完成连接、创建 Task、确认 Secret GET 挂起、断开、放行旧响应的交互;页面保持等待凭据且没有重开编辑器。测试没有真实数据库、生产 credential 或写入 authority,不能代替镜像端到端验收。
|
||||
|
||||
上述本地验证不代表修复已进入既有下载产物。D-431 的 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 产物也不包含本修复;本切片必须以自己的 source revision、CI 和阶段归档证据验收。
|
||||
|
||||
提交 `03afd7e8` 的独立 Kubernetes deployment 验证通过,但主 CI [run 33799350582](https://github.com/whyour/qinglong/actions/runs/33799350582) 在 CloudNativePG live gate 失败:rollout 已完成,旧 operator Pod 仍为 Terminating,`verifyImageIds` 纳入其空 imageID 后拒绝。`2005cb6b` 单独修正 operator 取样,排除 deletionTimestamp 已设置的 Pod;存活 Pod 仍执行原摘要校验,空集合仍失败,针对性回归 11/11。此本地修复不改写旧 run 的失败结论,须由后续同源 CI 验证。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR-0535:现有面板的规范 Task/Run 执行管理
|
||||
|
||||
- 状态:Accepted(D-433 源码候选;同源 CI 与实物待验证)
|
||||
- 日期:2026-09-04
|
||||
- 关联:QL-RFC-0001 D-433、ADR-0530、ADR-0531、ADR-0534
|
||||
|
||||
## 背景
|
||||
|
||||
现有面板已可登录、查看定时条目和定时执行日志,但不能实际管理运行。旧 Cron 的运行状态与 ID 不是 3.0 Task revision 或 durable Run authority;直接恢复旧运行/停止按钮会混淆定时绑定版本、当前任务版本,以及多个并发运行。
|
||||
|
||||
## 决策
|
||||
|
||||
- Local capability v1 增加可选 `panel.runControl: task_run_v1`。缺省保持旧只读体验,未知值拒绝,旧客户端可忽略新增字段。`cronList=bounded_read_only` 和 `legacyMutations=false` 不变:不开放旧 Cron 写接口、Task 编辑、Trigger 编辑、批量操作或其他旧页面。
|
||||
- 定时行新增“执行管理”,复用当前 Ant Design 页面和同源 API。在确认前读取 Task 当前 revision/content digest,分别展示定时绑定版本和本次运行版本;显式确认后才 POST `/api/v3/projects/:project/tasks/:task/runs`,不自动重绑 Trigger。
|
||||
- Run 浏览复用规范项目 keyset API,每个显式动作至多读取 64 条并仅展示当前 Task 的匹配项。页内及跨页顺序、重复条目、next cursor 和 canonical ID 均校验;翻页替换窗口,没有总数扫描、全文累积、自动查找或轮询。空页不证明更早没有匹配运行。
|
||||
- 必须读取并选择具体 Run 才能请求取消,取消 POST 指向该 Run,不按 Cron 或“最新一次”猜测目标。取消 receipt 只证明请求已登记;运行终态仍以手动刷新后的 durable projection 为准。
|
||||
- 手动启动返回的 Run 可直接选择、刷新并读取 latest Attempt 首片日志;不使用定时 Trigger 匹配,以免看错另外一次运行。Edge 16 KiB、Standalone 32 KiB,日志不累计或轮询。
|
||||
- 每份确认绑定一个 mutation UUID、确切版本或 Run ID。传输结果不明时保留原确认,显式重试复用同一请求体;禁止并发确认,不静默创建另一份启动请求。关闭或重连不撤销已发送的服务端操作。
|
||||
- 新客户端使用不含 credential 的连接代次和窗口生命周期标记,关闭、登出或同 credential 重登后的响应不得继续产生操作。凭据仍只保留内存,不写 URL、Cookie 或 Web Storage;服务端 `run.start`/`run.stop` Policy、audit 与 mutation fence 不变。
|
||||
- 新模块位于 `src/components/qinglong3`,避免 Umi 将辅助模块注册为独立页面。不新建 package、依赖、listener、timer 或数据库权威;默认 headless 和 Cluster 不携带这个 Local 面板。
|
||||
|
||||
## 验证与边界
|
||||
|
||||
客户端测试执行真实 TypeScript 模块,覆盖确认前无 POST、current revision 与旧 pin 区分、实际规范 body parser、相同 mutation 重试、并发确认拒绝、权限/版本错误、错误 receipt、准确 Run 取消、分页顺序和预算、关闭/重登、延迟解码、明确 Run 日志与新旧 capability 兼容。
|
||||
|
||||
客户端专项 14/14、Local API 130/130,最终后端 1700 项(1698 pass / 2 环境条件 skip / 0 fail);Local image 与 18-package boundary audit 均 compatible。真实 Chromium 在构建后的裁剪面板和合成服务上完成登录、版本 1/2 差异展示、确认运行、选中返回 Run、读取该次日志、确认取消与 cancelled 展示。服务记录只有一个启动 POST 和一个针对相同 Run 的取消 POST;整个交互期间只有一次 Cron 列表和一次项目 Run 列表,没有后台轮询。桌面 1280 与手机 390 宽度截图复核了弹窗滚动、表格横向窗口与底部关闭入口。上述浏览器证据不包含真实执行进程或数据库写入。
|
||||
|
||||
Node 20 legacy migration toolchain 的 production panel build 通过;裁剪包为 240 files / 11,993,647 bytes,仍在 256 files / 13 MiB 边界。全量前端 TypeScript 检查存在 41 项错误;通过 compiler host 读取提交前源码作对比,确认本次没有新增诊断,不将存量类型错误称为通过。
|
||||
|
||||
本切片不是完整旧面板迁移:任务/定时创建编辑、脚本、订阅、环境变量、依赖与多用户远程 Web 会话仍未闭合。Local 仍仅 loopback/SSH tunnel;不可将本地 mock 或 unit tests 当成实际 Owner 写入、公开发布或双架构镜像证据。D-431 实物不含本切片,交付必须绑定自己的 source revision。
|
||||
+2
-1
@@ -536,8 +536,9 @@
|
||||
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Accepted(D-428 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | Accepted(D-430 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 源码候选;实物待验证) |
|
||||
| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 exact 双架构 Console 阶段实物已交付并复核) |
|
||||
| [ADR-0534](./ADR-0534-native-console-session-isolation.md) | 原生 Console 异步操作的连接代次隔离 | Accepted(D-432 源码候选;同源 CI 与实物待验证) |
|
||||
| [ADR-0535](./ADR-0535-canonical-panel-run-control.md) | 现有面板的规范 Task/Run 执行管理 | Accepted(D-433 源码候选;同源 CI 与实物待验证) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -21,10 +21,13 @@ D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一
|
||||
| --- | --- | --- |
|
||||
| D-429 | 原生 Task/Trigger/Secret/Run 操作;适配后旧面板登录、只读 Cron 列表与首片日志 | `09ef1745226c05521a9a44accb9d9ef95dd46c85`;[Console milestone run 33789576578](https://github.com/whyour/qinglong/actions/runs/33789576578),已交付,见 [ADR-0531](../adr/ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) |
|
||||
| D-430 | 原生 `/console` 日志“下一片段”“回到开头”“刷新当前片段” | `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5`;[Console artifact run 33793687627](https://github.com/whyour/qinglong/actions/runs/33793687627) 已交付,两个下载 bundle 与 milestone 均通过离线审计,见 [ADR-0532](../adr/ADR-0532-native-console-log-window-navigation.md) |
|
||||
| D-431 | 原生 Task/Trigger/Run 列表下一页、回到首页与当前页刷新 | `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53`;[Console artifact run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 已下载并完成双架构 checksum、auditor 和 archive 源码复核,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md) |
|
||||
|
||||
上表的旧面板是 bundle 自带的适配版本,不是未修改的 2.x 静态页面;旧账号密码/JWT、现有 2.x 数据和 Cluster 管理能力不会因页面可打开而自动接通。
|
||||
|
||||
D-431 源码候选进一步给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首页/刷新当前页”。每次只显示最多 64 条,翻页替换旧记录和详情,不累积全文;顶部刷新与切换栏目回首页,页脚刷新保留当前边界。浏览期间数据可能变化,因此不提供固定总页数或快照承诺。Secret 目录和 Run Event/Step 仍维持现有窗口限制。此功能尚未交付到上表的 D-429 或 D-430 archive,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md)。
|
||||
D-431 给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首页/刷新当前页”。每次只显示最多 64 条,翻页替换旧记录和详情,不累积全文;顶部刷新与切换栏目回首页,页脚刷新保留当前边界。浏览期间数据可能变化,因此不提供固定总页数或快照承诺。Secret 目录和 Run Event/Step 仍维持现有窗口限制。此功能不在 D-429 或 D-430 archive 中,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md)。
|
||||
|
||||
D-433 源码候选为旧面板定时行增加“执行管理”:先展示当前 Task revision,再显式确认运行;选择确切 Run 后可以刷新、读该次日志或请求取消。它不恢复旧 Cron 写接口,也不修改定时绑定版本。网络结果不明时只重试同一 mutation,关闭窗口不会撤销已提交请求。此功能需服务端 capability 显式开放,不包含在上表任何旧归档中,见 [ADR-0535](../adr/ADR-0535-canonical-panel-run-control.md)。
|
||||
|
||||
D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config <local-api.json>`,同时绑定外层 API 配置、内层 Application 配置与 exact mounts,但该模式不会启动本页使用的 listener、credential、scheduler 或 mutation surface。正常启动仍使用下文不带 `--cutover-probe` 的命令;提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的 [artifact run 33463415938](https://github.com/whyour/qinglong/actions/runs/33463415938) 已交付 exact amd64/arm64 Console Trial Kit 与 milestone,三份下载产物的离线 auditor 均为 `compatible=true`。
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export function panelCapabilities(
|
||||
panel: Object.freeze({
|
||||
bootstrap: true,
|
||||
cronList: 'bounded_read_only',
|
||||
runControl: 'task_run_v1',
|
||||
taskRead: true,
|
||||
triggerRead: true,
|
||||
runRead: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ test('publishes an exact profile-aware capability contract', () => {
|
||||
panel: {
|
||||
bootstrap: true,
|
||||
cronList: 'bounded_read_only',
|
||||
runControl: 'task_run_v1',
|
||||
taskRead: true,
|
||||
triggerRead: true,
|
||||
runRead: true,
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
qingLong3Credential,
|
||||
qingLong3Session,
|
||||
type QingLong3Capabilities,
|
||||
} from '@/utils/qinglong3';
|
||||
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[a-f0-9]{64}$/;
|
||||
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
|
||||
const STATUSES = new Set([
|
||||
...TERMINAL,
|
||||
'created',
|
||||
'queued',
|
||||
'dispatching',
|
||||
'running',
|
||||
'waiting_approval',
|
||||
'retry_wait',
|
||||
'lost',
|
||||
]);
|
||||
|
||||
export interface PanelTask {
|
||||
taskId: string;
|
||||
name: string;
|
||||
revision: number;
|
||||
contentDigest: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface PanelRun {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskRevision: string;
|
||||
status: string;
|
||||
createdAtMs: number;
|
||||
latestAttempt?: { id: string; logAvailable: boolean } | null;
|
||||
}
|
||||
export interface RunCursor {
|
||||
createdAtMs: number;
|
||||
runId: string;
|
||||
}
|
||||
export interface RunPage {
|
||||
runs: PanelRun[];
|
||||
next?: RunCursor;
|
||||
scanned: number;
|
||||
}
|
||||
export interface PreparedRunAction {
|
||||
kind: 'start' | 'cancel';
|
||||
target: string;
|
||||
mutationId: string;
|
||||
execute(): Promise<{ runId: string; status: string }>;
|
||||
}
|
||||
|
||||
export class PanelRunControlError extends Error {
|
||||
constructor(readonly code: string, readonly uncertain = false) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function validId(value: unknown): value is string {
|
||||
return typeof value === 'string' && ID.test(value);
|
||||
}
|
||||
function validTime(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
function cursorOf(value: any): RunCursor {
|
||||
if (
|
||||
!value ||
|
||||
!validTime(value.createdAtMs) ||
|
||||
!validId(value.runId) ||
|
||||
Object.keys(value).sort().join(',') !== 'createdAtMs,runId'
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_run_page');
|
||||
}
|
||||
return Object.freeze({ createdAtMs: value.createdAtMs, runId: value.runId });
|
||||
}
|
||||
function follows(left: RunCursor, right: RunCursor): boolean {
|
||||
return (
|
||||
right.createdAtMs < left.createdAtMs ||
|
||||
(right.createdAtMs === left.createdAtMs && right.runId < left.runId)
|
||||
);
|
||||
}
|
||||
function runOf(value: any): PanelRun {
|
||||
if (
|
||||
!value ||
|
||||
!validId(value.id) ||
|
||||
!validId(value.taskId) ||
|
||||
typeof value.taskRevision !== 'string' ||
|
||||
value.taskRevision.length > 255 ||
|
||||
!value.taskRevision ||
|
||||
!STATUSES.has(value.status) ||
|
||||
!validTime(value.createdAtMs)
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_run_response');
|
||||
}
|
||||
if (
|
||||
value.latestAttempt != null &&
|
||||
(!validId(value.latestAttempt.id) ||
|
||||
typeof value.latestAttempt.logAvailable !== 'boolean')
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_run_response');
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
taskId: value.taskId,
|
||||
taskRevision: value.taskRevision,
|
||||
status: value.status,
|
||||
createdAtMs: value.createdAtMs,
|
||||
...(value.latestAttempt === undefined
|
||||
? {}
|
||||
: {
|
||||
latestAttempt:
|
||||
value.latestAttempt === null
|
||||
? null
|
||||
: Object.freeze({
|
||||
id: value.latestAttempt.id,
|
||||
logAvailable: value.latestAttempt.logAvailable,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createPanelRunControl(
|
||||
cron: any,
|
||||
capabilities: Readonly<QingLong3Capabilities>,
|
||||
) {
|
||||
const projectId = cron?.ql3?.projectId,
|
||||
taskId = cron?.ql3?.taskId;
|
||||
if (
|
||||
!validId(projectId) ||
|
||||
!validId(taskId) ||
|
||||
capabilities.panel.runControl !== 'task_run_v1'
|
||||
) {
|
||||
throw new PanelRunControlError('run_control_unavailable');
|
||||
}
|
||||
const session = qingLong3Session();
|
||||
let disposed = false,
|
||||
writing = false;
|
||||
let currentTask: PanelTask | null = null,
|
||||
currentRun: PanelRun | null = null;
|
||||
const base = `/api/v3/projects/${projectId}`;
|
||||
const isCurrent = () =>
|
||||
!disposed &&
|
||||
session === qingLong3Session() &&
|
||||
Boolean(qingLong3Credential());
|
||||
const assertCurrent = () => {
|
||||
if (!isCurrent()) throw new PanelRunControlError('session_changed');
|
||||
};
|
||||
const request = async (
|
||||
path: string,
|
||||
body?: object,
|
||||
accepted: number[] = [],
|
||||
) => {
|
||||
assertCurrent();
|
||||
let response: Response, value: any;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${qingLong3Credential()}`,
|
||||
...(body ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
value = await response.json();
|
||||
} catch {
|
||||
assertCurrent();
|
||||
throw new PanelRunControlError('request_unavailable', Boolean(body));
|
||||
}
|
||||
assertCurrent();
|
||||
if (!response.ok && !accepted.includes(response.status)) {
|
||||
throw new PanelRunControlError(
|
||||
typeof value?.code === 'string' ? value.code : 'request_unavailable',
|
||||
Boolean(body) && response.status >= 500,
|
||||
);
|
||||
}
|
||||
return { value, status: response.status };
|
||||
};
|
||||
const prepared = (
|
||||
kind: 'start' | 'cancel',
|
||||
target: string,
|
||||
fields: object,
|
||||
validate: (value: any) => boolean,
|
||||
): PreparedRunAction => {
|
||||
const mutationId = crypto.randomUUID();
|
||||
const body = Object.freeze({ ...fields, mutationId });
|
||||
const path =
|
||||
kind === 'start'
|
||||
? `${base}/tasks/${taskId}/runs`
|
||||
: `${base}/runs/${target}/cancellation`;
|
||||
return Object.freeze({
|
||||
kind,
|
||||
target,
|
||||
mutationId,
|
||||
async execute() {
|
||||
assertCurrent();
|
||||
if (writing) throw new PanelRunControlError('operation_pending');
|
||||
writing = true;
|
||||
try {
|
||||
const { value } = await request(path, body);
|
||||
if (!validate(value))
|
||||
throw new PanelRunControlError('invalid_mutation_response', true);
|
||||
return Object.freeze({
|
||||
runId: value.runId as string,
|
||||
status: value.status as string,
|
||||
});
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
return Object.freeze({
|
||||
isCurrent,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
currentTask = null;
|
||||
currentRun = null;
|
||||
},
|
||||
async readTask(): Promise<PanelTask> {
|
||||
const { value } = await request(`${base}/tasks/${taskId}`);
|
||||
const task = value?.task;
|
||||
if (
|
||||
!task ||
|
||||
task.taskId !== taskId ||
|
||||
typeof task.name !== 'string' ||
|
||||
task.name.length > 512 ||
|
||||
!Number.isSafeInteger(task.revision) ||
|
||||
task.revision < 1 ||
|
||||
!DIGEST.test(task.contentDigest) ||
|
||||
typeof task.enabled !== 'boolean'
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_task_response');
|
||||
}
|
||||
currentTask = Object.freeze({
|
||||
taskId,
|
||||
name: task.name,
|
||||
revision: task.revision,
|
||||
contentDigest: task.contentDigest,
|
||||
enabled: task.enabled,
|
||||
});
|
||||
return currentTask;
|
||||
},
|
||||
async listRuns(after?: RunCursor): Promise<RunPage> {
|
||||
const cursor = after ? cursorOf(after) : undefined;
|
||||
// Canonical IDs contain no query separators; the HTTP parser rejects encoded colon aliases.
|
||||
const query = cursor
|
||||
? `&after_created_at_ms=${cursor.createdAtMs}&after_run_id=${cursor.runId}`
|
||||
: '';
|
||||
const { value } = await request(`${base}/runs?limit=64${query}`);
|
||||
if (
|
||||
!Array.isArray(value?.runs) ||
|
||||
value.runs.length > 64 ||
|
||||
typeof value.hasMore !== 'boolean' ||
|
||||
value.hasMore !== Boolean(value.next)
|
||||
)
|
||||
throw new PanelRunControlError('invalid_run_page');
|
||||
const runs: PanelRun[] = value.runs.map(runOf);
|
||||
let previous = cursor;
|
||||
for (const run of runs) {
|
||||
const next = { createdAtMs: run.createdAtMs, runId: run.id };
|
||||
if (previous && !follows(previous, next))
|
||||
throw new PanelRunControlError('invalid_run_page');
|
||||
previous = next;
|
||||
}
|
||||
const next = value.next ? cursorOf(value.next) : undefined;
|
||||
if (
|
||||
next &&
|
||||
(!runs.length ||
|
||||
next.runId !== previous?.runId ||
|
||||
next.createdAtMs !== previous?.createdAtMs)
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_run_page');
|
||||
}
|
||||
return {
|
||||
runs: runs.filter((run) => run.taskId === taskId),
|
||||
scanned: runs.length,
|
||||
next,
|
||||
};
|
||||
},
|
||||
async readRun(runId: string): Promise<PanelRun> {
|
||||
if (!validId(runId))
|
||||
throw new PanelRunControlError('invalid_run_identity');
|
||||
const { value } = await request(`${base}/runs/${runId}`);
|
||||
const run = runOf(value?.run);
|
||||
if (
|
||||
run.id !== runId ||
|
||||
run.taskId !== taskId ||
|
||||
value.run.projectId !== projectId
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_run_identity');
|
||||
}
|
||||
currentRun = run;
|
||||
return run;
|
||||
},
|
||||
prepareStart(task: PanelTask) {
|
||||
assertCurrent();
|
||||
if (task !== currentTask || !task.enabled)
|
||||
throw new PanelRunControlError('task_not_ready');
|
||||
return prepared(
|
||||
'start',
|
||||
taskId,
|
||||
{
|
||||
schema: 'qinglong/task-start@v1',
|
||||
expectedRevision: task.revision,
|
||||
expectedContentDigest: task.contentDigest,
|
||||
},
|
||||
(value) =>
|
||||
value?.schema === 'qinglong/task-start@v1' &&
|
||||
value.projectId === projectId &&
|
||||
value.taskId === taskId &&
|
||||
value.taskRevision === task.revision &&
|
||||
value.taskContentDigest === task.contentDigest &&
|
||||
validId(value.runId) &&
|
||||
['accepted', 'existing'].includes(value.status),
|
||||
);
|
||||
},
|
||||
prepareCancel(run: PanelRun) {
|
||||
assertCurrent();
|
||||
if (run !== currentRun || TERMINAL.has(run.status))
|
||||
throw new PanelRunControlError('run_not_active');
|
||||
return prepared(
|
||||
'cancel',
|
||||
run.id,
|
||||
{ schema: 'qinglong/run-cancellation@v1' },
|
||||
(value) =>
|
||||
value?.schema === 'qinglong/run-cancellation@v1' &&
|
||||
value.projectId === projectId &&
|
||||
value.runId === run.id &&
|
||||
['accepted', 'already_requested', 'already_terminal'].includes(
|
||||
value.status,
|
||||
),
|
||||
);
|
||||
},
|
||||
async readLog(run: PanelRun): Promise<string> {
|
||||
if (run !== currentRun)
|
||||
throw new PanelRunControlError('invalid_run_identity');
|
||||
if (!run.latestAttempt?.logAvailable)
|
||||
return '当前运行尚无可读日志,请刷新运行状态后重试。';
|
||||
const length = capabilities.limits.logChunkBytes;
|
||||
const attemptId = run.latestAttempt.id;
|
||||
const { value, status } = await request(
|
||||
`${base}/runs/${run.id}/attempts/${attemptId}/log?offset=0&length=${length}`,
|
||||
undefined,
|
||||
[202, 410],
|
||||
);
|
||||
if (status === 202) return '日志尚未就绪。';
|
||||
if (status === 410) return '日志已按保留策略清理。';
|
||||
if (
|
||||
value?.status !== 'available' ||
|
||||
value.encoding !== 'base64' ||
|
||||
value.runId !== run.id ||
|
||||
value.attemptId !== attemptId ||
|
||||
typeof value.content !== 'string' ||
|
||||
value.content.length > Math.ceil(length / 3) * 4
|
||||
) {
|
||||
throw new PanelRunControlError('invalid_log_response');
|
||||
}
|
||||
const bytes = Uint8Array.from(atob(value.content), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
if (bytes.length > length)
|
||||
throw new PanelRunControlError('invalid_log_response');
|
||||
return `${new TextDecoder().decode(
|
||||
bytes,
|
||||
)}\n\n[本次仅显示首个至多 ${length} 字节;刷新不自动续读]`;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { QingLong3Capabilities } from '@/utils/qinglong3';
|
||||
import {
|
||||
createPanelRunControl,
|
||||
PanelRunControlError,
|
||||
type PanelTask,
|
||||
type PanelRun,
|
||||
type PreparedRunAction,
|
||||
type RunPage,
|
||||
type RunCursor,
|
||||
} from './runControl';
|
||||
|
||||
const terminal = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
|
||||
const labels: Record<string, string> = {
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
timed_out: '已超时',
|
||||
created: '已创建',
|
||||
queued: '排队中',
|
||||
dispatching: '派发中',
|
||||
running: '运行中',
|
||||
waiting_approval: '等待审批',
|
||||
retry_wait: '等待重试',
|
||||
lost: '执行状态待核对',
|
||||
};
|
||||
const errors: Record<string, string> = {
|
||||
authentication_required: '凭据已失效,请重新登录。',
|
||||
authorization_denied: '当前身份没有执行该操作的权限。',
|
||||
task_start_fence_rejected:
|
||||
'任务版本或权限在确认期间已改变。本次启动被拒绝,请刷新任务后重新确认。',
|
||||
session_changed: '连接已改变,请关闭窗口并重新登录。',
|
||||
task_not_ready: '请先读取当前任务,已停用的任务不能启动。',
|
||||
run_not_active: '请选择并刷新一个尚未结束的运行。',
|
||||
};
|
||||
|
||||
export default function QingLong3RunControlModal({
|
||||
cron,
|
||||
capabilities,
|
||||
onClose,
|
||||
}: {
|
||||
cron: any;
|
||||
capabilities: Readonly<QingLong3Capabilities>;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const clientRef = useRef<ReturnType<typeof createPanelRunControl>>();
|
||||
const busyRef = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [task, setTask] = useState<PanelTask>();
|
||||
const [page, setPage] = useState<RunPage>();
|
||||
const [cursor, setCursor] = useState<RunCursor>();
|
||||
const [run, setRun] = useState<PanelRun>();
|
||||
const [log, setLog] = useState('');
|
||||
const [prepared, setPrepared] = useState<PreparedRunAction>();
|
||||
const [uncertain, setUncertain] = useState(false);
|
||||
|
||||
const work = async (
|
||||
action: (client: ReturnType<typeof createPanelRunControl>) => Promise<void>,
|
||||
) => {
|
||||
const client = clientRef.current;
|
||||
if (!client || busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await action(client);
|
||||
} catch (caught) {
|
||||
if (clientRef.current !== client) return;
|
||||
const failure = caught instanceof PanelRunControlError ? caught : null;
|
||||
if (!client.isCurrent()) {
|
||||
setTask(undefined);
|
||||
setRun(undefined);
|
||||
setPage(undefined);
|
||||
setLog('');
|
||||
setPrepared(undefined);
|
||||
setError(errors.session_changed);
|
||||
} else {
|
||||
setError(
|
||||
failure?.uncertain
|
||||
? '服务端可能已收到请求。请重试同一请求以核对结果;不要新建另一份启动请求。'
|
||||
: errors[failure?.code || ''] ||
|
||||
'读取或操作失败,请检查服务与权限后手动重试。',
|
||||
);
|
||||
setUncertain(Boolean(failure?.uncertain));
|
||||
}
|
||||
} finally {
|
||||
if (clientRef.current === client) {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const loadRuns = async (
|
||||
client: ReturnType<typeof createPanelRunControl>,
|
||||
after?: RunCursor,
|
||||
) => {
|
||||
const result = await client.listRuns(after);
|
||||
if (!client.isCurrent()) return;
|
||||
setPage(result);
|
||||
setCursor(after);
|
||||
setRun(undefined);
|
||||
setLog('');
|
||||
};
|
||||
const selectRun = async (
|
||||
client: ReturnType<typeof createPanelRunControl>,
|
||||
id: string,
|
||||
) => {
|
||||
const result = await client.readRun(id);
|
||||
if (client.isCurrent()) {
|
||||
setRun(result);
|
||||
setLog('');
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
let client: ReturnType<typeof createPanelRunControl>;
|
||||
try {
|
||||
client = createPanelRunControl(cron, capabilities);
|
||||
} catch {
|
||||
setError('当前服务未开放执行管理,或条目标识无效。');
|
||||
return;
|
||||
}
|
||||
clientRef.current = client;
|
||||
void work(async () => {
|
||||
const current = await client.readTask();
|
||||
if (client.isCurrent()) setTask(current);
|
||||
await loadRuns(client);
|
||||
});
|
||||
return () => {
|
||||
client.dispose();
|
||||
clientRef.current = undefined;
|
||||
busyRef.current = false;
|
||||
};
|
||||
}, [cron, capabilities]);
|
||||
|
||||
const prepare = (kind: 'start' | 'cancel') =>
|
||||
void work(async (client) => {
|
||||
const action =
|
||||
kind === 'start'
|
||||
? client.prepareStart(task!)
|
||||
: client.prepareCancel(run!);
|
||||
setPrepared(action);
|
||||
setNotice('');
|
||||
setUncertain(false);
|
||||
});
|
||||
const execute = () =>
|
||||
void work(async (client) => {
|
||||
if (!prepared) return;
|
||||
const receipt = await prepared.execute();
|
||||
if (!client.isCurrent()) return;
|
||||
setPrepared(undefined);
|
||||
setUncertain(false);
|
||||
setNotice(
|
||||
prepared.kind === 'start'
|
||||
? `运行已登记:${receipt.runId}。排队不代表已经执行成功。`
|
||||
: `取消请求结果:${receipt.status}。只有运行进入终态才表示已结束。`,
|
||||
);
|
||||
await selectRun(client, receipt.runId);
|
||||
});
|
||||
const close = () => {
|
||||
if (busy || uncertain) {
|
||||
Modal.confirm({
|
||||
title: '关闭执行管理?',
|
||||
content:
|
||||
'关闭窗口不会撤销已发送的请求。再次打开时请先核对运行记录,避免重复启动。',
|
||||
onOk: onClose,
|
||||
});
|
||||
} else onClose();
|
||||
};
|
||||
const blocked = busy || Boolean(prepared);
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="执行管理 · QingLong 3.0"
|
||||
width={860}
|
||||
onCancel={close}
|
||||
footer={<Button onClick={close}>关闭</Button>}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="手动运行使用 Task 当前版本,不修改定时计划。"
|
||||
description="取消针对选中的 Run;没有后台轮询,请手动刷新确认状态。实际操作仍受项目权限控制。"
|
||||
/>
|
||||
{error && <Alert type="error" showIcon message={error} />}
|
||||
{notice && <Alert type="success" showIcon message={notice} />}
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="任务">
|
||||
{task?.name || cron.name} ·{' '}
|
||||
<Typography.Text code>{cron.ql3.taskId}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="定时绑定版本">
|
||||
{cron.ql3.taskRevision}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="本次运行版本">
|
||||
{task
|
||||
? `${task.revision}${task.enabled ? '' : '(已停用)'}`
|
||||
: '未读取'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space wrap>
|
||||
<Button
|
||||
disabled={blocked}
|
||||
onClick={() =>
|
||||
void work(async (client) => {
|
||||
const current = await client.readTask();
|
||||
if (client.isCurrent()) setTask(current);
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新任务版本
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={blocked || !task?.enabled}
|
||||
onClick={() => prepare('start')}
|
||||
>
|
||||
运行一次
|
||||
</Button>
|
||||
</Space>
|
||||
{prepared && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
prepared.kind === 'start'
|
||||
? `确认运行 Task ${prepared.target} · revision ${task?.revision}?`
|
||||
: `确认请求取消 Run ${prepared.target}?`
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
请求 ID:{prepared.mutationId}。重试会复用此 ID。
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button
|
||||
danger={prepared.kind === 'cancel'}
|
||||
type="primary"
|
||||
loading={busy}
|
||||
onClick={execute}
|
||||
>
|
||||
{uncertain
|
||||
? '重试同一请求'
|
||||
: prepared.kind === 'start'
|
||||
? '确认运行一次'
|
||||
: '确认请求取消'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || uncertain}
|
||||
onClick={() => setPrepared(undefined)}
|
||||
>
|
||||
放弃确认
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
运行记录
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每页扫描项目最近至多 64
|
||||
条,仅显示本任务匹配项;空页不表示没有更早的运行。翻页替换当前窗口。
|
||||
</Typography.Text>
|
||||
<Table<PanelRun>
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={busy}
|
||||
dataSource={page?.runs || []}
|
||||
scroll={{ x: 600 }}
|
||||
locale={{
|
||||
emptyText: '当前窗口没有匹配运行,可继续查找或回到最近记录。',
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: 'Run ID',
|
||||
dataIndex: 'id',
|
||||
render: (id: string) => (
|
||||
<Button
|
||||
type="link"
|
||||
disabled={blocked}
|
||||
style={{ padding: 0, maxWidth: '100%' }}
|
||||
onClick={() => void work((client) => selectRun(client, id))}
|
||||
>
|
||||
{id}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ title: '版本', dataIndex: 'taskRevision' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (status: string) => labels[status],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Button
|
||||
disabled={blocked}
|
||||
onClick={() => void work((client) => loadRuns(client, cursor))}
|
||||
>
|
||||
刷新当前窗口
|
||||
</Button>
|
||||
<Button
|
||||
disabled={blocked || !cursor}
|
||||
onClick={() => void work((client) => loadRuns(client))}
|
||||
>
|
||||
最近记录
|
||||
</Button>
|
||||
<Button
|
||||
disabled={blocked || !page?.next}
|
||||
onClick={() => void work((client) => loadRuns(client, page?.next))}
|
||||
>
|
||||
继续查找更早运行
|
||||
</Button>
|
||||
</Space>
|
||||
{run && (
|
||||
<>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="已选 Run">
|
||||
<Typography.Text code>{run.id}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{labels[run.status]}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space wrap>
|
||||
<Button
|
||||
disabled={blocked}
|
||||
onClick={() => void work((client) => selectRun(client, run.id))}
|
||||
>
|
||||
刷新运行状态
|
||||
</Button>
|
||||
<Button
|
||||
disabled={blocked}
|
||||
onClick={() =>
|
||||
void work(async (client) => {
|
||||
const text = await client.readLog(run);
|
||||
if (client.isCurrent()) setLog(text);
|
||||
})
|
||||
}
|
||||
>
|
||||
读取本次日志
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
disabled={blocked || terminal.has(run.status)}
|
||||
onClick={() => prepare('cancel')}
|
||||
>
|
||||
请求取消本次运行
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
{log && (
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{log}
|
||||
</pre>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { getScheduleType } from './const';
|
||||
import CronDetailModal from './detail';
|
||||
import './index.less';
|
||||
import CronLogModal from './logModal';
|
||||
import QingLong3RunControlModal from '@/components/qinglong3/runControlModal';
|
||||
import CronModal, { CronLabelModal } from './modal';
|
||||
import {
|
||||
CrontabStatus,
|
||||
@@ -305,6 +306,16 @@ const Crontab = () => {
|
||||
const isPc = !isPhone;
|
||||
return (
|
||||
<Space size="middle">
|
||||
{qingLong3?.panel.runControl === 'task_run_v1' && (
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRunControlCron(record);
|
||||
}}
|
||||
>
|
||||
执行管理
|
||||
</a>
|
||||
)}
|
||||
{!qingLong3ReadOnly && record.status === CrontabStatus.idle && (
|
||||
<a
|
||||
onClick={(e) => {
|
||||
@@ -350,6 +361,7 @@ const Crontab = () => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
|
||||
const [logCron, setLogCron] = useState<any>();
|
||||
const [runControlCron, setRunControlCron] = useState<any>();
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||
const [pageConf, setPageConf] = useState<{
|
||||
page: number;
|
||||
@@ -992,7 +1004,13 @@ const Crontab = () => {
|
||||
title={intl.get('定时任务')}
|
||||
extra={
|
||||
qingLong3ReadOnly
|
||||
? [<Tag key="ql3-read-only">QingLong 3.0 · 有界只读</Tag>]
|
||||
? [
|
||||
<Tag key="ql3-read-only">
|
||||
{qingLong3?.panel.runControl === 'task_run_v1'
|
||||
? 'QingLong 3.0 · 定时只读 / 执行可管理'
|
||||
: 'QingLong 3.0 · 有界只读'}
|
||||
</Tag>,
|
||||
]
|
||||
: [
|
||||
<Search
|
||||
key="search"
|
||||
@@ -1145,6 +1163,13 @@ const Crontab = () => {
|
||||
qingLong3={qingLong3}
|
||||
/>
|
||||
)}
|
||||
{runControlCron && qingLong3?.panel.runControl === 'task_run_v1' && (
|
||||
<QingLong3RunControlModal
|
||||
cron={runControlCron}
|
||||
capabilities={qingLong3}
|
||||
onClose={() => setRunControlCron(undefined)}
|
||||
/>
|
||||
)}
|
||||
{!qingLong3ReadOnly && isModalVisible && (
|
||||
<CronModal handleCancel={handleCancel} cron={editedCron} />
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface QingLong3Capabilities {
|
||||
readonly panel: Readonly<{
|
||||
bootstrap: true;
|
||||
cronList: 'bounded_read_only';
|
||||
runControl?: 'task_run_v1';
|
||||
legacyMutations: false;
|
||||
legacyLogin: false;
|
||||
subscriptions: false;
|
||||
@@ -34,6 +35,11 @@ const CREDENTIAL_PATTERN =
|
||||
|
||||
let capabilities: Readonly<QingLong3Capabilities> | null = null;
|
||||
let credential: string | null = null;
|
||||
let session: Readonly<object> = Object.freeze({});
|
||||
|
||||
export function qingLong3Session(): Readonly<object> {
|
||||
return session;
|
||||
}
|
||||
|
||||
function validCapabilities(value: any): value is QingLong3Capabilities {
|
||||
const profile = value?.deployment?.profile;
|
||||
@@ -50,6 +56,8 @@ function validCapabilities(value: any): value is QingLong3Capabilities {
|
||||
value?.authentication?.loginEndpoint === null &&
|
||||
value?.panel?.bootstrap === true &&
|
||||
value?.panel?.cronList === 'bounded_read_only' &&
|
||||
(value?.panel?.runControl === undefined ||
|
||||
value.panel.runControl === 'task_run_v1') &&
|
||||
value?.panel?.legacyMutations === false &&
|
||||
value?.panel?.legacyLogin === false &&
|
||||
value?.panel?.subscriptions === false &&
|
||||
@@ -96,6 +104,7 @@ export function qingLong3Capabilities(): Readonly<QingLong3Capabilities> | null
|
||||
|
||||
export function setQingLong3Credential(value: string): boolean {
|
||||
if (!CREDENTIAL_PATTERN.test(value)) return false;
|
||||
session = Object.freeze({});
|
||||
credential = value;
|
||||
return true;
|
||||
}
|
||||
@@ -105,6 +114,7 @@ export function qingLong3Credential(): string | null {
|
||||
}
|
||||
|
||||
export function clearQingLong3Credential(): void {
|
||||
session = Object.freeze({});
|
||||
credential = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const { test } = require('node:test');
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const credential = `ql3c_test_${'A'.repeat(43)}`;
|
||||
const task = {
|
||||
taskId: 'task:a',
|
||||
revision: 2,
|
||||
contentDigest: 'a'.repeat(64),
|
||||
enabled: true,
|
||||
name: 'Task A',
|
||||
};
|
||||
const run = {
|
||||
id: 'run:a',
|
||||
projectId: 'default',
|
||||
taskId: 'task:a',
|
||||
taskRevision: '2',
|
||||
status: 'running',
|
||||
createdAtMs: 100,
|
||||
latestAttempt: { id: 'attempt:a', logAvailable: true },
|
||||
};
|
||||
const cron = {
|
||||
ql3: { projectId: 'default', taskId: task.taskId, taskRevision: 1 },
|
||||
};
|
||||
const capabilities = {
|
||||
panel: { runControl: 'task_run_v1' },
|
||||
limits: { logChunkBytes: 16384 },
|
||||
};
|
||||
const startReceipt = {
|
||||
schema: 'qinglong/task-start@v1',
|
||||
projectId: 'default',
|
||||
taskId: task.taskId,
|
||||
taskRevision: 2,
|
||||
taskContentDigest: task.contentDigest,
|
||||
runId: run.id,
|
||||
status: 'accepted',
|
||||
};
|
||||
function fixture(response) {
|
||||
const calls = [];
|
||||
const context = vm.createContext({
|
||||
TextDecoder,
|
||||
Uint8Array,
|
||||
atob,
|
||||
crypto: require('node:crypto').webcrypto,
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
const value = await response(url, options, calls.length);
|
||||
return {
|
||||
ok: !value.httpStatus || value.httpStatus < 400,
|
||||
status: value.httpStatus || 200,
|
||||
json: value.json || (async () => value),
|
||||
};
|
||||
},
|
||||
});
|
||||
const load = (relative, imports = {}) => {
|
||||
const code = ts.transpileModule(
|
||||
fs.readFileSync(path.resolve(__dirname, '../..', relative), 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
const mod = { exports: {} };
|
||||
vm.runInContext(`(function(require,exports,module){${code}\n})`, context)(
|
||||
(id) => {
|
||||
assert.ok(imports[id], `Unexpected import ${id}`);
|
||||
return imports[id];
|
||||
},
|
||||
mod.exports,
|
||||
mod,
|
||||
);
|
||||
return mod.exports;
|
||||
};
|
||||
const auth = load('src/utils/qinglong3.ts');
|
||||
auth.setQingLong3Credential(credential);
|
||||
const control = load('src/components/qinglong3/runControl.ts', {
|
||||
'@/utils/qinglong3': auth,
|
||||
});
|
||||
return {
|
||||
auth,
|
||||
control,
|
||||
calls,
|
||||
client: control.createPanelRunControl(cron, capabilities),
|
||||
};
|
||||
}
|
||||
const deferred = () => {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
test('preparing explicit current revision does not send a write and start uses only canonical fields', async () => {
|
||||
const f = fixture((_url, options) =>
|
||||
options.method === 'GET' ? { task } : startReceipt,
|
||||
);
|
||||
const current = await f.client.readTask();
|
||||
const action = f.client.prepareStart(current);
|
||||
assert.equal(f.calls.length, 1);
|
||||
await action.execute();
|
||||
assert.equal(f.calls[1].url, '/api/v3/projects/default/tasks/task:a/runs');
|
||||
const body = JSON.parse(f.calls[1].options.body);
|
||||
assert.deepEqual(body, {
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: action.mutationId,
|
||||
expectedRevision: 2,
|
||||
expectedContentDigest: task.contentDigest,
|
||||
});
|
||||
assert.equal(f.calls[1].options.credentials, 'omit');
|
||||
assert.equal(f.calls[1].options.redirect, 'error');
|
||||
assert.equal(
|
||||
f.calls[1].options.headers.authorization,
|
||||
`Bearer ${credential}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('ambiguous transport failure retries exactly the same mutation rather than creating another run', async () => {
|
||||
const f = fixture((_url, options, count) => {
|
||||
if (options.method === 'GET') return { task };
|
||||
if (count === 2) throw new Error('response lost after commit');
|
||||
return { ...startReceipt, status: 'existing' };
|
||||
});
|
||||
const action = f.client.prepareStart(await f.client.readTask());
|
||||
await assert.rejects(action.execute(), (e) => e.uncertain === true);
|
||||
assert.equal((await action.execute()).status, 'existing');
|
||||
assert.equal(f.calls[1].options.body, f.calls[2].options.body);
|
||||
});
|
||||
|
||||
test('concurrent confirmation is rejected without a second POST', async () => {
|
||||
const response = deferred();
|
||||
const f = fixture((_url, options) =>
|
||||
options.method === 'GET' ? { task } : response.promise,
|
||||
);
|
||||
const action = f.client.prepareStart(await f.client.readTask());
|
||||
const first = action.execute();
|
||||
await assert.rejects(action.execute(), (e) => e.code === 'operation_pending');
|
||||
response.resolve(startReceipt);
|
||||
await first;
|
||||
assert.equal(f.calls.length, 2);
|
||||
});
|
||||
|
||||
test('stale revision and denied identity never fall back to legacy mutation endpoints', async () => {
|
||||
for (const code of ['task_start_fence_rejected', 'authorization_denied']) {
|
||||
const f = fixture((_url, options) =>
|
||||
options.method === 'GET' ? { task } : { httpStatus: 409, code },
|
||||
);
|
||||
const action = f.client.prepareStart(await f.client.readTask());
|
||||
await assert.rejects(
|
||||
action.execute(),
|
||||
(e) => e.code === code && !e.uncertain,
|
||||
);
|
||||
assert.equal(f.calls.length, 2);
|
||||
assert.ok(
|
||||
f.calls.every((x) => x.url.startsWith('/api/v3/projects/default/')),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('mismatched mutation receipts remain uncertain rather than reporting false success', async () => {
|
||||
const f = fixture((_url, options) =>
|
||||
options.method === 'GET' ? { task } : { ...startReceipt, taskRevision: 99 },
|
||||
);
|
||||
await assert.rejects(
|
||||
f.client.prepareStart(await f.client.readTask()).execute(),
|
||||
(e) => e.uncertain,
|
||||
);
|
||||
});
|
||||
|
||||
test('cancellation names a previously read Run and terminal or foreign runs cannot be submitted', async () => {
|
||||
const f = fixture((_url, options) =>
|
||||
options.method === 'GET'
|
||||
? { run }
|
||||
: {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
projectId: 'default',
|
||||
runId: run.id,
|
||||
status: 'accepted',
|
||||
},
|
||||
);
|
||||
const current = await f.client.readRun(run.id);
|
||||
const action = f.client.prepareCancel(current);
|
||||
assert.equal(f.calls.length, 1);
|
||||
await action.execute();
|
||||
assert.equal(
|
||||
f.calls[1].url,
|
||||
'/api/v3/projects/default/runs/run:a/cancellation',
|
||||
);
|
||||
assert.deepEqual(Object.keys(JSON.parse(f.calls[1].options.body)).sort(), [
|
||||
'mutationId',
|
||||
'schema',
|
||||
]);
|
||||
assert.throws(() =>
|
||||
f.client.prepareCancel({ ...current, id: 'someone:else' }),
|
||||
);
|
||||
const terminal = fixture(() => ({ run: { ...run, status: 'succeeded' } }));
|
||||
const terminalRun = await terminal.client.readRun(run.id);
|
||||
assert.throws(() => terminal.client.prepareCancel(terminalRun));
|
||||
const foreign = fixture(() => ({ run: { ...run, taskId: 'task:other' } }));
|
||||
await assert.rejects(foreign.client.readRun(run.id));
|
||||
});
|
||||
|
||||
test('project paging filters task records after validating monotonic canonical cursors', async () => {
|
||||
const f = fixture(() => ({
|
||||
runs: [run, { ...run, id: 'run:0', taskId: 'another' }],
|
||||
hasMore: true,
|
||||
next: { createdAtMs: 100, runId: 'run:0' },
|
||||
}));
|
||||
const page = await f.client.listRuns();
|
||||
assert.equal(page.scanned, 2);
|
||||
assert.equal(page.runs.length, 1);
|
||||
await assert.rejects(f.client.listRuns(page.next)); // repeated page cannot advance
|
||||
assert.equal(
|
||||
f.calls[1].url,
|
||||
'/api/v3/projects/default/runs?limit=64&after_created_at_ms=100&after_run_id=run:0',
|
||||
);
|
||||
await assert.rejects(
|
||||
f.client.listRuns({ createdAtMs: 100, runId: 'x&limit=999' }),
|
||||
);
|
||||
assert.equal(f.calls.length, 2);
|
||||
});
|
||||
|
||||
test('over-budget pages and forged next cursors fail closed', async () => {
|
||||
for (const body of [
|
||||
{ runs: Array(65).fill(run), hasMore: false },
|
||||
{ runs: [run], hasMore: true, next: { createdAtMs: 99, runId: 'wrong' } },
|
||||
{ runs: [run, run], hasMore: false },
|
||||
])
|
||||
await assert.rejects(fixture(() => body).client.listRuns());
|
||||
});
|
||||
|
||||
test('closing or reconnecting with the same credential rejects pending results and prepared writes', async () => {
|
||||
for (const finish of [
|
||||
(f) => f.client.dispose(),
|
||||
(f) => f.auth.setQingLong3Credential(credential),
|
||||
(f) => f.auth.clearQingLong3Credential(),
|
||||
]) {
|
||||
const response = deferred();
|
||||
const f = fixture((_url, _options, count) =>
|
||||
count === 1 ? { task } : response.promise,
|
||||
);
|
||||
const action = f.client.prepareStart(await f.client.readTask());
|
||||
const reading = f.client.readTask();
|
||||
finish(f);
|
||||
response.resolve({ task });
|
||||
await assert.rejects(reading, (e) => e.code === 'session_changed');
|
||||
await assert.rejects(action.execute(), (e) => e.code === 'session_changed');
|
||||
assert.equal(f.calls.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('same-credential reconnect while JSON decoding discards response and never follows up', async () => {
|
||||
const body = deferred(),
|
||||
decoding = deferred();
|
||||
const f = fixture(() => ({
|
||||
json() {
|
||||
decoding.resolve();
|
||||
return body.promise;
|
||||
},
|
||||
}));
|
||||
const reading = f.client.readTask();
|
||||
await decoding.promise;
|
||||
f.auth.setQingLong3Credential(credential);
|
||||
body.resolve({ task });
|
||||
await assert.rejects(reading, (e) => e.code === 'session_changed');
|
||||
assert.equal(f.calls.length, 1);
|
||||
});
|
||||
|
||||
test('explicit Run log reads one bounded first chunk without using scheduled Trigger matching', async () => {
|
||||
const f = fixture((url) =>
|
||||
url.includes('/log?')
|
||||
? {
|
||||
status: 'available',
|
||||
encoding: 'base64',
|
||||
projectId: 'default',
|
||||
runId: run.id,
|
||||
attemptId: run.latestAttempt.id,
|
||||
content: Buffer.from('manual execution 日志').toString('base64'),
|
||||
}
|
||||
: { run },
|
||||
);
|
||||
const current = await f.client.readRun(run.id);
|
||||
assert.match(await f.client.readLog(current), /manual execution 日志/);
|
||||
assert.equal(
|
||||
f.calls[1].url,
|
||||
'/api/v3/projects/default/runs/run:a/attempts/attempt:a/log?offset=0&length=16384',
|
||||
);
|
||||
assert.equal(f.calls.length, 2);
|
||||
});
|
||||
|
||||
test('old capabilities do not silently enable canonical writes', () => {
|
||||
const f = fixture(() => ({}));
|
||||
assert.throws(() =>
|
||||
f.control.createPanelRunControl(cron, {
|
||||
panel: {},
|
||||
limits: capabilities.limits,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('capability discovery supports old read-only servers and rejects an unknown execution contract', async () => {
|
||||
const {
|
||||
panelCapabilities,
|
||||
} = require('../../packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute');
|
||||
for (const supported of [undefined, 'task_run_v1']) {
|
||||
const value = panelCapabilities('edge');
|
||||
const f = fixture(() => ({
|
||||
capabilities: {
|
||||
...value,
|
||||
panel: { ...value.panel, runControl: supported },
|
||||
},
|
||||
}));
|
||||
const discovered = await f.auth.discoverQingLong3('/api/v3/capabilities');
|
||||
assert.ok(discovered);
|
||||
assert.equal(discovered.panel.runControl, supported);
|
||||
assert.equal(discovered.panel.legacyMutations, false);
|
||||
}
|
||||
const value = panelCapabilities('edge');
|
||||
const f = fixture(() => ({
|
||||
capabilities: {
|
||||
...value,
|
||||
panel: { ...value.panel, runControl: 'allow_everything' },
|
||||
},
|
||||
}));
|
||||
assert.equal(await f.auth.discoverQingLong3('/api/v3/capabilities'), null);
|
||||
});
|
||||
|
||||
test('browser mutation bodies pass the actual profile-neutral request parsers', async () => {
|
||||
const {
|
||||
parseTaskStartRequestBody,
|
||||
} = require('../../packages/ql3-runtime-core/src/task-start/taskStart');
|
||||
const {
|
||||
parseRunCancellationRequestBody,
|
||||
} = require('../../packages/ql3-runtime-core/src/run/clusterRunCancellation');
|
||||
const f = fixture((url, options) => {
|
||||
if (options.method === 'GET')
|
||||
return url.includes('/tasks/') ? { task } : { run };
|
||||
const body = JSON.parse(options.body);
|
||||
if (url.endsWith('/cancellation')) {
|
||||
assert.deepEqual(parseRunCancellationRequestBody(body), body);
|
||||
return {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
projectId: 'default',
|
||||
runId: run.id,
|
||||
status: 'accepted',
|
||||
};
|
||||
}
|
||||
assert.deepEqual(parseTaskStartRequestBody(body), body);
|
||||
return startReceipt;
|
||||
});
|
||||
await f.client.prepareStart(await f.client.readTask()).execute();
|
||||
await f.client.prepareCancel(await f.client.readRun(run.id)).execute();
|
||||
});
|
||||
Reference in New Issue
Block a user