feat: improve pipeline ui and remote update labels

This commit is contained in:
Codex
2026-05-06 06:50:16 +00:00
parent a9b5756dfe
commit 0aa5c1e021
11 changed files with 1202 additions and 62 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
## Critical Provider Gateway Upgrade Rule
- `src/components/provider-gateway` 有任何代码或行为变更时,必须在同一变更集中递增 `src/components/provider-gateway/package.json` 的版本号,并在升级后通过 frontend 或 `debug health` 确认目标节点上报新版本;权威规则见 `docs/reference/provider-gateway.md`
- `provider.upgrade` 预检、执行升级和自动更新记录必须显式显示指定 Provider 的 gateway 版本号,不能只把版本放进原始 JSON;前端和 E2E 要求见 `docs/reference/provider-gateway.md``TEST.md`
- `provider.upgrade` 预检、执行升级和远程更新记录必须显式显示指定 Provider 的 gateway 版本号,不能只把版本放进原始 JSON;前端和 E2E 要求见 `docs/reference/provider-gateway.md``TEST.md`
- 计算节点 `provider-gateway` 容器的重建/升级必须走带 sleep-and-validate 回滚保护的 `provider.upgrade mode=schedule` 远程升级路径或前端等价调度;禁止通过 `bun scripts/cli.ts ssh <providerId>` 同步执行 `docker compose up --build provider-gateway` 这类自重建命令,权威规则见 `docs/reference/provider-gateway.md`
- Host SSH / WSL SSH 透传只能用于节点诊断、前置条件修复和升级后验证,不能作为计算节点 `provider-gateway` 自身的重建/升级通道;部署验收必须同时证明远程升级和 SSH 透传可用,测试门禁见 `TEST.md`
+3 -3
View File
@@ -72,9 +72,9 @@
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认目标 provider-gateway 已只读挂载维护私钥目录到 `/run/host-ssh`,目标宿主或 WSL 的 sshd 已启动且 `authorized_keys` 包含对应公钥;运行 `bun scripts/cli.ts debug dispatch main-server host.ssh --wait-ms 15000`,再运行 `bun scripts/cli.ts debug task latest`,确认任务通过真实 WebSocket 下发、状态为 `succeeded`、result 中 `probeLine` 包含 `UNIDESK_SSH_TEST``exitCode` 为 0、`hostSshKeyPresent` 为 true。随后运行 `bun scripts/cli.ts ssh main-server hostname`,确认输出是远端 hostname 且进程 exit code 为 0;再用 `printf 'pwd\nexit\n' | bun scripts/cli.ts ssh main-server` 验证无命令参数时能进入并退出远端登录 shell。对 D518 这类无公网 SSH 的 WSL 节点,使用同一命令替换 Provider ID 为 `D518`,必要时先用 debug dispatch 加 `--cwd /home/ubuntu` 覆盖远端工作目录,只能通过 provider-gateway 自连维护桥验证,不得把主 server 直连节点公网 22 端口作为通过标准;在计算节点本机还必须用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000``bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 自测 remote CLI 透传,命令不得要求 `--main-server-key`
## T18 Provider Gateway 版本与自动更新记录
## T18 Provider Gateway 版本与远程更新记录
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:gateway-version-label``frontend:gateway-version-records-visible``frontend:gateway-duration-subsecond-visible``frontend:provider-operation-availability-visible` passed;再登录公网 frontend,进入 `资源节点 / 网关版本`,确认每个 Provider 行都显示 provider-gateway 版本号、升级策略、SSH 透传可用性、远程更新可用性、能力摘要、最近自动更新记录,并在下方以表格记录 `provider.upgrade` 的状态、模式、任务 id、来源、耗时、策略、指定 Provider 的 gateway 版本号、结果摘要和更新时间。自动更新记录的真实亚秒级耗时必须显示小数秒,不得显示成 `0s`自动更新记录默认必须是结构化控件,不得展示裸 JSON;完整 task/result 只能通过 `查看原始JSON` 按钮查看。
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:gateway-version-label``frontend:gateway-version-records-visible``frontend:gateway-duration-subsecond-visible``frontend:provider-operation-availability-visible` passed;再登录公网 frontend,进入 `资源节点 / 网关版本`,确认每个 Provider 行都显示 provider-gateway 版本号、升级策略、SSH 透传可用性、远程更新可用性、能力摘要、最近远程更新记录,并在下方以表格记录 `provider.upgrade` 的状态、模式、任务 id、来源、耗时、策略、指定 Provider 的 gateway 版本号、结果摘要和更新时间。远程更新记录的真实亚秒级耗时必须显示小数秒,不得显示成 `0s`远程更新记录默认必须是结构化控件,不得展示裸 JSON;完整 task/result 只能通过 `查看原始JSON` 按钮查看。
## T19 前端单服务重建
@@ -86,7 +86,7 @@
## T21 D601 Pipeline Microservice
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601``public=false``frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST``pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline``bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registryPipeline run 预览;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录``微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、最近运行和证据日志摘要,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601``public=false``frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST``pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline``bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registryPipeline run 预览,并且 run/procedure 摘要包含甘特图需要的 `startedAt``finishedAt``durationMs`;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录``微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、epoch 列表、epoch 甘特图和运行材料索引,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;甘特图必须按纵向时间轴绘制 node 工作竖条,滚动到某个时间窗口时自动隐藏该窗口内无工作的 node 列;默认没有裸 JSON、JSONL 或逐行日志,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。
## T22 Main Server Todo Note Microservice
+1 -1
View File
@@ -108,7 +108,7 @@
"id": "pipeline",
"name": "Pipeline v2",
"providerId": "D601",
"description": "Pipeline v2 观测后端部署在 D601 Docker 中,UniDesk frontend 负责渲染组件矩阵、运行状态和证据摘要。",
"description": "Pipeline v2 观测后端部署在 D601 Docker 中,UniDesk frontend 负责渲染组件矩阵、运行状态、epoch 甘特图和运行材料索引。",
"repository": {
"url": "https://github.com/pikasTech/pipeline",
"commitId": "87811a8d43edf216a4f4d8efa55bbb96bad8df14",
+2 -2
View File
@@ -18,14 +18,14 @@ UniDesk delivery is not complete until the public frontend, public provider ingr
- Provider remote control: internal `/api/dispatch` must successfully complete a real `provider.upgrade` task in `mode: "plan"` so the upgrade path is validated without recreating the running gateway during E2E.
- Microservices: internal `/api/microservices` must include `todo-note` on `main-server` plus `findjob`, `pipeline` and `met-nonlinear` on `D601` with `public=false`; `/api/microservices/todo-note/health` must report `storage=postgres`, `/api/microservices/todo-note/proxy/api/instances` must expose the migrated Todo Note lists, and a temporary Todo Note list create/add/toggle/undo/delete cycle must succeed through the real provider-gateway proxy; `/api/microservices/findjob/health` and `/api/microservices/findjob/proxy/api/summary` must succeed through the real provider-gateway proxy; `/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5` must return a bounded preview with `_unidesk.arrayLimits` metadata; `/api/microservices/pipeline/health` and `/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3` must return Pipeline health, registry and run previews; `/api/microservices/met-nonlinear/health`, `/api/microservices/met-nonlinear/proxy/api/queue`, `/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects?root=ex_projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects/config?path=<projectPath>` and `/api/microservices/met-nonlinear/proxy/api/images` must return the D601 TS backend health, queue/GPU policy, full project tree inputs, structured project detail and ready `met-nonlinear-ml:tf26` image status.
- Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql`, confirms provider state is stored in PostgreSQL, and checks Todo Note rows exist in `todo_note_instances` using the same named volume.
- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, verifies desktop sidebar collapse and `PGDATA` overview metric, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves, the structured process resource table, default memory-desc sorting, sortable CPU column and provider upgrade precheck dispatch, opens `Docker 状态`, switches to `main-server`, and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured automatic update records for `provider.upgrade`, then opens `微服务 / 服务目录``微服务 / Todo Note``微服务 / FindJob``微服务 / Pipeline` and `微服务 / MET Nonlinear` to verify 主 server Todo Note、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Task history and provider upgrade records must not display a real sub-second duration as `0s`; MET Nonlinear running rows must show an ETA derived from backend progress or from `startedAt` plus epoch progress, and queue/completed rows must show training speed as `epoch/h`.
- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, verifies desktop sidebar collapse and `PGDATA` overview metric, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves, the structured process resource table, default memory-desc sorting, sortable CPU column and provider upgrade precheck dispatch, opens `Docker 状态`, switches to `main-server`, and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured remote update records for `provider.upgrade`, then opens `微服务 / 服务目录``微服务 / Todo Note``微服务 / FindJob``微服务 / Pipeline` and `微服务 / MET Nonlinear` to verify 主 server Todo Note、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Task history and provider upgrade records must not display a real sub-second duration as `0s`; MET Nonlinear running rows must show an ETA derived from backend progress or from `startedAt` plus epoch progress, and queue/completed rows must show training speed as `epoch/h`.
- Microservice frontend assertions must wait for real backend data, not only the page skeleton. For Todo Note this means the page must show the migrated lists `CONSTAR``大论文``找工作``小论文``事务`, support creating a temporary list and task through the frontend, and delete that temporary list afterwards. The temporary list must be selected again by its unique generated name before deletion so E2E never deletes a migrated source list by accident. For FindJob this means the page must show a numeric `岗位总量`, `HEALTH OK`, and a non-empty `PREVIEW` count such as `40/1463 PREVIEW`; for Pipeline this means the page must show `Pipeline v2 工作台`, `Health OK`, a numeric component count, a non-empty React Flow control graph, `控制图`, and `最近运行`; for MET Nonlinear this means the page must show `MET Nonlinear 训练编排`, `Health OK`, `Fork Project`, `加入待启动队列`, `启动队列`, `当前队列`, 最大并发设置、task queue and GPU/image panels, and must not show the removed hard-coded `创建10个10轮任务` frontend entry. The MET Nonlinear project library must render `projects/` and `ex_projects/` as a true path tree with folder Project counts; clicking a project row must open a structured detail panel containing `config.json`, `data/ 训练状态`, `模型参数`, `指标` and a parameter count such as `Total Params`; clicking a completed/current/failed job row must open a structured job detail and both the row and detail must show `epoch/h`. Full MET Nonlinear acceptance is driven by public frontend controls: choose a visible source Project, set batch size, epochs and max concurrency in inputs, fork into `projects/unidesk_forks/`, stage the selected forks, start the queue, and verify completed rows plus automatic `metnl-train-*` container removal; loading placeholders like `--` or empty states are not sufficient for E2E success.
## Frontend JSON Rule
The frontend must render JSON data into React controls by default. Raw JSON is allowed only after an explicit `查看原始JSON` user action, and E2E must fail if the initial page exposes raw JSON text or a raw JSON block.
Automatic update records in the frontend are covered by the same rule: `provider.upgrade` task history must be rendered as rows/cards with status, mode, task id, source, duration, policy, outcome summary, and updated time. The page must not expose upgrade plan/result JSON as a log block unless the operator clicks `查看原始JSON`.
Remote update records in the frontend are covered by the same rule: `provider.upgrade` task history must be rendered as rows/cards with status, mode, task id, source, duration, policy, outcome summary, and updated time. The page must not expose upgrade plan/result JSON as a log block unless the operator clicks `查看原始JSON`.
Provider operation availability is also covered by the structured rendering rule. `host.ssh` availability must be displayed as badges or equivalent controls derived from capabilities and `hostSsh*` labels, and remote update availability must be displayed from `provider.upgrade` capability plus the `always-enabled` policy; these fields must not require opening raw Provider JSON.
+5 -3
View File
@@ -30,7 +30,7 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
## Provider Gateway Version View
资源节点模块必须提供 `网关版本` 子标签,按每个 Provider 展示 provider-gateway 版本号、升级策略、启动时间、能力摘要、SSH 透传可用性、远程更新可用性、最近自动更新状态和自动更新记录。SSH 透传可用性必须由 `unideskCapabilities` 是否包含 `host.ssh``hostSshConfigured``hostSshKeyPresent``hostSshTarget` 渲染为结构化徽标;远程更新可用性必须由 `unideskCapabilities` 是否包含 `provider.upgrade``providerGatewayUpgradePolicy: "always-enabled"` 渲染为结构化徽标。自动更新记录的数据源是 `provider.upgrade` 任务历史,默认必须渲染为结构化表格字段:状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间;亚秒级升级耗时必须显示小数秒,不得显示成 `0s`;不得把升级 plan、task result 或服务日志作为裸 JSON 直接铺在页面上。`最近自动更新` 应优先选择最新 `mode: "schedule"` 的真实升级记录,避免后续预检 plan 覆盖真正的升级结果;完整升级任务 JSON 只能通过对应行的 `查看原始JSON` 按钮显式打开。
资源节点模块必须提供 `网关版本` 子标签,按每个 Provider 展示 provider-gateway 版本号、升级策略、启动时间、能力摘要、SSH 透传可用性、远程更新可用性、最近远程更新状态和远程更新记录。SSH 透传可用性必须由 `unideskCapabilities` 是否包含 `host.ssh``hostSshConfigured``hostSshKeyPresent``hostSshTarget` 渲染为结构化徽标;远程更新可用性必须由 `unideskCapabilities` 是否包含 `provider.upgrade``providerGatewayUpgradePolicy: "always-enabled"` 渲染为结构化徽标。远程更新记录的数据源是 `provider.upgrade` 任务历史,默认必须渲染为结构化表格字段:状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间;亚秒级升级耗时必须显示小数秒,不得显示成 `0s`;不得把升级 plan、task result 或服务日志作为裸 JSON 直接铺在页面上。`最近远程更新` 应优先选择最新 `mode: "schedule"` 的真实升级记录,避免后续预检 plan 覆盖真正的升级结果;完整升级任务 JSON 只能通过对应行的 `查看原始JSON` 按钮显式打开。
## Provider Operation Availability
@@ -38,11 +38,13 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
## Provider Gateway Upgrade Control
`资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id、摘要和当前节点的自动更新记录,完整升级计划必须通过 `查看原始JSON` 显式查看。
`资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id、摘要和当前节点的远程更新记录,完整升级计划必须通过 `查看原始JSON` 显式查看。
## Microservice Frontend
`微服务` 主模块用于展示挂载在计算节点或主 server Docker 中的业务后端。`服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要;`Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮;`FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮;`Pipeline` 子标签必须把 D601 `/home/ubuntu/pipeline` 后端渲染为组件矩阵、React Flow 控制图框图、最近运行卡片、证据日志摘要和 node 精细控制面板,用户点击控制图中的 node 后必须能通过同源 microservice 代理抓取该 node 执行过程、向运行中 node 追加 prompt、给下次尝试下发 guide,以及排队 restart/redo`MET Nonlinear` 子标签必须把 D601 `/home/ubuntu/met_nonlinear` 的训练编排后端渲染为下载器式工作台,包括项目库选择、从已有 Project fork 新 Project、加入待启动队列、启动队列、最大并发设置、当前队列、已完成、失败诊断、GPU/镜像、训练进度、ETA、历史记录和显式原始 JSON 按钮;运行中训练若后端未直接给出 ETA,前端必须用 `startedAt`、当前 epoch 和目标 epoch 做可解释的剩余时间估算;训练队列和已完成列表必须显示训练速度 `epoch/h`。MET Nonlinear 项目库必须按真实文件路径分层显示 `projects/``ex_projects/`,文件夹计数必须等于其子树中的 Project 数,不能用模型名、状态或其他派生字段替代文件树层级。项目库、当前队列、已完成和失败诊断中的行必须可点击打开结构化详情;详情必须把 `config.json``data/training_state.json``data/training_info.json``data/metrics.json``data/model_info.json``data/compute_analysis.json` 中的训练状态、模型参数量、模型层、指标和 data 文件清单渲染为字段卡、表格和 chip,不得默认显示裸 JSON。不得提供硬编码的固定数量/固定轮数测试按钮。该模块不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。
`微服务` 主模块用于展示挂载在计算节点或主 server Docker 中的业务后端。`服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要;`Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮;`FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮;`Pipeline` 子标签必须把 D601 `/home/ubuntu/pipeline` 后端渲染为组件矩阵、React Flow 控制图框图、epoch 列表、运行材料索引、epoch 甘特图和 node 精细控制面板,用户点击控制图中的 node 后必须能通过同源 microservice 代理抓取该 node 执行过程、向运行中 node 追加 prompt、给下次尝试下发 guide,以及排队 restart/redo`MET Nonlinear` 子标签必须把 D601 `/home/ubuntu/met_nonlinear` 的训练编排后端渲染为下载器式工作台,包括项目库选择、从已有 Project fork 新 Project、加入待启动队列、启动队列、最大并发设置、当前队列、已完成、失败诊断、GPU/镜像、训练进度、ETA、历史记录和显式原始 JSON 按钮;运行中训练若后端未直接给出 ETA,前端必须用 `startedAt`、当前 epoch 和目标 epoch 做可解释的剩余时间估算;训练队列和已完成列表必须显示训练速度 `epoch/h`。MET Nonlinear 项目库必须按真实文件路径分层显示 `projects/``ex_projects/`,文件夹计数必须等于其子树中的 Project 数,不能用模型名、状态或其他派生字段替代文件树层级。项目库、当前队列、已完成和失败诊断中的行必须可点击打开结构化详情;详情必须把 `config.json``data/training_state.json``data/training_info.json``data/metrics.json``data/model_info.json``data/compute_analysis.json` 中的训练状态、模型参数量、模型层、指标和 data 文件清单渲染为字段卡、表格和 chip,不得默认显示裸 JSON。不得提供硬编码的固定数量/固定轮数测试按钮。该模块不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。
Pipeline 的一个 epoch 定义为同一个 pipeline 从入口到终态完整执行一遍。Pipeline 前端必须把同一 `pipelineId` 下的多个 run 作为多个 epoch 展示,并允许操作员切换当前 epoch;控制图、node 控制面板、运行材料索引和甘特图都必须跟随当前 epoch。epoch 甘特图的纵轴是时间,左侧第一列展示时间刻度,后续每列对应一个 node;node 在 `startedAt``finishedAt` 或当前时间之间处于工作态时绘制竖向工作条,空闲时间留空。甘特图必须根据当前可见时间窗口自动隐藏该窗口内没有任何工作区间的 node 列,避免宽图把无关空闲 node 挤在屏幕中。Pipeline 默认界面不得以一行一条 JSON、JSONL、worker log 或 control event 的形式展示“证据”;运行材料只能作为结构化索引行展示计数、状态、时间和来源摘要,完整 JSON、JSONL 或 log tail 只能通过显式 `查看原始JSON` 按钮打开。
## Component Data Rendering
+8 -6
View File
@@ -53,7 +53,7 @@ Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/insta
当前 `D601` 同时承载以下 UniDesk microservice
- `findjob`FindJob 纯后端服务,UniDesk frontend 渲染岗位指标、岗位预览和草稿报告。
- `pipeline`Pipeline v2 控制与观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、运行状态、证据日志摘要和 node 精细控制面板。
- `pipeline`Pipeline v2 控制与观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、epoch 甘特图、运行材料索引和 node 精细控制面板。
- `met-nonlinear`MET Nonlinear 训练编排服务,UniDesk frontend 渲染 GPU/镜像、训练队列、Project config 预览、训练进度、ETA 和历史记录。
### FindJob On D601
@@ -80,10 +80,12 @@ FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 ifram
- 部署引用:业务仓库自身 `Dockerfile``docker-compose.yml``composeService=pipeline-webui``containerName=pipeline-v2-webui`
- 节点后端:D601 上 `127.0.0.1:18082`provider-gateway 容器内通过 `http://host.docker.internal:18082` 访问。
- 代理路径:只允许 `/health``/api/` 前缀;允许方法为 `GET``HEAD``POST`,其中 `POST` 仅用于 `/api/node-control/...` 这类 node 控制动作;Pipeline 自身 WebUI 静态页面即使仍由 `pipeline-webui` 提供,也不作为 UniDesk microservice 入口使用。
- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、最近运行、OA/procedure 摘要、证据日志、点击 node 后的执行过程抓取、append prompt、guide 和 redo/restart 控件,以及显式原始 JSON 按钮。
- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、epoch 列表、epoch 甘特图、OA/procedure 结构化摘要、运行材料索引、点击 node 后的执行过程抓取、append prompt、guide 和 redo/restart 控件,以及显式原始 JSON 按钮。
Pipeline 在 UniDesk 语境中按控制与观测后端服务管理:默认页面不得 iframe 或跳转到 Pipeline 自身 WebUI,也不得直接暴露 D601 的 `18082` 到公网。UniDesk frontend 只能通过 `/api/microservices/pipeline/health``/api/microservices/pipeline/proxy/api/snapshot?...``/api/microservices/pipeline/proxy/api/node-control/...` 访问 Pipeline 后端;超大 snapshot 必须使用 `__unideskArrayLimit=registry.components:<limit>,runs:<limit>` 做展示级裁剪。node 控制入口必须走 Pipeline 后端 HTTP API,前端不得直接写 `.state`、runner prompt 文件或命令队列。
Pipeline 的一个 epoch 是同一个 pipeline 从入口到终态完整执行一遍,UniDesk 前端把同一 `pipelineId` 下的多个 run 作为多个 epoch 管理。甘特图必须从 Pipeline snapshot 中的 `startedAt``finishedAt``durationMs` 和 procedure run 摘要生成,不得按某个 pipeline 实例或 node 名称硬编码布局。甘特图纵轴按时间从上到下排列,左侧固定时间列,后续每列对应一个 node;当前可见时间窗口内没有工作区间的 node 列应自动隐藏。默认页面不得展示裸 JSON、JSONL、worker log 或 control event 行;运行材料和 node 过程只能作为一组一行的结构化索引展示,完整原始内容只能在操作员点击 `查看原始JSON` 后显示。
### MET Nonlinear On D601
当前 MET Nonlinear 作为 `id=met-nonlinear` 的 microservice 登记在 `config.json`
@@ -108,7 +110,7 @@ MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成
- `bun scripts/cli.ts microservice health findjob`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 FindJob `/api/health`
- `bun scripts/cli.ts microservice proxy findjob /api/summary`:通过同一私有代理读取业务 API,适合人工验证,不用于公开业务端口。
- `bun scripts/cli.ts microservice health pipeline`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 Pipeline `/health`
- `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`:读取 Pipeline snapshot 的有界预览,适合人工验证,不用于公开业务端口;若 body 仍超过 CLI 阈值,默认只输出 `bodyPreview`,需要完整 body 时显式追加 `--raw`
- `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`:读取 Pipeline snapshot 的有界预览,适合人工验证,不用于公开业务端口;验证甘特图时应确认 run 和 procedureRun 摘要包含 `startedAt``finishedAt``durationMs`若 body 仍超过 CLI 阈值,默认只输出 `bodyPreview`,需要完整 body 时显式追加 `--raw`
- Pipeline node 控制写入由 UniDesk frontend 调用同源 `/api/microservices/pipeline/proxy/api/node-control/...` 完成;通用 CLI `microservice proxy` 仍主要作为读取验证入口,不作为人工批量写入工具。
- `bun scripts/cli.ts microservice health met-nonlinear`:通过 backend-core -> provider-gateway -> D601 本机 TS 编排后端链路探测 MET Nonlinear `/health`
- `bun scripts/cli.ts microservice proxy met-nonlinear /api/queue``bun scripts/cli.ts microservice proxy met-nonlinear /api/images`:读取 MET Nonlinear 队列、GPU 策略和训练镜像状态,适合人工验证,不用于公开业务端口。
@@ -120,7 +122,7 @@ MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成
## Frontend Rules
microservice 前端必须整合到 `src/components/frontend/src/` 下的 TypeScript + React 模块中。`app.tsx` 只做 shell/router 和导入分发,业务页面必须拆成独立 TSX,例如 `todo-note.tsx``findjob.tsx``pipeline.tsx`。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、树形任务、表单控件、日志摘要、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。
microservice 前端必须整合到 `src/components/frontend/src/` 下的 TypeScript + React 模块中。`app.tsx` 只做 shell/router 和导入分发,业务页面必须拆成独立 TSX,例如 `todo-note.tsx``findjob.tsx``pipeline.tsx`。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、树形任务、表单控件、结构化材料索引、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。日志、JSONL 和大块 JSON 不得在主界面按行展示,避免把裸数据伪装成 UI。
对于超大业务 JSONbackend-core 可把 `__unideskArrayLimit=<path>:<limit>` 作为 frontend-only 代理参数传给 provider-gateway,由 provider-gateway 在返回前裁剪指定 JSON 数组并写入 `_unidesk.arrayLimits` 元数据。该参数只用于控制 UniDesk 展示预览,不能替代业务后端自身分页 API 的长期设计。CLI 的 `microservice proxy` 还会对超过默认阈值的 body 做二次有界预览,防止人工验证时输出爆炸;只有显式 `--raw` 才允许倾倒完整 body。
@@ -132,11 +134,11 @@ microservice 交付必须同时通过后端、CLI 和公网 frontend 验证:
- 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `pipeline``providerId=D601``public=false``frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:18082` 映射和 `pipeline-v2-webui` 容器摘要可见。
- 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear``providerId=D601``public=false``frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3288` 映射和 `met-nonlinear-ts` 容器摘要可见。
- 运行 `bun scripts/cli.ts microservice health findjob``bun scripts/cli.ts microservice proxy findjob /api/summary`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 FindJob 后端。
- 运行 `bun scripts/cli.ts microservice health pipeline``bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 Pipeline 后端。
- 运行 `bun scripts/cli.ts microservice health pipeline``bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 Pipeline 后端,且 run/procedure 摘要包含甘特图所需时间字段
- 运行 `bun scripts/cli.ts microservice health met-nonlinear``bun scripts/cli.ts microservice proxy met-nonlinear /api/queue``bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=20'``bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 MET Nonlinear TS 后端。
- 运行 `bun scripts/cli.ts microservice health todo-note``bun scripts/cli.ts microservice proxy todo-note /api/instances`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `todo-note-backend` 后端;输出中必须包含五个迁移清单和 PostgreSQL 存储健康状态。
- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:3254/api/health` 可用;不要把调试服务部署到主 server。
- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:18082/health``curl http://127.0.0.1:18082/api/snapshot` 可用;不要把 Pipeline 调试服务部署到主 server。
- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试 `~/met_nonlinear`,确认 `curl http://127.0.0.1:3288/health` 可用;最终验收必须回到公网 UniDesk frontend,通过项目库选择、Fork、加入待启动队列和启动队列完成,不要把 MET Nonlinear 后端、Docker build 或训练任务部署到主 server。
- 运行 `bun scripts/cli.ts e2e run`,确认 microservice 相关检查 passed,并确认 Playwright 访问的是公网 `http://74.48.78.17:18081/`
- 登录公网 frontend,进入 `微服务 / 服务目录``微服务 / Todo Note``微服务 / FindJob``微服务 / Pipeline``微服务 / MET Nonlinear`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行、MET Nonlinear 队列/GPU/镜像/Project config/训练历史;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台``Health OK`、组件数和最近运行MET Nonlinear 页面必须显示 `Health OK``Fork Project``启动队列``当前队列`、最大并发设置和 GPU/镜像面板,不能只停留在 loading 骨架;页面默认不得出现裸 JSON。
- 登录公网 frontend,进入 `微服务 / 服务目录``微服务 / Todo Note``微服务 / FindJob``微服务 / Pipeline``微服务 / MET Nonlinear`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图、epoch 列表、epoch 甘特图和运行材料索引、MET Nonlinear 队列/GPU/镜像/Project config/训练历史;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台``Health OK`、组件数、epoch 甘特图和结构化运行材料索引MET Nonlinear 页面必须显示 `Health OK``Fork Project``启动队列``当前队列`、最大并发设置和 GPU/镜像面板,不能只停留在 loading 骨架;页面默认不得出现裸 JSON、JSONL 或逐行日志
+2 -2
View File
@@ -90,7 +90,7 @@ provider-gateway 必须从自身 `package.json` 读取版本号,并在 registe
`src/components/provider-gateway` 下任何代码或行为变更都必须在同一变更集中递增 `src/components/provider-gateway/package.json``version`,不得只改实现而沿用旧版本号。验收时必须通过公网 frontend 的 `资源节点 / 网关版本``bun scripts/cli.ts debug health` 确认目标 provider 的 `providerGatewayVersion` 已上报新版本;如果线上节点仍显示旧版本,该 provider-gateway 变更不能视为交付完成。
任何远程升级预检、执行升级和自动更新记录都必须显式显示指定 Provider 的 gateway 版本号。`provider.upgrade` result 的 plan 中必须包含 `providerId``providerName`、当前运行中的 `providerGatewayVersion` 和从升级 workspace 的 `src/components/provider-gateway/package.json` 读取到的 `targetProviderGatewayVersion`frontend 的升级控件与 `资源节点 / 网关版本` 自动更新记录必须把该版本渲染为结构化字段,不能只把版本埋在原始 JSON 中。
任何远程升级预检、执行升级和远程更新记录都必须显式显示指定 Provider 的 gateway 版本号。`provider.upgrade` result 的 plan 中必须包含 `providerId``providerName`、当前运行中的 `providerGatewayVersion` 和从升级 workspace 的 `src/components/provider-gateway/package.json` 读取到的 `targetProviderGatewayVersion`frontend 的升级控件与 `资源节点 / 网关版本` 远程更新记录必须把该版本渲染为结构化字段,不能只把版本埋在原始 JSON 中。
## Docker Status Telemetry
@@ -108,7 +108,7 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi
`mode: "schedule"` 的成功返回只代表 updater 已被调度,最终升级成败由候选 gateway 自验证决定。updater 必须先按 Compose 构建新镜像,再用旧容器的 `Config.Env` 生成候选 env-file,并复用旧容器的 Docker socket、日志目录、SSH 私钥只读挂载、Compose 网络和 `extra_hosts`;候选容器启动时 restart policy 必须先是 `no`,并显式使用 `--pid host` 保持节点级进程资源采集,验证通过后才能改成 `always` 并删除旧容器。升级计划的 `replacementStrategy` 必须包含 `oldGatewaySleepMs``validationTimeoutMs``promoteOnlyAfterCandidateValidation``candidateRestartPolicyAfterPromotion: "always"``candidateUsesOldContainerEnvironment``candidateUsesOldContainerMounts``candidateUsesOldContainerNetworks``candidateUsesOldContainerExtraHosts``candidateUsesHostPidNamespace`,并且必须在 plan 中显示指定 Provider 的当前/目标 gateway 版本号,便于前端和 CLI 判断这不是旧的先删旧容器再 up 的危险流程。
自动更新记录的权威来源是 backend-core 保存的 `provider.upgrade` 任务历史,而不是 provider-gateway 容器日志文件。frontend 必须按 Provider 聚合这些任务,并把状态、模式、task id、来源、耗时、策略、updater 容器摘要、失败原因和更新时间渲染为表格或卡片;完整 task/result JSON 只能由操作员点击 `查看原始JSON` 后查看。
远程更新记录的权威来源是 backend-core 保存的 `provider.upgrade` 任务历史,而不是 provider-gateway 容器日志文件。frontend 必须按 Provider 聚合这些任务,并把状态、模式、task id、来源、耗时、策略、updater 容器摘要、失败原因和更新时间渲染为表格或卡片;完整 task/result JSON 只能由操作员点击 `查看原始JSON` 后查看。
旧版 provider-gateway 如果只能返回 plan 或因为旧环境中的 `PROVIDER_UPGRADE_ENABLED=false` 拒绝 schedule,需要先通过任意现有维护通道手动 bootstrap 一次。bootstrap 的目标不是长期流程,而是把节点更新到支持 always-enabled 远程升级和 Host SSH / WSL SSH 维护桥的版本;完成后必须立刻用 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 做一次真实一键升级验证,再用 `bun scripts/cli.ts debug health` 或公网 frontend 确认该节点仍在线、`unideskCapabilities` 包含 `provider.upgrade`,需要 SSH 维护的 WSL 节点还必须包含 `host.ssh`
+5 -4
View File
@@ -520,7 +520,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForSelector(`[data-testid="gateway-version-${safeTestId(config.providerGateway.id)}"]`, { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase();
return text.includes("provider gateway 版本") && text.includes("自动更新记录") && text.includes("provider.upgrade") && text.includes("ssh 透传") && text.includes("远程更新");
return text.includes("provider gateway 版本") && text.includes("远程更新记录") && text.includes("provider.upgrade") && text.includes("ssh 透传") && text.includes("远程更新");
}, undefined, { timeout: 10000 });
const gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 });
const gatewayTextLower = gatewayText.toLowerCase();
@@ -592,7 +592,8 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const lower = text.toLowerCase();
return lower.includes("pipeline v2 工作台")
&& text.includes("控制图")
&& text.includes("最近运行")
&& /epoch\s+甘特图/i.test(text)
&& text.includes("运行材料索引")
&& /Health\s+OK/i.test(text)
&& /组件\s+\d+/.test(text)
&& /运行记录\s+[1-9]\d*/.test(text);
@@ -666,14 +667,14 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
addCheck(checks, "frontend:process-resource-sorting", processTableText.includes("进程") && processTableText.includes("PID") && processTableText.includes("CPU") && processTableText.includes("内存") && processTableText.includes("磁盘 I/O") && processMemorySortAria === "descending" && processDefaultMemoryDescending && processCpuSortAria === "descending" && processCpuDescending, { processMemorySortAria, processCpuSortAria, processMemoryValues: processMemoryValues.slice(0, 12), processCpuValues: processCpuValues.slice(0, 12), processTablePreview: processTableText.slice(0, 1000) });
addCheck(checks, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发") && upgradeControlText.includes("指定 Provider") && upgradeControlText.includes(`v${providerGatewayPackageVersion()}`), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) });
addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) });
addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayTextLower.includes("gateway 版本") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayTextLower.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("远程更新记录") && gatewayTextLower.includes("gateway 版本") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayTextLower.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
addCheck(checks, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) });
addCheck(checks, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts });
addCheck(checks, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) });
addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1600) });
addCheck(checks, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) });
addCheck(checks, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("最近运行") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && /epoch\s+甘特图/i.test(pipelineText) && pipelineText.includes("运行材料索引") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
addCheck(checks, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount });
addCheck(checks, "frontend:met-nonlinear-integrated-visible", metNonlinearTextLower.includes("met nonlinear 训练编排") && metNonlinearInitialText.includes("D601") && metNonlinearInitialText.includes("当前队列") && metNonlinearInitialText.includes("GPU/镜像") && metNonlinearInitialText.includes("Fork Project") && metNonlinearInitialText.includes("加入待启动队列") && metNonlinearInitialText.includes("启动队列") && !metNonlinearInitialText.includes("创建10个10轮任务") && metNonlinearInitialText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(metNonlinearInitialText), { metNonlinearTextPreview: metNonlinearInitialText.slice(0, 1400) });
addCheck(checks, "frontend:met-nonlinear-project-tree-detail", metProjectTreeText.includes("projects") && metProjectTreeText.includes("ex_projects") && metProjectDetailText.toLowerCase().includes("project 详情") && metProjectDetailText.toLowerCase().includes("config.json") && metProjectDetailText.toLowerCase().includes("data/ 训练状态") && metProjectDetailText.includes("模型参数") && metProjectDetailText.includes("指标") && metProjectDetailText.toLowerCase().includes("total params") && !metProjectDetailText.includes('{\n'), { metProjectTreePreview: metProjectTreeText.slice(0, 1200), metProjectDetailPreview: metProjectDetailText.slice(0, 1400) });
+414 -5
View File
@@ -1159,6 +1159,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
align-items: start;
}
.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; }
.pipeline-grid .pipeline-wide-panel { grid-column: 1 / -1; }
.met-grid {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(560px, 1.25fr);
@@ -1484,10 +1485,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: var(--text);
}
.pipeline-flow-frame .react-flow__controls-button svg { fill: currentColor; }
.pipeline-flow-frame .react-flow__minimap {
border: 1px solid var(--line);
background: rgba(8, 13, 18, 0.88);
}
.pipeline-flow-frame .react-flow__edges {
z-index: 30 !important;
}
@@ -1598,6 +1595,347 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.03);
}
.pipeline-gantt-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 7px;
}
.pipeline-gantt-actions select {
width: min(420px, 48vw);
min-height: 28px;
padding: 4px 8px;
}
.pipeline-gantt-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--line-soft);
color: var(--muted);
background: rgba(255,255,255,0.03);
}
.pipeline-gantt-wrap {
display: grid;
gap: 8px;
}
.pipeline-gantt-detail-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.36fr);
gap: 10px;
align-items: start;
}
.pipeline-gantt-main {
min-width: 0;
}
.pipeline-gantt-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--muted);
}
.pipeline-gantt-meta span {
padding: 4px 8px;
border: 1px solid rgba(78, 183, 168, 0.18);
background: rgba(78, 183, 168, 0.04);
}
.pipeline-gantt-viewport {
max-height: min(78vh, 820px);
overflow: auto;
border: 1px solid rgba(78, 183, 168, 0.26);
background:
linear-gradient(90deg, rgba(215, 161, 58, 0.08), transparent 18%),
repeating-linear-gradient(0deg, rgba(255,255,255,0.026) 0, rgba(255,255,255,0.026) 1px, transparent 1px, transparent 32px),
#081118;
}
.pipeline-gantt-board {
display: grid;
align-items: start;
}
.pipeline-gantt-head {
position: sticky;
top: 0;
z-index: 6;
min-height: 64px;
padding: 8px 5px;
border-bottom: 1px solid var(--line);
border-right: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(18, 31, 39, 0.98), rgba(8, 17, 24, 0.96));
color: var(--accent);
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
overflow: visible;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.15;
}
.pipeline-gantt-head.time {
left: 0;
z-index: 8;
}
.pipeline-gantt-head.node {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
text-transform: none;
letter-spacing: 0.02em;
}
.pipeline-gantt-head.empty {
color: var(--muted);
text-transform: none;
letter-spacing: 0.04em;
}
.pipeline-gantt-time-axis {
position: sticky;
left: 0;
z-index: 4;
border-right: 1px solid rgba(215, 161, 58, 0.22);
background:
linear-gradient(90deg, rgba(215, 161, 58, 0.08), rgba(8, 17, 24, 0.92)),
#081118;
}
.pipeline-gantt-tick {
position: absolute;
left: 0;
right: 0;
transform: translateY(-50%);
display: grid;
gap: 1px;
padding: 0 7px;
border-top: 1px solid rgba(215, 161, 58, 0.18);
color: var(--muted);
font-size: 10px;
}
.pipeline-gantt-tick:first-child {
transform: translateY(0);
padding-top: 3px;
}
.pipeline-gantt-tick:last-child {
transform: translateY(-100%);
}
.pipeline-gantt-tick b {
color: var(--text);
font-weight: 700;
}
.pipeline-gantt-tick span {
color: var(--faint);
}
.pipeline-gantt-node-col,
.pipeline-gantt-empty-col {
position: relative;
border-right: 1px solid rgba(255,255,255,0.045);
background:
linear-gradient(180deg, rgba(255,255,255,0.018), transparent),
repeating-linear-gradient(0deg, rgba(255,255,255,0.032) 0, rgba(255,255,255,0.032) 1px, transparent 1px, transparent 96px);
}
.pipeline-gantt-empty-col {
display: grid;
place-items: start center;
padding-top: 28px;
color: var(--muted);
}
.pipeline-gantt-bar {
position: absolute;
left: 50%;
width: 6px;
min-height: 10px;
border: 1px solid rgba(215, 161, 58, 0.92);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(246, 197, 91, 0.98), rgba(171, 107, 27, 0.92)),
#20170b;
color: #f7e8c5;
box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28);
overflow: hidden;
padding: 0;
text-indent: -999px;
transform: translateX(-50%);
}
.pipeline-gantt-bar strong {
display: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pipeline-gantt-bar span {
display: none;
color: rgba(255,255,255,0.72);
font-size: 10px;
}
.pipeline-gantt-bar.succeeded {
border-color: rgba(78, 183, 168, 0.76);
background: linear-gradient(180deg, rgba(78, 183, 168, 0.88), rgba(31, 111, 103, 0.72));
color: #dffaf4;
}
.pipeline-gantt-bar.failed {
border-color: rgba(207, 106, 84, 0.85);
background: linear-gradient(180deg, rgba(207, 106, 84, 0.92), rgba(110, 42, 32, 0.74));
color: #ffe3db;
}
.pipeline-gantt-bar.running {
animation: ganttPulse 1.8s ease-in-out infinite;
}
.pipeline-gantt-bar.selected {
width: 10px;
border-color: rgba(255, 240, 198, 0.98);
box-shadow: 0 0 0 2px rgba(8, 17, 24, 0.88), 0 0 22px rgba(246, 197, 91, 0.55);
}
.pipeline-gantt-detail-panel {
position: sticky;
top: 8px;
display: grid;
gap: 9px;
max-height: min(78vh, 820px);
min-height: 420px;
overflow: auto;
padding: 10px;
border: 1px solid rgba(215, 161, 58, 0.28);
background:
linear-gradient(135deg, rgba(215, 161, 58, 0.11), transparent 36%),
linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.014)),
#091219;
box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.36);
}
.pipeline-gantt-detail-panel.empty {
place-items: center;
}
.pipeline-gantt-detail-head,
.pipeline-attempt-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: start;
}
.pipeline-gantt-detail-head h3 {
margin: 0;
overflow-wrap: anywhere;
}
.pipeline-gantt-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pipeline-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.pipeline-kv-grid > span {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.028);
color: var(--muted);
overflow-wrap: anywhere;
}
.pipeline-kv-grid b {
display: block;
color: var(--accent);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.pipeline-chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pipeline-chip-row span {
padding: 3px 6px;
border: 1px solid rgba(78, 183, 168, 0.18);
background: rgba(78, 183, 168, 0.055);
color: var(--muted);
font-size: 10px;
}
.pipeline-attempt-card {
display: grid;
gap: 8px;
padding: 9px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.026);
}
.pipeline-attempt-head span {
color: var(--muted);
font-size: 11px;
}
.pipeline-opencode-flow {
display: grid;
gap: 7px;
}
.pipeline-opencode-step,
.pipeline-opencode-part {
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.15);
}
.pipeline-opencode-step > summary,
.pipeline-opencode-part > summary {
display: grid;
gap: 5px;
padding: 8px;
cursor: pointer;
}
.pipeline-opencode-step > summary::-webkit-details-marker,
.pipeline-opencode-part > summary::-webkit-details-marker {
display: none;
}
.pipeline-step-index {
color: var(--accent-2);
font-size: 10px;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.pipeline-opencode-step summary small {
color: var(--muted);
}
.pipeline-opencode-step-body,
.pipeline-opencode-part-body {
display: grid;
gap: 8px;
padding: 8px;
border-top: 1px solid var(--line-soft);
}
.pipeline-text-preview {
margin: 0;
padding: 7px 8px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(4, 9, 13, 0.5);
color: #bfd3d4;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.45;
}
.pipeline-field-list {
display: grid;
grid-template-columns: minmax(82px, 0.38fr) minmax(0, 1fr);
gap: 4px 7px;
margin: 5px 0 0;
}
.pipeline-field-list dt {
color: var(--accent-2);
font-size: 10px;
overflow-wrap: anywhere;
}
.pipeline-field-list dd {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.pipeline-opencode-part-list {
display: grid;
gap: 6px;
}
@keyframes ganttPulse {
0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28); }
50% { box-shadow: 0 0 0 1px rgba(215, 161, 58, 0.52), 0 0 20px rgba(215, 161, 58, 0.48); }
}
.pipeline-node-control {
min-width: 0;
max-height: min(68vh, 720px);
@@ -1686,6 +2024,53 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.pipeline-evidence-list {
display: grid;
gap: 7px;
}
.pipeline-evidence-list.compact {
gap: 6px;
}
.pipeline-evidence-row {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.4fr) auto;
gap: 8px;
align-items: center;
padding: 8px;
border: 1px solid var(--line-soft);
background:
linear-gradient(90deg, rgba(78, 183, 168, 0.05), transparent 38%),
rgba(255,255,255,0.026);
}
.pipeline-evidence-main {
display: grid;
gap: 2px;
min-width: 0;
}
.pipeline-evidence-main strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.pipeline-evidence-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 11px;
}
.pipeline-evidence-facts {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pipeline-evidence-facts span {
padding: 3px 6px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(0,0,0,0.16);
color: var(--muted);
}
.compact-log code { font-size: 11px; }
.component-strata {
display: grid;
@@ -1699,6 +2084,21 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.pipeline-run-card {
cursor: pointer;
}
.pipeline-run-card.active {
border-color: rgba(215, 161, 58, 0.68);
box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.62);
}
.pipeline-run-card:focus {
outline: 2px solid rgba(78, 183, 168, 0.58);
outline-offset: 2px;
}
.pipeline-run-time {
color: var(--muted);
font-size: 12px;
}
.component-stratum span {
color: var(--muted);
font-size: 10px;
@@ -1855,9 +2255,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
body { font-size: 12px; }
.pipeline-control-runbar,
.pipeline-control-meta,
.pipeline-control-evidence-grid {
.pipeline-control-evidence-grid,
.pipeline-evidence-row,
.pipeline-gantt-detail-layout,
.pipeline-kv-grid,
.pipeline-field-list {
grid-template-columns: 1fr;
}
.pipeline-gantt-actions { justify-content: stretch; }
.pipeline-gantt-actions select { width: 100%; }
.pipeline-gantt-bar { left: 50%; width: 5px; padding: 0; }
.pipeline-gantt-bar.selected { width: 9px; }
.pipeline-gantt-detail-panel { position: static; max-height: none; }
.pipeline-flow-frame {
min-height: 430px;
}
+5 -5
View File
@@ -934,7 +934,7 @@ function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) {
function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
if (records.length === 0) {
return h(EmptyState, { title: "暂无自动更新记录", text: "该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录" });
return h(EmptyState, { title: "暂无远程更新记录", text: "该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录" });
}
return h("div", { className: `upgrade-record-table-wrap table-wrap ${compact ? "compact" : ""}` }, h("table", { className: "upgrade-record-table" },
h("thead", null, h("tr", null,
@@ -967,7 +967,7 @@ function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
function ProviderUpgradeRecordsPanel({ provider, tasks, onRaw, limit = 5 }: AnyRecord) {
const records = providerUpgradeTasks(tasks, provider.providerId).slice(0, limit);
return h(Panel, {
title: "自动更新记录",
title: "远程更新记录",
eyebrow: provider.providerId,
actions: h(GatewayVersionBadge, { node: provider }),
className: "provider-upgrade-records-panel",
@@ -997,7 +997,7 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
h("th", null, "运维可用性"),
h("th", null, "运行时间"),
h("th", null, "能力"),
h("th", null, "最近自动更新"),
h("th", null, "最近远程更新"),
h("th", null, "操作"),
)),
h("tbody", null, rows.map((row: any) => h("tr", { key: row.node.providerId },
@@ -1021,8 +1021,8 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
))),
)),
),
h(Panel, { title: "自动更新记录", eyebrow: "Structured provider.upgrade records" },
nodes.length === 0 ? h(EmptyState, { title: "暂无记录", text: "没有 provider 节点时不会生成自动更新记录" }) :
h(Panel, { title: "远程更新记录", eyebrow: "Structured provider.upgrade records" },
nodes.length === 0 ? h(EmptyState, { title: "暂无记录", text: "没有 provider 节点时不会生成远程更新记录" }) :
h("div", { className: "gateway-record-grid" }, rows.map((row: any) => h("article", {
key: row.node.providerId,
className: "gateway-record-card",
+756 -30
View File
@@ -1,11 +1,12 @@
import React from "react";
import { Background, BaseEdge, Controls, Handle, MarkerType, MiniMap, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
import { Background, BaseEdge, Controls, Handle, MarkerType, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
const useRef: any = React.useRef;
const pipelineInputPorts: AnyRecord[] = [
{ id: "in-left", side: "left", position: Position.Left, style: { top: "50%" } },
@@ -193,6 +194,81 @@ function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtDurationMs(value: any): string {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) return "--";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function isRecord(value: any): value is AnyRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function timeMs(value: any): number | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.getTime();
}
function isoFromMs(value: number | null | undefined): string {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toISOString() : "";
}
function firstIso(...values: any[]): string {
for (const value of values) {
const ms = timeMs(value);
if (ms !== null) return new Date(ms).toISOString();
}
return "";
}
function latestIso(...values: any[]): string {
const candidates = values.map(timeMs).filter((value): value is number => value !== null);
return candidates.length > 0 ? new Date(Math.max(...candidates)).toISOString() : "";
}
function terminalStatus(status: any): boolean {
return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase());
}
function statusCounts(items: any[], key = "status"): AnyRecord {
return items.reduce((counts: AnyRecord, item: any) => {
const status = String(item?.[key] || "unknown").toLowerCase();
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});
}
function parseJsonLine(value: any): AnyRecord | null {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function tailSummary(lines: any[]): AnyRecord {
const records = lines.map(parseJsonLine).filter((item): item is AnyRecord => Boolean(item));
const timestamps = records.flatMap((record) => [record.timestamp, record.createdAt, record.updatedAt]).filter(Boolean);
const lastAt = latestIso(...timestamps);
const eventKinds = Array.from(new Set(records.map((record) => String(record.event || record.action || record.type || "")).filter(Boolean))).slice(0, 3);
return {
total: lines.length,
parsed: records.length,
lastAt,
eventKinds,
};
}
function summarizeValue(value: any): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "是" : "否";
@@ -203,6 +279,192 @@ function summarizeValue(value: any): string {
return String(value);
}
function previewText(value: any, maxLength = 280): string {
if (value === null || value === undefined) return "";
const text = typeof value === "string" ? value : String(value);
const normalized = text.replace(/\r\n/gu, "\n").trim();
return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 1))}...` : normalized;
}
function statusValue(value: any): string {
if (typeof value === "string") return value;
if (isRecord(value)) return String(value.status || value.state || value.phase || "unknown");
return "unknown";
}
function tokenFacts(tokens: any): string[] {
if (!isRecord(tokens)) return [];
return [
typeof tokens.total === "number" ? `tokens ${tokens.total}` : "",
typeof tokens.input === "number" ? `in ${tokens.input}` : "",
typeof tokens.output === "number" ? `out ${tokens.output}` : "",
typeof tokens.reasoning === "number" ? `reason ${tokens.reasoning}` : "",
typeof tokens.cacheRead === "number" ? `cache read ${tokens.cacheRead}` : "",
typeof tokens.cacheWrite === "number" ? `cache write ${tokens.cacheWrite}` : "",
].filter(Boolean);
}
function kvItems(items: any[]): any[] {
return items.filter((item) => item && item.value !== undefined && item.value !== null && String(item.value) !== "");
}
function PipelineKvGrid({ items }: AnyRecord) {
const safeItems = kvItems(asArray(items));
return h("div", { className: "pipeline-kv-grid" }, safeItems.map((item: AnyRecord) => h("span", { key: item.label },
h("b", null, item.label),
h("span", null, item.value),
)));
}
function PipelineChipRow({ items }: AnyRecord) {
const safeItems = asArray(items).map((item) => String(item || "")).filter(Boolean);
if (safeItems.length === 0) return null;
return h("div", { className: "pipeline-chip-row" }, safeItems.map((item, index) => h("span", { key: `${index}-${item}` }, item)));
}
function findProcedureRun(details: any, interval: any): AnyRecord | null {
const procedureRunId = String(interval?.procedureRunId || "");
const procedures = asArray(details?.procedureRuns);
return procedures.find((procedure: any) => String(procedureRunIdOf(procedure)) === procedureRunId) || procedures.at(-1) || null;
}
function opencodeSteps(attempt: any): AnyRecord[] {
return asArray(attempt?.opencodeMessages?.steps).filter(isRecord);
}
function opencodePartFacts(part: any): string[] {
return [
`type ${part?.type || "unknown"}`,
part?.tool ? `tool ${part.tool}` : "",
part?.status ? `status ${part.status}` : "",
part?.durationMs !== undefined ? `duration ${fmtDurationMs(part.durationMs)}` : "",
part?.outputSize !== undefined ? `output ${part.outputSize} chars` : "",
].filter(Boolean);
}
function PipelineFieldList({ fields }: AnyRecord) {
const safeFields = asArray(fields).filter((field: any) => field?.key);
if (safeFields.length === 0) return null;
return h("dl", { className: "pipeline-field-list" }, safeFields.flatMap((field: AnyRecord) => [
h("dt", { key: `${field.key}-k` }, field.key),
h("dd", { key: `${field.key}-v` }, previewText(field.value, 260)),
]));
}
function PipelineOpenCodePart({ part }: AnyRecord) {
const title = String(part?.title || part?.tool || part?.type || "part");
return h("details", { className: "pipeline-opencode-part" },
h("summary", null,
h("strong", null, title),
h(PipelineChipRow, { items: opencodePartFacts(part) }),
),
h("div", { className: "pipeline-opencode-part-body" },
part?.textPreview ? h("p", { className: "pipeline-text-preview" }, part.textPreview) : null,
asArray(part?.inputFields).length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: part.inputFields })) : null,
part?.outputPreview && part.outputPreview !== "--" ? h("div", null, h("b", null, "输出摘要"), h("p", { className: "pipeline-text-preview" }, part.outputPreview)) : null,
asArray(part?.metadataFields).length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: part.metadataFields })) : null,
tokenFacts(part?.tokens).length > 0 ? h(PipelineChipRow, { items: tokenFacts(part.tokens) }) : null,
),
);
}
function PipelineOpenCodeStep({ step }: AnyRecord) {
const tools = asArray(step?.tools).map((item) => String(item || "")).filter(Boolean);
const partTypes = isRecord(step?.partTypes) ? Object.entries(step.partTypes).map(([type, count]) => `${type} ${count}`) : [];
const facts = [
`role ${step?.role || "unknown"}`,
step?.finish ? `finish ${step.finish}` : "",
step?.durationMs !== undefined ? `duration ${fmtDurationMs(step.durationMs)}` : "",
`parts ${step?.partCount ?? asArray(step?.parts).length}`,
...tools.slice(0, 4).map((tool) => `tool ${tool}`),
].filter(Boolean);
return h("details", { className: "pipeline-opencode-step", "data-testid": "pipeline-opencode-step" },
h("summary", null,
h("span", { className: "pipeline-step-index" }, `Step ${step?.index ?? "?"}`),
h("strong", null, `${step?.agent || "agent"} / ${step?.model || "model"}`),
h("small", null, `${fmtDate(step?.createdAt)} -> ${fmtDate(step?.completedAt)}`),
h(PipelineChipRow, { items: facts }),
),
h("div", { className: "pipeline-opencode-step-body" },
h(PipelineKvGrid, { items: [
{ label: "message", value: step?.messageId || "--" },
{ label: "session", value: step?.sessionId || "--" },
{ label: "provider", value: step?.provider || "--" },
{ label: "cost", value: step?.cost ?? "--" },
] }),
tokenFacts(step?.tokens).length > 0 ? h(PipelineChipRow, { items: tokenFacts(step.tokens) }) : null,
partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null,
step?.textPreview ? h("p", { className: "pipeline-text-preview" }, step.textPreview) : null,
asArray(step?.parts).length > 0 ? h("div", { className: "pipeline-opencode-part-list" }, asArray(step.parts).map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || index, part }))) : null,
),
);
}
function PipelineGanttDetailPanel({ selection, onRaw }: AnyRecord) {
const interval = selection?.interval;
if (!interval) {
return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" },
h(EmptyState, { title: "选择一条执行线", text: "点击甘特图中的 node 执行线,在这里查看结构化过程和 OpenCode step。" }),
);
}
const details = selection?.details;
const procedure = findProcedureRun(details, interval) || interval.raw || {};
const attempts = asArray(procedure?.attempts);
const status = statusValue(procedure?.status || interval.status);
return h("aside", { className: "pipeline-gantt-detail-panel", "data-testid": "pipeline-gantt-detail-panel" },
h("div", { className: "pipeline-gantt-detail-head" },
h("div", null,
h("span", { className: "panel-eyebrow" }, "Gantt Line Detail"),
h("h3", null, interval.nodeId || "node"),
),
h(StatusBadge, { status }, status),
),
h(PipelineKvGrid, { items: [
{ label: "epoch", value: interval.runId || "--" },
{ label: "procedure", value: interval.procedureRunId || procedureRunIdOf(procedure) || "--" },
{ label: "started", value: fmtDate(interval.startedAt || procedure?.startedAt) },
{ label: "finished", value: fmtDate(interval.finishedAt || procedure?.finishedAt) },
{ label: "duration", value: fmtDurationMs(interval.durationMs || procedure?.durationMs) },
{ label: "fetched", value: selection?.fetchedAt ? fmtClock(selection.fetchedAt) : "--" },
] }),
selection?.loading ? h("div", { className: "form-success" }, "正在抓取 node 执行过程...") : null,
selection?.error ? h("div", { className: "form-error" }, selection.error) : null,
h("div", { className: "pipeline-gantt-detail-actions" },
h(RawButton, { title: `Procedure ${interval.procedureRunId || interval.nodeId}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }),
details ? h(RawButton, { title: `Node execution ${interval.nodeId}`, data: details, onOpen: onRaw, testId: "raw-pipeline-gantt-node-details" }) : null,
),
attempts.length === 0 && !selection?.loading ? h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" }) : null,
attempts.map((attempt: any, attemptIndex: number) => {
const messages = attempt?.opencodeMessages || {};
const steps = opencodeSteps(attempt);
const sessionIds = asArray(messages.sessionIds).map((item) => String(item)).filter(Boolean);
return h("article", { key: attempt?.attempt || attemptIndex, className: "pipeline-attempt-card" },
h("div", { className: "pipeline-attempt-head" },
h("strong", null, attempt?.attempt || `attempt-${attemptIndex + 1}`),
h("span", null, messages.source || "opencode"),
),
h(PipelineKvGrid, { items: [
{ label: "messages", value: messages.messageCount ?? "--" },
{ label: "steps", value: messages.stepCount ?? steps.length },
{ label: "tools", value: messages.toolCallCount ?? "--" },
{ label: "updated", value: fmtDate(messages.updatedAt) },
{ label: "sessions", value: sessionIds.join(", ") || "--" },
] }),
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-webui 已重建并重新抓取。") :
h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => h(PipelineOpenCodeStep, { key: step?.messageId || stepIndex, step }))),
);
}),
);
}
function GanttHeaderLabel({ value }: AnyRecord) {
const text = String(value || "--");
const pieces = text.split(/([_-])/u);
return h(React.Fragment, null, pieces.map((piece, index) => (
piece === "-" || piece === "_" ? h(React.Fragment, { key: index }, piece, h("wbr", null)) : h(React.Fragment, { key: index }, piece)
)));
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
@@ -258,6 +520,18 @@ function RawButton({ title, data, onOpen, testId }: AnyRecord) {
}, "查看原始JSON");
}
function EvidenceIndexRow({ title, subtitle, facts, data, onRaw, testId }: AnyRecord) {
const safeFacts = asArray(facts).map((item) => String(item || "")).filter(Boolean);
return h("article", { className: "pipeline-evidence-row" },
h("div", { className: "pipeline-evidence-main" },
h("strong", null, title),
subtitle ? h("span", null, subtitle) : null,
),
h("div", { className: "pipeline-evidence-facts" }, safeFacts.map((fact, index) => h("span", { key: `${index}-${fact.slice(0, 16)}` }, fact))),
data !== undefined ? h(RawButton, { title, data, onOpen: onRaw, testId }) : null,
);
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
@@ -793,6 +1067,413 @@ function pipelineLatestRun(runs: any[], pipelineId: string): any {
return runs.find((run) => String(run?.pipelineId || "") === pipelineId) || null;
}
function pipelineRunStartMs(run: any): number {
return timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt) ?? timeMs(run?.updatedAt) ?? 0;
}
function pipelineEpochRuns(runs: any[], pipelineId: string): any[] {
return runs
.filter((run: any) => String(run?.pipelineId || "") === pipelineId)
.slice()
.sort((left: any, right: any) => pipelineRunStartMs(left) - pipelineRunStartMs(right) || String(left?.runId || "").localeCompare(String(right?.runId || "")));
}
function pipelineEpochLabel(epochs: any[], run: any): string {
const runId = String(run?.runId || "");
const index = epochs.findIndex((item: any) => String(item?.runId || "") === runId);
const number = index >= 0 ? index + 1 : epochs.length;
const status = String(run?.status || "--");
return `Epoch ${number} / ${runId || "--"} / ${status}`;
}
function procedureRunIdOf(procedure: any): string {
return String(procedure?.procedureRunId || procedure?.runId || "");
}
function inferProcedureNodeId(procedure: any, parentRunId: string): string {
const direct = String(procedure?.nodeId || procedure?.request?.nodeId || "");
if (direct) return direct;
const procedureRunId = procedureRunIdOf(procedure);
const prefix = `${parentRunId}__`;
if (procedureRunId.startsWith(prefix)) return procedureRunId.slice(prefix.length).replace(/__\d+$/u, "");
return "";
}
function procedureStartIso(procedure: any, run: any): string {
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const request = isRecord(procedure?.request) ? procedure.request : {};
return firstIso(
procedure?.startedAt,
artifact.startedAt,
request.createdAt,
request.startedAt,
procedure?.createdAt,
procedure?.updatedAt,
run?.startedAt,
run?.request?.createdAt,
);
}
function procedureEndIso(procedure: any, run: any): string {
const status = String(procedure?.status || procedure?.artifact?.status || procedure?.status?.status || "").toLowerCase();
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const terminal = terminalStatus(status);
return firstIso(
procedure?.finishedAt,
artifact.finishedAt,
procedure?.completedAt,
terminal ? procedure?.updatedAt : undefined,
terminal ? artifact.updatedAt : undefined,
terminal ? run?.updatedAt : undefined,
);
}
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] {
const runId = String(run?.runId || "");
const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean));
return asArray(run?.procedureRuns).flatMap((procedure: any) => {
const nodeId = inferProcedureNodeId(procedure, runId);
if (!nodeId) return [];
const status = String(procedure?.status || procedure?.artifact?.status || "unknown").toLowerCase();
const startIso = procedureStartIso(procedure, run);
const startMs = timeMs(startIso);
if (startMs === null) return [];
const explicitEndIso = procedureEndIso(procedure, run);
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : Date.now());
const safeEndMs = Math.max(startMs + 1000, endMs);
return [{
nodeId,
knownNode: knownNodeIds.has(nodeId),
procedureRunId: procedureRunIdOf(procedure),
status,
startMs,
endMs: safeEndMs,
startedAt: isoFromMs(startMs),
finishedAt: isoFromMs(safeEndMs),
durationMs: safeEndMs - startMs,
runId,
raw: procedure,
}];
}).sort((left: AnyRecord, right: AnyRecord) => left.startMs - right.startMs || left.endMs - right.endMs || left.nodeId.localeCompare(right.nodeId));
}
function pipelineRunTimeBounds(run: any, intervals: AnyRecord[]): AnyRecord {
const starts = intervals.map((item) => Number(item.startMs)).filter(Number.isFinite);
const ends = intervals.map((item) => Number(item.endMs)).filter(Number.isFinite);
const runStart = timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt);
const runEnd = timeMs(run?.finishedAt) ?? timeMs(run?.artifact?.finishedAt) ?? timeMs(run?.updatedAt);
if (runStart !== null) starts.push(runStart);
if (runEnd !== null) ends.push(runEnd);
const now = Date.now();
const startMs = starts.length > 0 ? Math.min(...starts) : now - 60_000;
const endMs = Math.max(startMs + 60_000, ends.length > 0 ? Math.max(...ends) : now);
return { startMs, endMs, durationMs: endMs - startMs };
}
function pipelineGanttHeight(bounds: AnyRecord): number {
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
return Math.round(Math.max(440, Math.min(1800, minutes * 48)));
}
function pipelineGanttTicks(bounds: AnyRecord, count = 7): AnyRecord[] {
const duration = Math.max(1, Number(bounds.endMs || 0) - Number(bounds.startMs || 0));
return Array.from({ length: count }, (_item, index) => {
const ratio = count === 1 ? 0 : index / (count - 1);
const ms = Number(bounds.startMs) + duration * ratio;
return { ms, percent: ratio * 100 };
});
}
function intervalOverlaps(interval: AnyRecord, range: AnyRecord): boolean {
return Number(interval.startMs) <= Number(range.endMs) && Number(interval.endMs) >= Number(range.startMs);
}
function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) {
if (!details) {
return h("span", { className: "muted" }, "点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");
}
const procedureRuns = asArray(details.procedureRuns);
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = asArray(latestProcedure.attempts);
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = asArray(latestProcedure.workerLogTail);
const controlEventsTail = asArray(latestAttempt.controlEventsTail);
const controlPromptsTail = asArray(latestAttempt.controlPromptsTail);
const monitorPromptsTail = asArray(latestAttempt.monitorPromptsTail);
const eventTail = tailSummary(controlEventsTail);
const controlPromptTail = tailSummary(controlPromptsTail);
const monitorPromptTail = tailSummary(monitorPromptsTail);
const latestMessage = latestAttempt.opencodeMessages || {};
return h("div", { className: "pipeline-evidence-list compact" },
h(EvidenceIndexRow, {
title: "Node runtime",
subtitle: selectedNodeId || "--",
facts: [
`status ${selectedNodeRuntime?.status || "pending"}`,
`attempts ${selectedNodeRuntime?.attempts ?? attempts.length}`,
`procedure ${selectedNodeRuntime?.currentProcedureRunId || procedureRunIdOf(latestProcedure) || "--"}`,
control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched",
],
data: details.node || details,
onRaw,
testId: "raw-pipeline-node-runtime",
}),
h(EvidenceIndexRow, {
title: "Procedure runs",
subtitle: `${procedureRuns.length} groups`,
facts: [
`latest ${latestProcedure.status?.status || latestProcedure.status || "--"}`,
`steps ${asArray(latestProcedure.recentSteps).length}`,
`duration ${fmtDurationMs(timeMs(latestProcedure.finishedAt) && timeMs(latestProcedure.startedAt) ? Number(timeMs(latestProcedure.finishedAt)) - Number(timeMs(latestProcedure.startedAt)) : latestProcedure.durationMs)}`,
],
data: procedureRuns,
onRaw,
testId: "raw-pipeline-node-procedures",
}),
h(EvidenceIndexRow, {
title: "OpenCode messages",
subtitle: String(latestMessage.exists ? "available" : "not indexed"),
facts: [
`messages ${summarizeValue(latestMessage.messageCount)}`,
`size ${summarizeValue(latestMessage.size)}`,
`updated ${fmtDate(latestMessage.updatedAt)}`,
],
data: latestMessage,
onRaw,
testId: "raw-pipeline-node-messages",
}),
h(EvidenceIndexRow, {
title: "Control prompts",
subtitle: "manual / monitor append queues",
facts: [
`manual tail ${controlPromptTail.total}`,
`monitor tail ${monitorPromptTail.total}`,
`last ${fmtDate(latestIso(controlPromptTail.lastAt, monitorPromptTail.lastAt))}`,
],
data: { controlPromptsTail, monitorPromptsTail },
onRaw,
testId: "raw-pipeline-node-prompts",
}),
h(EvidenceIndexRow, {
title: "Control events",
subtitle: eventTail.eventKinds.length > 0 ? eventTail.eventKinds.join(", ") : "event tail",
facts: [
`tail ${eventTail.total}`,
`parsed ${eventTail.parsed}`,
`last ${fmtDate(eventTail.lastAt)}`,
],
data: controlEventsTail,
onRaw,
testId: "raw-pipeline-node-events",
}),
h(EvidenceIndexRow, {
title: "Worker log",
subtitle: "tail is hidden on main canvas",
facts: [`tail ${workerLogTail.length} lines`, `raw only via button`, `procedure ${procedureRunIdOf(latestProcedure) || "--"}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-node-worker-log",
}),
);
}
function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) {
if (!activeRun) return h(EmptyState, { title: "暂无运行材料", text: "没有 Pipeline epoch 时不会展示运行材料索引。" });
const nodes = asArray(activeRun.nodes);
const procedures = asArray(activeRun.procedureRuns);
const submissions = asArray(activeRun.submissions);
const workerLogTail = asArray(activeRun.workerLogTail);
const nodeCounts = statusCounts(nodes);
const procedureCounts = statusCounts(procedures);
const failedProcedures = procedures.filter((procedure: any) => String(procedure?.status || "").toLowerCase() === "failed");
const latestProcedureAt = latestIso(...procedures.flatMap((procedure: any) => [procedure.updatedAt, procedure.finishedAt, procedure.startedAt]));
return h("div", { className: "pipeline-evidence-list" },
h(EvidenceIndexRow, {
title: "Epoch overview",
subtitle: activeRun.runId || "--",
facts: [
`pipeline ${activeRun.pipelineId || "--"}`,
`status ${activeRun.status || "--"}`,
`started ${fmtDate(activeRun.startedAt)}`,
`updated ${fmtDate(activeRun.updatedAt)}`,
],
data: activeRun,
onRaw,
testId: "raw-pipeline-run",
}),
h(EvidenceIndexRow, {
title: "Node states",
subtitle: `${nodes.length} nodes`,
facts: [
`running ${nodeCounts.running || 0}`,
`succeeded ${nodeCounts.succeeded || 0}`,
`failed ${nodeCounts.failed || 0}`,
`pending ${nodeCounts.pending || 0}`,
],
data: nodes,
onRaw,
testId: "raw-pipeline-run-nodes",
}),
h(EvidenceIndexRow, {
title: "Procedure run index",
subtitle: `${procedures.length} procedure records`,
facts: [
`succeeded ${procedureCounts.succeeded || 0}`,
`failed ${procedureCounts.failed || 0}`,
`latest ${fmtDate(latestProcedureAt)}`,
`errors ${failedProcedures.length}`,
],
data: procedures,
onRaw,
testId: "raw-pipeline-run-procedures",
}),
h(EvidenceIndexRow, {
title: "OA submissions",
subtitle: `${submissions.length} submission files`,
facts: [`records ${submissions.length}`, `task ${summarizeValue(activeRun.task)}`, `raw grouped by run`],
data: submissions,
onRaw,
testId: "raw-pipeline-run-submissions",
}),
h(EvidenceIndexRow, {
title: "Worker log tail",
subtitle: "hidden from main interface",
facts: [`tail ${workerLogTail.length} lines`, `display raw only after click`, `updated ${fmtDate(activeRun.updatedAt)}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-run-worker-log",
}),
);
}
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, onRunChange, onIntervalSelect, selection, onRaw }: AnyRecord) {
const [autoHideIdle, setAutoHideIdle] = useState(true);
const [visibleRange, setVisibleRange] = useState({ startMs: 0, endMs: 0 });
const viewportRef = useRef(null);
const activeRunId = String(activeRun?.runId || "");
const intervals = pipelineRunIntervals(activeRun, pipelineNodes);
const bounds = pipelineRunTimeBounds(activeRun, intervals);
const chartHeight = pipelineGanttHeight(bounds);
const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const intervalNodeIds = intervals.map((interval: AnyRecord) => String(interval.nodeId || "")).filter(Boolean);
const allNodeIds = Array.from(new Set([...configuredNodeIds, ...intervalNodeIds]));
const safeRange = visibleRange.startMs > 0 ? visibleRange : bounds;
const activeNodeIds = new Set(allNodeIds.filter((nodeId) => intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalOverlaps(interval, safeRange))));
const visibleNodeIds = autoHideIdle ? allNodeIds.filter((nodeId) => activeNodeIds.has(nodeId)) : allNodeIds;
const gridTemplateColumns = `96px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => "72px").join(" ") : "minmax(160px, 1fr)"}`;
const ticks = pipelineGanttTicks(bounds);
const selectedIntervalKey = String(selection?.interval?.procedureRunId || "");
const updateVisibleRange = () => {
const element = viewportRef.current as HTMLElement | null;
if (!element) {
setVisibleRange({ startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) });
return;
}
const headerHeight = 64;
const topPx = Math.max(0, element.scrollTop - headerHeight);
const visiblePx = Math.max(120, element.clientHeight - headerHeight);
const bottomPx = Math.min(chartHeight, topPx + visiblePx);
const startRatio = Math.max(0, Math.min(1, topPx / chartHeight));
const endRatio = Math.max(startRatio, Math.min(1, bottomPx / chartHeight));
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
setVisibleRange({
startMs: Number(bounds.startMs) + duration * startRatio,
endMs: Number(bounds.startMs) + duration * endRatio,
});
};
useEffect(() => {
const element = viewportRef.current as HTMLElement | null;
const timer = window.setTimeout(updateVisibleRange, 0);
element?.addEventListener("scroll", updateVisibleRange);
window.addEventListener("resize", updateVisibleRange);
return () => {
window.clearTimeout(timer);
element?.removeEventListener("scroll", updateVisibleRange);
window.removeEventListener("resize", updateVisibleRange);
};
}, [activeRunId, bounds.startMs, bounds.endMs, chartHeight]);
const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length);
return h(Panel, {
title: "Epoch 甘特图",
eyebrow: `${activePipeline?.id || "pipeline"} / ${epochs.length} epochs`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-gantt-actions" },
h("select", {
value: activeRunId,
disabled: epochs.length === 0,
onChange: (event: any) => onRunChange(event.target.value),
"data-testid": "pipeline-epoch-select",
}, epochs.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(epochs, run)))),
h("label", { className: "pipeline-gantt-toggle" },
h("input", {
type: "checkbox",
checked: autoHideIdle,
onChange: (event: any) => {
setAutoHideIdle(Boolean(event.target.checked));
window.setTimeout(updateVisibleRange, 0);
},
}),
h("span", null, "自动隐藏空闲列"),
),
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
),
},
!activeRun ? h(EmptyState, { title: "暂无 Epoch", text: "当前 pipeline 还没有完整运行记录。" }) :
intervals.length === 0 ? h(EmptyState, { title: "暂无时间区间", text: "等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。" }) :
h("div", { className: "pipeline-gantt-wrap" },
h("div", { className: "pipeline-gantt-detail-layout" },
h("div", { className: "pipeline-gantt-main" },
h("div", { className: "pipeline-gantt-meta" },
h("span", null, `time ${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`),
h("span", null, `duration ${fmtDurationMs(bounds.durationMs)}`),
h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
),
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt" },
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${96 + Math.max(1, visibleNodeIds.length) * 72}px` } },
h("div", { className: "pipeline-gantt-head time" }, "Time"),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId }, h(GanttHeaderLabel, { value: nodeId }))),
h("div", { className: "pipeline-gantt-time-axis", style: { height: `${chartHeight}px` } },
ticks.map((tick: AnyRecord) => h("div", { key: `tick-${tick.ms}`, className: "pipeline-gantt-tick", style: { top: `${tick.percent}%` } },
h("b", null, fmtDate(tick.ms)),
h("span", null, `+${fmtDurationMs(tick.ms - bounds.startMs)}`),
)),
),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-empty-col", style: { height: `${chartHeight}px` } }, "滚动到有活动的时间段后,相关 node 列会自动出现。") :
visibleNodeIds.map((nodeId) => {
const nodeIntervals = intervals.filter((interval: AnyRecord) => interval.nodeId === nodeId);
return h("div", { key: `col-${nodeId}`, className: "pipeline-gantt-node-col", style: { height: `${chartHeight}px` } },
nodeIntervals.map((interval: AnyRecord) => {
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
const top = Math.max(0, Math.min(100, ((Number(interval.startMs) - Number(bounds.startMs)) / duration) * 100));
const bottom = Math.max(top, Math.min(100, ((Number(interval.endMs) - Number(bounds.startMs)) / duration) * 100));
const height = Math.max(10, ((bottom - top) / 100) * chartHeight);
const intervalKey = String(interval.procedureRunId || `${nodeId}-${interval.startMs}`);
return h("button", {
key: intervalKey,
type: "button",
className: `pipeline-gantt-bar ${interval.status} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
style: { top: `${top}%`, height: `${height}px` },
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt)} -> ${fmtDate(interval.finishedAt)}`,
onClick: () => onIntervalSelect(interval),
"data-testid": "pipeline-gantt-line",
},
h("strong", null, interval.status || "working"),
h("span", null, fmtDurationMs(interval.durationMs)),
);
}),
);
}),
),
),
),
h(PipelineGanttDetailPanel, { selection, onRaw }),
),
),
);
}
function pipelineNodeControlState(): AnyRecord {
return {
loading: false,
@@ -814,13 +1495,6 @@ function pipelineProxyPath(apiBaseUrl: string, path: string): string {
function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRunChange, selectedNodeId, selectedNodeConfig, selectedNodeRuntime, control, onControlChange, onFetch, onAction, onRaw }: AnyRecord) {
const runId = String(activeRun?.runId || "");
const status = String(selectedNodeRuntime?.status || "pending");
const details = control.details || {};
const procedureRuns = Array.isArray(details.procedureRuns) ? details.procedureRuns : [];
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = Array.isArray(latestProcedure.attempts) ? latestProcedure.attempts : [];
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = Array.isArray(latestProcedure.workerLogTail) ? latestProcedure.workerLogTail : [];
const controlEventsTail = Array.isArray(latestAttempt.controlEventsTail) ? latestAttempt.controlEventsTail : [];
const disabled = !runId || !selectedNodeId || control.loading || Boolean(control.actionLoading);
const updateText = (field: string) => (event: any) => onControlChange({ [field]: event.target.value, error: "", message: "" });
const runOptions = pipelineRuns.length > 0 ? pipelineRuns : (activeRun ? [activeRun] : []);
@@ -896,15 +1570,14 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu
),
),
h("div", { className: "pipeline-control-evidence" },
h("strong", null, "执行证据"),
control.details ? h("div", { className: "pipeline-control-evidence-grid" },
h("span", null, `${procedureRuns.length} procedure runs`),
h("span", null, `${attempts.length} attempts`),
h("span", null, `messages ${summarizeValue(latestAttempt.opencodeMessages?.messageCount)}`),
h("span", null, control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched"),
) : h("span", { className: "muted" }, "点击“抓取过程”读取 node 的 request/artifact/worker log/control prompt tail。"),
workerLogTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, workerLogTail.slice(-8).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 18)}` }, line))) : null,
controlEventsTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, controlEventsTail.slice(-6).map((line: string, index: number) => h("code", { key: `event-${index}-${line.slice(0, 18)}` }, line))) : null,
h("strong", null, "Node 过程索引"),
h(PipelineNodeExecutionIndex, {
details: control.details,
selectedNodeId,
selectedNodeRuntime,
control,
onRaw,
}),
),
);
}
@@ -916,6 +1589,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [nodeControl, setNodeControl] = useState(pipelineNodeControlState());
const [ganttSelection, setGanttSelection] = useState({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
async function load(): Promise<void> {
if (!service) return;
@@ -945,7 +1619,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const pipelineNodes = pipelineConfigNodes(activePipeline);
const pipelineEdges = pipelineConfigEdges(activePipeline);
const latestRun = pipelineLatestRun(runs, activePipelineId);
const pipelineRuns = runs.filter((run: any) => String(run?.pipelineId || "") === activePipelineId);
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
const activeRunId = String(activeRun?.runId || "");
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
@@ -979,6 +1653,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
if (!selectedNodeId || pipelineNodeIds.split("|").includes(selectedNodeId)) return;
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
}, [selectedNodeId, pipelineNodeIds]);
async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise<void> {
@@ -995,6 +1670,26 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
}
}
async function selectGanttInterval(interval: AnyRecord): Promise<void> {
const runId = String(interval?.runId || activeRunId || "");
const nodeId = String(interval?.nodeId || "");
setGanttSelection({ interval, loading: true, error: "", details: null, fetchedAt: null });
if (!runId || !nodeId) {
setGanttSelection({ interval, loading: false, error: "缺少 runId 或 nodeId,无法抓取执行过程", details: null, fetchedAt: null });
return;
}
if (runId !== activeRunId) setSelectedRunId(runId);
setSelectedNodeId(nodeId);
setNodeControl(pipelineNodeControlState());
try {
const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=220`));
setGanttSelection({ interval, loading: false, error: "", details, fetchedAt: new Date() });
setNodeControl((prev: AnyRecord) => ({ ...prev, details, fetchedAt: new Date(), error: "" }));
} catch (err) {
setGanttSelection({ interval, loading: false, error: errorMessage(err, "抓取线条执行过程失败"), details: null, fetchedAt: null });
}
}
async function postNodeAction(action: "append" | "guide" | "redo"): Promise<void> {
if (!activeRunId || !selectedNodeId) {
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
@@ -1090,6 +1785,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h(Panel, {
title: "控制图",
eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-toolbar" },
h("select", {
value: activePipelineId,
@@ -1099,9 +1795,20 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
setSelectedRunId("");
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
},
"data-testid": "pipeline-select",
}, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))),
h("select", {
value: activeRunId,
disabled: pipelineRuns.length === 0,
onChange: (event: any) => {
setSelectedRunId(event.target.value);
setNodeControl(pipelineNodeControlState());
if (selectedNodeId) void fetchNodeDetails(event.target.value, selectedNodeId);
},
"data-testid": "pipeline-run-select",
}, pipelineRuns.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(pipelineRuns, run)))),
h("button", {
type: "button",
className: "ghost-btn",
@@ -1142,7 +1849,6 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
},
},
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
h(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
h(Controls, { showInteractive: false }),
),
),
@@ -1174,10 +1880,34 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
),
),
h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` },
runs.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "Pipeline .state/pipeline-runs 还没有可展示状态" }) :
h("div", { className: "pipeline-run-list" }, runs.map((run: any) => h("article", { key: run.runId, className: "pipeline-run-card" },
h("div", { className: "node-card-head" }, h("strong", null, run.runId || "--"), h(StatusBadge, { status: run.status }, run.status || "--")),
h(PipelineEpochGantt, {
epochs: pipelineRuns,
activeRun,
activePipeline,
pipelineNodes,
selection: ganttSelection,
onIntervalSelect: selectGanttInterval,
onRunChange: (runId: string) => {
setSelectedRunId(runId);
setNodeControl(pipelineNodeControlState());
setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null });
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
},
onRaw,
}),
h(Panel, { title: "Epoch 列表", eyebrow: `${pipelineRuns.length}/${runCount} preview` },
pipelineRuns.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。" }) :
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => h("article", {
key: run.runId,
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
role: "button",
tabIndex: 0,
onClick: () => setSelectedRunId(String(run.runId || "")),
onKeyDown: (event: any) => {
if (event.key === "Enter" || event.key === " ") setSelectedRunId(String(run.runId || ""));
},
},
h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")),
h("div", { className: "docker-meta compact" },
h("span", null, run.pipelineId || "--"),
h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`),
@@ -1185,15 +1915,11 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
),
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
h("code", null, fmtDate(run.updatedAt)),
h("span", { className: "pipeline-run-time" }, fmtDate(run.updatedAt)),
))),
),
h(Panel, { title: "证据日志", eyebrow: activeRun?.runId || "selected worker tail" },
!activeRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
h("div", { className: "pipeline-log-list" },
(Array.isArray(activeRun.workerLogTail) && activeRun.workerLogTail.length > 0 ? activeRun.workerLogTail.slice(-12) : [`${activeRun.runId} ${activeRun.status || "--"} / ${fmtDate(activeRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
),
h("div", { className: "panel-actions inline-actions" }, activeRun ? h(RawButton, { title: `Pipeline Run ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
),
),
);