mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add bounded local web console
This commit is contained in:
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-28):
|
最新增量证据(2026-08-28):
|
||||||
|
|
||||||
|
- D-417/ADR-0512(已实现,Console Trial Kit 交付待后续切片):开发约二十天后的阶段产物从“可直接启动的 headless runtime”继续推进到首个可操作 Web 产品面。既有可选 `@qinglong/local-api` 现在内置 `/`、`/console.css`、`/console.js` 三文件离线 Console,总计 43,252 bytes,无前端框架、CDN、网络字体、遥测或 Web Storage;它复用同一 Node 进程、loopback listener、SQLite authority、Bearer/Project Policy/durable audit/re-confirm 链,不新增 package、sidecar、数据库连接、timer、watcher 或 cache。部署者可以按 Project 查看 Task revision/content fence、显式确认启动,查看 durable Run、Workflow Step 数与 Event sequence,并对未终止 Run 提交显式 cancellation request;取消请求不会伪装成停止终态。Credential 只在当前页面内存,服务端固定 CSP/COOP/CORP/Permissions Policy/no-referrer/nosniff/DENY/no-store,远程设备只能由操作者建立 SSH tunnel,不开放 LAN/public listener。默认 Edge/Standalone 与 D-416 Trial Kit 继续 headless、零增量;Console 只进入 opt-in `edge-application-api|standalone-application-api`,两档 artifact 为 `3,953,346 / 3,953,490 bytes`、467 files、12 packages、90 loaded modules,低于 6 MiB/640-file 门。Local API 48/48、pruner 11/11、完整 backend `1,637 total / 1,635 pass / 2 conditional skip / 0 fail`、18-package clean build/test 退出 0;package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency、122-module Edge import 与全部 14 档 Local artifact audit 均 compatible。桌面 1440×960 与手机 390×844 已检查 Task、Run/Event spine 和显式确认工作态。该切片形成可构建、可运行的 source/application-api 阶段制品,但当前 v3 Trial Kit 尚未携带 Console;把 Console image/quickstart 变成路由/NAS 可下载物、固定低性能物理 Edge 容量数据和 public release 仍是后续门。
|
||||||
|
|
||||||
- D-416/ADR-0511(已实现,首份真实 v3 Trial Kit 待授权):阶段产物的判定从“可下载、可验真”推进到“部署者可直接试运行”。Local Trial Kit schema 升为 `qinglong/alpha-local-trial-kit@v3`,每个 amd64/arm64 bundle 新增由唯一模板生成并绑定 archive、两个 exact image ID、source 和 architecture 的 `quickstart.sh`;离线 auditor 会重新渲染 canonical journey,拒绝即使重算 manifest/checksum 的本地脚本改写。目标路由/NAS 或单节点只需 POSIX shell、`sha256sum` 和 Docker,即可在一个尚不存在的隔离目录完成全包 checksum、镜像 load/identity 复核、128 MiB 短生命周期 operator fresh setup、首 Owner 建立,并按 Edge `128 MiB/64 PID` 或 Standalone `256 MiB/256 PID` 启动 Application、等待 `active` 后输出 logs/stop/remove 指令。显式 artifact job 在 upload 前必须执行将要上传的 exact quickstart、检查 SQLite/Owner delivery 并 graceful stop;普通 push 不生成大 archive。聚焦 materializer/milestone/stage/workflow 回归为 `26/26`,完整 backend 为 `1636 total / 1634 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package boundary 保持 `18 packages / singleSourcePackages=[] / shallowSourcePackages=[]`,Cluster dependency、122-module Edge import、镜像/版本与 14 档 Local artifact 均 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。历史同源 arm64 镜像对已本地物化为 v3 八文件开发 bundle,184,648,192-byte archive 完成 checksum、canonical audit、load 与 exact identity;Docker Desktop 在 Owner directory ownership 处按既有 POSIX 不等价失败关闭,未冒充原生 Linux 成功,正式 native exact-script 证据由手动 artifact job 产生。该切片不安装宿主 Node.js,不把 Operator 变成 daemon,不增加镜像 layer、workspace package、端口或稳态 RSS,也不触碰 2.x/migration。当前产物是无外部 listener、AI-excluded 的 headless Alpha runtime,不冒充 2.x Web UI、公开 release、生产 HA 或 LTS;首份真实双架构 v3 下载物仍需维护者授权 `produce_alpha_artifacts=true`。
|
- D-416/ADR-0511(已实现,首份真实 v3 Trial Kit 待授权):阶段产物的判定从“可下载、可验真”推进到“部署者可直接试运行”。Local Trial Kit schema 升为 `qinglong/alpha-local-trial-kit@v3`,每个 amd64/arm64 bundle 新增由唯一模板生成并绑定 archive、两个 exact image ID、source 和 architecture 的 `quickstart.sh`;离线 auditor 会重新渲染 canonical journey,拒绝即使重算 manifest/checksum 的本地脚本改写。目标路由/NAS 或单节点只需 POSIX shell、`sha256sum` 和 Docker,即可在一个尚不存在的隔离目录完成全包 checksum、镜像 load/identity 复核、128 MiB 短生命周期 operator fresh setup、首 Owner 建立,并按 Edge `128 MiB/64 PID` 或 Standalone `256 MiB/256 PID` 启动 Application、等待 `active` 后输出 logs/stop/remove 指令。显式 artifact job 在 upload 前必须执行将要上传的 exact quickstart、检查 SQLite/Owner delivery 并 graceful stop;普通 push 不生成大 archive。聚焦 materializer/milestone/stage/workflow 回归为 `26/26`,完整 backend 为 `1636 total / 1634 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package boundary 保持 `18 packages / singleSourcePackages=[] / shallowSourcePackages=[]`,Cluster dependency、122-module Edge import、镜像/版本与 14 档 Local artifact 均 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。历史同源 arm64 镜像对已本地物化为 v3 八文件开发 bundle,184,648,192-byte archive 完成 checksum、canonical audit、load 与 exact identity;Docker Desktop 在 Owner directory ownership 处按既有 POSIX 不等价失败关闭,未冒充原生 Linux 成功,正式 native exact-script 证据由手动 artifact job 产生。该切片不安装宿主 Node.js,不把 Operator 变成 daemon,不增加镜像 layer、workspace package、端口或稳态 RSS,也不触碰 2.x/migration。当前产物是无外部 listener、AI-excluded 的 headless Alpha runtime,不冒充 2.x Web UI、公开 release、生产 HA 或 LTS;首份真实双架构 v3 下载物仍需维护者授权 `produce_alpha_artifacts=true`。
|
||||||
|
|
||||||
- D-415/ADR-0510(已实现,首份真实 stage index 待授权):二十天开发形成的 Local 与 Cluster 候选现在有统一、面向部署者的最外层阶段交付入口。新增 `qinglong/alpha-stage-index@v1` finalizer/auditor,仅在显式 `produce_alpha_artifacts=true + alpha_artifact_scope=all` 且 Local/Cluster milestone 都成功后运行;它重新离线审计两个索引,要求 version/source/workflow SHA/ref/run/attempt 完全一致,记录两个 milestone manifest digest,并为 amd64/arm64 写出机器可读选择:低配 Edge/Standalone 只下载一个 Local Trial Kit、稳态只运行 Application 且 Operator 短时启动;Cluster 下载 control/admin/worker,按需增加 control-ai。跨索引审计必须同时持有 stage 与两个 milestone,拒绝跨运行混用、内容/选择漂移和额外文件。该索引不重复十个大 archive,不改变 Local-only/Cluster-only 授权路径,也不冒充受保护 tag、GHCR immutable digest、签名、正式 OCI catalog、生产 deployment lock 或 HA;普通 push/PR 不生成实际阶段 artifact。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS。
|
- D-415/ADR-0510(已实现,首份真实 stage index 待授权):二十天开发形成的 Local 与 Cluster 候选现在有统一、面向部署者的最外层阶段交付入口。新增 `qinglong/alpha-stage-index@v1` finalizer/auditor,仅在显式 `produce_alpha_artifacts=true + alpha_artifact_scope=all` 且 Local/Cluster milestone 都成功后运行;它重新离线审计两个索引,要求 version/source/workflow SHA/ref/run/attempt 完全一致,记录两个 milestone manifest digest,并为 amd64/arm64 写出机器可读选择:低配 Edge/Standalone 只下载一个 Local Trial Kit、稳态只运行 Application 且 Operator 短时启动;Cluster 下载 control/admin/worker,按需增加 control-ai。跨索引审计必须同时持有 stage 与两个 milestone,拒绝跨运行混用、内容/选择漂移和额外文件。该索引不重复十个大 archive,不改变 Local-only/Cluster-only 授权路径,也不冒充受保护 tag、GHCR immutable digest、签名、正式 OCI catalog、生产 deployment lock 或 HA;普通 push/PR 不生成实际阶段 artifact。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS。
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0512:有界、离线的 Local Web Console
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-28
|
||||||
|
- 决策:D-417
|
||||||
|
- 关联:ADR-0367、ADR-0370、ADR-0371、ADR-0374、ADR-0375、ADR-0503、ADR-0511
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
开发约二十天后,D-416 已把 Local Alpha 闭合为可在 fresh 目录直接启动的阶段产物,但常驻 Application 仍是 headless runtime。仓库已有可选 `@qinglong/local-api`:它在同一 Node.js 进程、同一 SQLite authority 和一个 loopback listener 上提供 Task/Run 读取、启动、取消、事件、步骤和日志 API。部署者仍需手写 HTTP 请求,无法把已有后端能力视为可操作产品。
|
||||||
|
|
||||||
|
直接把 2.x 前端带入 3.0 会重新引入大依赖树、旧 API 假设和低配设备负担;单独启动前端 sidecar 又会增加第二个常驻进程、端口和部署故障面。阶段性产品需要一个足够小、可离线、与现有 authority 同源的操作界面,同时必须诚实区分默认 headless Trial Kit 和显式 opt-in 的 `application-api` Profile。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### 1. Console 属于既有 Local API 制品
|
||||||
|
|
||||||
|
Console 由 `@qinglong/local-api` 自带固定的 `/`、`/console.css`、`/console.js` 三文件闭包,通过既有 loopback HTTP surface 提供。它不新增 workspace package、前端框架、第三方运行时依赖、listener、sidecar、数据库连接、timer、watcher 或 cache;静态文件在进程启动时一次性加载并校验。
|
||||||
|
|
||||||
|
单文件上限为 96 KiB,总闭包上限为 192 KiB。当前三文件共 43,252 bytes。运行时 artifact pruner 只允许调用方显式保留 `local-api/assets/console/console.js`;路径必须是已安装、规范化的 package-relative regular file,不能用目录通配保留未审计脚本。
|
||||||
|
|
||||||
|
### 2. 第一阶段只关闭最常用操作回路
|
||||||
|
|
||||||
|
Console 提供:
|
||||||
|
|
||||||
|
- 按 Project 查看最多 64 个 Task,读取 revision、schema、content digest 和 enabled 状态;
|
||||||
|
- 对当前 Task 进行带 revision/content fence 的显式确认启动;
|
||||||
|
- 查看最多 64 个 durable Run,读取状态、version、执行归属和创建时间;
|
||||||
|
- 查看 Run 的最多 64 条 Event sequence 与 Workflow Step 数;
|
||||||
|
- 对未终止 Run 进行显式确认的 durable cancellation request。
|
||||||
|
|
||||||
|
页面不创建或编辑 Task,不管理 Identity/Policy/Secret/Plugin,不内嵌终端,也不把“取消已请求”展示成“执行已经停止”。超过窗口的数据继续通过 API keyset/pagination 读取。
|
||||||
|
|
||||||
|
### 3. 凭据和浏览器边界失败关闭
|
||||||
|
|
||||||
|
静态壳层无需凭据即可从 loopback 读取,所有 `/api/v3` 请求继续经过原 Bearer authentication、Project Policy、durable audit 和 authority re-confirm。Credential 只保存在当前页面 JavaScript closure;输入框在连接后清空并禁用,断开时清除状态。禁止 URL、Cookie、`localStorage`、`sessionStorage`、Service Worker 和外部资源保存或传输 credential。
|
||||||
|
|
||||||
|
服务端发送 `default-src 'none'` CSP、same-origin COOP/CORP、Permissions Policy、no-referrer、nosniff、DENY 和 no-store。HTML/CSS/JS 不引用网络字体、CDN、遥测或远程图片,不使用 inline script/style、`innerHTML` 或 `eval`。listener 继续只允许 `127.0.0.1|::1`;远程管理必须由部署者显式建立 SSH tunnel 或等价受信通道,Console 不把 Local API 扩展为 LAN/public listener。
|
||||||
|
|
||||||
|
### 4. 默认 Edge/Standalone 保持零增量
|
||||||
|
|
||||||
|
D-416 的 Local Trial Kit 和基础 `edge|standalone` Application 仍保持 headless,不包含 `@qinglong/local-api`。只有选择 `edge-application-api|standalone-application-api` 的制品才携带 Console,因此低配路由器不为未使用的 UI 支付包体、listener 或稳态 RSS;Cluster 节点继续使用 Cluster Control 的独立产品与部署路径。
|
||||||
|
|
||||||
|
本决策形成可构建、可运行的 `application-api` 阶段制品,但不声称当前 v3 Trial Kit 已经携带 Web Console。把 opt-in Console image/quickstart 变成面向路由/NAS 的实际下载物是下一交付切片。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 新建前端 workspace package
|
||||||
|
|
||||||
|
拒绝。三个离线资产没有独立领域 authority 或复用消费者;新包会重新制造用户此前指出的过细 package 和单文件包问题。
|
||||||
|
|
||||||
|
### 引入 React/Vue/Umi 构建链
|
||||||
|
|
||||||
|
拒绝。当前操作面规模不足以抵消依赖、漏洞、构建和低配包体成本;后续功能达到独立应用规模时再以新 ADR 评估。
|
||||||
|
|
||||||
|
### 独立静态服务器或 sidecar
|
||||||
|
|
||||||
|
拒绝。它增加常驻进程、端口、健康检查和跨 origin credential 处理,同时没有新的 authority 价值。
|
||||||
|
|
||||||
|
### 直接监听局域网地址
|
||||||
|
|
||||||
|
拒绝。Alpha 尚未关闭 TLS、CSRF、可信代理、会话撤销和远程暴露门;loopback + 显式 tunnel 保持风险可见。
|
||||||
|
|
||||||
|
## 影响
|
||||||
|
|
||||||
|
- `application-api` Edge/Standalone 制品当前为 3,953,346 / 3,953,490 bytes、467 files、12 packages,远低于 6 MiB/640-file budget;加载闭包为 90 modules,实测 import RSS delta 约 14.0 MiB;
|
||||||
|
- 基础 headless Edge/Standalone、D-416 Trial Kit、Cluster Profile 和 AI/MCP Profile 不因本切片新增 Console 进程或端口;
|
||||||
|
- Console 视觉和交互已在 1440×960 与 390×844 视口验证,工作态覆盖 Task detail、运行确认、Run evidence/Event sequence 和取消入口;
|
||||||
|
- 当前仍缺少面向部署用户的 Console Docker image/Trial Kit 选择、真实低性能物理 Edge 容量数据,以及受保护 public release。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- Local API 完整测试 48/48:包含三资产闭包、离线/credential custody、真实 loopback 静态响应、favicon、GET body/query alias 拒绝,以及既有认证 API/SQLite/过载/drain 回归;
|
||||||
|
- runtime artifact pruner 11/11:显式 JS asset 保留、缺失/逃逸/重复路径在任何删除前失败关闭;
|
||||||
|
- `edge-application-api` 与 `standalone-application-api` artifact audit 均 `compatible=true`,且证明 Console 被 pack、未被 runtime pruning 删除;
|
||||||
|
- 完整 backend 为 1,637 total / 1,635 pass / 2 conditional skip / 0 fail,18-package clean build/test 退出 0;
|
||||||
|
- package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency、122-module Edge import 与全部 14 档 Local artifact audit 均 `compatible=true`;远程 CI 结果在本切片推送后记录。
|
||||||
@@ -515,6 +515,7 @@
|
|||||||
| [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) |
|
| [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) |
|
||||||
| [ADR-0510](./ADR-0510-cross-profile-alpha-stage-delivery-index.md) | 跨 Profile Alpha 阶段交付索引 | Accepted(首份实际 stage index 待维护者授权) |
|
| [ADR-0510](./ADR-0510-cross-profile-alpha-stage-delivery-index.md) | 跨 Profile Alpha 阶段交付索引 | Accepted(首份实际 stage index 待维护者授权) |
|
||||||
| [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) |
|
| [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) |
|
||||||
|
| [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Console Trial Kit 交付待后续切片) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
|
|
||||||
## 当前阶段实物(2026-08-27)
|
## 当前阶段实物(2026-08-27)
|
||||||
|
|
||||||
|
在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已经形成两条可独立验收的产品线:
|
||||||
|
|
||||||
|
| 阶段产物 | 当前可用能力 | 仍缺少 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D-416 Local Alpha Trial Kit v3 | POSIX shell + Docker 一条命令完成 checksum、load、fresh setup、首 Owner、headless Application active/stop | 首份远端双架构实际 artifact 仍需维护者授权 `produce_alpha_artifacts=true` |
|
||||||
|
| D-417 opt-in Local Web Console | `application-api` 制品可在同进程/同 SQLite/loopback listener 上查看 Task/Run/Event 并显式运行或取消;Edge/Standalone 制品审计通过 | 尚未进入 Trial Kit Docker image/quickstart,不能冒充当前路由/NAS 下载物 |
|
||||||
|
|
||||||
|
这两个边界共同防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:D-416 已关闭 fresh headless 用户旅程,D-417 已关闭 source/application-api 操作界面,但两者尚未合并成实际可下载的 Console Trial Kit。操作说明见 [Local Web Console](./ql3-local-web-console.md)。
|
||||||
|
|
||||||
提交 `4239464af6937d56528a0a2c573d12329bc7ca55` 已形成最新 owner-private arm64 工程候选:
|
提交 `4239464af6937d56528a0a2c573d12329bc7ca55` 已形成最新 owner-private arm64 工程候选:
|
||||||
|
|
||||||
- Application image ID `sha256:0d1d4b80ee46e9bb671d846f93d9a6d832c9856a91eed03f299055904da88a50`,operator image ID `sha256:b9122f481b1ba60d7eee9a3ed5ca57c9c141cbc389e7c7dbe19c6f6b1c98b49e`;
|
- Application image ID `sha256:0d1d4b80ee46e9bb671d846f93d9a6d832c9856a91eed03f299055904da88a50`,operator image ID `sha256:b9122f481b1ba60d7eee9a3ed5ca57c9c141cbc389e7c7dbe19c6f6b1c98b49e`;
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command
|
|||||||
|
|
||||||
当前 Application 是无外部 listener、AI-excluded 的 headless Alpha runtime。该试运行可
|
当前 Application 是无外部 listener、AI-excluded 的 headless Alpha runtime。该试运行可
|
||||||
验证 3.0 SQLite、Owner authority、调度/插件基础与生命周期,但还不是 2.x Web UI 的
|
验证 3.0 SQLite、Owner authority、调度/插件基础与生命周期,但还不是 2.x Web UI 的
|
||||||
可替代版本。
|
可替代版本。D-417 已在 opt-in `application-api` Profile 提供首个 Local Web Console,
|
||||||
|
但本 v3 Trial Kit 尚未携带该 Profile;不要期待 quickstart 启动后出现 Web 端口。Console
|
||||||
|
当前使用方式与边界见 [Local Web Console](./ql3-local-web-console.md)。
|
||||||
|
|
||||||
## 手工加载与最小 smoke
|
## 手工加载与最小 smoke
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# QingLong 3.0 Local Web Console
|
||||||
|
|
||||||
|
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它不是当前 headless Local Alpha Trial Kit 的组成部分,也不是 2.x Web UI 的完整替代品。
|
||||||
|
|
||||||
|
## 选择部署档位
|
||||||
|
|
||||||
|
| 场景 | 建议 |
|
||||||
|
| --- | --- |
|
||||||
|
| 内存很小、只需后台自动化的路由/NAS | 继续使用默认 `edge` headless Application;Console 零增量 |
|
||||||
|
| 路由/NAS 需要临时人工查看与操作 | 选择 `edge-application-api`,只通过 loopback 或 SSH tunnel 访问 |
|
||||||
|
| 普通单节点服务器 | 选择 `standalone-application-api` |
|
||||||
|
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
|
||||||
|
|
||||||
|
当前仓库能生成 Node.js `application-api` 制品;D-416 的 v3 Docker Trial Kit 仍只包含 headless Application。需要下载即用的 Console image/quickstart 时,应等待后续 Console Trial Kit 切片,不能把本页命令误认为当前公开下载物。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- 已完成 Local fresh setup,并有受支持的 Application config;
|
||||||
|
- Owner pepper keyring 与 SQLite active pepper 一致;
|
||||||
|
- 已通过 [`ql3-identity`](./ql3-local-identity-credential.md) 为 active Identity 签发 API credential;
|
||||||
|
- credential 对目标 Project 至少有读取 Task/Run 的权限;启动和取消分别还需要 `run.start` 与 `run.stop`;
|
||||||
|
- config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
创建私有 `local-api.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "qinglong/local-api-process@v1",
|
||||||
|
"deploymentRoot": "/srv/qinglong3",
|
||||||
|
"applicationConfigFilePath": "/srv/qinglong3/private/application.json",
|
||||||
|
"ownerPepperKeyringDirectory": "/srv/qinglong3/private/owner-pepper",
|
||||||
|
"listener": { "host": "127.0.0.1", "port": 5701 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
运行同一进程的 Application + API + Console:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ql3-local-api --config /srv/qinglong3/private/local-api.json
|
||||||
|
```
|
||||||
|
|
||||||
|
在设备本机打开 `http://127.0.0.1:5701/`。服务只接受 `127.0.0.1` 或 `::1`,不会监听 LAN 地址。
|
||||||
|
|
||||||
|
从管理电脑访问路由/NAS 时,显式建立受信 SSH tunnel:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -L 5701:127.0.0.1:5701 router.example
|
||||||
|
```
|
||||||
|
|
||||||
|
随后在管理电脑打开 `http://127.0.0.1:5701/`。不要用反向代理临时绕过 loopback;TLS、可信代理、CSRF 和远程会话边界尚未作为本阶段产品门验收。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。
|
||||||
|
2. 在“任务”中选择 Task,核对 revision/content fence 后才能“运行一次”。
|
||||||
|
3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度。
|
||||||
|
4. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||||
|
5. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||||
|
|
||||||
|
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
|
||||||
|
|
||||||
|
## 当前阶段可用边界
|
||||||
|
|
||||||
|
当前可操作闭环是 Task list/read/start 与 Run list/read/events/steps/cancel。页面不负责:
|
||||||
|
|
||||||
|
- 创建、编辑、启停 Task;
|
||||||
|
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
|
||||||
|
- Run log 展示、终端、文件管理或 2.x 数据迁移;
|
||||||
|
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
|
||||||
|
|
||||||
|
三项静态资产总计 43,252 bytes,不依赖 CDN、网络字体或前端框架。`edge-application-api` 制品为 3,953,346 bytes/467 files,`standalone-application-api` 为 3,953,490 bytes/467 files;基础 headless Profile 不携带这些资产。
|
||||||
|
|
||||||
|
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
:root {
|
||||||
|
--fog: #e8eeef;
|
||||||
|
--paper: #f8fbfa;
|
||||||
|
--ink: #14272c;
|
||||||
|
--deep: #19353d;
|
||||||
|
--deep-soft: #254750;
|
||||||
|
--signal: #009fb4;
|
||||||
|
--signal-soft: #c8edf0;
|
||||||
|
--amber: #e3972f;
|
||||||
|
--danger: #ba4a43;
|
||||||
|
--line: #b9c8ca;
|
||||||
|
--muted: #64777b;
|
||||||
|
--white: #ffffff;
|
||||||
|
--shadow: 0 18px 60px rgba(15, 45, 53, 0.12);
|
||||||
|
font-family: 'Avenir Next', Avenir, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(rgba(20, 39, 44, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(20, 39, 44, 0.035) 1px, transparent 1px),
|
||||||
|
var(--fog);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
[tabindex]:focus-visible {
|
||||||
|
outline: 3px solid var(--amber);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--white);
|
||||||
|
background: var(--deep);
|
||||||
|
transform: translateY(-150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 22vw) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28px clamp(24px, 3vw, 44px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--white);
|
||||||
|
background: var(--deep);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark i {
|
||||||
|
display: block;
|
||||||
|
background: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark i:nth-child(1) {
|
||||||
|
height: 42%;
|
||||||
|
}
|
||||||
|
.brand-mark i:nth-child(2) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.brand-mark i:nth-child(3) {
|
||||||
|
height: 68%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-version {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
font: 600 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-copy {
|
||||||
|
margin: clamp(56px, 11vh, 104px) 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--signal);
|
||||||
|
font: 700 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Avenir Next Condensed', 'Arial Narrow', 'Segoe UI', sans-serif;
|
||||||
|
font-size: clamp(42px, 5.4vw, 70px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 0.92;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-copy > p:last-child {
|
||||||
|
max-width: 27ch;
|
||||||
|
margin: 24px 0 0;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
font: 650 12px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 0 4px rgba(227, 151, 47, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state[data-state='connected'] .signal {
|
||||||
|
background: var(--signal);
|
||||||
|
box-shadow: 0 0 0 4px rgba(0, 159, 180, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-state[data-state='error'] .signal {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 0 4px rgba(186, 74, 67, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-form label {
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
color: var(--white);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
font: 500 13px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-form input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.quiet-button,
|
||||||
|
.danger-button,
|
||||||
|
.refresh-button,
|
||||||
|
.action-button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--deep);
|
||||||
|
background: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet-button {
|
||||||
|
color: inherit;
|
||||||
|
border-color: currentColor;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
color: var(--white);
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-note {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav {
|
||||||
|
margin-top: 28px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav button[aria-current='page'] {
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav button[aria-current='page'] span::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 3px;
|
||||||
|
margin: 0 10px 3px 0;
|
||||||
|
background: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav kbd {
|
||||||
|
color: inherit;
|
||||||
|
font: 600 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
font: 600 9px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
min-width: 0;
|
||||||
|
padding: clamp(28px, 5vw, 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Avenir Next Condensed', 'Arial Narrow', 'Segoe UI', sans-serif;
|
||||||
|
font-size: clamp(34px, 4.2vw, 58px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead > div > p:last-child {
|
||||||
|
max-width: 65ch;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--deep);
|
||||||
|
border-color: var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.85fr);
|
||||||
|
min-height: 620px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(248, 251, 250, 0.82);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger,
|
||||||
|
.detail {
|
||||||
|
min-width: 0;
|
||||||
|
padding: clamp(20px, 3vw, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
background: rgba(255, 255, 255, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-state,
|
||||||
|
.detail-empty,
|
||||||
|
.empty-state,
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
min-height: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-index {
|
||||||
|
color: var(--signal);
|
||||||
|
font: 800 clamp(46px, 8vw, 100px) / 0.85 'Avenir Next Condensed',
|
||||||
|
'Arial Narrow', sans-serif;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-state p {
|
||||||
|
margin: 28px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-rule {
|
||||||
|
width: 180px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-rule i {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-rule i:nth-child(2) {
|
||||||
|
background: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-glyph {
|
||||||
|
color: var(--signal);
|
||||||
|
font-size: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty p,
|
||||||
|
.empty-state p,
|
||||||
|
.error-state p {
|
||||||
|
max-width: 30ch;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header span {
|
||||||
|
color: var(--muted);
|
||||||
|
font: 600 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record:hover,
|
||||||
|
.record[aria-current='true'] {
|
||||||
|
background: linear-gradient(90deg, var(--signal-soft), transparent 74%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-title {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-meta {
|
||||||
|
margin-top: 7px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 550 11px/1.4 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-side {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
align-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font: 750 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-tone='waiting']::before {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
.status[data-tone='failed']::before {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
.status[data-tone='quiet']::before {
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-time {
|
||||||
|
color: var(--muted);
|
||||||
|
font: 500 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h3 {
|
||||||
|
margin: 5px 0 8px;
|
||||||
|
font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header code,
|
||||||
|
.fact dd,
|
||||||
|
.event-sequence {
|
||||||
|
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header code {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
margin: 22px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact dd {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 22px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
color: var(--deep);
|
||||||
|
border-color: var(--deep);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button[data-kind='danger'] {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-heading {
|
||||||
|
margin: 30px 0 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-spine {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-spine::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
left: 16px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
position: relative;
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 0 0 18px 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-sequence {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--deep);
|
||||||
|
border: 2px solid var(--signal);
|
||||||
|
background: var(--paper);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event span:last-child {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 500 10px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-dialog {
|
||||||
|
width: min(460px, calc(100vw - 32px));
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-dialog::backdrop {
|
||||||
|
background: rgba(11, 31, 36, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-dialog form {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-dialog h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-dialog form > p:not(.eyebrow) {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
margin-top: 26px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-dialog .quiet-button {
|
||||||
|
color: var(--deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
max-width: min(420px, calc(100vw - 48px));
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: var(--white);
|
||||||
|
background: var(--deep-soft);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast[data-tone='error'] {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state span {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 3px solid var(--line);
|
||||||
|
border-top-color: var(--signal);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
position: relative;
|
||||||
|
min-height: auto;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-copy {
|
||||||
|
margin: 42px 0 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-copy h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-footer {
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 26px 18px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead h2 {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-state,
|
||||||
|
.detail-empty,
|
||||||
|
.empty-state,
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-side {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const TOKEN_PATTERN =
|
||||||
|
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||||
|
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
|
||||||
|
const STATUS_LABELS = Object.freeze({
|
||||||
|
created: '已创建',
|
||||||
|
queued: '排队中',
|
||||||
|
dispatching: '派发中',
|
||||||
|
running: '运行中',
|
||||||
|
cancel_requested: '正在取消',
|
||||||
|
succeeded: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
timed_out: '已超时',
|
||||||
|
});
|
||||||
|
const ERROR_LABELS = Object.freeze({
|
||||||
|
authentication_required: '凭据无效或已经失效。请断开连接后重新输入。',
|
||||||
|
authorization_denied: '当前身份没有执行该操作的权限。',
|
||||||
|
server_overloaded: '设备正在处理其他请求,请稍后刷新。',
|
||||||
|
server_draining: '服务正在停止,暂不接受新请求。',
|
||||||
|
task_list_unavailable: '任务列表暂时不可用。请检查本机服务与数据库。',
|
||||||
|
task_query_unavailable: '任务详情暂时不可用。',
|
||||||
|
run_list_unavailable: '运行列表暂时不可用。请检查本机服务与数据库。',
|
||||||
|
run_query_unavailable: '运行详情暂时不可用。',
|
||||||
|
run_event_list_unavailable: '运行事件暂时不可用。',
|
||||||
|
run_step_list_unavailable: 'Workflow Step 暂时不可用。',
|
||||||
|
task_start_fence_rejected:
|
||||||
|
'任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。',
|
||||||
|
run_cancellation_fence_rejected:
|
||||||
|
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
|
||||||
|
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes = Object.freeze({
|
||||||
|
form: document.getElementById('credential-form'),
|
||||||
|
project: document.getElementById('project-input'),
|
||||||
|
token: document.getElementById('token-input'),
|
||||||
|
disconnect: document.getElementById('disconnect-button'),
|
||||||
|
connection: document.getElementById('connection-state'),
|
||||||
|
connectionLabel: document.getElementById('connection-label'),
|
||||||
|
nav: document.querySelector('.section-nav'),
|
||||||
|
ledger: document.getElementById('ledger'),
|
||||||
|
detail: document.getElementById('detail'),
|
||||||
|
kicker: document.getElementById('section-kicker'),
|
||||||
|
title: document.getElementById('section-title'),
|
||||||
|
description: document.getElementById('section-description'),
|
||||||
|
refresh: document.getElementById('refresh-button'),
|
||||||
|
dialog: document.getElementById('confirmation-dialog'),
|
||||||
|
dialogTitle: document.getElementById('confirmation-title'),
|
||||||
|
dialogCopy: document.getElementById('confirmation-copy'),
|
||||||
|
dialogAccept: document.getElementById('confirmation-accept'),
|
||||||
|
toast: document.getElementById('toast'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
token: null,
|
||||||
|
project: 'default',
|
||||||
|
view: 'tasks',
|
||||||
|
selectedId: null,
|
||||||
|
pendingAction: null,
|
||||||
|
toastTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
class ConsoleRequestError extends Error {
|
||||||
|
constructor(code, status, requestId) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'ConsoleRequestError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.requestId = requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const value = document.createElement(tag);
|
||||||
|
if (className) value.className = className;
|
||||||
|
if (text !== undefined) value.textContent = String(text);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replace(node, ...children) {
|
||||||
|
node.replaceChildren(...children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) return '—';
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDigest(value) {
|
||||||
|
return typeof value === 'string' && value.length > 18
|
||||||
|
? `${value.slice(0, 10)}…${value.slice(-6)}`
|
||||||
|
: value || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status) {
|
||||||
|
if (status === 'failed' || status === 'timed_out') return 'failed';
|
||||||
|
if (
|
||||||
|
status === 'created' ||
|
||||||
|
status === 'queued' ||
|
||||||
|
status === 'dispatching' ||
|
||||||
|
status === 'cancel_requested'
|
||||||
|
) {
|
||||||
|
return 'waiting';
|
||||||
|
}
|
||||||
|
if (status === 'cancelled') return 'quiet';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
const badge = element('span', 'status', STATUS_LABELS[status] || status);
|
||||||
|
badge.dataset.tone = statusTone(status);
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newMutationId() {
|
||||||
|
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = [...bytes].map((value) => value.toString(16).padStart(2, '0'));
|
||||||
|
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
|
||||||
|
.slice(6, 8)
|
||||||
|
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
if (!state.token) {
|
||||||
|
throw new ConsoleRequestError('authentication_required', 401, null);
|
||||||
|
}
|
||||||
|
const headers = {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${state.token}`,
|
||||||
|
};
|
||||||
|
let body;
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
body = JSON.stringify(options.body);
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
}
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(path, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
...(body === undefined ? {} : { body }),
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'omit',
|
||||||
|
redirect: 'error',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ConsoleRequestError('request_unavailable', 503, null);
|
||||||
|
}
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new ConsoleRequestError(
|
||||||
|
'response_unavailable',
|
||||||
|
response.status,
|
||||||
|
response.headers.get('x-request-id'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ConsoleRequestError(
|
||||||
|
typeof value.code === 'string' ? value.code : 'request_unavailable',
|
||||||
|
response.status,
|
||||||
|
response.headers.get('x-request-id'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error) {
|
||||||
|
if (!(error instanceof ConsoleRequestError))
|
||||||
|
return '操作没有完成,请刷新后重试。';
|
||||||
|
const label = ERROR_LABELS[error.code] || `请求被拒绝:${error.code}`;
|
||||||
|
return error.requestId ? `${label} 请求编号 ${error.requestId}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, tone = 'ok') {
|
||||||
|
if (state.toastTimer) window.clearTimeout(state.toastTimer);
|
||||||
|
nodes.toast.textContent = message;
|
||||||
|
nodes.toast.dataset.tone = tone;
|
||||||
|
nodes.toast.hidden = false;
|
||||||
|
state.toastTimer = window.setTimeout(() => {
|
||||||
|
nodes.toast.hidden = true;
|
||||||
|
state.toastTimer = null;
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnection(mode, label) {
|
||||||
|
nodes.connection.dataset.state = mode;
|
||||||
|
nodes.connectionLabel.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(value) {
|
||||||
|
nodes.ledger.setAttribute('aria-busy', String(value));
|
||||||
|
nodes.refresh.disabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loading() {
|
||||||
|
const box = element('div', 'loading-state');
|
||||||
|
box.append(element('span'));
|
||||||
|
replace(nodes.ledger, box);
|
||||||
|
setBusy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty(message) {
|
||||||
|
const box = element('div', 'empty-state');
|
||||||
|
box.append(element('p', null, message));
|
||||||
|
replace(nodes.ledger, box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorState(error) {
|
||||||
|
const box = element('div', 'error-state');
|
||||||
|
box.append(element('p', null, describeError(error)));
|
||||||
|
const retry = element('button', 'action-button', '重新读取');
|
||||||
|
retry.type = 'button';
|
||||||
|
retry.addEventListener('click', () => refresh());
|
||||||
|
box.append(retry);
|
||||||
|
replace(nodes.ledger, box);
|
||||||
|
setConnection('error', '连接需要检查');
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailEmpty(message = '选择一项,查看它的执行围栏与事件轨迹。') {
|
||||||
|
const box = element('div', 'detail-empty');
|
||||||
|
box.append(element('span', 'detail-glyph', '⌁'));
|
||||||
|
box.append(element('p', null, message));
|
||||||
|
replace(nodes.detail, box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listHeader(label, count) {
|
||||||
|
const header = element('div', 'list-header');
|
||||||
|
header.append(element('strong', null, label));
|
||||||
|
header.append(element('span', null, `${count} 条 / 当前窗口`));
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordMeta(values) {
|
||||||
|
const meta = element('span', 'record-meta');
|
||||||
|
for (const value of values) meta.append(element('span', null, value));
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
const item = element('dl', 'fact');
|
||||||
|
item.append(element('dt', null, label));
|
||||||
|
item.append(element('dd', null, value));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailHeader(kicker, title, identity) {
|
||||||
|
const header = element('header', 'detail-header');
|
||||||
|
header.append(element('p', 'eyebrow', kicker));
|
||||||
|
header.append(element('h3', null, title));
|
||||||
|
header.append(element('code', null, identity));
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionButton(label, handler, kind = 'normal') {
|
||||||
|
const button = element('button', 'action-button', label);
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.kind = kind;
|
||||||
|
button.addEventListener('click', handler);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAction(title, copy, acceptLabel, action, dangerous = false) {
|
||||||
|
state.pendingAction = action;
|
||||||
|
nodes.dialogTitle.textContent = title;
|
||||||
|
nodes.dialogCopy.textContent = copy;
|
||||||
|
nodes.dialogAccept.textContent = acceptLabel;
|
||||||
|
nodes.dialogAccept.className = dangerous
|
||||||
|
? 'danger-button'
|
||||||
|
: 'primary-button';
|
||||||
|
nodes.dialog.returnValue = '';
|
||||||
|
nodes.dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTasks() {
|
||||||
|
const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`);
|
||||||
|
const tasks = Array.isArray(value.tasks) ? value.tasks : [];
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
empty('还没有可见任务。请先通过受信任的管理入口发布 Task。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
fragment.append(listHeader('Task switchboard', tasks.length));
|
||||||
|
const list = element('div', 'record-list');
|
||||||
|
for (const task of tasks) {
|
||||||
|
const button = element('button', 'record');
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.identity = task.taskId;
|
||||||
|
if (state.selectedId === task.taskId)
|
||||||
|
button.setAttribute('aria-current', 'true');
|
||||||
|
const main = element('span');
|
||||||
|
main.append(element('span', 'record-title', task.name));
|
||||||
|
main.append(
|
||||||
|
recordMeta([
|
||||||
|
task.taskId,
|
||||||
|
`${task.kind} · rev ${task.revision}`,
|
||||||
|
task.specSchema,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const side = element('span', 'record-side');
|
||||||
|
const enabled = element(
|
||||||
|
'span',
|
||||||
|
'status',
|
||||||
|
task.enabled ? '可运行' : '已停用',
|
||||||
|
);
|
||||||
|
enabled.dataset.tone = task.enabled ? 'active' : 'quiet';
|
||||||
|
side.append(enabled);
|
||||||
|
side.append(element('span', 'record-time', formatTime(task.updatedAtMs)));
|
||||||
|
button.append(main, side);
|
||||||
|
button.addEventListener('click', () => selectTask(task.taskId));
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
fragment.append(list);
|
||||||
|
if (value.hasMore) {
|
||||||
|
fragment.append(
|
||||||
|
element(
|
||||||
|
'p',
|
||||||
|
'privacy-note',
|
||||||
|
'当前只展示前 64 条;完整分页将在后续 Console 切片开放。',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
replace(nodes.ledger, fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTask(taskId) {
|
||||||
|
state.selectedId = taskId;
|
||||||
|
const selected = nodes.ledger.querySelectorAll('.record');
|
||||||
|
for (const row of selected) {
|
||||||
|
if (row.dataset.identity === taskId) {
|
||||||
|
row.setAttribute('aria-current', 'true');
|
||||||
|
} else {
|
||||||
|
row.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loadingBox = element('div', 'loading-state');
|
||||||
|
loadingBox.append(element('span'));
|
||||||
|
replace(nodes.detail, loadingBox);
|
||||||
|
try {
|
||||||
|
const value = await api(
|
||||||
|
`/api/v3/projects/${state.project}/tasks/${taskId}`,
|
||||||
|
);
|
||||||
|
renderTaskDetail(value.task);
|
||||||
|
} catch (error) {
|
||||||
|
detailEmpty(describeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskDetail(task) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
fragment.append(detailHeader('Task definition', task.name, task.taskId));
|
||||||
|
const facts = element('div', 'facts');
|
||||||
|
facts.append(
|
||||||
|
fact('状态', task.enabled ? '可运行' : '已停用'),
|
||||||
|
fact('类型', task.kind),
|
||||||
|
fact('Revision', task.revision),
|
||||||
|
fact('Schema', task.specSchema),
|
||||||
|
fact('Content fence', shortDigest(task.contentDigest)),
|
||||||
|
fact('更新时间', formatTime(task.updatedAtMs)),
|
||||||
|
);
|
||||||
|
fragment.append(facts);
|
||||||
|
const actions = element('div', 'detail-actions');
|
||||||
|
if (task.enabled) {
|
||||||
|
actions.append(
|
||||||
|
actionButton('运行一次', () => {
|
||||||
|
confirmAction(
|
||||||
|
'运行这个任务?',
|
||||||
|
`将按 revision ${task.revision} 与当前内容摘要启动“${task.name}”。定义如有变化,服务端会拒绝本次操作。`,
|
||||||
|
'确认运行',
|
||||||
|
() => startTask(task),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fragment.append(actions);
|
||||||
|
replace(nodes.detail, fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTask(task) {
|
||||||
|
try {
|
||||||
|
const value = await api(
|
||||||
|
`/api/v3/projects/${state.project}/tasks/${task.taskId}/runs`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
schema: 'qinglong/task-start@v1',
|
||||||
|
mutationId: newMutationId(),
|
||||||
|
expectedRevision: task.revision,
|
||||||
|
expectedContentDigest: task.contentDigest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
value.status === 'existing'
|
||||||
|
? '已找到同一启动请求。'
|
||||||
|
: '任务已进入运行队列。',
|
||||||
|
);
|
||||||
|
state.view = 'runs';
|
||||||
|
state.selectedId = value.runId;
|
||||||
|
updateNavigation();
|
||||||
|
await refresh();
|
||||||
|
await selectRun(value.runId);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(describeError(error), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRuns() {
|
||||||
|
const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`);
|
||||||
|
const runs = Array.isArray(value.runs) ? value.runs : [];
|
||||||
|
if (runs.length === 0) {
|
||||||
|
empty('还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
fragment.append(listHeader('Run ledger', runs.length));
|
||||||
|
const list = element('div', 'record-list');
|
||||||
|
for (const run of runs) {
|
||||||
|
const button = element('button', 'record');
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.identity = run.id;
|
||||||
|
if (state.selectedId === run.id)
|
||||||
|
button.setAttribute('aria-current', 'true');
|
||||||
|
const main = element('span');
|
||||||
|
main.append(element('span', 'record-title', run.taskId));
|
||||||
|
main.append(
|
||||||
|
recordMeta([
|
||||||
|
run.id,
|
||||||
|
`rev ${run.taskRevision}`,
|
||||||
|
`${run.executionOrigin} / ${run.executionOwner}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const side = element('span', 'record-side');
|
||||||
|
side.append(statusBadge(run.status));
|
||||||
|
side.append(element('span', 'record-time', formatTime(run.createdAtMs)));
|
||||||
|
button.append(main, side);
|
||||||
|
button.addEventListener('click', () => selectRun(run.id));
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
fragment.append(list);
|
||||||
|
if (value.hasMore) {
|
||||||
|
fragment.append(
|
||||||
|
element(
|
||||||
|
'p',
|
||||||
|
'privacy-note',
|
||||||
|
'当前只展示最近 64 条;使用 API 可继续读取下一页。',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
replace(nodes.ledger, fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRun(runId) {
|
||||||
|
state.selectedId = runId;
|
||||||
|
const selected = nodes.ledger.querySelectorAll('.record');
|
||||||
|
for (const row of selected) {
|
||||||
|
if (row.dataset.identity === runId) {
|
||||||
|
row.setAttribute('aria-current', 'true');
|
||||||
|
} else {
|
||||||
|
row.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loadingBox = element('div', 'loading-state');
|
||||||
|
loadingBox.append(element('span'));
|
||||||
|
replace(nodes.detail, loadingBox);
|
||||||
|
try {
|
||||||
|
const [runValue, eventValue, stepValue] = await Promise.all([
|
||||||
|
api(`/api/v3/projects/${state.project}/runs/${runId}`),
|
||||||
|
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
|
||||||
|
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
|
||||||
|
]);
|
||||||
|
renderRunDetail(runValue.run, eventValue, stepValue);
|
||||||
|
} catch (error) {
|
||||||
|
detailEmpty(describeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRunDetail(run, eventPage, stepPage) {
|
||||||
|
const events = Array.isArray(eventPage.events) ? eventPage.events : [];
|
||||||
|
const steps = Array.isArray(stepPage.steps) ? stepPage.steps : [];
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
fragment.append(detailHeader('Run evidence', run.taskId, run.id));
|
||||||
|
const facts = element('div', 'facts');
|
||||||
|
facts.append(
|
||||||
|
fact('状态', STATUS_LABELS[run.status] || run.status),
|
||||||
|
fact('Version', run.version),
|
||||||
|
fact('事件', run.eventSequence),
|
||||||
|
fact('Workflow steps', steps.length),
|
||||||
|
fact('执行归属', run.executionOwner),
|
||||||
|
fact('创建时间', formatTime(run.createdAtMs)),
|
||||||
|
);
|
||||||
|
fragment.append(facts);
|
||||||
|
if (!TERMINAL.has(run.status) && run.status !== 'cancel_requested') {
|
||||||
|
const actions = element('div', 'detail-actions');
|
||||||
|
actions.append(
|
||||||
|
actionButton(
|
||||||
|
'请求取消',
|
||||||
|
() => {
|
||||||
|
confirmAction(
|
||||||
|
'请求取消运行?',
|
||||||
|
'取消请求会先写入 durable intent。正在执行的进程只有在安全控制链确认后才会停止。',
|
||||||
|
'确认请求取消',
|
||||||
|
() => cancelRun(run),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'danger',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fragment.append(actions);
|
||||||
|
}
|
||||||
|
fragment.append(element('h4', 'timeline-heading', 'Event sequence'));
|
||||||
|
if (events.length === 0) {
|
||||||
|
fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。'));
|
||||||
|
} else {
|
||||||
|
const timeline = element('ol', 'event-spine');
|
||||||
|
for (const event of events) {
|
||||||
|
const item = element('li', 'event');
|
||||||
|
item.append(element('span', 'event-sequence', event.sequence));
|
||||||
|
const copy = element('div');
|
||||||
|
copy.append(element('strong', null, event.type));
|
||||||
|
copy.append(
|
||||||
|
element(
|
||||||
|
'span',
|
||||||
|
null,
|
||||||
|
`${event.actorType} · ${formatTime(event.createdAtMs)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
item.append(copy);
|
||||||
|
timeline.append(item);
|
||||||
|
}
|
||||||
|
fragment.append(timeline);
|
||||||
|
}
|
||||||
|
if (eventPage.hasMore || stepPage.hasMore) {
|
||||||
|
fragment.append(
|
||||||
|
element(
|
||||||
|
'p',
|
||||||
|
'privacy-note',
|
||||||
|
'详情超过当前 64 条窗口;完整证据可通过 API 分页读取。',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
replace(nodes.detail, fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRun(run) {
|
||||||
|
try {
|
||||||
|
const value = await api(
|
||||||
|
`/api/v3/projects/${state.project}/runs/${run.id}/cancellation`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
schema: 'qinglong/run-cancellation@v1',
|
||||||
|
mutationId: newMutationId(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
value.status === 'already_terminal'
|
||||||
|
? '运行已经结束。'
|
||||||
|
: value.status === 'already_requested'
|
||||||
|
? '取消请求已经存在。'
|
||||||
|
: '取消请求已写入。',
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
await selectRun(run.id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(describeError(error), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNavigation() {
|
||||||
|
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
|
||||||
|
if (button.dataset.view === state.view)
|
||||||
|
button.setAttribute('aria-current', 'page');
|
||||||
|
else button.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
if (state.view === 'tasks') {
|
||||||
|
nodes.kicker.textContent = 'Project task authority';
|
||||||
|
nodes.title.textContent = '任务调度台';
|
||||||
|
nodes.description.textContent =
|
||||||
|
'查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。';
|
||||||
|
} else {
|
||||||
|
nodes.kicker.textContent = 'Durable run evidence';
|
||||||
|
nodes.title.textContent = '运行事实账本';
|
||||||
|
nodes.description.textContent =
|
||||||
|
'状态来自持久化 Run;取消只是请求,只有终态事件才能证明执行已经停止。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!state.token) return;
|
||||||
|
loading();
|
||||||
|
try {
|
||||||
|
if (state.view === 'tasks') await renderTasks();
|
||||||
|
else await renderRuns();
|
||||||
|
setConnection('connected', `${state.project} · 已连接`);
|
||||||
|
} catch (error) {
|
||||||
|
errorState(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(token, project) {
|
||||||
|
state.token = token;
|
||||||
|
state.project = project;
|
||||||
|
state.view = 'tasks';
|
||||||
|
state.selectedId = null;
|
||||||
|
nodes.token.value = '';
|
||||||
|
nodes.token.disabled = true;
|
||||||
|
nodes.project.disabled = true;
|
||||||
|
nodes.form.querySelector('.primary-button').hidden = true;
|
||||||
|
nodes.disconnect.hidden = false;
|
||||||
|
nodes.nav.hidden = false;
|
||||||
|
nodes.refresh.hidden = false;
|
||||||
|
detailEmpty();
|
||||||
|
updateNavigation();
|
||||||
|
setConnection('connected', `${project} · 正在验证`);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
state.token = null;
|
||||||
|
state.selectedId = null;
|
||||||
|
state.pendingAction = null;
|
||||||
|
nodes.token.value = '';
|
||||||
|
nodes.token.disabled = false;
|
||||||
|
nodes.project.disabled = false;
|
||||||
|
nodes.form.querySelector('.primary-button').hidden = false;
|
||||||
|
nodes.disconnect.hidden = true;
|
||||||
|
nodes.nav.hidden = true;
|
||||||
|
nodes.refresh.hidden = true;
|
||||||
|
setConnection('idle', '等待凭据');
|
||||||
|
nodes.kicker.textContent = 'Connection gate';
|
||||||
|
nodes.title.textContent = '先建立一条本机连接';
|
||||||
|
nodes.description.textContent =
|
||||||
|
'输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。';
|
||||||
|
const welcome = element('div', 'welcome-state');
|
||||||
|
welcome.append(element('span', 'welcome-index', 'QL / 03'));
|
||||||
|
welcome.append(element('p', null, '任务是计划,运行是事实,事件是证据。'));
|
||||||
|
const rule = element('div', 'welcome-rule');
|
||||||
|
rule.setAttribute('aria-hidden', 'true');
|
||||||
|
rule.append(element('i'), element('i'), element('i'));
|
||||||
|
welcome.append(rule);
|
||||||
|
replace(nodes.ledger, welcome);
|
||||||
|
detailEmpty();
|
||||||
|
nodes.token.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const token = nodes.token.value.trim();
|
||||||
|
const project = nodes.project.value.trim();
|
||||||
|
if (!PROJECT_PATTERN.test(project)) {
|
||||||
|
showToast('项目 ID 格式无效。', 'error');
|
||||||
|
nodes.project.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!TOKEN_PATTERN.test(token)) {
|
||||||
|
showToast('API Credential 格式无效。', 'error');
|
||||||
|
nodes.token.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(token, project);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.disconnect.addEventListener('click', disconnect);
|
||||||
|
nodes.refresh.addEventListener('click', refresh);
|
||||||
|
|
||||||
|
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
if (button.dataset.view === state.view) return;
|
||||||
|
state.view = button.dataset.view;
|
||||||
|
state.selectedId = null;
|
||||||
|
detailEmpty();
|
||||||
|
updateNavigation();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.dialog.addEventListener('close', async () => {
|
||||||
|
const action = state.pendingAction;
|
||||||
|
state.pendingAction = null;
|
||||||
|
if (nodes.dialog.returnValue === 'accept' && typeof action === 'function') {
|
||||||
|
await action();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (
|
||||||
|
!state.token ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.altKey ||
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
nodes.dialog.open
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const view =
|
||||||
|
event.key.toLowerCase() === 't'
|
||||||
|
? 'tasks'
|
||||||
|
: event.key.toLowerCase() === 'r'
|
||||||
|
? 'runs'
|
||||||
|
: null;
|
||||||
|
if (!view || view === state.view) return;
|
||||||
|
state.view = view;
|
||||||
|
state.selectedId = null;
|
||||||
|
detailEmpty();
|
||||||
|
updateNavigation();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="theme-color" content="#19353d" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'"
|
||||||
|
/>
|
||||||
|
<title>QingLong 3.0 · 自动化值班台</title>
|
||||||
|
<link rel="stylesheet" href="/console.css" />
|
||||||
|
<script src="/console.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#workspace">跳到操作区</a>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="gate" aria-label="连接与导航">
|
||||||
|
<header class="brand">
|
||||||
|
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
<span class="brand-name">QingLong</span>
|
||||||
|
<span class="brand-version">3.0</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="gate-copy">
|
||||||
|
<p class="eyebrow">Local operations</p>
|
||||||
|
<h1>自动化<br />值班台</h1>
|
||||||
|
<p>在一块屏幕上看清任务、运行与事件,然后只执行你明确确认的动作。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="connection-state" id="connection-state" data-state="idle">
|
||||||
|
<span class="signal" aria-hidden="true"></span>
|
||||||
|
<span id="connection-label">等待凭据</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="credential-form" class="credential-form" autocomplete="off">
|
||||||
|
<label for="project-input">项目</label>
|
||||||
|
<input
|
||||||
|
id="project-input"
|
||||||
|
name="project"
|
||||||
|
value="default"
|
||||||
|
inputmode="text"
|
||||||
|
maxlength="128"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label for="token-input">API Credential</label>
|
||||||
|
<input
|
||||||
|
id="token-input"
|
||||||
|
name="token"
|
||||||
|
type="password"
|
||||||
|
placeholder="ql3c_…"
|
||||||
|
maxlength="128"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button class="primary-button" type="submit">连接本机</button>
|
||||||
|
<button class="quiet-button" id="disconnect-button" type="button" hidden>
|
||||||
|
断开并清除凭据
|
||||||
|
</button>
|
||||||
|
<p class="privacy-note">凭据只保存在当前页面内存;不写入 URL、Cookie 或浏览器存储。</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<nav class="section-nav" aria-label="工作区" hidden>
|
||||||
|
<button type="button" data-view="tasks" aria-current="page">
|
||||||
|
<span>任务</span><kbd>T</kbd>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-view="runs">
|
||||||
|
<span>运行</span><kbd>R</kbd>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<footer class="gate-footer">
|
||||||
|
<span>Loopback only</span>
|
||||||
|
<span>Alpha · API v3</span>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="workspace" class="workspace" tabindex="-1">
|
||||||
|
<header class="masthead">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" id="section-kicker">Connection gate</p>
|
||||||
|
<h2 id="section-title">先建立一条本机连接</h2>
|
||||||
|
<p id="section-description">
|
||||||
|
输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="refresh-button" id="refresh-button" type="button" hidden>
|
||||||
|
<span aria-hidden="true">↻</span> 刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="workspace-grid">
|
||||||
|
<section class="ledger" id="ledger" aria-live="polite" aria-busy="false">
|
||||||
|
<div class="welcome-state">
|
||||||
|
<span class="welcome-index">QL / 03</span>
|
||||||
|
<p>任务是计划,运行是事实,事件是证据。</p>
|
||||||
|
<div class="welcome-rule" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="detail" id="detail" aria-label="详情">
|
||||||
|
<div class="detail-empty">
|
||||||
|
<span class="detail-glyph" aria-hidden="true">⌁</span>
|
||||||
|
<p>选择一项,查看它的执行围栏与事件轨迹。</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="confirmation-dialog" class="confirmation-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<p class="eyebrow">Explicit action</p>
|
||||||
|
<h2 id="confirmation-title">确认操作</h2>
|
||||||
|
<p id="confirmation-copy"></p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="quiet-button" value="cancel">返回</button>
|
||||||
|
<button class="danger-button" id="confirmation-accept" value="accept">
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"assets/console/*",
|
||||||
"dist/**/*.js",
|
"dist/**/*.js",
|
||||||
"dist/**/*.d.ts"
|
"dist/**/*.d.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const MAX_ASSET_BYTES = 96 * 1_024;
|
||||||
|
const MAX_TOTAL_BYTES = 192 * 1_024;
|
||||||
|
|
||||||
|
const DEFINITIONS = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
requestPath: '/',
|
||||||
|
file: 'index.html',
|
||||||
|
contentType: 'text/html; charset=utf-8',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
requestPath: '/console.css',
|
||||||
|
file: 'console.css',
|
||||||
|
contentType: 'text/css; charset=utf-8',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
requestPath: '/console.js',
|
||||||
|
file: 'console.js',
|
||||||
|
contentType: 'text/javascript; charset=utf-8',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface LocalConsoleAsset {
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly etag: string;
|
||||||
|
readonly body: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>;
|
||||||
|
|
||||||
|
export class LocalConsoleAssetError extends Error {
|
||||||
|
readonly code = 'QL3_LOCAL_CONSOLE_ASSET_INVALID';
|
||||||
|
|
||||||
|
constructor(message: string, options?: ErrorOptions) {
|
||||||
|
super(`Local Console asset is invalid: ${message}`, options);
|
||||||
|
this.name = 'LocalConsoleAssetError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAsset(
|
||||||
|
root: string,
|
||||||
|
definition: (typeof DEFINITIONS)[number],
|
||||||
|
): Readonly<LocalConsoleAsset> {
|
||||||
|
const filePath = path.join(root, definition.file);
|
||||||
|
let stat;
|
||||||
|
let body: Buffer;
|
||||||
|
try {
|
||||||
|
stat = lstatSync(filePath);
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.size < 2 ||
|
||||||
|
stat.size > MAX_ASSET_BYTES ||
|
||||||
|
realpathSync(filePath) !== filePath
|
||||||
|
) {
|
||||||
|
throw new TypeError('asset identity is incompatible');
|
||||||
|
}
|
||||||
|
body = readFileSync(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new LocalConsoleAssetError(definition.file, { cause: error });
|
||||||
|
}
|
||||||
|
if (body.byteLength !== stat.size) {
|
||||||
|
throw new LocalConsoleAssetError(
|
||||||
|
`${definition.file} changed while loading`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
contentType: definition.contentType,
|
||||||
|
etag: `"${createHash('sha256').update(body).digest('hex')}"`,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLocalConsoleAssets(): LocalConsoleAssets {
|
||||||
|
const root = path.resolve(__dirname, '../../assets/console');
|
||||||
|
let canonicalRoot: string;
|
||||||
|
try {
|
||||||
|
const stat = lstatSync(root);
|
||||||
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError();
|
||||||
|
canonicalRoot = realpathSync(root);
|
||||||
|
} catch (error) {
|
||||||
|
throw new LocalConsoleAssetError('asset root', { cause: error });
|
||||||
|
}
|
||||||
|
const assets = new Map<string, Readonly<LocalConsoleAsset>>();
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const definition of DEFINITIONS) {
|
||||||
|
const asset = loadAsset(canonicalRoot, definition);
|
||||||
|
totalBytes += asset.body.byteLength;
|
||||||
|
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||||
|
throw new LocalConsoleAssetError('asset set exceeds its byte budget');
|
||||||
|
}
|
||||||
|
assets.set(definition.requestPath, asset);
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis
|
|||||||
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||||
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||||
|
import {
|
||||||
|
loadLocalConsoleAssets,
|
||||||
|
type LocalConsoleAsset,
|
||||||
|
} from '../console/localConsoleAssets';
|
||||||
import type { LocalApiResponse } from './contract';
|
import type { LocalApiResponse } from './contract';
|
||||||
|
|
||||||
const MAX_HEADER_BYTES = 8 * 1_024;
|
const MAX_HEADER_BYTES = 8 * 1_024;
|
||||||
@@ -38,6 +42,8 @@ const TASK_START_ROUTE_PATTERN =
|
|||||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
|
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
|
||||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
|
||||||
|
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||||
|
|
||||||
type LocalApiRouteResolution =
|
type LocalApiRouteResolution =
|
||||||
| LocalApiAdmissionOperation
|
| LocalApiAdmissionOperation
|
||||||
@@ -559,6 +565,45 @@ function send(
|
|||||||
response.end(body);
|
response.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendConsoleAsset(
|
||||||
|
response: ServerResponse,
|
||||||
|
requestId: string,
|
||||||
|
asset: Readonly<LocalConsoleAsset>,
|
||||||
|
): void {
|
||||||
|
if (response.destroyed || response.headersSent) return;
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader('content-type', asset.contentType);
|
||||||
|
response.setHeader('cache-control', 'no-store');
|
||||||
|
response.setHeader(
|
||||||
|
'content-security-policy',
|
||||||
|
LOCAL_CONSOLE_CONTENT_SECURITY_POLICY,
|
||||||
|
);
|
||||||
|
response.setHeader('cross-origin-opener-policy', 'same-origin');
|
||||||
|
response.setHeader('cross-origin-resource-policy', 'same-origin');
|
||||||
|
response.setHeader(
|
||||||
|
'permissions-policy',
|
||||||
|
'camera=(), geolocation=(), microphone=()',
|
||||||
|
);
|
||||||
|
response.setHeader('referrer-policy', 'no-referrer');
|
||||||
|
response.setHeader('x-content-type-options', 'nosniff');
|
||||||
|
response.setHeader('x-frame-options', 'DENY');
|
||||||
|
response.setHeader('x-request-id', requestId);
|
||||||
|
response.setHeader('etag', asset.etag);
|
||||||
|
response.setHeader('content-length', asset.body.byteLength);
|
||||||
|
response.end(asset.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendConsoleFavicon(response: ServerResponse, requestId: string): void {
|
||||||
|
if (response.destroyed || response.headersSent) return;
|
||||||
|
response.statusCode = 204;
|
||||||
|
response.setHeader('cache-control', 'no-store');
|
||||||
|
response.setHeader('cross-origin-resource-policy', 'same-origin');
|
||||||
|
response.setHeader('referrer-policy', 'no-referrer');
|
||||||
|
response.setHeader('x-content-type-options', 'nosniff');
|
||||||
|
response.setHeader('x-request-id', requestId);
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
|
||||||
function errorResponse(statusCode: number, code: string): LocalApiResponse {
|
function errorResponse(statusCode: number, code: string): LocalApiResponse {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
statusCode,
|
statusCode,
|
||||||
@@ -588,6 +633,7 @@ export async function startLocalApiHttpSurface(
|
|||||||
options: LocalApiHttpSurfaceOptions,
|
options: LocalApiHttpSurfaceOptions,
|
||||||
): Promise<Readonly<ActiveLocalApiHttpSurface>> {
|
): Promise<Readonly<ActiveLocalApiHttpSurface>> {
|
||||||
validateOptions(options);
|
validateOptions(options);
|
||||||
|
const consoleAssets = loadLocalConsoleAssets();
|
||||||
const uuid = options.randomUuid ?? randomUUID;
|
const uuid = options.randomUuid ?? randomUUID;
|
||||||
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
|
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
|
||||||
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
|
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
|
||||||
@@ -611,6 +657,28 @@ export async function startLocalApiHttpSurface(
|
|||||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const consoleAsset =
|
||||||
|
request.method === 'GET' && typeof request.url === 'string'
|
||||||
|
? consoleAssets.get(request.url)
|
||||||
|
: undefined;
|
||||||
|
if (consoleAsset) {
|
||||||
|
if (hasRequestBody(request)) {
|
||||||
|
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||||
|
request.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendConsoleAsset(response, requestId, consoleAsset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && request.url === '/favicon.ico') {
|
||||||
|
if (hasRequestBody(request)) {
|
||||||
|
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||||
|
request.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendConsoleFavicon(response, requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resolvedRoute = route(request, options.profile);
|
const resolvedRoute = route(request, options.profile);
|
||||||
if (!resolvedRoute) {
|
if (!resolvedRoute) {
|
||||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const { test } = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadLocalConsoleAssets,
|
||||||
|
} = require('../dist/console/localConsoleAssets.js');
|
||||||
|
const {
|
||||||
|
startLocalApiHttpSurface,
|
||||||
|
} = require('../dist/transport/httpSurface.js');
|
||||||
|
|
||||||
|
function reservePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(port, requestPath, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const outgoing = http.request(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path: requestPath,
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
connection: 'close',
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () =>
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
headers: response.headers,
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
outgoing.once('error', reject);
|
||||||
|
outgoing.end(options.body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loads one bounded offline Console asset closure', () => {
|
||||||
|
const assets = loadLocalConsoleAssets();
|
||||||
|
assert.deepEqual([...assets.keys()], ['/', '/console.css', '/console.js']);
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const [requestPath, asset] of assets) {
|
||||||
|
assert.match(asset.etag, /^"[0-9a-f]{64}"$/);
|
||||||
|
assert.ok(asset.body.byteLength >= 100);
|
||||||
|
assert.ok(asset.body.byteLength <= 96 * 1024);
|
||||||
|
totalBytes += asset.body.byteLength;
|
||||||
|
const text = asset.body.toString('utf8');
|
||||||
|
assert.equal(/https?:\/\//u.test(text), false);
|
||||||
|
if (requestPath === '/console.js') {
|
||||||
|
assert.equal(
|
||||||
|
/\b(?:localStorage|sessionStorage|innerHTML|eval)\b/u.test(text),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.match(text, /authorization: `Bearer \$\{state\.token\}`/u);
|
||||||
|
assert.match(text, /credentials: 'omit'/u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(totalBytes <= 192 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serves the Console without authentication and preserves API admission', async (t) => {
|
||||||
|
const calls = [];
|
||||||
|
const port = await reservePort();
|
||||||
|
const active = await startLocalApiHttpSurface({
|
||||||
|
profile: 'edge',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
admission: {
|
||||||
|
async prepare(value) {
|
||||||
|
calls.push(value.operation.operationId);
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
body: { code: 'authentication_required' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
randomUuid: () => '00000000-0000-4000-8000-000000000001',
|
||||||
|
});
|
||||||
|
t.after(() => active.stopAndDrain());
|
||||||
|
|
||||||
|
for (const [requestPath, contentType] of [
|
||||||
|
['/', 'text/html; charset=utf-8'],
|
||||||
|
['/console.css', 'text/css; charset=utf-8'],
|
||||||
|
['/console.js', 'text/javascript; charset=utf-8'],
|
||||||
|
]) {
|
||||||
|
const response = await request(port, requestPath);
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(response.headers['content-type'], contentType);
|
||||||
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
|
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
||||||
|
assert.equal(response.headers['x-frame-options'], 'DENY');
|
||||||
|
assert.equal(response.headers['referrer-policy'], 'no-referrer');
|
||||||
|
assert.equal(response.headers['cross-origin-opener-policy'], 'same-origin');
|
||||||
|
assert.equal(
|
||||||
|
response.headers['cross-origin-resource-policy'],
|
||||||
|
'same-origin',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
response.headers['content-security-policy'],
|
||||||
|
/default-src 'none'/u,
|
||||||
|
);
|
||||||
|
assert.match(response.headers.etag, /^"[0-9a-f]{64}"$/);
|
||||||
|
assert.ok(response.body.byteLength >= 100);
|
||||||
|
}
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
|
||||||
|
const favicon = await request(port, '/favicon.ico');
|
||||||
|
assert.equal(favicon.statusCode, 204);
|
||||||
|
assert.equal(favicon.headers['cache-control'], 'no-store');
|
||||||
|
assert.equal(favicon.body.byteLength, 0);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
|
||||||
|
const api = await request(port, '/api/v3/projects/default/tasks?limit=1');
|
||||||
|
assert.equal(api.statusCode, 401);
|
||||||
|
assert.deepEqual(JSON.parse(api.body.toString('utf8')), {
|
||||||
|
code: 'authentication_required',
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, ['task.list']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects request bodies and query aliases on Console assets', async (t) => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const active = await startLocalApiHttpSurface({
|
||||||
|
profile: 'standalone',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
admission: {
|
||||||
|
async prepare() {
|
||||||
|
throw new Error('static assets must not reach admission');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(() => active.stopAndDrain());
|
||||||
|
|
||||||
|
const body = await request(port, '/', {
|
||||||
|
headers: { 'content-length': '1' },
|
||||||
|
body: 'x',
|
||||||
|
});
|
||||||
|
assert.equal(body.statusCode, 400);
|
||||||
|
assert.deepEqual(JSON.parse(body.body.toString('utf8')), {
|
||||||
|
code: 'invalid_request_body',
|
||||||
|
});
|
||||||
|
|
||||||
|
const alias = await request(port, '/console.js?cache=1');
|
||||||
|
assert.equal(alias.statusCode, 404);
|
||||||
|
assert.deepEqual(JSON.parse(alias.body.toString('utf8')), {
|
||||||
|
code: 'route_not_found',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3323,6 +3323,9 @@ function auditPackageScripts(packagePath, manifest, findings) {
|
|||||||
|
|
||||||
function auditPackageFiles(packagePath, manifest, findings) {
|
function auditPackageFiles(packagePath, manifest, findings) {
|
||||||
const expected = ['dist/**/*.js', 'dist/**/*.d.ts'];
|
const expected = ['dist/**/*.js', 'dist/**/*.d.ts'];
|
||||||
|
if (packagePath === 'packages/ql3-local-api') {
|
||||||
|
expected.unshift('assets/console/*');
|
||||||
|
}
|
||||||
if (packagePath === 'packages/ql3-cluster-admin') {
|
if (packagePath === 'packages/ql3-cluster-admin') {
|
||||||
expected.push('assets/copilot-console/*');
|
expected.push('assets/copilot-console/*');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,44 @@ function auditLocalApiExecutable(artifactDirectory) {
|
|||||||
) {
|
) {
|
||||||
fail('local API executable help output is invalid');
|
fail('local API executable help output is invalid');
|
||||||
}
|
}
|
||||||
|
const consoleDirectory = path.join(packageDirectory, 'assets', 'console');
|
||||||
|
const expectedAssets = ['console.css', 'console.js', 'index.html'];
|
||||||
|
const actualAssets = fs.readdirSync(consoleDirectory).sort();
|
||||||
|
if (JSON.stringify(actualAssets) !== JSON.stringify(expectedAssets)) {
|
||||||
|
fail(
|
||||||
|
`local Console asset closure is invalid: ${JSON.stringify(actualAssets)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let totalBytes = 0;
|
||||||
|
const contents = {};
|
||||||
|
for (const name of expectedAssets) {
|
||||||
|
const assetPath = path.join(consoleDirectory, name);
|
||||||
|
const stat = fs.lstatSync(assetPath);
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.size < 100 ||
|
||||||
|
stat.size > 96 * 1024
|
||||||
|
) {
|
||||||
|
fail(`local Console asset is invalid: ${name}`);
|
||||||
|
}
|
||||||
|
totalBytes += stat.size;
|
||||||
|
contents[name] = fs.readFileSync(assetPath, 'utf8');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
totalBytes > 192 * 1024 ||
|
||||||
|
!contents['index.html'].includes('href="/console.css"') ||
|
||||||
|
!contents['index.html'].includes('src="/console.js"') ||
|
||||||
|
/<(?:script|style)(?:\s|>)[^>]*>\s*[^<\s]/u.test(contents['index.html']) ||
|
||||||
|
/\b(?:localStorage|sessionStorage|innerHTML|eval)\b/u.test(
|
||||||
|
contents['console.js'],
|
||||||
|
) ||
|
||||||
|
/https?:\/\//u.test(contents['index.html']) ||
|
||||||
|
/https?:\/\//u.test(contents['console.css']) ||
|
||||||
|
/https?:\/\//u.test(contents['console.js'])
|
||||||
|
) {
|
||||||
|
fail('local Console offline or credential-custody contract is invalid');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageRootFromEntry(entryPath, packageName) {
|
function packageRootFromEntry(entryPath, packageName) {
|
||||||
@@ -672,6 +710,9 @@ function main() {
|
|||||||
entrySpecifiers,
|
entrySpecifiers,
|
||||||
excludedInternalPackages:
|
excludedInternalPackages:
|
||||||
options.application && !options.ai ? ['@qinglong/ai'] : [],
|
options.application && !options.ai ? ['@qinglong/ai'] : [],
|
||||||
|
retainedJavaScriptFiles: options.api
|
||||||
|
? ['local-api/assets/console/console.js']
|
||||||
|
: [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const prunedMcpExternalDevelopment = options.mcp
|
const prunedMcpExternalDevelopment = options.mcp
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ function projectRuntimeExports(value, requiredKeys = null) {
|
|||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(value)
|
Object.entries(value)
|
||||||
.filter(([key]) => {
|
.filter(([key]) => {
|
||||||
if (key !== '.' && !/^\.\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(key)) {
|
if (
|
||||||
|
key !== '.' &&
|
||||||
|
!/^\.\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(key)
|
||||||
|
) {
|
||||||
fail(`runtime package export key ${key} is invalid`);
|
fail(`runtime package export key ${key} is invalid`);
|
||||||
}
|
}
|
||||||
return requiredKeys === null || requiredKeys.has(key);
|
return requiredKeys === null || requiredKeys.has(key);
|
||||||
@@ -127,7 +130,9 @@ function normalizeEntrySpecifiers(options) {
|
|||||||
Array.isArray(options) ||
|
Array.isArray(options) ||
|
||||||
Reflect.ownKeys(options).some(
|
Reflect.ownKeys(options).some(
|
||||||
(key) =>
|
(key) =>
|
||||||
key !== 'entrySpecifiers' && key !== 'excludedInternalPackages',
|
key !== 'entrySpecifiers' &&
|
||||||
|
key !== 'excludedInternalPackages' &&
|
||||||
|
key !== 'retainedJavaScriptFiles',
|
||||||
) ||
|
) ||
|
||||||
!Array.isArray(options.entrySpecifiers) ||
|
!Array.isArray(options.entrySpecifiers) ||
|
||||||
options.entrySpecifiers.length > 64
|
options.entrySpecifiers.length > 64
|
||||||
@@ -147,6 +152,41 @@ function normalizeEntrySpecifiers(options) {
|
|||||||
return Object.freeze(entries);
|
return Object.freeze(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRetainedJavaScriptFiles(options, inventory) {
|
||||||
|
if (options === undefined || options.retainedJavaScriptFiles === undefined) {
|
||||||
|
return Object.freeze([]);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(options.retainedJavaScriptFiles) ||
|
||||||
|
options.retainedJavaScriptFiles.length > 64
|
||||||
|
) {
|
||||||
|
fail('retained JavaScript file options are invalid');
|
||||||
|
}
|
||||||
|
const inventoryPaths = new Set(inventory.files.map(({ path: file }) => file));
|
||||||
|
const retained = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of options.retainedJavaScriptFiles) {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > 256 ||
|
||||||
|
!/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[A-Za-z0-9._-]+)+\.js$/u.test(value) ||
|
||||||
|
seen.has(value)
|
||||||
|
) {
|
||||||
|
fail('retained JavaScript file is invalid');
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const resolved = path.resolve(inventory.resolved, ...value.split('/'));
|
||||||
|
if (
|
||||||
|
!resolved.startsWith(`${inventory.resolved}${path.sep}`) ||
|
||||||
|
!inventoryPaths.has(resolved)
|
||||||
|
) {
|
||||||
|
fail('retained JavaScript file is not installed');
|
||||||
|
}
|
||||||
|
retained.push(resolved);
|
||||||
|
}
|
||||||
|
return Object.freeze(retained);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeExcludedInternalPackages(options) {
|
function normalizeExcludedInternalPackages(options) {
|
||||||
if (options === undefined || options.excludedInternalPackages === undefined) {
|
if (options === undefined || options.excludedInternalPackages === undefined) {
|
||||||
return Object.freeze([]);
|
return Object.freeze([]);
|
||||||
@@ -195,14 +235,18 @@ function collectStaticModuleSpecifiers(files) {
|
|||||||
for (const match of contents.matchAll(STATIC_MODULE_SPECIFIER_PATTERN)) {
|
for (const match of contents.matchAll(STATIC_MODULE_SPECIFIER_PATTERN)) {
|
||||||
const specifier = match[2];
|
const specifier = match[2];
|
||||||
const start = match.index + match[0].indexOf(specifier);
|
const start = match.index + match[0].indexOf(specifier);
|
||||||
literalRanges.push(Object.freeze({ start, end: start + specifier.length }));
|
literalRanges.push(
|
||||||
|
Object.freeze({ start, end: start + specifier.length }),
|
||||||
|
);
|
||||||
literalCalls.add(match.index);
|
literalCalls.add(match.index);
|
||||||
specifiers.push(specifier);
|
specifiers.push(specifier);
|
||||||
}
|
}
|
||||||
MODULE_LOAD_START_PATTERN.lastIndex = 0;
|
MODULE_LOAD_START_PATTERN.lastIndex = 0;
|
||||||
for (const match of contents.matchAll(MODULE_LOAD_START_PATTERN)) {
|
for (const match of contents.matchAll(MODULE_LOAD_START_PATTERN)) {
|
||||||
if (!literalCalls.has(match.index)) {
|
if (!literalCalls.has(match.index)) {
|
||||||
fail(`runtime JavaScript contains a non-literal module load at ${file.path}`);
|
fail(
|
||||||
|
`runtime JavaScript contains a non-literal module load at ${file.path}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let occurrence = contents.indexOf('@qinglong/');
|
let occurrence = contents.indexOf('@qinglong/');
|
||||||
@@ -212,7 +256,9 @@ function collectStaticModuleSpecifiers(files) {
|
|||||||
({ start, end }) => occurrence >= start && occurrence < end,
|
({ start, end }) => occurrence >= start && occurrence < end,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
fail(`runtime JavaScript contains an unproved internal specifier at ${file.path}`);
|
fail(
|
||||||
|
`runtime JavaScript contains an unproved internal specifier at ${file.path}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
occurrence = contents.indexOf('@qinglong/', occurrence + 1);
|
occurrence = contents.indexOf('@qinglong/', occurrence + 1);
|
||||||
}
|
}
|
||||||
@@ -269,8 +315,8 @@ function manifestBinTargets(record) {
|
|||||||
typeof bin === 'string'
|
typeof bin === 'string'
|
||||||
? [bin]
|
? [bin]
|
||||||
: bin && typeof bin === 'object' && !Array.isArray(bin)
|
: bin && typeof bin === 'object' && !Array.isArray(bin)
|
||||||
? Object.values(bin)
|
? Object.values(bin)
|
||||||
: null;
|
: null;
|
||||||
if (!values || values.some((value) => typeof value !== 'string')) {
|
if (!values || values.some((value) => typeof value !== 'string')) {
|
||||||
fail(`runtime package bin is invalid for ${record.manifest.name}`);
|
fail(`runtime package bin is invalid for ${record.manifest.name}`);
|
||||||
}
|
}
|
||||||
@@ -297,10 +343,11 @@ function canonicalRelativeSpecifier(specifier) {
|
|||||||
else while (remainder.startsWith('../')) remainder = remainder.slice(3);
|
else while (remainder.startsWith('../')) remainder = remainder.slice(3);
|
||||||
return (
|
return (
|
||||||
remainder.length > 0 &&
|
remainder.length > 0 &&
|
||||||
remainder.split('/').every(
|
remainder
|
||||||
(segment) =>
|
.split('/')
|
||||||
segment.length > 0 && segment !== '.' && segment !== '..',
|
.every(
|
||||||
)
|
(segment) => segment.length > 0 && segment !== '.' && segment !== '..',
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +420,10 @@ function pruneRuntimeArtifact(directory, options) {
|
|||||||
normalizeExcludedInternalPackages(options),
|
normalizeExcludedInternalPackages(options),
|
||||||
);
|
);
|
||||||
const inventory = inventoryRuntimeArtifact(directory);
|
const inventory = inventoryRuntimeArtifact(directory);
|
||||||
|
const retainedJavaScriptFiles = normalizeRetainedJavaScriptFiles(
|
||||||
|
options,
|
||||||
|
inventory,
|
||||||
|
);
|
||||||
const manifests = inventory.files.filter(
|
const manifests = inventory.files.filter(
|
||||||
(file) =>
|
(file) =>
|
||||||
path.basename(file.path) === 'package.json' &&
|
path.basename(file.path) === 'package.json' &&
|
||||||
@@ -523,6 +574,9 @@ function pruneRuntimeArtifact(directory, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const file of retainedJavaScriptFiles) {
|
||||||
|
enqueueJavascript(file, 'explicit runtime asset');
|
||||||
|
}
|
||||||
for (const specifier of entrySpecifiers) requireExport(specifier);
|
for (const specifier of entrySpecifiers) requireExport(specifier);
|
||||||
|
|
||||||
while (pendingJavascript.length > 0) {
|
while (pendingJavascript.length > 0) {
|
||||||
@@ -536,11 +590,7 @@ function pruneRuntimeArtifact(directory, options) {
|
|||||||
requireExport(specifier, filePath);
|
requireExport(specifier, filePath);
|
||||||
} else if (specifier.startsWith('.')) {
|
} else if (specifier.startsWith('.')) {
|
||||||
enqueueJavascript(
|
enqueueJavascript(
|
||||||
resolveRelativeRuntimeTarget(
|
resolveRelativeRuntimeTarget(filePath, specifier, owner.packageRoot),
|
||||||
filePath,
|
|
||||||
specifier,
|
|
||||||
owner.packageRoot,
|
|
||||||
),
|
|
||||||
`relative import from ${filePath}`,
|
`relative import from ${filePath}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -567,13 +617,8 @@ function pruneRuntimeArtifact(directory, options) {
|
|||||||
for (const record of packageRecords) {
|
for (const record of packageRecords) {
|
||||||
const { file, before, manifest, packageRoot } = record;
|
const { file, before, manifest, packageRoot } = record;
|
||||||
const required = requiredExports.get(manifest.name);
|
const required = requiredExports.get(manifest.name);
|
||||||
const retainMain =
|
const retainMain = !manifest.exports || required.has('.');
|
||||||
!manifest.exports || required.has('.');
|
const projected = projectRuntimeManifest(manifest, required, retainMain);
|
||||||
const projected = projectRuntimeManifest(
|
|
||||||
manifest,
|
|
||||||
required,
|
|
||||||
retainMain,
|
|
||||||
);
|
|
||||||
if (projected.exports) {
|
if (projected.exports) {
|
||||||
for (const target of Object.values(projected.exports)) {
|
for (const target of Object.values(projected.exports)) {
|
||||||
validateRuntimeExportTarget(packageRoot, target);
|
validateRuntimeExportTarget(packageRoot, target);
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const {
|
|||||||
test('ships runtime JavaScript and declarations without development maps', () => {
|
test('ships runtime JavaScript and declarations without development maps', () => {
|
||||||
for (const [packagePath, files] of [
|
for (const [packagePath, files] of [
|
||||||
['packages/ql3-runtime-core', ['dist/**/*.js', 'dist/**/*.d.ts']],
|
['packages/ql3-runtime-core', ['dist/**/*.js', 'dist/**/*.d.ts']],
|
||||||
|
[
|
||||||
|
'packages/ql3-local-api',
|
||||||
|
['assets/console/*', 'dist/**/*.js', 'dist/**/*.d.ts'],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'packages/ql3-cluster-admin',
|
'packages/ql3-cluster-admin',
|
||||||
['dist/**/*.js', 'dist/**/*.d.ts', 'assets/copilot-console/*'],
|
['dist/**/*.js', 'dist/**/*.d.ts', 'assets/copilot-console/*'],
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 17,
|
sourceFiles: 18,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 84,
|
rootSourceLines: 84,
|
||||||
nestedSourceFiles: 16,
|
nestedSourceFiles: 17,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ test('prunes only deployment waste while preserving runtime package semantics',
|
|||||||
`${JSON.stringify(application.manifest, null, 2)}\n`,
|
`${JSON.stringify(application.manifest, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
const executable = path.join(application.dist, 'cli.js');
|
const executable = path.join(application.dist, 'cli.js');
|
||||||
fs.writeFileSync(path.join(application.dist, 'index.js'), "'use strict';\n");
|
fs.writeFileSync(
|
||||||
|
path.join(application.dist, 'index.js'),
|
||||||
|
"'use strict';\n",
|
||||||
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
executable,
|
executable,
|
||||||
"#!/usr/bin/env node\n'use strict';\n//# sourceMappingURL=cli.js.map\n",
|
"#!/usr/bin/env node\n'use strict';\n//# sourceMappingURL=cli.js.map\n",
|
||||||
@@ -163,7 +166,10 @@ test('projects runtime exports from static imports and explicit Profile entries'
|
|||||||
`${JSON.stringify(runtime.manifest, null, 2)}\n`,
|
`${JSON.stringify(runtime.manifest, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
for (const name of ['index', 'used', 'lazy', 'unused']) {
|
for (const name of ['index', 'used', 'lazy', 'unused']) {
|
||||||
fs.writeFileSync(path.join(runtime.dist, `${name}.js`), "'use strict';\n");
|
fs.writeFileSync(
|
||||||
|
path.join(runtime.dist, `${name}.js`),
|
||||||
|
"'use strict';\n",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const application = writePackage(
|
const application = writePackage(
|
||||||
@@ -277,10 +283,7 @@ test('allows only explicitly excluded and declared optional feature imports', ()
|
|||||||
entrySpecifiers: ['@qinglong/local-application'],
|
entrySpecifiers: ['@qinglong/local-application'],
|
||||||
excludedInternalPackages: ['@qinglong/ai'],
|
excludedInternalPackages: ['@qinglong/ai'],
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(report.packageManifests.runtimeExports.excludedSpecifiers, 1);
|
||||||
report.packageManifests.runtimeExports.excludedSpecifiers,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
current.close();
|
current.close();
|
||||||
}
|
}
|
||||||
@@ -370,7 +373,10 @@ test('retains relative, dynamic, cyclic, bin, migration and asset closure', () =
|
|||||||
`${JSON.stringify(runtime.manifest, null, 2)}\n`,
|
`${JSON.stringify(runtime.manifest, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
for (const name of ['index', 'used', 'unused']) {
|
for (const name of ['index', 'used', 'unused']) {
|
||||||
fs.writeFileSync(path.join(runtime.dist, `${name}.js`), "'use strict';\n");
|
fs.writeFileSync(
|
||||||
|
path.join(runtime.dist, `${name}.js`),
|
||||||
|
"'use strict';\n",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const application = writePackage(
|
const application = writePackage(
|
||||||
@@ -422,9 +428,16 @@ test('retains relative, dynamic, cyclic, bin, migration and asset closure', () =
|
|||||||
const assetDirectory = path.join(application.dist, 'assets');
|
const assetDirectory = path.join(application.dist, 'assets');
|
||||||
fs.mkdirSync(assetDirectory);
|
fs.mkdirSync(assetDirectory);
|
||||||
fs.writeFileSync(path.join(assetDirectory, 'schema.json'), '{}\n');
|
fs.writeFileSync(path.join(assetDirectory, 'schema.json'), '{}\n');
|
||||||
|
const consoleDirectory = path.join(application.packageRoot, 'assets');
|
||||||
|
fs.mkdirSync(consoleDirectory);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(consoleDirectory, 'console.js'),
|
||||||
|
"'use strict';\ndocument.title = 'QingLong';\n",
|
||||||
|
);
|
||||||
|
|
||||||
const report = pruneRuntimeArtifact(current.root, {
|
const report = pruneRuntimeArtifact(current.root, {
|
||||||
entrySpecifiers: ['@qinglong/local-application'],
|
entrySpecifiers: ['@qinglong/local-application'],
|
||||||
|
retainedJavaScriptFiles: ['local-application/assets/console.js'],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const relative of [
|
for (const relative of [
|
||||||
@@ -435,10 +448,14 @@ test('retains relative, dynamic, cyclic, bin, migration and asset closure', () =
|
|||||||
'bin-support.js',
|
'bin-support.js',
|
||||||
'migration/ledger.js',
|
'migration/ledger.js',
|
||||||
'assets/schema.json',
|
'assets/schema.json',
|
||||||
|
'../assets/console.js',
|
||||||
]) {
|
]) {
|
||||||
assert.equal(fs.existsSync(path.join(application.dist, relative)), true);
|
assert.equal(fs.existsSync(path.join(application.dist, relative)), true);
|
||||||
}
|
}
|
||||||
assert.equal(fs.existsSync(path.join(application.dist, 'unused.js')), false);
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(application.dist, 'unused.js')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(fs.existsSync(path.join(runtime.dist, 'used.js')), true);
|
assert.equal(fs.existsSync(path.join(runtime.dist, 'used.js')), true);
|
||||||
assert.equal(fs.existsSync(path.join(runtime.dist, 'index.js')), false);
|
assert.equal(fs.existsSync(path.join(runtime.dist, 'index.js')), false);
|
||||||
assert.equal(fs.existsSync(path.join(runtime.dist, 'unused.js')), false);
|
assert.equal(fs.existsSync(path.join(runtime.dist, 'unused.js')), false);
|
||||||
@@ -525,6 +542,39 @@ test('rejects escaping and missing relative targets before mutation', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects invalid or missing explicit JavaScript assets before mutation', () => {
|
||||||
|
const current = fixture();
|
||||||
|
try {
|
||||||
|
const application = writePackage(
|
||||||
|
current.root,
|
||||||
|
'local-application',
|
||||||
|
'@qinglong/local-application',
|
||||||
|
);
|
||||||
|
const entry = path.join(application.dist, 'index.js');
|
||||||
|
const map = path.join(application.dist, 'index.js.map');
|
||||||
|
fs.writeFileSync(entry, "'use strict';\n");
|
||||||
|
fs.writeFileSync(map, '{}\n');
|
||||||
|
|
||||||
|
for (const retainedJavaScriptFiles of [
|
||||||
|
['../outside.js'],
|
||||||
|
['local-application/assets/missing.js'],
|
||||||
|
['local-application/dist/index.js', 'local-application/dist/index.js'],
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
pruneRuntimeArtifact(current.root, {
|
||||||
|
entrySpecifiers: ['@qinglong/local-application'],
|
||||||
|
retainedJavaScriptFiles,
|
||||||
|
}),
|
||||||
|
/retained JavaScript file/u,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(map), true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
current.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects symbolic links before making partial changes', () => {
|
test('rejects symbolic links before making partial changes', () => {
|
||||||
const current = fixture();
|
const current = fixture();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user