diff --git a/AGENTS.md b/AGENTS.md index 7943cf62..fc879621 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,18 +19,18 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 - `bun scripts/cli.ts server rebuild `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 -- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 +- `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`;`bun scripts/cli.ts ssh apply-patch < patch.diff` 可直接调用该远端补丁工具,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入、Todo Note 迁移数据、资源指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 +- `bun scripts/cli.ts e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]`:支持按 check/prefix/wildcard 选择性执行公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入与 Playwright 验证;日常迭代先跑当前问题对应的最小检查集,最终交付再跑全量回归,验收规则见 `docs/reference/e2e.md`。 ## Runtime - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 - `docker-compose.yml`:主 server 统一编排 core、frontend、database、本机 provider gateway 和 Todo Note 后端,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear 等业务页必须拆到独立 TSX 模块,并采用高信息密度工业控制台设计,界面规则见 `docs/reference/frontend.md`。 +- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,左侧主模块与顶部子标签统一编译为模块前缀路由:`/ops//`、`/nodes//`、`/tasks//`、`/config//`,只有微服务使用 `/app//` 深链接,资源监控含曲线和进程资源排序表,Todo Note、FindJob、Pipeline、MET Nonlinear 等业务页必须拆到独立 TSX 模块,界面规则见 `docs/reference/frontend.md`。 - `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,必须以 `restart: always` 部署 always-enabled 远程升级、sleep-and-validate 回滚保护和 Host SSH / WSL SSH 透传并完成自测,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`。 - `microservices`:主 server 本地开发边界固定为只开发 UniDesk frontend;非 UniDesk 核心业务后端、Dockerfile、GPU/训练调试必须在目标计算节点通过 SSH 透传完成,Todo Note 这类明确写入主 server 的例外需单独登记,规则见 `docs/reference/microservices.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录、资源监控进程排序、JSON 展示断言和数据库命名卷持久化要求。 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 6cdb4986..d87d5bb2 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -12,14 +12,34 @@ UniDesk delivery is not complete until the public frontend, public provider ingr `bun scripts/cli.ts e2e run` validates the following URLs and internal checks derived from `config.json`. The CLI response is intentionally bounded: it prints check names/statuses, screenshot path, counts, and `resultPath`; the full per-check diagnostics are written to `resultPath` under `.state/e2e/` so failures remain inspectable without flooding stdout. +## Selective Execution Rule + +E2E must be run in two stages instead of blindly re-running the full suite after every edit. + +- First run only the smallest verification set that covers the current change. For example, a Pipeline right-sidebar layout fix should first use focused Playwright or module-scoped checks against Pipeline timeline visibility, height, overflow and interaction, rather than immediately re-running every Todo Note / FindJob / MET Nonlinear path. +- `bun scripts/cli.ts e2e run --only ` selects only matching checks. Pattern matching accepts a full check name such as `frontend:pipeline-step-timeline-visible`, a prefix such as `frontend:pipeline` / `frontend`, or `*` wildcards such as `frontend:*`. +- `bun scripts/cli.ts e2e run --skip ` removes matching checks from the current selection. `--only` and `--skip` can be combined, for example `bun scripts/cli.ts e2e run --only frontend:* --skip frontend:todo-note-integrated-visible,frontend:findjob-integrated-visible`. +- Targeted execution is real execution rather than output filtering only: when a selection contains only frontend checks, the command skips unrelated network/database/service check groups instead of still running the full suite in the background. +- Only after the targeted check is green should the operator run the full public `bun scripts/cli.ts e2e run` regression gate to ensure the local fix did not break unrelated modules. +- `总高度`、`横向滚动条`、关键交互可见性 and the exact module being edited are all valid reasons to prefer a targeted Playwright pass before the final full regression. +- The full-suite run remains mandatory before claiming delivery; selective execution is an efficiency rule for iteration, not a replacement for final regression. + +Typical targeted commands: + +- `bun scripts/cli.ts e2e run --only frontend:pipeline-step-timeline-visible` +- `bun scripts/cli.ts e2e run --only frontend:pipeline` +- `bun scripts/cli.ts e2e run --only frontend --skip frontend:todo-note-integrated-visible,frontend:findjob-integrated-visible` +- `bun scripts/cli.ts e2e run --only network,provider-ingress` + - Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private microservice ports such as FindJob `3254`, MET Nonlinear `3288` and Todo Note `4211` probes must fail. - Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true`, `pgdata.volumeName=unidesk_pgdata_10gb`, a positive PostgreSQL database byte count, and at least one online node. - Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`, `labels.providerGatewayVersion` equal to `src/components/provider-gateway/package.json` and `labels.providerGatewayUpgradePolicy: "always-enabled"`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples plus a non-empty process resource list sorted by memory by default; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; public provider ingress `/health` must return ok. - 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=` 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 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: 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 控制图、epoch 甘特图和 OpenCode Step Timeline、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Playwright 还必须验证至少一个深链接直达路由,例如公网 `http://:/app/pipeline/` 能直接落到 Pipeline 页面,随后切到 `资源节点 / Docker 状态` 时地址栏更新为 `/nodes/docker/`,并且浏览器 history 返回链路仍能回到 `/app/pipeline/`;同时 `态势总览` 这类非微服务页面应落在自己的模块前缀下,例如 `/ops/status/`。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 dense-layout regression gate: whenever a frontend change touches Pipeline 右侧边栏、step timeline、详情抽屉或其他高信息密度面板, Playwright acceptance must inspect both `总高度` and `横向滚动条`. For Pipeline specifically, the OpenCode Step Timeline session head must carry shared agent/model/session facts, each step summary must keep time in the header rather than a left narrow column, and Playwright must fail if the timeline or the collapsed step summary introduces an internal horizontal scrollbar or if the collapsed summary height grows beyond a bounded compact threshold. +- 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, `控制图`, `Epoch 甘特图`, and after clicking a Gantt execution line it must show `OpenCode Step Timeline` with role/model/tokens/text/reasoning/tool sections; 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 @@ -29,7 +49,7 @@ Remote update records in the frontend are covered by the same rule: `provider.up 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. -Microservice pages are covered by the same rule. `Todo Note` must show lists, task tree, filters, reminder input, movement controls, undo/redo and metrics as controls; `FindJob` must show metrics, jobs and drafts as cards/tables; `Pipeline` must show component classes, React Flow graph nodes/edges, run cards and log summaries as controls; `MET Nonlinear` must show queue rows, GPU/image cards, a real path tree for the project library, structured project/job detail panels, project config preview, `data/` training state, model parameter count, metrics, progress bars, ETA, `epoch/h` speed and history diagnostics as controls; the full microservice config, summary, snapshot, jobs preview, drafts and run JSON can only appear after an explicit `查看原始JSON` click. +Microservice pages are covered by the same rule. `Todo Note` must show lists, task tree, filters, reminder input, movement controls, undo/redo and metrics as controls; `FindJob` must show metrics, jobs and drafts as cards/tables; `Pipeline` must show component classes, React Flow graph nodes/edges, run cards, Gantt execution lines and OpenCode step timelines as controls; `MET Nonlinear` must show queue rows, GPU/image cards, a real path tree for the project library, structured project/job detail panels, project config preview, `data/` training state, model parameter count, metrics, progress bars, ETA, `epoch/h` speed and history diagnostics as controls; the full microservice config, summary, snapshot, jobs preview, drafts and run JSON can only appear after an explicit `查看原始JSON` click. ## Public Boundary Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 37077431..968b497b 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -12,6 +12,18 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components 左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 +## Route Model + +frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL 路由,而不是在 router 中手工为每个页面逐个挂路径。长期规则如下: + +- 导航权威数据只有一份:主模块、子标签、显示文案、route segment、默认子标签都从同一个 TypeScript 导航定义派生,左侧边栏、顶部标签、浏览器地址栏解析和前进/后退都复用同一套 registry。 +- Canonical route 必须按主模块前缀分组:运行总览使用 `/ops//`,资源节点使用 `/nodes//`,任务调度使用 `/tasks//`,系统配置使用 `/config//`;只有微服务主模块使用 `/app//`。例如 `态势总览` 固定为 `/ops/status/`,`Docker 状态` 固定为 `/nodes/docker/`,`Pipeline` 固定为 `/app/pipeline/`。 +- 当 future 需要新增主模块时,通用机制必须允许为该模块声明自己的顶层前缀,而不是继续把所有页面都塞进 `/app/*`。 +- 主模块根路径如 `/ops/`、`/nodes/`、`/tasks/`、`/app/`、`/config/` 只作为默认子标签或最近活动子标签的入口别名,实际当前页面仍应落到某个具体子标签;浏览器地址栏不能停留在“只有主模块,没有具体页面”的模糊状态。 +- route segment 生成顺序固定为:显式 `routeSegment` > ASCII-safe `id` > 由 label 派生的 Unicode-safe slug > 稳定 hash fallback。这样新增 Unicode 标签时默认仍可得到稳定路径,而不要求每个标签单独写一段路由代码。 +- 浏览器直开、刷新、`history.back()` / `history.forward()`、点击总览 drilldown 卡片、点击左侧边栏、点击顶部标签都必须走同一个路由状态机;不得出现“页面内容切换了,但 URL 没变”或“URL 变了,但 shell 仍停在旧 tab”的分裂状态。 +- frontend Bun server 必须把这些模块前缀下的深链接路由作为 SPA 入口返回同一个 `index.html`;实现上允许统一把非静态资源路径都回到同一个 shell,但判定标准是公网直开 `/ops/status/`、`/nodes/docker/`、`/app/pipeline/` 等深链接时都不得 404。 + ## Overview Task Drilldown `态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。核心指标还必须展示 `PGDATA`,显示 PostgreSQL 当前数据库用量、命名卷 `unidesk_pgdata_10gb` 和配置容量,便于从总览判断数据库状态。 @@ -42,9 +54,60 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components ## 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 控制图框图、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` 按钮打开。 +- `微服务` 主模块用于展示挂载在计算节点或主 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 按钮。 + - 业务 microservice 页面不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。 +- `Pipeline` 子标签是 D601 `/home/ubuntu/pipeline` 的 UniDesk host UI。 + - 基础视图必须包含组件矩阵、React Flow 控制图框图、epoch 列表、运行材料索引、epoch 甘特图和 node 精细控制面板。 + - 用户点击控制图中的 node 后,必须通过同源 microservice 代理抓取该 node 执行过程,并支持向运行中 node 追加 prompt、给下次尝试下发 guide、对已完成 node 排队 modify、提交 monitor 审核 approve,以及排队 restart/redo。 + - append-prompt、guide、modify、redo/restart 统称为 node 的管理行为;approve 是 monitor 审核决策;fetch/status 只属于观察行为。 + - UniDesk 只负责同源代理、结构化展示和人工控制入口,不直接写 Pipeline `.state` 文件。 + - node 精细控制面板的 append、guide、modify、approve、redo/restart 必须调用 Pipeline HTTP node-control 后端,并把 WebUI 发起者记录为结构化 `sourceKind=webui` 事件。 +- `Pipeline` epoch 和甘特图规则。 + - 一个 epoch 定义为同一个 pipeline 从入口到终态完整执行一遍。 + - 同一 `pipelineId` 下的多个 run 必须作为多个 epoch 展示,并允许操作员切换当前 epoch;控制图、node 控制面板、运行材料索引和甘特图都必须跟随当前 epoch。 + - epoch 下拉框必须通过同源 snapshot 轮询自动看到新 run,不能依赖浏览器强制刷新,但也不应在用户已选中旧 epoch 时自动跳走。 + - 甘特图默认时间尺度固定为 `100 px/min`,并且默认关闭 `自动隐藏空闲列`,让操作员首次进入页面时先看到完整 node 列。 + - epoch 甘特图的纵轴是时间,左侧第一列展示时间刻度,后续每列对应一个 node;node 在 `startedAt` 到 `finishedAt` 或当前时间之间处于工作态时绘制竖向工作条,空闲时间留空。 + - 甘特图 node 列顺序必须遵循 pipeline 拓扑,从上游到下游依次向右展开;不得把下游 node 排在上游左侧。 + - 甘特图必须提供时间尺度滑块,用同一份时间数据调整每分钟像素密度:全局尺度压缩纵向高度以查看完整 epoch,细节尺度拉长纵向高度以查看短时间内的 prompt 点、控制点和执行线。 +- 精确数学图形的坐标权威规则。 + - 凡是精确时序图、甘特图或其他包含严格数学映射关系的渲染,坐标权威必须在后端完成。 + - Pipeline `GET /api/node-control/runs/{runId}?view=gantt&scale=0..100` 应返回可直接展示的 `layout.chartHeight`、时间刻度 `ticks[].y`、执行区间 `startY/endY/y1/y2/height`、事件点 `y/timeAxisY`、控制箭头 `sourceY/targetY/y1/y2` 和对齐诊断。 + - UniDesk 前端在该 DTO 存在时不得独立把时间戳重新换算为 y 轴坐标,只能按后端坐标纯展示。 + - 时间尺度滑块变化必须重新请求后端布局。 + - 甘特图必须根据当前可见的后端 y 区间自动隐藏该窗口内没有任何工作区间或事件点的 node 列,避免宽图把无关空闲 node 挤在屏幕中。 +- `Pipeline` 甘特图事件来源。 + - 甘特图上的执行线、prompt 点、控制点和 monitor 虚线箭头必须通过同源 `node-control` HTTP 读取接口驱动。 + - run 级图形数据使用 `GET /api/node-control/runs/{runId}?view=gantt&scale=0..100`。 + - node 级 OpenCode 明细来自用户选择后的 `GET /api/node-control/runs/{runId}/nodes/{nodeId}`,避免跨 UniDesk provider HTTP 代理时一次性拉取全 run 的大体积 step 数组。 + - prompt 点来源于 attempt 级 `controlEventRecords` 中的 prompt-delivered 事件,必要时回退到 `controlPromptRecords` / `monitorPromptRecords`。 + - 控制点和 monitor/人工控制箭头来源于 run 级 `controlEvents`,必要时回退到 `controlCommands`。 + - 控制事件必须依赖后端记录的 `sourceKind`、`sourceNodeId`、`targetNodeId`、`commandId`、`eventId`、`resetNodeIds` 和 `interruptedProcedureRunIds` 等结构化字段识别 monitor、WebUI、CLI 等发起者,不能通过解析 monitor 自己的 step 文本来反推。 +- `Pipeline` monitor 审核与详情展示。 + - Pipeline 后端可通过 `control.monitoring.audit.enabled=true` 开启 monitor 审核门禁。 + - 当前 pipeline 存在 monitor node 时,node 成功后必须等待后端记录的 `approve` 审核通过,或由 monitor 发起 `modify` 增量修改管理行为、`redo/restart` 重做管理行为。 + - 前端应把审核/控制事件渲染为结构化点和虚线箭头。 + - 点击甘特图中的执行线、prompt 点或控制点后,右侧边栏必须展示结构化事件字段、匹配的 procedure/attempt、以及对应 OpenCode step 的摘要与展开详情,而不是在主界面直接铺 raw JSON、JSONL、worker log 或 control event 文本。 + - OpenCode step 展示必须参考 `~/.agents/skills/agent-sessions/scripts/webui/src/components/MessageList.tsx` 的信息组织方式:按消息流时间线展示角色、模型、tokens、创建/完成时间,正文摘要直接可读,思考和工具调用分区折叠,工具调用要显示工具名、状态、输入字段、输出摘要和元数据。 + - 右侧边栏中的 OpenCode Step Timeline 必须把公共 session 信息(agent、model、session id)聚合到 session 头部,不得在每个 step 重复;单个 step 的默认摘要只保留时间、消息、工具调用三类信息,统计信息和 tag 必须折叠到展开层。 + - 右侧边栏排版必须优先保护横向可读宽度:时间放在 step 顶部 header,而不是单独占用左侧窄列;默认摘要不得引入右侧边栏内部横向滚动条,也不得因为窄列挤压把 step 高度拉得过高。 + - 调整任何高信息密度右侧边栏布局时,都必须把 `总高度` 与 `横向滚动条` 作为显式验收指标,用 Playwright 打开真实页面验证,而不是只看静态代码或本地想象。 + - 运行材料只能作为结构化索引行展示计数、状态、时间和来源摘要,完整 JSON、JSONL 或 log tail 只能通过显式 `查看原始JSON` 按钮打开。 +- `Pipeline` 渲染与算法验证。 + - 涉及 monitor 审核、管理行为或甘特图算法的改动,必须用 Pipeline 侧通用 fixture 组合验证。 + - 验证组合至少包括拓扑 split/merge/feedback、生产型 PikaPython workflow、`monitor-management-behavior-test` 和 `monitor-audit-behavior-test`。 + - 所有 fixture 都应走同一渲染与事件解析路径;不得为了某个 pipeline id、node id 或 run id 在 UniDesk 前端加入特例。 +- `MET Nonlinear` 子标签必须把 D601 `/home/ubuntu/met_nonlinear` 的训练编排后端渲染为下载器式工作台。 + - 工作台包括项目库选择、从已有 Project fork 新 Project、加入待启动队列、启动队列、最大并发设置、当前队列、已完成、失败诊断、GPU/镜像、训练进度、ETA、历史记录和显式原始 JSON 按钮。 + - 运行中训练若后端未直接给出 ETA,前端必须用 `startedAt`、当前 epoch 和目标 epoch 做可解释的剩余时间估算。 + - 训练队列和已完成列表必须显示训练速度 `epoch/h`。 + - 项目库必须按真实文件路径分层显示 `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。 + - 不得提供硬编码的固定数量/固定轮数测试按钮。 ## Component Data Rendering diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index b52ad41b..6aa1e6c2 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -23,6 +23,230 @@ interface PublicUrls { blockedDatabasePort: number; } +export interface E2ERunOptions { + only: string[]; + skip: string[]; +} + +const NETWORK_CHECK_NAMES = [ + "network:only-frontend-provider-ports", + "network:core-public-blocked", + "network:database-public-blocked", + "network:findjob-public-blocked", + "network:met-nonlinear-public-blocked", + "network:todo-note-public-blocked", +] as const; + +const SERVICE_CHECK_NAMES = [ + "core:internal-overview", + "core:pgdata-usage", + "provider:self-node-online", + "provider:gateway-version-label", + "provider:system-status", + "provider:process-resource-status", + "provider:docker-status", + "provider:upgrade-plan", + "provider-ingress:public-health", + "microservice:catalog-findjob", + "microservice:catalog-pipeline", + "microservice:catalog-met-nonlinear", + "microservice:catalog-todo-note", + "microservice:findjob-status", + "microservice:findjob-health", + "microservice:findjob-summary", + "microservice:findjob-jobs-preview", + "microservice:pipeline-status", + "microservice:pipeline-health", + "microservice:pipeline-snapshot", + "microservice:met-nonlinear-status", + "microservice:met-nonlinear-health", + "microservice:met-nonlinear-queue", + "microservice:met-nonlinear-projects", + "microservice:met-nonlinear-image", + "microservice:todo-note-status", + "microservice:todo-note-health", + "microservice:todo-note-migrated-data", + "microservice:todo-note-write-path", +] as const; + +const DATABASE_CHECK_NAMES = [ + "database:named-volume-write", + "database:provider-state", + "database:todo-note-pg-storage", +] as const; + +const FRONTEND_CHECK_NAMES = [ + "frontend:login-provider-visible", + "frontend:public-provider-info-visible", + "frontend:sidebar-collapse", + "frontend:mobile-nav-fixed-height", + "frontend:mobile-content-top-aligned", + "frontend:pending-task-drilldown", + "frontend:task-history-diagnostics", + "frontend:no-naked-json-before-click", + "frontend:raw-json-explicit-button", + "frontend:system-monitor-visible", + "frontend:process-resource-sorting", + "frontend:upgrade-plan-dispatch", + "frontend:docker-status-visible", + "frontend:gateway-version-records-visible", + "frontend:gateway-duration-subsecond-visible", + "frontend:provider-operation-availability-visible", + "frontend:overview-pgdata-visible", + "frontend:microservice-catalog-visible", + "frontend:todo-note-integrated-visible", + "frontend:findjob-integrated-visible", + "frontend:url-route-deeplink", + "frontend:pipeline-integrated-visible", + "frontend:pipeline-react-flow-visible", + "frontend:pipeline-gantt-defaults", + "frontend:pipeline-step-timeline-visible", + "frontend:met-nonlinear-integrated-visible", + "frontend:met-nonlinear-project-tree-detail", + "frontend:met-nonlinear-queue-detail-speed", + "frontend:no-console-errors", +] as const; + +const ALL_E2E_CHECK_NAMES = [ + ...NETWORK_CHECK_NAMES, + ...SERVICE_CHECK_NAMES, + ...DATABASE_CHECK_NAMES, + ...FRONTEND_CHECK_NAMES, +] as const; + +function uniqueText(values: string[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + ordered.push(value); + } + return ordered; +} + +function listOptionValues(args: string[], name: string): string[] { + const values: string[] = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== name) continue; + const raw = args[index + 1]; + if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`); + values.push(raw); + } + return uniqueText(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean)); +} + +export function parseE2ERunOptions(args: string[]): E2ERunOptions { + return { + only: listOptionValues(args, "--only"), + skip: listOptionValues(args, "--skip"), + }; +} + +function pipelineSnapshotConfigObject(pipeline: any): Record { + return pipeline && typeof pipeline?.config === "object" && !Array.isArray(pipeline.config) ? pipeline.config as Record : {}; +} + +function pipelineSnapshotNodeIds(value: any): string[] { + if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item : String(item?.id || item?.nodeId || "")).filter(Boolean); + if (Array.isArray(value?.nodes)) return pipelineSnapshotNodeIds(value.nodes); + if (Array.isArray(value?.nodeIds)) return pipelineSnapshotNodeIds(value.nodeIds); + return []; +} + +function pipelineSnapshotEdges(pipeline: any): Array<{ source: string; target: string; edgeType: string }> { + const config = pipelineSnapshotConfigObject(pipeline); + const rawEdges = Array.isArray(config.edges) ? config.edges : Array.isArray(pipeline?.edges) ? pipeline.edges : []; + return rawEdges.map((edge: any) => ({ + source: String(edge?.from || edge?.source || ""), + target: String(edge?.to || edge?.target || ""), + edgeType: String(edge?.edgeType || ""), + })).filter((edge) => edge.source && edge.target); +} + +function pipelineSnapshotNodes(pipeline: any): string[] { + const config = pipelineSnapshotConfigObject(pipeline); + const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : []; + const nodeIds = new Set(rawNodes.map((node: any) => String(node?.id || node?.nodeId || "")).filter(Boolean)); + const rawBatches = Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; + for (const batch of rawBatches) pipelineSnapshotNodeIds(batch).forEach((nodeId) => nodeIds.add(nodeId)); + for (const edge of pipelineSnapshotEdges(pipeline)) { + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + return Array.from(nodeIds); +} + +function pipelineSnapshotNodeOrder(pipeline: any): string[] { + const config = pipelineSnapshotConfigObject(pipeline); + const rawBatches = Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; + const explicit = rawBatches.map((batch: any) => pipelineSnapshotNodeIds(batch)).filter((batch: string[]) => batch.length > 0); + if (explicit.length > 0) return explicit.flatMap((batch: string[]) => batch); + + const nodeIds = pipelineSnapshotNodes(pipeline); + const idSet = new Set(nodeIds); + const incoming = new Map(nodeIds.map((nodeId) => [nodeId, 0])); + const outgoing = new Map(nodeIds.map((nodeId) => [nodeId, [] as string[]])); + for (const edge of pipelineSnapshotEdges(pipeline).filter((item) => item.edgeType.toLowerCase() !== "rework")) { + if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue; + outgoing.get(edge.source)?.push(edge.target); + incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1); + } + const levels = new Map(); + const queue = nodeIds.filter((nodeId) => (incoming.get(nodeId) || 0) === 0); + for (const nodeId of queue) levels.set(nodeId, 0); + while (queue.length > 0) { + const current = queue.shift()!; + const nextLevel = (levels.get(current) || 0) + 1; + for (const next of outgoing.get(current) || []) { + incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1)); + levels.set(next, Math.max(levels.get(next) || 0, nextLevel)); + if ((incoming.get(next) || 0) === 0) queue.push(next); + } + } + nodeIds.forEach((nodeId) => { if (!levels.has(nodeId)) levels.set(nodeId, 0); }); + const maxLevel = Math.max(0, ...Array.from(levels.values())); + return Array.from({ length: maxLevel + 1 }, (_item, level) => nodeIds.filter((nodeId) => levels.get(nodeId) === level)).flatMap((batch) => batch); +} + +function escapedPatternRegex(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +function matchCheckPattern(name: string, pattern: string): boolean { + const normalizedName = String(name || ""); + const normalizedPattern = String(pattern || "").trim(); + if (!normalizedPattern) return false; + if (normalizedPattern.includes("*")) { + const regex = new RegExp(`^${escapedPatternRegex(normalizedPattern).replace(/\\\*/g, ".*")}$`); + return regex.test(normalizedName); + } + return normalizedName === normalizedPattern + || normalizedName.startsWith(`${normalizedPattern}:`) + || normalizedName.startsWith(`${normalizedPattern}-`); +} + +function wantsCheck(options: E2ERunOptions, name: string): boolean { + const included = options.only.length === 0 || options.only.some((pattern) => matchCheckPattern(name, pattern)); + const skipped = options.skip.some((pattern) => matchCheckPattern(name, pattern)); + return included && !skipped; +} + +function wantsAnyCheck(options: E2ERunOptions, names: string[]): boolean { + return names.some((name) => wantsCheck(options, name)); +} + +function wantsPrefix(options: E2ERunOptions, prefix: string): boolean { + if (options.only.length === 0) return !options.skip.some((pattern) => matchCheckPattern(`${prefix}:probe`, pattern) || matchCheckPattern(prefix, pattern)); + return options.only.some((pattern) => { + const normalizedPattern = String(pattern || "").trim(); + return normalizedPattern === prefix + || normalizedPattern.startsWith(`${prefix}:`) + || normalizedPattern.startsWith(`${prefix}-`) + || matchCheckPattern(`${prefix}:probe`, normalizedPattern); + }) && !options.skip.some((pattern) => matchCheckPattern(`${prefix}:probe`, pattern) || matchCheckPattern(prefix, pattern)); +} + function publicUrls(config: UniDeskConfig): PublicUrls { return { frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`, @@ -80,6 +304,11 @@ function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unk }); } +function addSelectedCheck(checks: E2ECheck[], options: E2ERunOptions, name: string, passed: boolean, detail: unknown): void { + if (!wantsCheck(options, name)) return; + addCheck(checks, name, passed, detail); +} + function safeTestId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, "_"); } @@ -218,7 +447,7 @@ function systemStatusCheckDetail(systemStatus: unknown, providerId: string): unk }; } -async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { +async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): Promise { const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> }; const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n"); const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500); @@ -226,15 +455,15 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500); const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500); const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500); - addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); - addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); - addCheck(checks, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic); - addCheck(checks, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic); - addCheck(checks, "network:met-nonlinear-public-blocked", (metNonlinearPublic as { reachable?: boolean }).reachable === false, metNonlinearPublic); - addCheck(checks, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic); + addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); + addSelectedCheck(checks, options, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); + addSelectedCheck(checks, options, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic); + addSelectedCheck(checks, options, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic); + addSelectedCheck(checks, options, "network:met-nonlinear-public-blocked", (metNonlinearPublic as { reachable?: boolean }).reachable === false, metNonlinearPublic); + addSelectedCheck(checks, options, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic); } -async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { +async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): Promise { const coreOverview = dockerCoreJson("/api/overview"); const coreNodes = dockerCoreJson("/api/nodes"); const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24"); @@ -283,13 +512,13 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const processMemoryDescending = mainProcesses.length < 2 || mainProcesses.every((row, index, rows) => index === 0 || Number(rows[index - 1]?.rssBytes ?? 0) >= Number(row.rssBytes ?? 0)); const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? []; const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id); - addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); - addCheck(checks, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview); - addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); - addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); - addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id)); - addCheck(checks, "provider:process-resource-status", mainProcesses.length > 0 && mainSystem?.current?.processSummary?.defaultSort === "memory_desc" && processMemoryDescending && mainProcesses.some((row) => Number.isFinite(row.pid) && Number.isFinite(row.rssBytes) && Number.isFinite(row.cpuPercent) && typeof row.command === "string"), { providerId: config.providerGateway.id, processSummary: mainSystem?.current?.processSummary, sample: mainProcesses.slice(0, 5) }); - addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id)); + addSelectedCheck(checks, options, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); + addSelectedCheck(checks, options, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview); + addSelectedCheck(checks, options, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); + addSelectedCheck(checks, options, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); + addSelectedCheck(checks, options, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id)); + addSelectedCheck(checks, options, "provider:process-resource-status", mainProcesses.length > 0 && mainSystem?.current?.processSummary?.defaultSort === "memory_desc" && processMemoryDescending && mainProcesses.some((row) => Number.isFinite(row.pid) && Number.isFinite(row.rssBytes) && Number.isFinite(row.cpuPercent) && typeof row.command === "string"), { providerId: config.providerGateway.id, processSummary: mainSystem?.current?.processSummary, sample: mainProcesses.slice(0, 5) }); + addSelectedCheck(checks, options, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id)); const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? []; const findjob = microserviceList.find((service) => service.id === "findjob"); const pipeline = microserviceList.find((service) => service.id === "pipeline"); @@ -325,26 +554,26 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 arrayLimits: pipelineSnapshotBody?._unidesk?.arrayLimits, }, }; - addCheck(checks, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices }); - addCheck(checks, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices }); - addCheck(checks, "microservice:catalog-met-nonlinear", (microservices as { ok?: boolean }).ok === true && metNonlinear?.providerId === "D601" && metNonlinear.backend?.public === false && metNonlinear.runtime?.container?.name === "met-nonlinear-ts", { microservices }); - addCheck(checks, "microservice:catalog-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices }); - addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus); - addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth); - addCheck(checks, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary); - addCheck(checks, "microservice:findjob-jobs-preview", (findjobJobsPreview as { ok?: boolean }).ok === true && Array.isArray(findjobJobs?.jobs) && (findjobJobs.jobs.length ?? 0) > 0 && (findjobJobs._unidesk?.arrayLimits?.jobs?.returnedLength ?? 0) <= 5, findjobJobsPreview); - addCheck(checks, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus); - addCheck(checks, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth); - addCheck(checks, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail); - addCheck(checks, "microservice:met-nonlinear-status", (metNonlinearStatus as { ok?: boolean }).ok === true && (metNonlinearStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", metNonlinearStatus); - addCheck(checks, "microservice:met-nonlinear-health", (metNonlinearHealth as { ok?: boolean }).ok === true && metNonlinearHealthBody?.ok === true, metNonlinearHealth); - addCheck(checks, "microservice:met-nonlinear-queue", (metNonlinearQueue as { ok?: boolean }).ok === true && metNonlinearQueueBody?.ok === true && typeof metNonlinearQueueBody.queue?.counts === "object" && metNonlinearQueueBody.queue?.targetGpuName === "2080 Ti", metNonlinearQueue); - addCheck(checks, "microservice:met-nonlinear-projects", (metNonlinearProjects as { ok?: boolean }).ok === true && metNonlinearProjectsBody?.ok === true && Array.isArray(metNonlinearProjectsBody.projects) && metNonlinearProjectsBody.projects.length > 0, metNonlinearProjects); - addCheck(checks, "microservice:met-nonlinear-image", (metNonlinearImages as { ok?: boolean }).ok === true && metNonlinearImagesBody?.ok === true && metNonlinearImagesBody.mlImage?.present === true && metNonlinearImagesBody.mlImage?.image === "met-nonlinear-ml:tf26", metNonlinearImages); - addCheck(checks, "microservice:todo-note-status", (todoNoteStatus as { ok?: boolean }).ok === true && (todoNoteStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, todoNoteStatus); - addCheck(checks, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth); - addCheck(checks, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances }); - addCheck(checks, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete }); + addSelectedCheck(checks, options, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices }); + addSelectedCheck(checks, options, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices }); + addSelectedCheck(checks, options, "microservice:catalog-met-nonlinear", (microservices as { ok?: boolean }).ok === true && metNonlinear?.providerId === "D601" && metNonlinear.backend?.public === false && metNonlinear.runtime?.container?.name === "met-nonlinear-ts", { microservices }); + addSelectedCheck(checks, options, "microservice:catalog-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices }); + addSelectedCheck(checks, options, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus); + addSelectedCheck(checks, options, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth); + addSelectedCheck(checks, options, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary); + addSelectedCheck(checks, options, "microservice:findjob-jobs-preview", (findjobJobsPreview as { ok?: boolean }).ok === true && Array.isArray(findjobJobs?.jobs) && (findjobJobs.jobs.length ?? 0) > 0 && (findjobJobs._unidesk?.arrayLimits?.jobs?.returnedLength ?? 0) <= 5, findjobJobsPreview); + addSelectedCheck(checks, options, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus); + addSelectedCheck(checks, options, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth); + addSelectedCheck(checks, options, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail); + addSelectedCheck(checks, options, "microservice:met-nonlinear-status", (metNonlinearStatus as { ok?: boolean }).ok === true && (metNonlinearStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", metNonlinearStatus); + addSelectedCheck(checks, options, "microservice:met-nonlinear-health", (metNonlinearHealth as { ok?: boolean }).ok === true && metNonlinearHealthBody?.ok === true, metNonlinearHealth); + addSelectedCheck(checks, options, "microservice:met-nonlinear-queue", (metNonlinearQueue as { ok?: boolean }).ok === true && metNonlinearQueueBody?.ok === true && typeof metNonlinearQueueBody.queue?.counts === "object" && metNonlinearQueueBody.queue?.targetGpuName === "2080 Ti", metNonlinearQueue); + addSelectedCheck(checks, options, "microservice:met-nonlinear-projects", (metNonlinearProjects as { ok?: boolean }).ok === true && metNonlinearProjectsBody?.ok === true && Array.isArray(metNonlinearProjectsBody.projects) && metNonlinearProjectsBody.projects.length > 0, metNonlinearProjects); + addSelectedCheck(checks, options, "microservice:met-nonlinear-image", (metNonlinearImages as { ok?: boolean }).ok === true && metNonlinearImagesBody?.ok === true && metNonlinearImagesBody.mlImage?.present === true && metNonlinearImagesBody.mlImage?.image === "met-nonlinear-ml:tf26", metNonlinearImages); + addSelectedCheck(checks, options, "microservice:todo-note-status", (todoNoteStatus as { ok?: boolean }).ok === true && (todoNoteStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, todoNoteStatus); + addSelectedCheck(checks, options, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth); + addSelectedCheck(checks, options, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances }); + addSelectedCheck(checks, options, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete }); const upgradeDispatch = dockerCoreJson("/api/dispatch", { method: "POST", body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } }, @@ -353,11 +582,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const upgradeTask = upgradeTaskId ? await waitForTaskStatus(upgradeTaskId, "succeeded") : { ok: false, error: "missing taskId", upgradeDispatch }; const taskResult = (upgradeTask as { task?: { result?: { plan?: { providerGatewayVersion?: string; targetProviderGatewayVersion?: string }; mode?: string } }; ok?: boolean }).task?.result; const upgradePlan = taskResult?.plan; - addCheck(checks, "provider:upgrade-plan", (upgradeDispatch as { ok?: boolean }).ok === true && (upgradeTask as { ok?: boolean }).ok === true && taskResult?.mode === "plan" && upgradePlan !== undefined && upgradePlan.providerGatewayVersion === expectedGatewayVersion && upgradePlan.targetProviderGatewayVersion === expectedGatewayVersion, { expectedGatewayVersion, upgradeDispatch, upgradeTask }); - addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress); + addSelectedCheck(checks, options, "provider:upgrade-plan", (upgradeDispatch as { ok?: boolean }).ok === true && (upgradeTask as { ok?: boolean }).ok === true && taskResult?.mode === "plan" && upgradePlan !== undefined && upgradePlan.providerGatewayVersion === expectedGatewayVersion && upgradePlan.targetProviderGatewayVersion === expectedGatewayVersion, { expectedGatewayVersion, upgradeDispatch, upgradeTask }); + addSelectedCheck(checks, options, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress); } -function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string { +function databaseChecks(config: UniDeskConfig, checks: E2ECheck[], options: E2ERunOptions): string { const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; const failedTaskId = `task_${markerId}_failed_diagnostic`; const markerSql = ` @@ -400,17 +629,59 @@ function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string { const marker = runPsql(config, markerSql); const todoNoteInstanceCount = Number(marker.stdout.match(/todo_note_instances=(\d+)/)?.[1] ?? NaN); const todoNoteTotalTodos = Number(marker.stdout.match(/todo_note_total_todos=(\d+)/)?.[1] ?? NaN); - addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker); - addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker); - addCheck(checks, "database:todo-note-pg-storage", marker.ok && todoNoteInstanceCount >= 5 && todoNoteTotalTodos >= 100, { ...marker, todoNoteInstanceCount, todoNoteTotalTodos }); + addSelectedCheck(checks, options, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker); + addSelectedCheck(checks, options, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker); + addSelectedCheck(checks, options, "database:todo-note-pg-storage", marker.ok && todoNoteInstanceCount >= 5 && todoNoteTotalTodos >= 100, { ...marker, todoNoteInstanceCount, todoNoteTotalTodos }); return markerId; } -async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<{ screenshotPath: string; bodyText: string; consoleErrors: string[] }> { +async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): Promise<{ screenshotPath: string; bodyText: string; consoleErrors: string[] }> { const e2eDir = rootPath(".state", "e2e"); mkdirSync(e2eDir, { recursive: true }); const screenshotPath = join(e2eDir, `${new Date().toISOString().replace(/[-:.TZ]/g, "")}_frontend.png`); const consoleErrors: string[] = []; + const wants = (name: string): boolean => wantsCheck(options, name); + const wantsAny = (names: string[]): boolean => wantsAnyCheck(options, names); + const needSidebar = wants("frontend:sidebar-collapse"); + const needMobileRail = wants("frontend:mobile-nav-fixed-height"); + const needMobileContent = wants("frontend:mobile-content-top-aligned"); + const needPendingTask = wants("frontend:pending-task-drilldown") || needMobileContent; + const needTaskHistory = wants("frontend:task-history-diagnostics"); + const needOverviewBody = wantsAny([ + "frontend:login-provider-visible", + "frontend:overview-pgdata-visible", + "frontend:no-naked-json-before-click", + ]) || needPendingTask || needTaskHistory; + const needRawProviderJson = wantsAny([ + "frontend:public-provider-info-visible", + "frontend:raw-json-explicit-button", + ]); + const needNodeMonitor = wantsAny([ + "frontend:system-monitor-visible", + "frontend:process-resource-sorting", + "frontend:upgrade-plan-dispatch", + ]); + const needDocker = wants("frontend:docker-status-visible"); + const needGatewayVersion = wantsAny([ + "frontend:gateway-version-records-visible", + "frontend:gateway-duration-subsecond-visible", + "frontend:provider-operation-availability-visible", + ]); + const needMicroserviceCatalog = wants("frontend:microservice-catalog-visible"); + const needTodoNote = wants("frontend:todo-note-integrated-visible"); + const needFindJob = wants("frontend:findjob-integrated-visible"); + const needRouteDeepLink = wants("frontend:url-route-deeplink"); + const needPipeline = wantsAny([ + "frontend:pipeline-integrated-visible", + "frontend:pipeline-react-flow-visible", + "frontend:pipeline-gantt-defaults", + "frontend:pipeline-step-timeline-visible", + ]); + const needMetNonlinear = wantsAny([ + "frontend:met-nonlinear-integrated-visible", + "frontend:met-nonlinear-project-tree-detail", + "frontend:met-nonlinear-queue-detail-speed", + ]); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 1440, height: 920 } }); @@ -432,282 +703,544 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 const publicFrontendReached = landed.origin === publicOrigin && !["127.0.0.1", "localhost", "::1"].includes(landed.hostname); await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 }); await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 }); - const railWidthBefore = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); - await page.getByTestId("rail-toggle").click(); - await page.waitForTimeout(120); - const railWidthCollapsed = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); - await page.getByTestId("rail-toggle").click(); - await page.waitForTimeout(80); - await page.setViewportSize({ width: 390, height: 860 }); - const mobileRailHeights: number[] = []; - for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) { - await page.getByRole("button", { name: new RegExp(moduleLabel) }).click(); + let railWidthBefore = 0; + let railWidthCollapsed = 0; + let mobileRailHeights: number[] = []; + let mobileRailMax = 0; + let mobileRailMin = 0; + let mobileContentMetrics = { pageTop: 0, emptyTextOffset: 0 }; + let bodyText = ""; + let rawBlocksBefore = 0; + let nakedJsonText = false; + let pendingTaskText = ""; + let taskHistoryText = ""; + let rawText = ""; + let monitorText = ""; + let processTableText = ""; + let processMemoryValues: number[] = []; + let processDefaultMemoryDescending = false; + let processMemorySortAria = ""; + let processCpuValues: number[] = []; + let processCpuDescending = false; + let processCpuSortAria = ""; + let upgradeControlText = ""; + let dockerText = ""; + let gatewayText = ""; + let gatewayTextLower = ""; + let gatewayHasSubsecondDuration = false; + let gatewayHasRoundedZeroDuration = false; + let sshAvailabilityTexts: string[] = []; + let upgradeAvailabilityTexts: string[] = []; + let microserviceCatalogText = ""; + let todoNoteText = ""; + let findjobText = ""; + let routeDeepLinkText = ""; + let routeInitialPath = ""; + let routeDockerPath = ""; + let routeBackIntermediatePath = ""; + let routeBackPath = ""; + let routeOverviewPath = ""; + let routeOverviewText = ""; + let pipelineText = ""; + let pipelineFlowNodeCount = 0; + let pipelineFlowEdgeCount = 0; + let pipelineGanttScaleLabel = ""; + let pipelineGanttAutoHideIdleChecked = true; + let pipelineGanttHeaderNodeOrder: string[] = []; + let pipelineSnapshotForFrontend: any = null; + let pipelineStepTimelineText = ""; + let pipelineSessionHeadText = ""; + let firstPipelineStepSummaryText = ""; + let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; + let firstPipelineStepSummaryMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; + let firstPipelineStepExpandedText = ""; + let metNonlinearInitialText = ""; + let metProjectTreeText = ""; + let metProjectDetailText = ""; + let metCompletedText = ""; + let metJobDetailText = ""; + + if (needSidebar) { + railWidthBefore = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); + await page.getByTestId("rail-toggle").click(); + await page.waitForTimeout(120); + railWidthCollapsed = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); + await page.getByTestId("rail-toggle").click(); await page.waitForTimeout(80); - const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height)); - mobileRailHeights.push(height); } - const mobileRailMax = Math.max(...mobileRailHeights); - const mobileRailMin = Math.min(...mobileRailHeights); - await page.getByRole("button", { name: /任务调度/ }).click(); - await page.getByRole("button", { name: /待处理任务/ }).click(); - await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); - const mobileContentMetrics = await page.locator('[data-testid="pending-task-page"]').evaluate((element) => { - const pageTop = element.getBoundingClientRect().top; - const empty = element.querySelector(".empty-state"); - const emptyBox = empty?.getBoundingClientRect(); - const emptyStrong = empty?.querySelector("strong")?.getBoundingClientRect(); - return { - pageTop: Math.round(pageTop), - emptyTextOffset: emptyBox && emptyStrong ? Math.round(emptyStrong.top - emptyBox.top) : 0, - }; - }); - await page.setViewportSize({ width: 1440, height: 920 }); - await page.getByRole("button", { name: /运行总览/ }).click(); - await page.getByRole("button", { name: /态势总览/ }).click(); - const bodyText = await page.locator("body").innerText({ timeout: 5000 }); - const rawBlocksBefore = await page.locator("pre.raw-json").count(); - const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"'); - await page.getByTestId("pending-task-card").click(); - await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); - const pendingTaskText = await page.locator('[data-testid="pending-task-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /任务历史/ }).click(); - await page.waitForSelector('[data-testid="task-history-page"]', { timeout: 5000 }); - const taskHistoryText = await page.locator('[data-testid="task-history-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /运行总览/ }).click(); - await page.getByRole("button", { name: /态势总览/ }).click(); + + if (needMobileRail || needMobileContent) { + await page.setViewportSize({ width: 390, height: 860 }); + if (needMobileRail) { + for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) { + await page.getByRole("button", { name: new RegExp(moduleLabel) }).click(); + await page.waitForTimeout(80); + const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height)); + mobileRailHeights.push(height); + } + mobileRailMax = Math.max(...mobileRailHeights); + mobileRailMin = Math.min(...mobileRailHeights); + } + if (needMobileContent) { + await page.getByRole("button", { name: /任务调度/ }).click(); + await page.getByRole("button", { name: /待处理任务/ }).click(); + await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); + mobileContentMetrics = await page.locator('[data-testid="pending-task-page"]').evaluate((element) => { + const pageTop = element.getBoundingClientRect().top; + const empty = element.querySelector(".empty-state"); + const emptyBox = empty?.getBoundingClientRect(); + const emptyStrong = empty?.querySelector("strong")?.getBoundingClientRect(); + return { + pageTop: Math.round(pageTop), + emptyTextOffset: emptyBox && emptyStrong ? Math.round(emptyStrong.top - emptyBox.top) : 0, + }; + }); + } + await page.setViewportSize({ width: 1440, height: 920 }); + } + + if (needOverviewBody || needRawProviderJson || needPendingTask || needTaskHistory) { + await page.getByRole("button", { name: /运行总览/ }).click(); + await page.getByRole("button", { name: /态势总览/ }).click(); + await page.waitForSelector('[data-testid="overview-page"]', { timeout: 5000 }); + if (needOverviewBody) { + bodyText = await page.locator("body").innerText({ timeout: 5000 }); + rawBlocksBefore = await page.locator("pre.raw-json").count(); + nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"'); + } + if (needPendingTask) { + await page.getByTestId("pending-task-card").click(); + await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); + pendingTaskText = await page.locator('[data-testid="pending-task-page"]').innerText({ timeout: 5000 }); + } + if (needTaskHistory) { + await page.getByRole("button", { name: /任务历史/ }).click(); + await page.waitForSelector('[data-testid="task-history-page"]', { timeout: 5000 }); + taskHistoryText = await page.locator('[data-testid="task-history-page"]').innerText({ timeout: 5000 }); + } + if (needRawProviderJson) { + await page.getByRole("button", { name: /运行总览/ }).click(); + await page.getByRole("button", { name: /态势总览/ }).click(); + await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click(); + await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 }); + rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: "关闭" }).click(); + } + } + + if (needNodeMonitor || needDocker || needGatewayVersion) { + await page.getByRole("button", { name: /资源节点/ }).click(); + if (needNodeMonitor) { + await page.getByRole("button", { name: /资源监控/ }).click(); + await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 }); + await page.locator('[data-testid="node-monitor-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click(); + await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="process-resource-table"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText.toLowerCase(); + return text.includes("任务管理器视图") && text.includes("cpu") && text.includes("memory") && text.includes("disk") && text.includes("不含缓存") && text.includes("进程资源占用"); + }, undefined, { timeout: 10000 }); + monitorText = await page.locator('[data-testid="node-monitor-page"]').innerText({ timeout: 5000 }); + processTableText = await page.locator('[data-testid="process-resource-table"]').innerText({ timeout: 5000 }); + processMemoryValues = await page.locator('[data-testid="process-resource-table"] tbody tr').evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.memoryBytes || "0"))); + processDefaultMemoryDescending = processMemoryValues.length > 0 && processMemoryValues.every((value, index, rows) => index === 0 || rows[index - 1] >= value); + processMemorySortAria = await page.getByTestId("process-sort-memory").evaluate((element) => element.closest("th")?.getAttribute("aria-sort") || ""); + await page.getByTestId("process-sort-cpu").click(); + await page.waitForFunction(() => document.querySelector('[data-testid="process-sort-cpu"]')?.closest("th")?.getAttribute("aria-sort") === "descending", undefined, { timeout: 5000 }); + processCpuValues = await page.locator('[data-testid="process-resource-table"] tbody tr').evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.cpuPercent || "0"))); + processCpuDescending = processCpuValues.length > 0 && processCpuValues.every((value, index, rows) => index === 0 || rows[index - 1] >= value); + processCpuSortAria = await page.getByTestId("process-sort-cpu").evaluate((element) => element.closest("th")?.getAttribute("aria-sort") || ""); + await page.getByTestId("upgrade-plan-button").click(); + await page.waitForFunction(() => document.body.innerText.includes("预检升级 已下发"), undefined, { timeout: 10000 }); + upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 }); + } + if (needDocker) { + await page.getByRole("button", { name: /Docker 状态/ }).click(); + await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 }); + await page.locator('[data-testid="docker-status-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click(); + await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="database-volume-card"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText.toLowerCase(); + return text.includes("docker desktop 视图") && text.includes("containers") && text.includes("unidesk_pgdata_10gb"); + }, undefined, { timeout: 10000 }); + dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); + } + if (needGatewayVersion) { + await page.getByRole("button", { name: /网关版本/ }).click(); + await page.waitForSelector('[data-testid="gateway-version-page"]', { timeout: 10000 }); + 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("远程更新"); + }, undefined, { timeout: 10000 }); + gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 }); + gatewayTextLower = gatewayText.toLowerCase(); + gatewayHasSubsecondDuration = /\b\d+\.\d+s\b|<0\.01s/.test(gatewayText); + gatewayHasRoundedZeroDuration = /(^|\s)0s($|\s)/.test(gatewayText); + sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); + upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); + } + } + + if (needMicroserviceCatalog || needTodoNote || needFindJob || needRouteDeepLink || needPipeline || needMetNonlinear) { + await page.getByRole("button", { name: /微服务/ }).click(); + if (needMicroserviceCatalog || needTodoNote || needFindJob || needRouteDeepLink || needPipeline || needMetNonlinear) { + await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); + } + if (needMicroserviceCatalog) { + await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="microservice-row-met-nonlinear"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 }); + microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 }); + } + if (needTodoNote) { + await page.getByRole("button", { name: /Todo Note/ }).click(); + await page.waitForSelector('[data-testid="todo-note-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("todo note 工作台") + && text.includes("CONSTAR") + && text.includes("大论文") + && text.includes("找工作") + && text.includes("小论文") + && text.includes("事务") + && text.includes("仅 UniDesk frontend 代理访问"); + }, undefined, { timeout: 30000 }); + const uiTodoListName = `UI E2E ${Date.now()}`; + await page.getByLabel("新清单名称").fill(uiTodoListName); + await page.getByRole("button", { name: "创建" }).click(); + const uiTodoRow = page.locator(".todo-instance-row", { hasText: uiTodoListName }); + await uiTodoRow.waitFor({ state: "visible", timeout: 10000 }); + await uiTodoRow.click(); + await page.waitForFunction((name) => { + const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; + return active?.innerText.includes(String(name)); + }, uiTodoListName, { timeout: 10000 }); + await page.waitForSelector('[data-testid="todo-note-tree"]', { timeout: 10000 }); + await page.getByLabel("新增根任务").fill("UI E2E smoke task"); + await page.getByRole("button", { name: "新增" }).click(); + await page.waitForSelector("text=UI E2E smoke task", { timeout: 10000 }); + todoNoteText = await page.locator('[data-testid="todo-note-page"]').innerText({ timeout: 5000 }); + await uiTodoRow.click(); + await page.waitForFunction((name) => { + const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; + return active?.innerText.includes(String(name)); + }, uiTodoListName, { timeout: 10000 }); + await page.getByRole("button", { name: "删除清单" }).click(); + await page.waitForFunction((name) => !document.body.innerText.includes(String(name)), uiTodoListName, { timeout: 10000 }); + } + if (needFindJob) { + await page.getByRole("button", { name: /FindJob/ }).click(); + await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText.toLowerCase(); + const originalText = document.body.innerText; + return text.includes("findjob 工作台".toLowerCase()) + && text.includes("岗位总量") + && text.includes("d601") + && text.includes("近期岗位") + && /岗位总量\s+\d+/.test(originalText) + && /health\s+ok/i.test(originalText) + && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(originalText); + }, undefined, { timeout: 30000 }); + findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 }); + } + if (needRouteDeepLink) { + await page.goto(`${urls.frontendUrl}/app/pipeline/`, { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 15000 }); + routeInitialPath = new URL(page.url()).pathname; + routeDeepLinkText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /资源节点/ }).click(); + await page.getByRole("button", { name: /Docker 状态/ }).click(); + await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 }); + routeDockerPath = new URL(page.url()).pathname; + await page.goBack({ waitUntil: "domcontentloaded" }); + routeBackIntermediatePath = new URL(page.url()).pathname; + if (routeBackIntermediatePath !== "/app/pipeline/") { + await page.goBack({ waitUntil: "domcontentloaded" }); + } + await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 15000 }); + routeBackPath = new URL(page.url()).pathname; + await page.goto(`${urls.frontendUrl}/ops/status/`, { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.waitForSelector('[data-testid="overview-page"]', { timeout: 10000 }); + routeOverviewPath = new URL(page.url()).pathname; + routeOverviewText = await page.locator('[data-testid="overview-page"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /微服务/ }).click(); + await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); + } + if (needPipeline) { + await page.getByRole("button", { name: /Pipeline/ }).click(); + await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="pipeline-react-flow"] .react-flow__node', { timeout: 30000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("pipeline v2 工作台") + && 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); + }, undefined, { timeout: 30000 }); + pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count(); + pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count(); + pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); + pipelineGanttScaleLabel = await page.locator('[data-testid="pipeline-gantt-scale-label"]').innerText({ timeout: 5000 }); + pipelineGanttAutoHideIdleChecked = await page.locator('[data-testid="pipeline-gantt-auto-hide-idle"]').isChecked(); + pipelineGanttHeaderNodeOrder = await page.locator('[data-testid="pipeline-gantt-head-node"]').evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-node-id") || "").filter(Boolean)); + pipelineSnapshotForFrontend = await page.evaluate(async () => { + const response = await fetch("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3", { credentials: "same-origin" }); + return response.ok ? response.json() : null; + }); + if (wants("frontend:pipeline-step-timeline-visible")) { + const firstGanttLine = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]').first(); + await firstGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 }); + await firstGanttLine.click({ force: true }); + await page.waitForSelector('[data-testid="pipeline-step-timeline"] [data-testid="pipeline-opencode-step"]', { timeout: 30000 }); + pipelineStepTimelineText = await page.locator('[data-testid="pipeline-step-timeline"]').innerText({ timeout: 5000 }); + pipelineSessionHeadText = await page.locator('[data-testid="pipeline-step-timeline-session"]').innerText({ timeout: 5000 }); + const firstPipelineStep = page.locator('[data-testid="pipeline-opencode-step"]').first(); + const firstPipelineStepSummary = firstPipelineStep.locator('[data-testid="pipeline-opencode-step-summary"]'); + firstPipelineStepSummaryText = await firstPipelineStepSummary.innerText({ timeout: 5000 }); + pipelineTimelineMetrics = await page.locator('[data-testid="pipeline-step-timeline"]').evaluate((element) => { + const target = element as HTMLElement; + return { + clientWidth: target.clientWidth, + scrollWidth: target.scrollWidth, + clientHeight: target.clientHeight, + scrollHeight: target.scrollHeight, + hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1, + }; + }); + firstPipelineStepSummaryMetrics = await firstPipelineStepSummary.evaluate((element) => { + const target = element as HTMLElement; + return { + clientWidth: target.clientWidth, + scrollWidth: target.scrollWidth, + clientHeight: target.clientHeight, + scrollHeight: target.scrollHeight, + hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1, + }; + }); + await firstPipelineStepSummary.click({ force: true }); + await firstPipelineStep.locator('[data-testid="pipeline-opencode-step-body"]').waitFor({ state: "visible", timeout: 10000 }); + firstPipelineStepExpandedText = await firstPipelineStep.innerText({ timeout: 5000 }); + } + } + if (needMetNonlinear) { + await page.getByRole("button", { name: /MET Nonlinear/ }).click(); + await page.waitForSelector('[data-testid="met-nonlinear-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("met nonlinear 训练编排") + && text.includes("D601") + && text.includes("Fork Project") + && text.includes("加入待启动队列") + && text.includes("启动队列") + && text.includes("当前队列") + && text.includes("GPU/镜像") + && !text.includes("创建10个10轮任务") + && text.includes("仅 UniDesk frontend 代理访问") + && /Health\s+OK/i.test(text); + }, undefined, { timeout: 30000 }); + metNonlinearInitialText = await page.locator('[data-testid="met-nonlinear-page"]').innerText({ timeout: 5000 }); + if (wants("frontend:met-nonlinear-project-tree-detail")) { + await page.waitForSelector('[data-testid="met-project-tree"] .met-tree-row.project', { timeout: 30000 }); + metProjectTreeText = await page.locator('[data-testid="met-project-tree"]').innerText({ timeout: 5000 }); + await page.locator('[data-testid="met-project-tree"] .met-tree-row.project').first().click(); + await page.waitForFunction(() => { + const detail = document.querySelector('[data-testid="met-detail-panel"]') as HTMLElement | null; + const text = detail?.innerText || ""; + const lower = text.toLowerCase(); + return lower.includes("project 详情") + && lower.includes("config.json") + && lower.includes("data/ 训练状态") + && text.includes("模型参数") + && text.includes("指标") + && lower.includes("total params"); + }, undefined, { timeout: 30000 }); + metProjectDetailText = await page.locator('[data-testid="met-detail-panel"]').innerText({ timeout: 5000 }); + } + if (wants("frontend:met-nonlinear-queue-detail-speed")) { + await page.getByTestId("met-tab-completed").click(); + await page.waitForSelector('[data-testid^="met-job-row-"]', { timeout: 30000 }); + metCompletedText = await page.locator('[data-testid="met-completed-pane"]').innerText({ timeout: 5000 }); + await page.locator('[data-testid^="met-job-row-"]').first().click(); + await page.waitForFunction(() => { + const detail = document.querySelector('[data-testid="met-detail-panel"]') as HTMLElement | null; + const text = detail?.innerText || ""; + const lower = text.toLowerCase(); + return text.includes("训练任务详情") + && text.includes("任务状态") + && text.includes("训练速度") + && lower.includes("epoch/h") + && lower.includes("config.json") + && lower.includes("data/ 训练状态"); + }, undefined, { timeout: 30000 }); + metJobDetailText = await page.locator('[data-testid="met-detail-panel"]').innerText({ timeout: 5000 }); + } + } + } + await page.screenshot({ path: screenshotPath, fullPage: true }); - await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click(); - await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 }); - const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: "关闭" }).click(); - await page.getByRole("button", { name: /资源节点/ }).click(); - await page.getByRole("button", { name: /资源监控/ }).click(); - await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 }); - await page.locator('[data-testid="node-monitor-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click(); - await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="process-resource-table"]', { timeout: 10000 }); - await page.waitForFunction(() => { - const text = document.body.innerText.toLowerCase(); - return text.includes("任务管理器视图") && text.includes("cpu") && text.includes("memory") && text.includes("disk") && text.includes("不含缓存") && text.includes("进程资源占用"); - }, undefined, { timeout: 10000 }); - const monitorText = await page.locator('[data-testid="node-monitor-page"]').innerText({ timeout: 5000 }); - const processTableText = await page.locator('[data-testid="process-resource-table"]').innerText({ timeout: 5000 }); - const processMemoryValues = await page.locator('[data-testid="process-resource-table"] tbody tr').evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.memoryBytes || "0"))); - const processDefaultMemoryDescending = processMemoryValues.length > 0 && processMemoryValues.every((value, index, rows) => index === 0 || rows[index - 1] >= value); - const processMemorySortAria = await page.getByTestId("process-sort-memory").evaluate((element) => element.closest("th")?.getAttribute("aria-sort") || ""); - await page.getByTestId("process-sort-cpu").click(); - await page.waitForFunction(() => document.querySelector('[data-testid="process-sort-cpu"]')?.closest("th")?.getAttribute("aria-sort") === "descending", undefined, { timeout: 5000 }); - const processCpuValues = await page.locator('[data-testid="process-resource-table"] tbody tr').evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.cpuPercent || "0"))); - const processCpuDescending = processCpuValues.length > 0 && processCpuValues.every((value, index, rows) => index === 0 || rows[index - 1] >= value); - const processCpuSortAria = await page.getByTestId("process-sort-cpu").evaluate((element) => element.closest("th")?.getAttribute("aria-sort") || ""); - await page.getByTestId("upgrade-plan-button").click(); - await page.waitForFunction(() => document.body.innerText.includes("预检升级 已下发"), undefined, { timeout: 10000 }); - const upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /Docker 状态/ }).click(); - await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 }); - await page.locator('[data-testid="docker-status-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click(); - await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="database-volume-card"]', { timeout: 10000 }); - await page.waitForFunction(() => { - const text = document.body.innerText.toLowerCase(); - return text.includes("docker desktop 视图") && text.includes("containers") && text.includes("unidesk_pgdata_10gb"); - }, undefined, { timeout: 10000 }); - const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /网关版本/ }).click(); - await page.waitForSelector('[data-testid="gateway-version-page"]', { timeout: 10000 }); - 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("远程更新"); - }, undefined, { timeout: 10000 }); - const gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 }); - const gatewayTextLower = gatewayText.toLowerCase(); - const gatewayHasSubsecondDuration = /\b\d+\.\d+s\b|<0\.01s/.test(gatewayText); - const gatewayHasRoundedZeroDuration = /(^|\s)0s($|\s)/.test(gatewayText); - const sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); - const upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); - await page.getByRole("button", { name: /微服务/ }).click(); - await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="microservice-row-met-nonlinear"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 }); - const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /Todo Note/ }).click(); - await page.waitForSelector('[data-testid="todo-note-page"]', { timeout: 10000 }); - await page.waitForFunction(() => { - const text = document.body.innerText; - const lower = text.toLowerCase(); - return lower.includes("todo note 工作台") - && text.includes("CONSTAR") - && text.includes("大论文") - && text.includes("找工作") - && text.includes("小论文") - && text.includes("事务") - && text.includes("仅 UniDesk frontend 代理访问"); - }, undefined, { timeout: 30000 }); - const uiTodoListName = `UI E2E ${Date.now()}`; - await page.getByLabel("新清单名称").fill(uiTodoListName); - await page.getByRole("button", { name: "创建" }).click(); - const uiTodoRow = page.locator(".todo-instance-row", { hasText: uiTodoListName }); - await uiTodoRow.waitFor({ state: "visible", timeout: 10000 }); - await uiTodoRow.click(); - await page.waitForFunction((name) => { - const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; - return active?.innerText.includes(String(name)); - }, uiTodoListName, { timeout: 10000 }); - await page.waitForSelector('[data-testid="todo-note-tree"]', { timeout: 10000 }); - await page.getByLabel("新增根任务").fill("UI E2E smoke task"); - await page.getByRole("button", { name: "新增" }).click(); - await page.waitForSelector("text=UI E2E smoke task", { timeout: 10000 }); - const todoNoteText = await page.locator('[data-testid="todo-note-page"]').innerText({ timeout: 5000 }); - await uiTodoRow.click(); - await page.waitForFunction((name) => { - const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; - return active?.innerText.includes(String(name)); - }, uiTodoListName, { timeout: 10000 }); - await page.getByRole("button", { name: "删除清单" }).click(); - await page.waitForFunction((name) => !document.body.innerText.includes(String(name)), uiTodoListName, { timeout: 10000 }); - await page.getByRole("button", { name: /FindJob/ }).click(); - await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 }); - await page.waitForFunction(() => { - const text = document.body.innerText.toLowerCase(); - const originalText = document.body.innerText; - return text.includes("findjob 工作台".toLowerCase()) - && text.includes("岗位总量") - && text.includes("d601") - && text.includes("近期岗位") - && /岗位总量\s+\d+/.test(originalText) - && /health\s+ok/i.test(originalText) - && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(originalText); - }, undefined, { timeout: 30000 }); - const findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /Pipeline/ }).click(); - await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 }); - await page.waitForSelector('[data-testid="pipeline-react-flow"] .react-flow__node', { timeout: 30000 }); - await page.waitForFunction(() => { - const text = document.body.innerText; - const lower = text.toLowerCase(); - return lower.includes("pipeline v2 工作台") - && 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); - }, undefined, { timeout: 30000 }); - const pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count(); - const pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count(); - const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); - await page.getByRole("button", { name: /MET Nonlinear/ }).click(); - await page.waitForSelector('[data-testid="met-nonlinear-page"]', { timeout: 10000 }); - await page.waitForFunction(() => { - const text = document.body.innerText; - const lower = text.toLowerCase(); - return lower.includes("met nonlinear 训练编排") - && text.includes("D601") - && text.includes("Fork Project") - && text.includes("加入待启动队列") - && text.includes("启动队列") - && text.includes("当前队列") - && text.includes("GPU/镜像") - && !text.includes("创建10个10轮任务") - && text.includes("仅 UniDesk frontend 代理访问") - && /Health\s+OK/i.test(text); - }, undefined, { timeout: 30000 }); - const metNonlinearInitialText = await page.locator('[data-testid="met-nonlinear-page"]').innerText({ timeout: 5000 }); - await page.waitForSelector('[data-testid="met-project-tree"] .met-tree-row.project', { timeout: 30000 }); - const metProjectTreeText = await page.locator('[data-testid="met-project-tree"]').innerText({ timeout: 5000 }); - await page.locator('[data-testid="met-project-tree"] .met-tree-row.project').first().click(); - await page.waitForFunction(() => { - const detail = document.querySelector('[data-testid="met-detail-panel"]') as HTMLElement | null; - const text = detail?.innerText || ""; - const lower = text.toLowerCase(); - return lower.includes("project 详情") - && lower.includes("config.json") - && lower.includes("data/ 训练状态") - && text.includes("模型参数") - && text.includes("指标") - && lower.includes("total params"); - }, undefined, { timeout: 30000 }); - const metProjectDetailText = await page.locator('[data-testid="met-detail-panel"]').innerText({ timeout: 5000 }); - await page.getByTestId("met-tab-completed").click(); - await page.waitForSelector('[data-testid^="met-job-row-"]', { timeout: 30000 }); - const metCompletedText = await page.locator('[data-testid="met-completed-pane"]').innerText({ timeout: 5000 }); - await page.locator('[data-testid^="met-job-row-"]').first().click(); - await page.waitForFunction(() => { - const detail = document.querySelector('[data-testid="met-detail-panel"]') as HTMLElement | null; - const text = detail?.innerText || ""; - const lower = text.toLowerCase(); - return text.includes("训练任务详情") - && text.includes("任务状态") - && text.includes("训练速度") - && lower.includes("epoch/h") - && lower.includes("config.json") - && lower.includes("data/ 训练状态"); - }, undefined, { timeout: 30000 }); - const metJobDetailText = await page.locator('[data-testid="met-detail-panel"]').innerText({ timeout: 5000 }); const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase(); const todoNoteTextLower = todoNoteText.toLowerCase(); const findjobTextLower = findjobText.toLowerCase(); const pipelineTextLower = pipelineText.toLowerCase(); + const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines) ? pipelineSnapshotForFrontend.pipelines[0] : null; + const expectedGanttNodeOrder = activePipeline ? pipelineSnapshotNodeOrder(activePipeline) : []; + const headerIndexByNodeId = new Map(pipelineGanttHeaderNodeOrder.map((nodeId, index) => [nodeId, index])); + const downstreamViolations = activePipeline + ? pipelineSnapshotEdges(activePipeline).filter((edge) => + edge.edgeType.toLowerCase() !== "rework" + && headerIndexByNodeId.has(edge.source) + && headerIndexByNodeId.has(edge.target) + && Number(headerIndexByNodeId.get(edge.source)) >= Number(headerIndexByNodeId.get(edge.target))) + : []; const metNonlinearTextLower = metNonlinearInitialText.toLowerCase(); - addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); - addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) }); - addCheck(checks, "frontend:sidebar-collapse", railWidthBefore >= 160 && railWidthCollapsed <= 70, { railWidthBefore, railWidthCollapsed }); - addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); - addCheck(checks, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics }); - addCheck(checks, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); - addCheck(checks, "frontend:task-history-diagnostics", taskHistoryText.includes("任务耗时") && taskHistoryText.includes("诊断信息") && taskHistoryText.includes("失败原因") && taskHistoryText.includes("e2e forced failure for diagnostics"), { taskHistoryPreview: taskHistoryText.slice(0, 900) }); - addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); - addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); - addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存") && monitorText.includes("进程资源占用"), { monitorTextPreview: monitorText.slice(0, 1000) }); - 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-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("控制图") && /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) }); - addCheck(checks, "frontend:met-nonlinear-queue-detail-speed", metCompletedText.includes("速度") && metCompletedText.toLowerCase().includes("epoch/h") && metJobDetailText.includes("训练任务详情") && metJobDetailText.includes("训练速度") && metJobDetailText.toLowerCase().includes("epoch/h") && metJobDetailText.toLowerCase().includes("config.json") && metJobDetailText.toLowerCase().includes("data/ 训练状态"), { metCompletedPreview: metCompletedText.slice(0, 1200), metJobDetailPreview: metJobDetailText.slice(0, 1400) }); - addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); + addSelectedCheck(checks, options, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); + addSelectedCheck(checks, options, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) }); + addSelectedCheck(checks, options, "frontend:sidebar-collapse", railWidthBefore >= 160 && railWidthCollapsed <= 70, { railWidthBefore, railWidthCollapsed }); + addSelectedCheck(checks, options, "frontend:mobile-nav-fixed-height", mobileRailHeights.length > 0 && mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); + addSelectedCheck(checks, options, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics }); + addSelectedCheck(checks, options, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); + addSelectedCheck(checks, options, "frontend:task-history-diagnostics", taskHistoryText.includes("任务耗时") && taskHistoryText.includes("诊断信息") && taskHistoryText.includes("失败原因") && taskHistoryText.includes("e2e forced failure for diagnostics"), { taskHistoryPreview: taskHistoryText.slice(0, 900) }); + addSelectedCheck(checks, options, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); + addSelectedCheck(checks, options, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); + addSelectedCheck(checks, options, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存") && monitorText.includes("进程资源占用"), { monitorTextPreview: monitorText.slice(0, 1000) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发") && upgradeControlText.includes("指定 Provider") && upgradeControlText.includes(`v${providerGatewayPackageVersion()}`), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) }); + addSelectedCheck(checks, options, "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 }); + addSelectedCheck(checks, options, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeDeepLinkPreview: routeDeepLinkText.slice(0, 1200), routeOverviewPreview: routeOverviewText.slice(0, 800) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount }); + addSelectedCheck(checks, options, "frontend:pipeline-gantt-defaults", pipelineGanttScaleLabel.includes("100 px/min") && pipelineGanttAutoHideIdleChecked === false && pipelineGanttHeaderNodeOrder.length > 0 && downstreamViolations.length === 0, { + pipelineGanttScaleLabel, + pipelineGanttAutoHideIdleChecked, + pipelineGanttHeaderNodeOrder, + expectedGanttNodeOrder, + downstreamViolations, + }); + addSelectedCheck(checks, options, "frontend:pipeline-step-timeline-visible", + pipelineStepTimelineText.includes("OpenCode Step Timeline") + && pipelineStepTimelineText.includes("时间") + && pipelineStepTimelineText.includes("工具调用") + && !pipelineStepTimelineText.includes("{\n") + && pipelineSessionHeadText.includes("agent") + && pipelineSessionHeadText.toLowerCase().includes("model") + && !firstPipelineStepSummaryText.toLowerCase().includes("tokens") + && !firstPipelineStepSummaryText.includes("MiniMax-M2.7") + && !firstPipelineStepSummaryText.includes("\nbuild\n") + && !pipelineTimelineMetrics.hasHorizontalScroll + && !firstPipelineStepSummaryMetrics.hasHorizontalScroll + && firstPipelineStepSummaryMetrics.clientHeight <= 190 + && firstPipelineStepExpandedText.toLowerCase().includes("tokens") + && firstPipelineStepExpandedText.includes("用户输入"), + { + pipelineStepTimelinePreview: pipelineStepTimelineText.slice(0, 1600), + pipelineSessionHeadText, + pipelineTimelineMetrics, + firstPipelineStepSummaryMetrics, + firstPipelineStepSummaryPreview: firstPipelineStepSummaryText.slice(0, 800), + firstPipelineStepExpandedPreview: firstPipelineStepExpandedText.slice(0, 1000), + }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "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) }); + addSelectedCheck(checks, options, "frontend:met-nonlinear-queue-detail-speed", metCompletedText.includes("速度") && metCompletedText.toLowerCase().includes("epoch/h") && metJobDetailText.includes("训练任务详情") && metJobDetailText.includes("训练速度") && metJobDetailText.toLowerCase().includes("epoch/h") && metJobDetailText.toLowerCase().includes("config.json") && metJobDetailText.toLowerCase().includes("data/ 训练状态"), { metCompletedPreview: metCompletedText.slice(0, 1200), metJobDetailPreview: metJobDetailText.slice(0, 1400) }); + addSelectedCheck(checks, options, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { await browser.close(); } } -export async function runE2E(config: UniDeskConfig): Promise { +export async function runE2E( + config: UniDeskConfig, + options: E2ERunOptions = { only: [], skip: [] }, +): Promise { + const selectedChecks = ALL_E2E_CHECK_NAMES.filter((name) => wantsCheck(options, name)); + if (selectedChecks.length === 0) { + throw new Error("e2e run selection matched no checks"); + } + const checks: E2ECheck[] = []; const urls = publicUrls(config); - await exposureChecks(config, urls, checks); - await serviceChecks(config, urls, checks); - const markerId = databaseChecks(config, checks); - const frontend = await frontendCheck(config, urls, checks); + const needNetwork = wantsPrefix(options, "network"); + const needService = wantsPrefix(options, "core") + || wantsPrefix(options, "provider") + || wantsPrefix(options, "provider-ingress") + || wantsPrefix(options, "microservice"); + const needDatabase = wantsPrefix(options, "database") + || wantsCheck(options, "frontend:task-history-diagnostics"); + const needFrontend = wantsPrefix(options, "frontend"); + const executedSections: string[] = []; + + if (needNetwork) { + executedSections.push("network"); + await exposureChecks(config, urls, checks, options); + } + if (needService) { + executedSections.push("service"); + await serviceChecks(config, urls, checks, options); + } + + let markerId: string | null = null; + if (needDatabase) { + executedSections.push("database"); + markerId = databaseChecks(config, checks, options); + } + + let frontend: { screenshotPath: string; bodyText: string; consoleErrors: string[] } | null = null; + if (needFrontend) { + executedSections.push("frontend"); + frontend = await frontendCheck(config, urls, checks, options); + } + const ok = checks.every((check) => check.status === "passed"); + const resultId = markerId ?? `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; const fullResult = { ok, urls, + selection: { + only: options.only, + skip: options.skip, + matchedChecks: selectedChecks, + executedSections, + }, markerId, - screenshotPath: frontend.screenshotPath, + screenshotPath: frontend?.screenshotPath ?? null, checks, }; - const resultPath = rootPath(".state", "e2e", `${markerId}_result.json`); + mkdirSync(rootPath(".state", "e2e"), { recursive: true }); + const resultPath = rootPath(".state", "e2e", `${resultId}_result.json`); writeFileSync(resultPath, `${JSON.stringify(fullResult, null, 2)}\n`, "utf8"); return { ok, urls, + selection: { + only: options.only, + skip: options.skip, + matchedChecks: selectedChecks, + executedSections, + }, markerId, - screenshotPath: frontend.screenshotPath, + screenshotPath: frontend?.screenshotPath ?? null, resultPath, checkCounts: { total: checks.length, diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index f68af8f5..c7301dbe 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -2,6 +2,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { FindJobPage } from "./findjob"; import { MetNonlinearPage } from "./met-nonlinear"; +import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation"; import { PipelinePage } from "./pipeline"; import { TodoNotePage } from "./todo-note"; @@ -22,45 +23,12 @@ const cfg: AnyRecord = readClientConfig(); const h = React.createElement; const { useEffect, useMemo } = React; const useState: any = React.useState; +const ROUTE_REGISTRY = createRouteRegistry(MODULES); function errorMessage(error: unknown, fallback = "操作失败"): string { return error instanceof Error ? error.message : String(error || fallback); } -const MODULES = [ - { id: "ops", label: "运行总览", code: "OPS", tabs: [ - { id: "status", label: "态势总览" }, - { id: "events", label: "事件摘要" }, - { id: "logs", label: "服务日志" }, - ] }, - { id: "nodes", label: "资源节点", code: "NODE", tabs: [ - { id: "list", label: "节点清单" }, - { id: "monitor", label: "资源监控" }, - { id: "docker", label: "Docker 状态" }, - { id: "gateway", label: "网关版本" }, - { id: "labels", label: "资源标签" }, - { id: "heartbeats", label: "心跳状态" }, - ] }, - { id: "tasks", label: "任务调度", code: "TASK", tabs: [ - { id: "dispatch", label: "下发任务" }, - { id: "pending", label: "待处理任务" }, - { id: "history", label: "任务历史" }, - { id: "results", label: "执行结果" }, - ] }, - { id: "apps", label: "微服务", code: "APP", tabs: [ - { id: "catalog", label: "服务目录" }, - { id: "todo-note", label: "Todo Note" }, - { id: "findjob", label: "FindJob" }, - { id: "pipeline", label: "Pipeline" }, - { id: "met-nonlinear", label: "MET Nonlinear" }, - ] }, - { id: "config", label: "系统配置", code: "CFG", tabs: [ - { id: "topology", label: "连接拓扑" }, - { id: "auth", label: "认证策略" }, - { id: "security", label: "安全边界" }, - ] }, -]; - function fmtDate(value: any): string { if (!value) return "--"; const date = new Date(value); @@ -496,7 +464,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock } ); } -function Sidebar({ activeModule, onChange, collapsed, onToggle }: AnyRecord) { +function Sidebar({ activeModule, activeTabs, onNavigate, collapsed, onToggle }: AnyRecord) { return h("aside", { className: `rail ${collapsed ? "collapsed" : ""}`, "aria-label": "主模块" }, h("div", { className: "brand" }, h("span", { className: "brand-mark" }, "UD"), @@ -507,19 +475,21 @@ function Sidebar({ activeModule, onChange, collapsed, onToggle }: AnyRecord) { key: module.id, type: "button", className: `module ${activeModule === module.id ? "active" : ""}`, - onClick: () => onChange(module.id), + onClick: () => onNavigate(module.id, activeTabs[module.id] || DEFAULT_ACTIVE_TABS[module.id] || module.tabs[0]?.id || ""), title: module.label, + "data-route": pathForTarget(ROUTE_REGISTRY, module.id, activeTabs[module.id] || DEFAULT_ACTIVE_TABS[module.id] || module.tabs[0]?.id || ""), }, h("span", { className: "module-code" }, module.code), h("span", null, module.label))), ); } -function TabBar({ module, activeTab, onChange }: AnyRecord) { +function TabBar({ module, activeTab, onNavigate }: AnyRecord) { return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` }, module.tabs.map((tab: any) => h("button", { key: tab.id, type: "button", className: `tab ${activeTab === tab.id ? "active" : ""}`, - onClick: () => onChange(tab.id), + onClick: () => onNavigate(module.id, tab.id), + "data-route": pathForTarget(ROUTE_REGISTRY, module.id, tab.id), }, tab.label)), ); } @@ -531,7 +501,7 @@ function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) { const pendingCount = overview.pendingTaskCount ?? pendingTasks.length; const recentTasks = data.tasks.slice(0, 5); const pgdata = overview.pgdata || {}; - return h("div", { className: "page-grid overview-grid" }, + return h("div", { className: "page-grid overview-grid", "data-testid": "overview-page" }, h(Panel, { title: "核心指标", eyebrow: "Control" }, h("div", { className: "metric-grid" }, h(MetricCard, { label: "数据库", value: overview.dbReady ? "READY" : "WAIT", hint: "PostgreSQL internal network", tone: overview.dbReady ? "ok" : "warn" }), @@ -1518,8 +1488,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa } function Shell({ session, onLogout }: AnyRecord) { - const [activeModule, setActiveModule] = useState("ops"); - const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", apps: "catalog", config: "topology" }); + const initialRouteTarget = resolveRouteTarget(ROUTE_REGISTRY, window.location.pathname); + const [activeModule, setActiveModule] = useState(initialRouteTarget.moduleId); + const [activeTabs, setActiveTabs] = useState({ ...DEFAULT_ACTIVE_TABS, [initialRouteTarget.moduleId]: initialRouteTarget.tabId }); const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], microservices: [], events: [], tasks: [], pendingTasks: [], logs: [] }); const [connection, setConnection] = useState({ ok: false, text: "连接中" }); const [lastRefresh, setLastRefresh] = useState(null); @@ -1527,8 +1498,8 @@ function Shell({ session, onLogout }: AnyRecord) { const [raw, setRaw] = useState(null); const [railCollapsed, setRailCollapsed] = useState(false); - const module = MODULES.find((item: any) => item.id === activeModule) || MODULES[0]; - const activeTab = activeTabs[activeModule] || module.tabs[0].id; + const module = ROUTE_REGISTRY.moduleById[activeModule] || ROUTE_REGISTRY.modules[0]; + const activeTab = activeTabs[activeModule] || DEFAULT_ACTIVE_TABS[activeModule] || module.tabs[0].id; async function refresh(): Promise { try { @@ -1573,17 +1544,40 @@ function Shell({ session, onLogout }: AnyRecord) { return () => clearInterval(timer); }, []); + useEffect(() => { + const canonicalPath = canonicalizeKnownRoute(ROUTE_REGISTRY, window.location.pathname); + if (canonicalPath && window.location.pathname !== canonicalPath) { + window.history.replaceState(null, "", canonicalPath); + } + }, []); + + useEffect(() => { + const onPopState = (): void => { + const next = resolveRouteTarget(ROUTE_REGISTRY, window.location.pathname); + setActiveModule(next.moduleId); + setActiveTabs((prev: AnyRecord) => ({ ...prev, [next.moduleId]: next.tabId })); + setRaw(null); + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [activeModule, activeTab]); - function setTab(tab: string): void { - setActiveTabs((prev: any) => ({ ...prev, [activeModule]: tab })); - } - - function navigate(moduleId: string, tabId: string): void { - setActiveModule(moduleId); - setActiveTabs((prev: any) => ({ ...prev, [moduleId]: tabId })); + function navigate(moduleId: string, tabId: string, historyMode: "push" | "replace" = "push"): void { + const safeModule = ROUTE_REGISTRY.moduleById[moduleId] ? moduleId : ROUTE_REGISTRY.fallbackTarget.moduleId; + const safeTab = ROUTE_REGISTRY.moduleById[safeModule]?.tabs.some((tab: any) => tab.id === tabId) + ? tabId + : DEFAULT_ACTIVE_TABS[safeModule] || ROUTE_REGISTRY.moduleById[safeModule]?.tabs[0]?.id || ROUTE_REGISTRY.fallbackTarget.tabId; + setActiveModule(safeModule); + setActiveTabs((prev: any) => ({ ...prev, [safeModule]: safeTab })); + const nextPath = pathForTarget(ROUTE_REGISTRY, safeModule, safeTab); + if (window.location.pathname !== nextPath) { + const method = historyMode === "replace" ? "replaceState" : "pushState"; + window.history[method](null, "", nextPath); + } } function openRaw(title: string, rawData: any): void { @@ -1591,10 +1585,10 @@ function Shell({ session, onLogout }: AnyRecord) { } return h("div", { className: `shell ${railCollapsed ? "rail-collapsed" : ""}`, "data-testid": "app-shell" }, - h(Sidebar, { activeModule, onChange: setActiveModule, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }), + h(Sidebar, { activeModule, activeTabs, onNavigate: navigate, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }), h("main", { className: "workspace" }, h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }), - h(TabBar, { module, activeTab, onChange: setTab }), + h(TabBar, { module, activeTab, onNavigate: navigate }), h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw, onNavigate: navigate }), ), h(RawDialog, { raw, onClose: () => setRaw(null) }), diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 6ad7413c..080b1458 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -254,7 +254,14 @@ async function proxyApi(req: Request, url: URL): Promise { if (req.method !== "GET" && req.method !== "HEAD") { init.body = await req.arrayBuffer(); } - const upstream = await fetch(upstreamUrl, init); + let upstream: Response; + try { + upstream = await fetch(upstreamUrl, init); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger("warn", "proxy_upstream_failed", { path: url.pathname, upstreamUrl: upstreamUrl.toString(), error: message }); + return jsonResponse({ ok: false, error: { message: "upstream proxy failed", detail: message } }, 502); + } const responseHeaders = new Headers(); const upstreamContentType = upstream.headers.get("content-type"); if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType); @@ -281,6 +288,10 @@ async function staticResponse(pathname: string): Promise { return new Response(file, { headers: { "content-type": contentType(pathname) } }); } +function isStaticAssetPath(pathname: string): boolean { + return /\/[^/]+\.[a-z0-9]+$/iu.test(pathname); +} + const server = Bun.serve({ port: config.port, hostname: "0.0.0.0", @@ -302,6 +313,9 @@ const server = Bun.serve({ if (safePath.includes("..") || safePath.includes("\0")) { return jsonResponse({ ok: false, error: "invalid path" }, 400); } + if (!isStaticAssetPath(url.pathname)) { + return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } }); + } return staticResponse(url.pathname); } catch (error) { logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) }); diff --git a/src/components/frontend/src/navigation.ts b/src/components/frontend/src/navigation.ts new file mode 100644 index 00000000..88f9a086 --- /dev/null +++ b/src/components/frontend/src/navigation.ts @@ -0,0 +1,206 @@ +export interface UniDeskTabDefinition { + id: string; + label: string; + routeSegment?: string; +} + +export interface UniDeskModuleDefinition { + id: string; + label: string; + code: string; + routeSegment?: string; + tabs: UniDeskTabDefinition[]; +} + +export interface UniDeskRouteTarget { + moduleId: string; + tabId: string; +} + +export interface UniDeskRoutedTabDefinition extends UniDeskTabDefinition { + routeSegment: string; + canonicalPath: string; + aliases: string[]; +} + +export interface UniDeskRoutedModuleDefinition extends UniDeskModuleDefinition { + routeSegment: string; + canonicalPath: string; + tabs: UniDeskRoutedTabDefinition[]; +} + +export interface UniDeskRouteRegistry { + modules: UniDeskRoutedModuleDefinition[]; + moduleById: Record; + defaultActiveTabs: Record; + routeMap: Record; + canonicalPathByTarget: Record; + fallbackTarget: UniDeskRouteTarget; +} + +export const MODULES: UniDeskModuleDefinition[] = [ + { id: "ops", label: "运行总览", code: "OPS", tabs: [ + { id: "status", label: "态势总览" }, + { id: "events", label: "事件摘要" }, + { id: "logs", label: "服务日志" }, + ] }, + { id: "nodes", label: "资源节点", code: "NODE", tabs: [ + { id: "list", label: "节点清单" }, + { id: "monitor", label: "资源监控" }, + { id: "docker", label: "Docker 状态" }, + { id: "gateway", label: "网关版本" }, + { id: "labels", label: "资源标签" }, + { id: "heartbeats", label: "心跳状态" }, + ] }, + { id: "tasks", label: "任务调度", code: "TASK", tabs: [ + { id: "dispatch", label: "下发任务" }, + { id: "pending", label: "待处理任务" }, + { id: "history", label: "任务历史" }, + { id: "results", label: "执行结果" }, + ] }, + { id: "apps", label: "微服务", code: "APP", routeSegment: "app", tabs: [ + { id: "catalog", label: "服务目录" }, + { id: "todo-note", label: "Todo Note" }, + { id: "findjob", label: "FindJob" }, + { id: "pipeline", label: "Pipeline" }, + { id: "met-nonlinear", label: "MET Nonlinear" }, + ] }, + { id: "config", label: "系统配置", code: "CFG", tabs: [ + { id: "topology", label: "连接拓扑" }, + { id: "auth", label: "认证策略" }, + { id: "security", label: "安全边界" }, + ] }, +]; + +export const DEFAULT_ACTIVE_TABS: Record = Object.fromEntries( + MODULES.map((module) => [module.id, module.tabs[0]?.id ?? ""]), +); + +function normalizeDecodedSegment(segment: string): string { + const trimmed = String(segment || "").trim(); + if (!trimmed) return ""; + try { + return decodeURIComponent(trimmed); + } catch { + return trimmed; + } +} + +function normalizeRoutePath(pathname: string): string { + const raw = String(pathname || "/"); + const [pathOnly] = raw.split(/[?#]/u, 1); + if (pathOnly === "/") return "/"; + const segments = pathOnly.split("/").map(normalizeDecodedSegment).filter(Boolean); + const joined = `/${segments.join("/")}`; + return joined.endsWith("/") ? joined : `${joined}/`; +} + +function hashText(value: string): string { + let hash = 2166136261; + for (const char of value) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return Math.abs(hash >>> 0).toString(36); +} + +function asciiSlug(value: string): string { + return String(value || "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/gu, "") + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} + +function unicodeSlug(value: string): string { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[\s/\\?#%]+/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} + +function routeSegmentFor(definition: { id?: string; label?: string; routeSegment?: string }): string { + const explicit = asciiSlug(definition.routeSegment || "") || unicodeSlug(definition.routeSegment || ""); + if (explicit) return explicit; + const stableId = asciiSlug(definition.id || ""); + if (stableId) return stableId; + const readableLabel = asciiSlug(definition.label || "") || unicodeSlug(definition.label || ""); + if (readableLabel) return readableLabel; + return `route-${hashText(JSON.stringify(definition))}`; +} + +function targetKey(moduleId: string, tabId: string): string { + return `${moduleId}:${tabId}`; +} + +export function createRouteRegistry(definitions: UniDeskModuleDefinition[]): UniDeskRouteRegistry { + const prebuiltModules = definitions.map((module) => { + const routeSegment = routeSegmentFor(module); + return { + ...module, + routeSegment, + tabs: module.tabs.map((tab) => ({ ...tab, routeSegment: routeSegmentFor(tab) })), + }; + }); + + const routeMap: Record = {}; + const canonicalPathByTarget: Record = {}; + const defaultActiveTabs: Record = {}; + const routedModules: UniDeskRoutedModuleDefinition[] = prebuiltModules.map((module) => { + const defaultTabId = module.tabs[0]?.id ?? ""; + defaultActiveTabs[module.id] = defaultTabId; + const routedTabs: UniDeskRoutedTabDefinition[] = module.tabs.map((tab) => { + const canonicalPath = `/${module.routeSegment}/${tab.routeSegment}/`; + const aliases = [canonicalPath]; + const target = { moduleId: module.id, tabId: tab.id }; + for (const alias of aliases) routeMap[normalizeRoutePath(alias)] = target; + canonicalPathByTarget[targetKey(module.id, tab.id)] = canonicalPath; + return { ...tab, canonicalPath, aliases }; + }); + const moduleCanonical = `/${module.routeSegment}/`; + const defaultTarget = { moduleId: module.id, tabId: defaultTabId }; + routeMap[normalizeRoutePath(moduleCanonical)] = defaultTarget; + return { + ...module, + routeSegment: module.routeSegment, + canonicalPath: moduleCanonical, + tabs: routedTabs, + }; + }); + + const fallbackModule = routedModules[0]; + const fallbackTarget = { + moduleId: fallbackModule?.id || "", + tabId: fallbackModule?.tabs[0]?.id || "", + }; + + routeMap["/"] = fallbackTarget; + + return { + modules: routedModules, + moduleById: Object.fromEntries(routedModules.map((module) => [module.id, module])), + defaultActiveTabs, + routeMap, + canonicalPathByTarget, + fallbackTarget, + }; +} + +export function resolveRouteTarget(registry: UniDeskRouteRegistry, pathname: string): UniDeskRouteTarget { + return registry.routeMap[normalizeRoutePath(pathname)] || registry.fallbackTarget; +} + +export function pathForTarget(registry: UniDeskRouteRegistry, moduleId: string, tabId: string): string { + return registry.canonicalPathByTarget[targetKey(moduleId, tabId)] + || registry.canonicalPathByTarget[targetKey(registry.fallbackTarget.moduleId, registry.fallbackTarget.tabId)] + || "/"; +} + +export function canonicalizeKnownRoute(registry: UniDeskRouteRegistry, pathname: string): string | null { + const target = registry.routeMap[normalizeRoutePath(pathname)]; + if (!target) return null; + return pathForTarget(registry, target.moduleId, target.tabId); +} diff --git a/src/components/frontend/src/pipeline.tsx b/src/components/frontend/src/pipeline.tsx index a8908307..73799667 100644 --- a/src/components/frontend/src/pipeline.tsx +++ b/src/components/frontend/src/pipeline.tsx @@ -25,6 +25,11 @@ const pipelineOutputPorts: AnyRecord[] = [ const pipelineOverlapEdgePalette = ["#4eb7a8", "#d7a13a", "#69aee8", "#e0835f", "#b7d86b", "#d98bd2", "#5fc6bf"]; const pipelineNodeWidth = 236; const pipelineNodeHeight = 88; +const pipelineAutoRefreshMs = 15000; +const pipelineSnapshotRunLimit = 10; +const pipelineGanttTimeAxisWidth = 96; +const pipelineGanttNodeColumnWidth = 72; +const pipelineGanttHeaderHeight = 64; function pipelinePercent(value: any, fallback: number): number { const number = Number.parseFloat(String(value || "")); @@ -194,6 +199,13 @@ function fmtClock(value: Date): string { return value.toLocaleTimeString("zh-CN", { hour12: false }); } +function fmtClockValue(value: any): string { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return fmtClock(date); +} + function fmtDurationMs(value: any): string { const ms = Number(value); if (!Number.isFinite(ms) || ms < 0) return "--"; @@ -337,7 +349,7 @@ function opencodePartFacts(part: any): string[] { `type ${part?.type || "unknown"}`, part?.tool ? `tool ${part.tool}` : "", part?.status ? `status ${part.status}` : "", - part?.durationMs !== undefined ? `duration ${fmtDurationMs(part.durationMs)}` : "", + part?.durationMs !== undefined && part?.durationMs !== null ? `duration ${fmtDurationMs(part.durationMs)}` : "", part?.outputSize !== undefined ? `output ${part.outputSize} chars` : "", ].filter(Boolean); } @@ -351,109 +363,922 @@ function PipelineFieldList({ fields }: AnyRecord) { ])); } -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 firstFieldValue(fields: any, keys: string[]): string { + const normalized = new Set(keys.map((key) => key.toLowerCase())); + for (const field of asArray(fields)) { + const key = String(field?.key || "").toLowerCase(); + if (normalized.has(key)) return String(field?.value || ""); + } + return ""; +} + +function stepRoleLabel(role: any): string { + const value = String(role || "unknown").toLowerCase(); + if (value === "user") return "用户"; + if (value === "assistant") return "助手"; + if (value === "system") return "系统"; + return value || "其他"; +} + +function stepRoleTone(role: any): string { + const value = String(role || "unknown").toLowerCase(); + return ["user", "assistant", "system"].includes(value) ? value : "unknown"; +} + +function pipelineNoisePartType(type: any): boolean { + const normalized = String(type || "").toLowerCase(); + return normalized === "step-start" || normalized === "step-finish"; +} + +function partKindLabel(part: any): string { + const type = String(part?.type || "unknown").toLowerCase(); + if (type === "text") return "正文"; + if (type === "reasoning") return "思考"; + if (type === "tool") return "工具"; + if (type === "step-start") return "开始"; + if (type === "step-finish") return "结束"; + return type || "part"; +} + +function partStatusTone(part: any): string { + const status = String(part?.status || "").toLowerCase(); + if (["error", "failed", "failure"].includes(status)) return "failed"; + if (["completed", "succeeded", "success"].includes(status)) return "succeeded"; + if (["running", "started", "in_progress"].includes(status)) return "running"; + return "unknown"; +} + +function stepStatusTone(step: any): string { + const tools = asArray(step?.parts).filter((part: any) => String(part?.type || "").toLowerCase() === "tool"); + if (tools.some((part: any) => partStatusTone(part) === "failed")) return "failed"; + if (!step?.completedAt && String(step?.role || "").toLowerCase() === "assistant") return "running"; + return "succeeded"; +} + +function stepTokenLine(tokens: any): string { + const facts = tokenFacts(tokens); + return facts.length > 0 ? facts.join(" / ") : "tokens --"; +} + +function pipelineStructuredSummary(value: any): AnyRecord { + if (Array.isArray(value)) { + const first = value[0]; + const keys = isRecord(first) ? Object.keys(first).slice(0, 4) : []; + return { + shape: "array", + facts: [`${value.length} items`, ...keys.map((key) => `key ${key}`)], + kv: [], + }; + } + if (isRecord(value)) { + const entries = Object.entries(value); + const primitiveEntries = entries.filter(([, item]) => item === null || ["string", "number", "boolean"].includes(typeof item)).slice(0, 6); + return { + shape: "object", + facts: [`${entries.length} fields`, ...entries.slice(0, 4).map(([key]) => `key ${key}`)], + kv: primitiveEntries.map(([key, item]) => ({ label: key, value: summarizeValue(item) })), + }; + } + return { + shape: typeof value, + facts: [summarizeValue(value)], + kv: [], + }; +} + +function readBalancedStructuredBlock(text: string, startIndex: number): AnyRecord | null { + const opening = text[startIndex]; + if (opening !== "{" && opening !== "[") return null; + const pairs: AnyRecord = { "{": "}", "[": "]" }; + const stack = [opening]; + let inString = false; + let escaped = false; + for (let index = startIndex + 1; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "\"") inString = false; + continue; + } + if (char === "\"") { + inString = true; + continue; + } + if (char === "{" || char === "[") { + stack.push(char); + continue; + } + if (char === "}" || char === "]") { + const expected = pairs[stack[stack.length - 1]]; + if (char !== expected) return null; + stack.pop(); + if (stack.length === 0) return { raw: text.slice(startIndex, index + 1), end: index + 1 }; + } + } + return null; +} + +function appendPipelineStructuredAwareText(blocks: AnyRecord[], text: string, defaultLabel = "structured payload"): void { + const source = String(text || "").trim(); + if (!source) return; + const standalonePattern = /(^|\n)(?=[\[{])/gu; + let cursor = 0; + while (cursor < source.length) { + standalonePattern.lastIndex = cursor; + const match = standalonePattern.exec(source); + if (!match) { + const remainder = source.slice(cursor).trim(); + if (remainder) blocks.push({ type: "text", text: remainder }); + return; + } + const start = match.index + match[1].length; + const before = source.slice(cursor, start).trim(); + if (before) blocks.push({ type: "text", text: before }); + const parsedBlock = readBalancedStructuredBlock(source, start); + if (!parsedBlock) { + blocks.push({ type: "text", text: `${defaultLabel}: structured payload preview unavailable` }); + const nextBoundary = source.indexOf("\n\n", start); + cursor = nextBoundary >= 0 ? nextBoundary + 2 : source.length; + continue; + } + try { + blocks.push({ type: "structured", label: defaultLabel, value: JSON.parse(parsedBlock.raw) }); + } catch { + blocks.push({ type: "text", text: `${defaultLabel}: structured payload` }); + } + cursor = parsedBlock.end; + while (cursor < source.length && /\s/u.test(source[cursor])) cursor += 1; + } +} + +function pipelineStructuredTextBlocks(text: any): AnyRecord[] { + const source = typeof text === "string" ? text.replace(/\r\n/gu, "\n").trim() : ""; + if (!source) return []; + const blocks: AnyRecord[] = []; + let cursor = 0; + const headingPattern = /(^|\n)([^\n]{1,120}):\n(?=[\[{])/gu; + while (cursor < source.length) { + headingPattern.lastIndex = cursor; + const match = headingPattern.exec(source); + if (!match) { + appendPipelineStructuredAwareText(blocks, source.slice(cursor)); + break; + } + const matchStart = match.index + match[1].length; + appendPipelineStructuredAwareText(blocks, source.slice(cursor, matchStart)); + const label = String(match[2] || "").trim(); + const jsonStart = headingPattern.lastIndex; + const parsedBlock = readBalancedStructuredBlock(source, jsonStart); + if (!parsedBlock) { + blocks.push({ type: "text", text: `${label}: structured payload preview unavailable` }); + const nextBoundary = source.indexOf("\n\n", jsonStart); + cursor = nextBoundary >= 0 ? nextBoundary + 2 : source.length; + continue; + } + try { + const parsed = JSON.parse(parsedBlock.raw); + blocks.push({ type: "structured", label, value: parsed }); + cursor = parsedBlock.end; + while (cursor < source.length && /\s/u.test(source[cursor])) cursor += 1; + continue; + } catch { + const fallbackChunk = `${label}: structured payload`; + blocks.push({ type: "text", text: fallbackChunk }); + cursor = parsedBlock.end; + } + } + return blocks; +} + +function PipelineStructuredPayloadBlock({ label, value }: AnyRecord) { + const summary = pipelineStructuredSummary(value); + return h("section", { className: "pipeline-structured-payload" }, + h("div", { className: "pipeline-structured-payload-head" }, + h("b", null, label || "structured payload"), + h("span", null, summary.shape), ), + h(PipelineChipRow, { items: summary.facts }), + summary.kv.length > 0 ? h(PipelineKvGrid, { items: summary.kv }) : 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 PipelineStepTextBlock({ label, text, tone = "", previewLimit = 760, maxBlocks = Number.POSITIVE_INFINITY, compactText = false }: AnyRecord) { + const safePreviewLimit = Number.isFinite(Number(previewLimit)) ? Number(previewLimit) : 760; + const safeMaxBlocks = Number.isFinite(Number(maxBlocks)) ? Math.max(1, Number(maxBlocks)) : Number.POSITIVE_INFINITY; + const blocks = pipelineStructuredTextBlocks(text); + if (blocks.length === 0) return null; + const visibleBlocks = blocks.slice(0, safeMaxBlocks); + const hiddenCount = Math.max(0, blocks.length - visibleBlocks.length); + return h("div", { className: "pipeline-step-text-stack" }, + visibleBlocks.map((block: AnyRecord, index: number) => { + if (block.type === "structured") { + return h(PipelineStructuredPayloadBlock, { key: `structured-${index}-${block.label || "payload"}`, label: block.label, value: block.value }); + } + const sourceText = compactText ? String(block.text || "").replace(/\s+/gu, " ").trim() : block.text; + const preview = previewText(sourceText, safePreviewLimit); + if (!preview) return null; + return h("section", { key: `text-${index}`, className: `pipeline-step-text-block ${tone}` }, + h("b", null, label), + h("p", null, preview), + ); + }), + hiddenCount > 0 ? h("div", { className: "pipeline-step-text-overflow" }, `已折叠 ${hiddenCount} 段`) : 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。" }), +function normalizedPipelinePreview(value: any): string { + return previewText(value, 3200).replace(/\s+/gu, " ").trim().toLowerCase(); +} + +function uniquePipelinePartPreviews(parts: any[], blockedValues: Set = new Set()): string[] { + const seen = new Set(); + const values: string[] = []; + for (const part of asArray(parts)) { + const text = previewText(part?.textPreview, 3200); + const normalized = normalizedPipelinePreview(text); + if (!normalized || seen.has(normalized) || blockedValues.has(normalized)) continue; + seen.add(normalized); + values.push(text); + } + return values; +} + +function pipelineSummaryBlockProps(step: any, textParts: any[], reasoningParts: any[]): AnyRecord | null { + const role = String(step?.role || "").toLowerCase(); + const reasoningValues = uniquePipelinePartPreviews(reasoningParts); + const reasoningSet = new Set(reasoningValues.map((item) => normalizedPipelinePreview(item)).filter(Boolean)); + const textValues = uniquePipelinePartPreviews(textParts, role === "assistant" ? reasoningSet : new Set()); + const textSummary = textValues.join("\n\n"); + const stepPreview = previewText(step?.textPreview, 3200); + const normalizedStepPreview = normalizedPipelinePreview(stepPreview); + const hasIndependentStepPreview = Boolean( + normalizedStepPreview + && !reasoningSet.has(normalizedStepPreview) + && !Array.from(reasoningSet).some((item) => item && normalizedStepPreview.includes(item)), + ); + + if (role === "assistant") { + if (textSummary) { + return { label: "消息摘要", text: textSummary, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true }; + } + if (hasIndependentStepPreview) { + return { label: "消息摘要", text: stepPreview, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true }; + } + return null; + } + + const summaryText = textSummary || stepPreview; + if (!summaryText) return null; + return { + label: role === "user" ? "用户输入" : role === "system" ? "系统输入" : "消息摘要", + text: summaryText, + tone: role, + previewLimit: role === "user" ? 200 : 180, + maxBlocks: 2, + compactText: true, + }; +} + +function pipelineReasoningSummaryText(reasoningParts: any[]): string { + return uniquePipelinePartPreviews(reasoningParts).join("\n\n"); +} + +function pipelineMessageCardRows(step: any, textParts: any[], reasoningParts: any[]): AnyRecord[] { + const role = String(step?.role || "").toLowerCase(); + const roleTone = stepRoleTone(role); + const messageSummary = pipelineSummaryBlockProps(step, textParts, reasoningParts); + const reasoningSummary = role === "assistant" ? pipelineReasoningSummaryText(reasoningParts) : ""; + const normalizedReasoning = normalizedPipelinePreview(reasoningSummary); + const normalizedMessage = normalizedPipelinePreview(messageSummary?.text || ""); + const duplicateSummary = Boolean( + normalizedReasoning + && normalizedMessage + && ( + normalizedReasoning === normalizedMessage + || normalizedReasoning.includes(normalizedMessage) + || normalizedMessage.includes(normalizedReasoning) + ), + ); + const rows: AnyRecord[] = []; + if (reasoningSummary) rows.push({ key: "reasoning", label: "思考", text: reasoningSummary, tone: "reasoning", previewLimit: 104 }); + if (messageSummary?.text && !(role === "assistant" && duplicateSummary)) rows.push({ + key: "message", + label: role === "assistant" ? "消息" : messageSummary.label, + text: messageSummary.text, + tone: messageSummary.tone || roleTone, + previewLimit: role === "user" ? 124 : 110, + }); + if (rows.length === 0 && role === "assistant") rows.push({ key: "message", label: "消息", text: "无正文输出,仅工具调用。", tone: roleTone, previewLimit: 120 }); + return rows; +} + +function pipelineToolPrimarySummary(part: any): AnyRecord { + const inputFields = asArray(part?.inputFields); + const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620); + const tool = String(part?.tool || part?.type || "tool").toUpperCase(); + const description = firstFieldValue(inputFields, ["description", "justification", "purpose", "summary"]); + const query = firstFieldValue(inputFields, ["q", "query"]); + const command = firstFieldValue(inputFields, ["command", "cmd"]); + const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]); + const primaryText = description + || query + || (tool === "BASH" ? command || filePath : filePath || command) + || previewText(part?.textPreview || outputPreview || part?.title || tool, 220); + return { + tool, + text: previewText(String(primaryText || "").replace(/\s+/gu, " ").trim(), 150), + }; +} + +function pipelineToolSummaryItems(parts: any[]): AnyRecord[] { + const seen = new Set(); + const items: AnyRecord[] = []; + for (const part of asArray(parts)) { + const summary = pipelineToolPrimarySummary(part); + const key = `${summary.tool}:${normalizedPipelinePreview(summary.text)}`; + if (!summary.text || seen.has(key)) continue; + seen.add(key); + items.push(summary); + } + return items; +} + +function pipelineSessionFacts(steps: any[], sessionIds: string[]): string[] { + const agents = uniqueStrings(steps.map((step: any) => step?.agent)).slice(0, 3); + const models = uniqueStrings(steps.map((step: any) => step?.model)).slice(0, 3); + const sessions = sessionIds.length <= 2 + ? sessionIds.map((sessionId) => `session ${sessionId}`) + : [`sessions ${sessionIds.length}`, ...sessionIds.slice(0, 2).map((sessionId) => `session ${sessionId}`)]; + return [ + ...agents.map((agent) => `agent ${agent}`), + ...models.map((model) => `model ${model}`), + ...sessions, + ]; +} + +function PipelineStepMessageCard({ rows, compact = false, role = "", matched = false }: AnyRecord) { + const safeRows = asArray(rows).filter((row: any) => row?.text); + if (safeRows.length === 0) return null; + if (compact) { + return h("section", { className: `pipeline-step-message-card compact ${role}` }, + h("div", { className: "pipeline-step-message-rows" }, + safeRows.map((row: AnyRecord, index: number) => { + const text = previewText(String(row.text || "").replace(/\s+/gu, " ").trim(), row.previewLimit || (index === 0 ? 116 : 124)); + return h("section", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row compact ${row.tone || ""}` }, + h("p", null, + h("b", null, row.label), + h("span", null, text || "--"), + ), + ); + }), + ), ); } - const details = selection?.details; - const procedure = findProcedureRun(details, interval) || interval.raw || {}; + return h("section", { className: `pipeline-step-message-card ${compact ? "compact" : "expanded"} ${role}` }, + h("div", { className: "pipeline-step-message-card-head" }, + h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)), + matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null, + ), + h("div", { className: "pipeline-step-message-rows" }, + safeRows.map((row: AnyRecord, index: number) => { + return h("div", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row expanded ${row.tone || ""}` }, + h(PipelineStepTextBlock, { + label: row.label, + text: row.text, + tone: row.tone || role, + previewLimit: row.previewLimitFull || 420, + maxBlocks: 3, + compactText: false, + }), + ); + }), + ), + ); +} + +function PipelineStepTimeSummary({ step, role = "", matched = false }: AnyRecord) { + const durationText = step?.durationMs !== undefined && step?.durationMs !== null ? fmtDurationMs(step.durationMs) : ""; + const meta = [ + `Step ${step?.index ?? "?"}`, + durationText, + step?.finish ? String(step.finish) : "", + ].filter(Boolean).join(" / "); + return h("section", { className: "pipeline-step-time-card" }, + h("b", null, "时间"), + h("strong", null, `${fmtClockValue(step?.createdAt)} -> ${fmtClockValue(step?.completedAt)}`), + h("div", { className: "pipeline-step-time-meta" }, + meta ? h("span", null, meta) : null, + role ? h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)) : null, + matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null, + ), + ); +} + +function PipelineStepToolSummary({ tools }: AnyRecord) { + const items = pipelineToolSummaryItems(tools); + return h("section", { className: `pipeline-step-tool-summary ${items.length > 0 ? "has-items" : "empty"}` }, + h("b", null, "工具调用"), + items.length === 0 + ? h("p", null, "无") + : h("div", { className: "pipeline-step-tool-summary-list" }, + items.slice(0, 2).map((item: AnyRecord, index: number) => h("div", { key: `${item.tool}-${index}`, className: "pipeline-step-tool-summary-item" }, + h("span", null, item.tool), + h("p", null, item.text), + )), + items.length > 2 ? h("small", null, `+${items.length - 2} more`) : null, + ), + ); +} + +function PipelineOpenCodePart({ part }: AnyRecord) { + const type = String(part?.type || "unknown").toLowerCase(); + if (pipelineNoisePartType(type)) return null; + const title = String(part?.title || part?.tool || partKindLabel(part)); + const facts = opencodePartFacts(part); + const tone = partStatusTone(part); + const inputFields = asArray(part?.inputFields); + const metadataFields = asArray(part?.metadataFields); + const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620); + const command = firstFieldValue(inputFields, ["command", "cmd"]); + const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]); + const compactSummary = command || filePath || previewText(part?.textPreview || outputPreview || title, 120); + + if (type === "text") { + return h(PipelineStepTextBlock, { label: "用户输入 / 上下文", text: part?.textPreview, tone: "user-text" }); + } + + if (type === "reasoning") { + return h("details", { className: "pipeline-opencode-part reasoning" }, + h("summary", null, + h("span", { className: "pipeline-part-kind" }, "思考"), + h("strong", null, previewText(part?.textPreview || "reasoning", 96)), + h(PipelineChipRow, { items: facts }), + ), + h("div", { className: "pipeline-opencode-part-body" }, + h(PipelineStepTextBlock, { label: "reasoning preview", text: part?.textPreview, tone: "reasoning" }), + metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null, + ), + ); + } + + if (type === "tool") { + return h("details", { className: `pipeline-opencode-part tool ${tone}` }, + h("summary", null, + h("span", { className: `pipeline-tool-badge ${tone}` }, part?.tool || "tool"), + h("strong", null, compactSummary || title), + ), + h("div", { className: "pipeline-opencode-part-body" }, + facts.length > 0 ? h(PipelineChipRow, { items: facts }) : null, + inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null, + outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview, tone: tone === "failed" ? "failed" : "tool-output" }) : null, + metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null, + ), + ); + } + + return h("details", { className: `pipeline-opencode-part ${tone}` }, + h("summary", null, + h("span", { className: "pipeline-part-kind" }, partKindLabel(part)), + h("strong", null, title), + h(PipelineChipRow, { items: facts }), + ), + h("div", { className: "pipeline-opencode-part-body" }, + part?.textPreview ? h(PipelineStepTextBlock, { label: partKindLabel(part), text: part.textPreview }) : null, + inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null, + outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview }) : null, + metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null, + ), + ); +} + +function opencodeStepKey(step: any, fallbackIndex = 0): string { + const key = String(step?.messageId || step?.index || ""); + return key || `step-${fallbackIndex}`; +} + +function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) { + const parts = asArray(step?.parts); + const visibleParts = parts.filter((part: any) => !pipelineNoisePartType(part?.type)); + const tools = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "tool"); + const textParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "text"); + const reasoningParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "reasoning"); + const otherParts = visibleParts.filter((part: any) => !["tool", "text", "reasoning"].includes(String(part?.type || "").toLowerCase())); + const partTypes = isRecord(step?.partTypes) + ? Object.entries(step.partTypes) + .filter(([type, count]) => !pipelineNoisePartType(type) && Number(count) > 0) + .map(([type, count]) => `${type} ${count}`) + : []; + const roleTone = stepRoleTone(step?.role); + const statusTone = stepStatusTone(step); + const toolNames = uniqueStrings([...asArray(step?.tools), ...tools.map((part: any) => part?.tool)]).slice(0, 5); + const facts = [ + step?.finish ? `finish ${step.finish}` : "", + step?.durationMs !== undefined && step?.durationMs !== null ? `duration ${fmtDurationMs(step.durationMs)}` : "", + `parts ${visibleParts.length}`, + tools.length > 0 ? `tools ${tools.length}` : "", + ...toolNames.map((tool) => `tool ${tool}`), + ].filter(Boolean); + const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts); + + return h("details", { className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`, open: matched ? true : undefined, "data-testid": "pipeline-opencode-step" }, + h("summary", { className: "pipeline-opencode-step-summary", "data-testid": "pipeline-opencode-step-summary" }, + h(PipelineStepTimeSummary, { step, role: roleTone, matched }), + h(PipelineStepMessageCard, { rows: messageRows, compact: true, role: roleTone, matched }), + h(PipelineStepToolSummary, { tools }), + ), + h("div", { className: "pipeline-step-body", "data-testid": "pipeline-opencode-step-body" }, + h("div", { className: "pipeline-step-factbar" }, + h(StatusBadge, { status: statusTone }, statusTone), + h("span", { className: "pipeline-step-token-line" }, stepTokenLine(step?.tokens)), + h(PipelineChipRow, { items: facts }), + partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null, + ), + h(PipelineStepMessageCard, { rows: messageRows, role: roleTone, matched }), + tools.length > 0 ? h("div", { className: "pipeline-tool-call-strip" }, + h("div", { className: "pipeline-tool-call-title" }, h("b", null, "工具调用"), h("span", null, `${tools.length} calls`)), + h("div", { className: "pipeline-opencode-part-list" }, tools.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `tool-${index}`, part }))), + ) : null, + otherParts.length > 0 ? h("div", { className: "pipeline-opencode-part-list meta-list" }, otherParts.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `part-${index}`, part }))) : null, + ), + ); +} + +function structuredEventRecords(value: any): AnyRecord[] { + return asArray(value).flatMap((item: any) => { + if (isRecord(item)) return [item]; + const parsed = parseJsonLine(item); + return parsed ? [parsed] : []; + }); +} + +function eventKind(record: any): string { + return String(record?.event || record?.action || record?.requestedAction || record?.type || "").toLowerCase(); +} + +function eventTimestampIso(record: any): string { + return firstIso(record?.timestamp, record?.createdAt, record?.updatedAt, record?.startedAt, record?.finishedAt); +} + +function eventTimestampMs(record: any): number | null { + return timeMs(eventTimestampIso(record)); +} + +function attemptLabel(attempt: any): string { + return String(attempt?.attempt || attempt?.id || ""); +} + +function uniqueStrings(values: any[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const value of values) { + const text = String(value || ""); + if (!text || seen.has(text)) continue; + seen.add(text); + ordered.push(text); + } + return ordered; +} + +function sourceKindLabel(value: any): string { + switch (String(value || "").toLowerCase()) { + case "monitor": return "monitor"; + case "webui": return "webui"; + case "cli": return "cli"; + case "system": return "runner"; + default: return String(value || "--"); + } +} + +function controlActionValue(record: any): string { + return String(record?.requestedAction || record?.action || "").toLowerCase(); +} + +function controlActionLabel(record: any): string { + switch (controlActionValue(record)) { + case "audit-request": return "待审核"; + case "guide": return "引导"; + case "modify": return "修改"; + case "approve": return "审核通过"; + case "restart": return "重启"; + case "redo": return "重做"; + default: return String(record?.requestedAction || record?.action || "控制"); + } +} + +function eventLabel(record: any): string { + switch (eventKind(record)) { + case "initial-prompt-delivered": return "初始 prompt"; + case "append-prompt-delivered": return "追加 prompt"; + case "append-prompt-queued": return "追加 prompt 已排队"; + case "monitor-prompt-delivered": return "Monitor prompt"; + case "monitor-audit-requested": return "待审核"; + case "monitor-audit-approved": return "审核通过"; + case "monitor-audit-intervened": return "审核被打断"; + case "control-command-queued": return `${controlActionLabel(record)} 已发起`; + case "control-command-applied": return `${controlActionLabel(record)} 已生效`; + case "control-command-ignored": return `${controlActionLabel(record)} 已忽略`; + default: return String(record?.event || record?.action || record?.requestedAction || "event"); + } +} + +function eventPreview(record: any): string { + return previewText(record?.promptPreview || record?.reasonPreview || record?.prompt || record?.reason || "", 240); +} + +function eventTextBlocks(record: any): AnyRecord[] { + const prompt = String(record?.prompt || ""); + const reason = String(record?.reason || record?.restartReason || ""); + const promptPreview = prompt ? "" : String(record?.promptPreview || ""); + const reasonPreview = reason ? "" : String(record?.reasonPreview || ""); + return [ + prompt || promptPreview ? { label: prompt ? "prompt" : "prompt preview", value: prompt || promptPreview } : null, + reason || reasonPreview ? { label: reason ? "reason" : "reason preview", value: reason || reasonPreview } : null, + asArray(record?.resetNodeIds).length > 0 ? { label: "reset nodes", value: asArray(record.resetNodeIds).join(", ") } : null, + asArray(record?.runningResetNodeIds).length > 0 ? { label: "interrupted running nodes", value: asArray(record.runningResetNodeIds).join(", ") } : null, + asArray(record?.interruptedProcedureRunIds).length > 0 ? { label: "interrupted procedures", value: asArray(record.interruptedProcedureRunIds).join(", ") } : null, + record?.interruptedProcedureRunId ? { label: "interrupted procedure", value: String(record.interruptedProcedureRunId) } : null, + ].filter(Boolean) as AnyRecord[]; +} + +function attemptWindow(attempt: any): AnyRecord { + const steps = opencodeSteps(attempt); + const started = steps.map((step: any) => timeMs(step?.createdAt)).filter((value): value is number => value !== null); + const finished = steps.map((step: any) => timeMs(step?.completedAt) ?? timeMs(step?.createdAt)).filter((value): value is number => value !== null); + const eventTimes = structuredEventRecords(attempt?.controlEventRecords).map((record) => eventTimestampMs(record)).filter((value): value is number => value !== null); + const outputTimes = asArray(attempt?.assistantOutputs).map((item: any) => timeMs(item?.updatedAt)).filter((value): value is number => value !== null); + const startMs = started[0] ?? eventTimes[0] ?? outputTimes[0] ?? null; + const endMs = finished.at(-1) ?? eventTimes.at(-1) ?? outputTimes.at(-1) ?? startMs; + return { startMs, endMs }; +} + +function nearestProcedureForNode(details: any, runId: string, nodeId: string, markerMs: number | null, procedureRunId = ""): AnyRecord | null { + const procedures = asArray(details?.procedureRuns).filter((procedure: any) => inferProcedureNodeId(procedure, runId) === nodeId); + if (procedures.length === 0) return null; + if (procedureRunId) { + const direct = procedures.find((procedure: any) => procedureRunIdOf(procedure) === procedureRunId); + if (direct) return direct; + } + if (markerMs === null) return procedures.at(-1) || null; + const containing = procedures.find((procedure: any) => { + const startMs = timeMs(procedureStartIso(procedure, details)); + const endMs = timeMs(procedureEndIso(procedure, details)) ?? startMs; + return startMs !== null && endMs !== null && markerMs >= startMs - 1000 && markerMs <= endMs + 1000; + }); + if (containing) return containing; + return procedures.slice().sort((left: any, right: any) => { + const leftStart = timeMs(procedureStartIso(left, details)) ?? markerMs; + const leftEnd = timeMs(procedureEndIso(left, details)) ?? leftStart; + const rightStart = timeMs(procedureStartIso(right, details)) ?? markerMs; + const rightEnd = timeMs(procedureEndIso(right, details)) ?? rightStart; + const leftDistance = Math.min(Math.abs(leftStart - markerMs), Math.abs(leftEnd - markerMs)); + const rightDistance = Math.min(Math.abs(rightStart - markerMs), Math.abs(rightEnd - markerMs)); + return leftDistance - rightDistance; + })[0] || null; +} + +function nearestAttemptForMarker(procedure: any, marker: any): AnyRecord | null { + const attempts = asArray(procedure?.attempts).filter(isRecord); + if (attempts.length === 0) return null; + const direct = String(marker?.attempt || ""); + if (direct) { + const matched = attempts.find((attempt: any) => attemptLabel(attempt) === direct); + if (matched) return matched; + } + const markerMs = Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null; + if (markerMs === null) return attempts.at(-1) || null; + const containing = attempts.find((attempt: any) => { + const window = attemptWindow(attempt); + return Number.isFinite(window.startMs) && Number.isFinite(window.endMs) && markerMs >= Number(window.startMs) - 1000 && markerMs <= Number(window.endMs) + 1000; + }); + if (containing) return containing; + return attempts.slice().sort((left: any, right: any) => { + const leftWindow = attemptWindow(left); + const rightWindow = attemptWindow(right); + const leftDistance = Math.min(Math.abs(Number(leftWindow.startMs ?? markerMs) - markerMs), Math.abs(Number(leftWindow.endMs ?? markerMs) - markerMs)); + const rightDistance = Math.min(Math.abs(Number(rightWindow.startMs ?? markerMs) - markerMs), Math.abs(Number(rightWindow.endMs ?? markerMs) - markerMs)); + return leftDistance - rightDistance; + })[0] || attempts.at(-1) || null; +} + +function nearestStepForMarker(attempt: any, markerMs: number | null): AnyRecord { + const steps = opencodeSteps(attempt); + if (steps.length === 0) return { step: null, stepIndex: -1, stepKey: "" }; + if (markerMs === null) { + const step = steps[0]; + return { step, stepIndex: 0, stepKey: opencodeStepKey(step, 0) }; + } + for (let index = 0; index < steps.length; index += 1) { + const step = steps[index]; + const startMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt); + const endMs = timeMs(step?.completedAt) ?? startMs; + if (startMs !== null && endMs !== null && markerMs >= startMs - 1000 && markerMs <= endMs + 1000) { + return { step, stepIndex: index, stepKey: opencodeStepKey(step, index) }; + } + } + const afterIndex = steps.findIndex((step: any) => { + const startMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt); + return startMs !== null && startMs >= markerMs; + }); + if (afterIndex >= 0) { + const step = steps[afterIndex]; + return { step, stepIndex: afterIndex, stepKey: opencodeStepKey(step, afterIndex) }; + } + const fallbackIndex = Math.max(0, steps.length - 1); + return { step: steps[fallbackIndex], stepIndex: fallbackIndex, stepKey: opencodeStepKey(steps[fallbackIndex], fallbackIndex) }; +} + +function pipelineSelectionContext(runDetails: any, selection: any): AnyRecord { + const runId = String(selection?.runId || runDetails?.runId || ""); + if (String(selection?.mode || "") === "interval") { + const interval = selection?.interval || {}; + const procedure = findProcedureRun(runDetails, interval) || interval.raw || {}; + return { + mode: "interval", + runId, + interval, + marker: null, + nodeId: String(interval?.nodeId || inferProcedureNodeId(procedure, runId) || ""), + procedure, + attempt: null, + matchedStep: null, + matchedStepIndex: -1, + matchedStepKey: "", + }; + } + const marker = isRecord(selection?.marker) ? selection.marker : {}; + const markerMs = Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null; + const nodeId = String(marker?.nodeId || ""); + const procedure = nodeId ? nearestProcedureForNode(runDetails, runId, nodeId, markerMs, String(marker?.procedureRunId || "")) : null; + const attempt = procedure ? nearestAttemptForMarker(procedure, marker) : null; + const matched = attempt ? nearestStepForMarker(attempt, markerMs) : { step: null, stepIndex: -1, stepKey: "" }; + return { + mode: "event", + runId, + interval: null, + marker, + nodeId, + procedure, + attempt, + matchedStep: matched.step, + matchedStepIndex: matched.stepIndex, + matchedStepKey: matched.stepKey, + }; +} + +function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedAttemptId = "" }: AnyRecord) { const attempts = asArray(procedure?.attempts); - const status = statusValue(procedure?.status || interval.status); + if (attempts.length === 0) return h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" }); + return 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); + const sessionFacts = pipelineSessionFacts(steps, sessionIds); + const currentAttemptId = attemptLabel(attempt) || `attempt-${attemptIndex + 1}`; + const failedToolCount = steps.reduce((sum, step: any) => sum + asArray(step?.parts).filter((part: any) => + String(part?.type || "").toLowerCase() === "tool" && partStatusTone(part) === "failed").length, 0); + return h("article", { key: currentAttemptId, className: `pipeline-attempt-card ${matchedAttemptId === currentAttemptId ? "matched" : ""}` }, + h("div", { className: "pipeline-attempt-head" }, + h("div", null, + h("strong", null, currentAttemptId), + h("span", null, messages.source || "opencode"), + ), + h("div", { className: "pipeline-attempt-badges" }, + h("span", null, `${steps.length} steps`), + h("span", null, `${messages.toolCallCount ?? "--"} tools`), + failedToolCount > 0 ? h("span", { className: "danger" }, `${failedToolCount} failed`) : null, + ), + ), + 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("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" }, + h("div", { className: "pipeline-opencode-timeline-head" }, + h("div", null, + h("b", null, "OpenCode Step Timeline"), + h("span", null, "step 外层只保留时间 / 消息 / 工具调用;agent 与 model 聚合到 session 头部,统计信息默认折叠。"), + ), + h("div", { className: "pipeline-opencode-session-head", "data-testid": "pipeline-step-timeline-session" }, + h("span", null, `${steps.length} steps / ${sessionIds.length} sessions`), + sessionFacts.length > 0 ? h(PipelineChipRow, { items: sessionFacts }) : null, + ), + ), + h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => { + const stepKey = opencodeStepKey(step, stepIndex); + return h(PipelineOpenCodeStep, { key: stepKey, step, matched: matchedStepKey === stepKey }); + })), + ), + ); + }); +} + +function pipelineGanttDetailPayload(baseDetails: any, nodeDetails: any, runId: string, nodeId: string): any { + if (!isRecord(nodeDetails) || String(nodeDetails.runId || "") !== runId || String(nodeDetails.nodeId || "") !== nodeId) return baseDetails; + const nodeProcedures = asArray(nodeDetails.procedureRuns); + const base = isRecord(baseDetails) ? baseDetails : {}; + return { + ...base, + ...nodeDetails, + // Keep run-level evidence unless the node endpoint has a more focused copy. + controlCommands: asArray(nodeDetails.controlCommands).length > 0 ? nodeDetails.controlCommands : base.controlCommands, + controlEvents: asArray(nodeDetails.controlEvents).length > 0 ? nodeDetails.controlEvents : base.controlEvents, + procedureRuns: nodeProcedures.length > 0 ? nodeProcedures : base.procedureRuns, + }; +} + +function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, onRaw }: AnyRecord) { + if (!selection?.mode) { + return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" }, + h(EmptyState, { title: "选择一条执行线或一个控制点", text: "点击甘特图中的 node 执行线、prompt 点或控制点,在这里查看结构化过程和 OpenCode step。" }), + ); + } + const runId = String(selection?.runId || ""); + const selectedNodeId = String(selection?.interval?.nodeId || selection?.marker?.nodeId || ""); + const baseDetails = runDetails?.runId === runId ? runDetails.details : null; + const details = pipelineGanttDetailPayload(baseDetails, nodeDetails, runId, selectedNodeId); + const loading = (String(runDetails?.runId || "") !== runId || Boolean(runDetails?.loading)) && !details; + const error = String(runDetails?.runId || "") === runId ? String(runDetails?.error || "") : ""; + const context = details ? pipelineSelectionContext(details, selection) : null; + const interval = context?.interval || selection?.interval || null; + const marker = context?.marker || selection?.marker || null; + const procedure = context?.procedure || (details ? findProcedureRun(details, interval) : null) || interval?.raw || {}; + const matchedAttemptId = attemptLabel(context?.attempt); + const matchedStepKey = String(context?.matchedStepKey || ""); + const status = statusValue(procedure?.status || interval?.status || marker?.status || marker?.event); + const detailTitle = selection?.mode === "event" ? (marker?.label || eventLabel(marker?.raw || marker) || "event") : (context?.nodeId || interval?.nodeId || "node"); + const markerBlocks = marker ? eventTextBlocks(marker?.raw || marker) : []; + const eventFacts = marker ? [ + eventKind(marker?.raw || marker) ? `event ${eventKind(marker?.raw || marker)}` : "", + marker?.promptEvent ? `prompt ${marker.promptEvent}` : "", + marker?.action ? `action ${marker.action}` : "", + marker?.sourceKind ? `source ${sourceKindLabel(marker.sourceKind)}` : "", + marker?.sourceNodeId ? `from ${marker.sourceNodeId}` : "", + marker?.targetNodeId ? `to ${marker.targetNodeId}` : "", + marker?.snapReason ? `draw ${marker.snapReason}` : "", + ].filter(Boolean) : []; 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("span", { className: "panel-eyebrow" }, selection?.mode === "event" ? "Gantt Event Detail" : "Gantt Line Detail"), + h("h3", null, detailTitle), ), h(StatusBadge, { status }, status), ), + marker ? h("article", { className: "pipeline-event-card" }, + h("div", { className: "pipeline-event-card-head" }, + h("strong", null, marker?.label || eventLabel(marker?.raw || marker)), + h(PipelineChipRow, { items: eventFacts }), + ), + h(PipelineKvGrid, { items: [ + { label: "event time", value: fmtDate(marker?.timestampIso || marker?.timestamp || "--") }, + marker?.snapped ? { label: "drawn time", value: fmtDate(marker?.renderedTimestampIso || marker?.ms) } : null, + { label: "node", value: marker?.nodeId || "--" }, + { label: "procedure", value: marker?.procedureRunId || procedureRunIdOf(procedure) || "--" }, + { label: "attempt", value: marker?.attempt || matchedAttemptId || "--" }, + { label: "source kind", value: marker?.sourceKind ? sourceKindLabel(marker.sourceKind) : "--" }, + { label: "source node", value: marker?.sourceNodeId || "--" }, + { label: "target node", value: marker?.targetNodeId || "--" }, + { label: "command", value: marker?.commandId || marker?.eventId || "--" }, + marker?.snapReason ? { label: "placement", value: marker.snapReason } : null, + ] }), + markerBlocks.length > 0 ? h("div", { className: "pipeline-event-blocks" }, + markerBlocks.map((block: AnyRecord, index: number) => h("section", { key: `${block.label}-${index}`, className: "pipeline-event-text-block" }, + h("b", null, block.label), + h("p", null, block.value), + )), + ) : null, + eventPreview(marker?.raw || marker) ? h("p", { className: "pipeline-text-preview" }, eventPreview(marker?.raw || marker)) : null, + ) : null, 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) : "--" }, + { label: "epoch", value: runId || interval?.runId || "--" }, + { label: "node", value: context?.nodeId || interval?.nodeId || marker?.nodeId || "--" }, + { label: "procedure", value: interval?.procedureRunId || marker?.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: runDetails?.fetchedAt ? fmtClock(runDetails.fetchedAt) : "--" }, + context?.matchedStep ? { label: "matched step", value: `Step ${context.matchedStep.index ?? context.matchedStepIndex + 1}` } : null, ] }), - selection?.loading ? h("div", { className: "form-success" }, "正在抓取 node 执行过程...") : null, - selection?.error ? h("div", { className: "form-error" }, selection.error) : null, + loading ? h("div", { className: "form-success" }, "正在抓取 epoch 执行过程..." ) : null, + error ? h("div", { className: "form-error" }, 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, + h(RawButton, { title: `Procedure ${interval?.procedureRunId || marker?.procedureRunId || context?.nodeId || "node"}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }), + marker ? h(RawButton, { title: `Pipeline event ${marker?.id || marker?.commandId || marker?.eventId || context?.nodeId || "event"}`, data: marker?.raw || marker, onOpen: onRaw, testId: "raw-pipeline-gantt-event" }) : null, + details ? h(RawButton, { title: `Pipeline run ${runId || "--"}`, 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 }))), - ); - }), + !loading && !procedureRunIdOf(procedure) && !marker ? h(EmptyState, { title: "暂无过程详情", text: "当前选择还没有可匹配的 procedure 运行记录。" }) : null, + !loading && procedureRunIdOf(procedure) ? h(PipelineProcedureAttemptList, { procedure, matchedStepKey, matchedAttemptId }) : null, ); } @@ -663,6 +1488,28 @@ function pipelineRawNodeIds(value: any): string[] { return []; } +function pipelineMonitorInputs(node: any): AnyRecord { + return isRecord(node?.instanceInputs?.monitor) ? node.instanceInputs.monitor : {}; +} + +function pipelineNodeIsMonitor(node: any, component?: any): boolean { + if (String(node?.kind || "").toLowerCase() !== "procedure") return false; + const monitorInputs = pipelineMonitorInputs(node); + if (node?.instanceInputs?.monitorMode === true || monitorInputs.enabled === true) return true; + return String(component?.id || component?.config?.id || "").toLowerCase().includes("monitor"); +} + +function pipelineColumnsWithLeadingMonitors(columns: string[][], monitorNodeIds: string[]): string[][] { + if (monitorNodeIds.length === 0) return columns; + const monitorSet = new Set(monitorNodeIds); + const leading = monitorNodeIds.filter((nodeId) => columns.some((column) => column.includes(nodeId))); + if (leading.length === 0) return columns; + const remaining = columns + .map((column) => column.filter((nodeId) => !monitorSet.has(nodeId))) + .filter((column) => column.length > 0); + return [leading, ...remaining]; +} + function pipelineGraphColumns(pipeline: any, pipelineNodes: AnyRecord[], pipelineEdges: AnyRecord[]): string[][] { const rawBatches = pipelineGraphBatches(pipeline); const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0); @@ -696,6 +1543,48 @@ function pipelineGraphColumns(pipeline: any, pipelineNodes: AnyRecord[], pipelin return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).filter((batch) => batch.length > 0); } +function pipelineGraphNodeOrder(pipeline: any, pipelineNodes: AnyRecord[], pipelineEdges: AnyRecord[]): string[] { + const rawBatches = pipelineGraphBatches(pipeline); + const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0); + const ordered = explicit.length > 0 ? explicit.flatMap((batch) => batch) : (() => { + const ids = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean); + const idSet = new Set(ids); + const forwardEdges = pipelineEdges.filter((edge: AnyRecord) => String(edge?.edgeType || "").toLowerCase() !== "rework"); + const incoming = new Map(ids.map((id) => [id, 0])); + const outgoing = new Map(ids.map((id) => [id, [] as string[]])); + for (const edge of forwardEdges) { + const from = String(edge?.from || edge?.source || ""); + const to = String(edge?.to || edge?.target || ""); + if (!idSet.has(from) || !idSet.has(to)) continue; + outgoing.get(from)?.push(to); + incoming.set(to, (incoming.get(to) || 0) + 1); + } + const levels = new Map(); + const queue = ids.filter((id) => (incoming.get(id) || 0) === 0); + for (const id of queue) levels.set(id, 0); + while (queue.length > 0) { + const current = queue.shift()!; + const nextLevel = (levels.get(current) || 0) + 1; + for (const next of outgoing.get(current) || []) { + incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1)); + levels.set(next, Math.max(levels.get(next) || 0, nextLevel)); + if ((incoming.get(next) || 0) === 0) queue.push(next); + } + } + ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); }); + const maxLevel = Math.max(0, ...Array.from(levels.values())); + return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).flatMap((batch) => batch); + })(); + const seen = new Set(ordered); + for (const node of pipelineNodes) { + const nodeId = String(node?.id || ""); + if (!nodeId || seen.has(nodeId)) continue; + ordered.push(nodeId); + seen.add(nodeId); + } + return ordered; +} + function pipelineFlowEdgeKey(edge: AnyRecord): string { return `${edge.source}->${edge.target}-${edge.index}`; } @@ -705,7 +1594,11 @@ function pipelineFlowElements(pipeline: any, latestRun: any, components: any[]): const pipelineEdges = pipelineConfigEdges(pipeline); const componentByRef = pipelineComponentLookup(components); const nodeById: Map = new Map(pipelineNodes.map((node: any) => [String(node?.id || ""), node])); - const columns = pipelineGraphColumns(pipeline, pipelineNodes, pipelineEdges); + const monitorNodeIds = pipelineNodes + .filter((node: any) => pipelineNodeIsMonitor(node, pipelineNodeComponent(node, componentByRef))) + .map((node: any) => String(node?.id || "")) + .filter(Boolean); + const columns = pipelineColumnsWithLeadingMonitors(pipelineGraphColumns(pipeline, pipelineNodes, pipelineEdges), monitorNodeIds); const flowNodes: Node[] = []; const nodeLayout: Map = new Map(); const columnGap = 330; @@ -1115,7 +2008,7 @@ function procedureStartIso(procedure: any, run: any): string { } function procedureEndIso(procedure: any, run: any): string { - const status = String(procedure?.status || procedure?.artifact?.status || procedure?.status?.status || "").toLowerCase(); + const status = String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "").toLowerCase(); const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {}; const terminal = terminalStatus(status); return firstIso( @@ -1134,7 +2027,7 @@ function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] 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 status = String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "unknown").toLowerCase(); const startIso = procedureStartIso(procedure, run); const startMs = timeMs(startIso); if (startMs === null) return []; @@ -1170,9 +2063,36 @@ function pipelineRunTimeBounds(run: any, intervals: AnyRecord[]): AnyRecord { return { startMs, endMs, durationMs: endMs - startMs }; } -function pipelineGanttHeight(bounds: AnyRecord): number { +const pipelineGanttScaleBasePxPerMinute = 12; +const pipelineGanttScaleExponentBase = 20; +const pipelineDefaultGanttPxPerMinute = 100; +const pipelineDefaultGanttAutoHideIdle = false; + +function clampPipelineGanttScale(value: any): number { + const number = Number(value); + if (!Number.isFinite(number)) return 0; + return Math.max(0, Math.min(100, Math.round(number * 100) / 100)); +} + +function pipelineScaleValueForPxPerMinute(pxPerMinute: number): number { + const safePxPerMinute = Math.max(pipelineGanttScaleBasePxPerMinute, Number(pxPerMinute || pipelineGanttScaleBasePxPerMinute)); + const normalized = Math.log(safePxPerMinute / pipelineGanttScaleBasePxPerMinute) / Math.log(pipelineGanttScaleExponentBase); + return clampPipelineGanttScale(normalized * 100); +} + +const pipelineDefaultGanttScale = pipelineScaleValueForPxPerMinute(pipelineDefaultGanttPxPerMinute); + +function pipelineGanttScaleConfig(value: number): AnyRecord { + const normalized = clampPipelineGanttScale(value) / 100; + const pxPerMinute = pipelineGanttScaleBasePxPerMinute * Math.pow(pipelineGanttScaleExponentBase, normalized); + const label = normalized < 0.24 ? "全局" : normalized < 0.64 ? "均衡" : "细节"; + return { value: clampPipelineGanttScale(normalized * 100), pxPerMinute, label }; +} + +function pipelineGanttHeight(bounds: AnyRecord, scaleValue = pipelineDefaultGanttScale): number { const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000); - return Math.round(Math.max(440, Math.min(1800, minutes * 48))); + const scale = pipelineGanttScaleConfig(scaleValue); + return Math.round(Math.max(360, Math.min(7200, minutes * Number(scale.pxPerMinute || 48)))); } function pipelineGanttTicks(bounds: AnyRecord, count = 7): AnyRecord[] { @@ -1188,6 +2108,418 @@ function intervalOverlaps(interval: AnyRecord, range: AnyRecord): boolean { return Number(interval.startMs) <= Number(range.endMs) && Number(interval.endMs) >= Number(range.startMs); } +function markerPercent(ms: number, bounds: AnyRecord): number { + const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs)); + return Math.max(0, Math.min(100, ((ms - Number(bounds.startMs)) / duration) * 100)); +} + +function pipelineGanttBackendLayout(gantt: any): AnyRecord | null { + const layout = isRecord(gantt?.layout) ? gantt.layout : null; + return layout && Number.isFinite(Number(layout.chartHeight)) && Number.isFinite(Number(layout.startMs)) && Number.isFinite(Number(layout.endMs)) ? layout : null; +} + +function pipelineGanttNumber(value: any): number | null { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: number): number { + return (markerPercent(ms, bounds) / 100) * chartHeight; +} + +function pipelineGanttItemY(item: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null, fields: string[]): number { + if (backendLayout) { + for (const field of fields) { + const y = pipelineGanttNumber(item?.[field]); + if (y !== null) return y; + } + } + const ms = pipelineGanttNumber(item?.ms ?? item?.eventMs ?? item?.startMs); + return pipelineGanttFallbackY(ms ?? Number(bounds.startMs), bounds, chartHeight); +} + +function pipelineGanttIntervalTop(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { + return pipelineGanttItemY(interval, bounds, chartHeight, backendLayout, ["y1", "startY"]); +} + +function pipelineGanttIntervalBottom(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { + if (backendLayout) { + const endY = pipelineGanttNumber(interval?.y2 ?? interval?.endY); + if (endY !== null) return endY; + } + const endMs = pipelineGanttNumber(interval?.endMs) ?? Number(bounds.endMs); + return pipelineGanttFallbackY(endMs, bounds, chartHeight); +} + +function pipelineGanttIntervalHeight(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { + if (backendLayout) { + const height = pipelineGanttNumber(interval?.height); + if (height !== null) return Math.max(1, height); + } + return Math.max(10, pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) - pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout)); +} + +function pipelineGanttMarkerY(marker: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { + return pipelineGanttItemY(marker, bounds, chartHeight, backendLayout, ["y", "timeAxisY"]); +} + +function pipelineGanttTickY(tick: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number { + if (backendLayout) { + const y = pipelineGanttNumber(tick?.y); + if (y !== null) return y; + } + const percent = pipelineGanttNumber(tick?.percent); + if (percent !== null) return (percent / 100) * chartHeight; + const ms = pipelineGanttNumber(tick?.ms) ?? Number(bounds.startMs); + return pipelineGanttFallbackY(ms, bounds, chartHeight); +} + +function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord { + const kind = String(marker?.kind || "event"); + const event = String(marker?.event || ""); + const timestampIso = String(marker?.timestampIso || marker?.timestamp || isoFromMs(pipelineGanttNumber(marker?.eventMs ?? marker?.ms))); + const actionRecord = { ...marker, event, action: marker?.action || marker?.requestedAction }; + const label = marker?.label || (kind === "prompt" + ? pipelinePromptMarkerLabel(actionRecord, event) + : kind === "control-source" + ? `${controlActionLabel(actionRecord)} 发起` + : kind === "control-target" + ? controlTargetLabel(actionRecord, String(marker?.nodeId || marker?.targetNodeId || "")) + : eventLabel(actionRecord)); + const tone = marker?.tone || (kind === "prompt" + ? pipelinePromptMarkerTone(actionRecord, event) + : kind === "control-source" + ? controlSourceTone(actionRecord) + : controlTargetTone(actionRecord)); + const status = marker?.status || (event.startsWith("control-command-") ? event.replace(/^control-command-/u, "") : ""); + return { + ...marker, + kind, + tone, + status, + label, + timestampIso, + renderedTimestampIso: marker?.renderedTimestampIso || isoFromMs(pipelineGanttNumber(marker?.renderedMs ?? marker?.ms)), + snapped: Number(marker?.ms || 0) !== Number(marker?.eventMs ?? marker?.ms ?? 0), + raw: marker?.raw || marker, + }; +} + +function pipelineBackendGanttSignals(details: any): AnyRecord { + const gantt = isRecord(details?.gantt) ? details.gantt : {}; + return { + markers: asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker), + arrows: asArray(gantt.arrows).filter(isRecord), + }; +} + +function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string { + const kind = eventKind(record) || fallbackKind; + const promptEvent = String(record?.promptEvent || ""); + if (kind === "initial-prompt-delivered") return "initial"; + if (promptEvent === "node-audit-required" || promptEvent.startsWith("monitor-")) return "monitor"; + if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor"; + return "append"; +} + +function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string { + const kind = eventKind(record) || fallbackKind; + const promptEvent = String(record?.promptEvent || ""); + if (kind === "initial-prompt-delivered") return "初始 prompt"; + if (promptEvent === "node-audit-required") return "审核请求"; + if (promptEvent === "monitor-interval") return "Monitor interval"; + if (promptEvent === "batch-finished") return "批次完成"; + if (promptEvent === "monitor-start") return "Monitor start"; + if (promptEvent === "monitor-stop") return "Monitor stop"; + if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt"; + if (kind === "append-prompt-queued") return "追加 prompt 已排队"; + return "追加 prompt"; +} + +function controlEventPriority(record: any): number { + const kind = eventKind(record); + if (kind === "control-command-applied") return 3; + if (kind === "control-command-ignored") return 2; + if (kind === "control-command-queued") return 1; + return 0; +} + +function controlRecordGroupKey(record: any, index: number): string { + const commandId = String(record?.commandId || ""); + if (commandId) return `command:${commandId}`; + const timestamp = eventTimestampIso(record) || firstIso(record?.createdAt, record?.timestamp) || `index-${index}`; + return [ + "fallback", + timestamp, + String(record?.sourceKind || ""), + String(record?.sourceNodeId || ""), + String(record?.targetNodeId || ""), + controlActionValue(record), + ].join(":"); +} + +function controlTargetNodeIds(record: any): string[] { + return uniqueStrings([ + record?.targetNodeId, + ...asArray(record?.resetNodeIds), + ]); +} + +function controlTargetLabel(record: any, targetNodeId: string): string { + const action = controlActionLabel(record); + const kind = eventKind(record); + const primaryTarget = String(record?.targetNodeId || ""); + const isResetSideEffect = Boolean(primaryTarget) && targetNodeId !== primaryTarget; + if (kind === "control-command-applied") return isResetSideEffect ? `${action} 波及` : `${action} 生效`; + if (kind === "control-command-ignored") return `${action} 忽略`; + if (kind === "control-command-queued") return `${action} 已发起`; + return isResetSideEffect ? `${action} 波及` : action; +} + +function controlTargetTone(record: any): string { + const kind = eventKind(record); + if (kind === "control-command-ignored") return "ignored"; + const action = controlActionValue(record); + if (action === "restart" || action === "redo") return "restart"; + if (action === "modify") return "modify"; + if (action === "approve") return "approve"; + if (action === "guide") return "guide"; + return "pending"; +} + +function controlSourceTone(record: any): string { + const sourceKind = String(record?.sourceKind || "").toLowerCase(); + if (sourceKind === "monitor") return "monitor"; + if (sourceKind === "webui") return "webui"; + if (sourceKind === "cli") return "cli"; + return "system"; +} + +function controlTargetMarkerPlacement(intervals: AnyRecord[], nodeId: string, eventMs: number, record: any): AnyRecord { + const nodeIntervals = intervals + .filter((interval: AnyRecord) => String(interval.nodeId || "") === nodeId) + .sort((left: AnyRecord, right: AnyRecord) => Number(left.startMs) - Number(right.startMs)); + const containing = nodeIntervals.find((interval: AnyRecord) => eventMs >= Number(interval.startMs) - 1000 && eventMs <= Number(interval.endMs) + 1000); + if (containing) { + return { ms: eventMs, onInterval: true, snapReason: "inside-interval", procedureRunId: String(containing.procedureRunId || "") }; + } + const action = controlActionValue(record); + const previous = nodeIntervals.slice().reverse().find((interval: AnyRecord) => Number(interval.endMs) <= eventMs + 1000); + if (previous && action === "approve") { + return { ms: Number(previous.endMs), onInterval: true, snapReason: "previous-interval-end", procedureRunId: String(previous.procedureRunId || "") }; + } + const next = nodeIntervals.find((interval: AnyRecord) => Number(interval.startMs) >= eventMs - 1000); + if (next && ["guide", "modify", "restart", "redo"].includes(action)) { + return { ms: Number(next.startMs), onInterval: true, snapReason: "next-interval-start", procedureRunId: String(next.procedureRunId || "") }; + } + return { ms: eventMs, onInterval: false, snapReason: "event-time", procedureRunId: String(record?.procedureRunId || "") }; +} + +function pipelineControlArrowPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string { + const deltaX = targetX - sourceX; + const bend = Math.max(16, Math.min(42, Math.abs(deltaX) * 0.45 + 12)); + const sign = deltaX === 0 ? 1 : Math.sign(deltaX); + return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${targetX - sign * bend},${targetY} ${targetX},${targetY}`; +} + +function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord { + const runId = String(details?.runId || activeRun?.runId || ""); + const intervals = pipelineRunIntervals({ ...(isRecord(activeRun) ? activeRun : {}), ...(isRecord(details) ? details : {}), runId, procedureRuns: asArray(details?.procedureRuns).length > 0 ? details.procedureRuns : activeRun?.procedureRuns }, []); + const promptMarkers: AnyRecord[] = []; + const controlMarkers: AnyRecord[] = []; + const controlArrows: AnyRecord[] = []; + const markerIds = new Set(); + const sourceMarkerByCommand = new Map(); + + const addMarker = (marker: AnyRecord, bucket: AnyRecord[]) => { + if (!marker.nodeId || !Number.isFinite(Number(marker.ms))) return; + if (markerIds.has(marker.id)) return; + markerIds.add(marker.id); + bucket.push(marker); + }; + + for (const procedure of asArray(details?.procedureRuns)) { + const nodeId = inferProcedureNodeId(procedure, runId); + const procedureRunId = procedureRunIdOf(procedure); + if (!nodeId) continue; + for (const attempt of asArray(procedure?.attempts)) { + const attemptId = attemptLabel(attempt); + const deliveredIds = new Set(); + const deliveredFallbackKeys = new Set(); + for (const record of structuredEventRecords(attempt?.controlEventRecords)) { + const kind = eventKind(record); + if (!["initial-prompt-delivered", "append-prompt-delivered", "monitor-prompt-delivered"].includes(kind)) continue; + const timestampIso = eventTimestampIso(record); + const ms = timeMs(timestampIso); + if (ms === null) continue; + const eventId = String(record?.eventId || ""); + if (eventId) deliveredIds.add(eventId); + deliveredFallbackKeys.add(`${kind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`); + addMarker({ + id: `prompt:${eventId || `${procedureRunId}:${attemptId}:${kind}:${ms}`}`, + runId, + nodeId, + procedureRunId, + attempt: attemptId, + kind: "prompt", + tone: pipelinePromptMarkerTone(record, kind), + status: "delivered", + label: pipelinePromptMarkerLabel(record, kind), + ms, + timestampIso, + sourceKind: String(record?.sourceKind || ""), + sourceNodeId: String(record?.sourceNodeId || ""), + targetNodeId: nodeId, + action: "", + eventId, + commandId: String(record?.commandId || ""), + raw: record, + }, promptMarkers); + } + const fallbackSources = [ + { records: structuredEventRecords(attempt?.controlPromptRecords), fallbackKind: "append-prompt-queued" }, + { records: structuredEventRecords(attempt?.monitorPromptRecords), fallbackKind: "monitor-prompt-queued" }, + ]; + for (const source of fallbackSources) { + for (const record of source.records) { + const timestampIso = eventTimestampIso(record); + const ms = timeMs(timestampIso); + if (ms === null) continue; + const eventId = String(record?.eventId || ""); + if (eventId && deliveredIds.has(eventId)) continue; + const deliveredKind = source.fallbackKind === "monitor-prompt-queued" ? "monitor-prompt-delivered" : "append-prompt-delivered"; + const fallbackKey = `${deliveredKind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`; + if (deliveredFallbackKeys.has(fallbackKey)) continue; + addMarker({ + id: `prompt-fallback:${eventId || `${procedureRunId}:${attemptId}:${source.fallbackKind}:${ms}`}`, + runId, + nodeId, + procedureRunId, + attempt: attemptId, + kind: "prompt", + tone: pipelinePromptMarkerTone(record, source.fallbackKind), + status: "queued", + label: pipelinePromptMarkerLabel(record, source.fallbackKind), + ms, + timestampIso, + sourceKind: String(record?.sourceKind || ""), + sourceNodeId: String(record?.sourceNodeId || ""), + targetNodeId: nodeId, + action: "", + eventId, + commandId: String(record?.commandId || ""), + raw: record, + }, promptMarkers); + } + } + } + } + + const grouped = new Map(); + structuredEventRecords(details?.controlEvents).forEach((record: AnyRecord, index: number) => { + const key = controlRecordGroupKey(record, index); + const group = grouped.get(key) || { key, events: [], commands: [] }; + group.events.push(record); + grouped.set(key, group); + }); + asArray(details?.controlCommands).filter(isRecord).forEach((record: AnyRecord, index: number) => { + const key = controlRecordGroupKey(record, index); + const group = grouped.get(key) || { key, events: [], commands: [] }; + group.commands.push(record); + grouped.set(key, group); + }); + + for (const group of grouped.values()) { + const events = asArray(group.events).slice().sort((left: any, right: any) => controlEventPriority(right) - controlEventPriority(left)); + const commands = asArray(group.commands); + const queuedRecord = asArray(group.events).find((record: any) => eventKind(record) === "control-command-queued") || commands[0] || null; + const outcomeRecord = events[0] || commands[0] || queuedRecord; + if (!queuedRecord && !outcomeRecord) continue; + const sourceNodeId = String(queuedRecord?.sourceNodeId || outcomeRecord?.sourceNodeId || ""); + const sourceKind = String(queuedRecord?.sourceKind || outcomeRecord?.sourceKind || ""); + const sourceTimestampIso = eventTimestampIso(queuedRecord) || eventTimestampIso(outcomeRecord) || firstIso(queuedRecord?.createdAt, outcomeRecord?.createdAt); + const sourceMs = timeMs(sourceTimestampIso); + const commandId = String(outcomeRecord?.commandId || queuedRecord?.commandId || group.key); + const outcomeStatus = (eventKind(outcomeRecord) || "control-command-queued").replace(/^control-command-/u, ""); + let sourceMarkerId = ""; + if (sourceNodeId && sourceMs !== null) { + sourceMarkerId = `control-source:${commandId}:${sourceNodeId}`; + sourceMarkerByCommand.set(commandId, sourceMarkerId); + addMarker({ + id: sourceMarkerId, + runId, + nodeId: sourceNodeId, + procedureRunId: String(queuedRecord?.procedureRunId || outcomeRecord?.procedureRunId || ""), + attempt: "", + kind: "control-source", + tone: controlSourceTone(queuedRecord || outcomeRecord), + status: outcomeStatus, + label: `${controlActionLabel(queuedRecord || outcomeRecord)} 发起`, + ms: sourceMs, + timestampIso: sourceTimestampIso, + action: controlActionValue(queuedRecord || outcomeRecord), + sourceKind, + sourceNodeId, + targetNodeId: String(outcomeRecord?.targetNodeId || queuedRecord?.targetNodeId || ""), + commandId, + raw: queuedRecord || outcomeRecord, + }, controlMarkers); + } + const targetRecord = outcomeRecord || queuedRecord; + const targetTimestampIso = eventTimestampIso(targetRecord) || sourceTimestampIso; + const targetMs = timeMs(targetTimestampIso); + if (targetMs === null) continue; + const targetIds = controlTargetNodeIds(targetRecord); + for (const targetNodeId of targetIds) { + const placement = controlTargetMarkerPlacement(intervals, targetNodeId, targetMs, targetRecord); + const targetMarkerId = `control-target:${commandId}:${targetNodeId}`; + addMarker({ + id: targetMarkerId, + runId, + nodeId: targetNodeId, + procedureRunId: placement.procedureRunId, + attempt: "", + kind: "control-target", + tone: controlTargetTone(targetRecord), + status: outcomeStatus, + label: controlTargetLabel(targetRecord, targetNodeId), + ms: placement.ms, + eventMs: targetMs, + onInterval: placement.onInterval, + snapReason: placement.snapReason, + snapped: Number(placement.ms) !== targetMs, + timestampIso: targetTimestampIso, + renderedTimestampIso: isoFromMs(Number(placement.ms)), + action: controlActionValue(targetRecord), + sourceKind, + sourceNodeId, + targetNodeId, + commandId, + raw: targetRecord, + }, controlMarkers); + if (sourceMarkerId && sourceNodeId && sourceNodeId !== targetNodeId) { + controlArrows.push({ + id: `control-arrow:${commandId}:${sourceNodeId}:${targetNodeId}`, + commandId, + sourceNodeId, + targetNodeId, + sourceMarkerId, + targetMarkerId, + sourceKind, + action: controlActionValue(targetRecord), + status: outcomeStatus, + }); + } + } + } + + const markers = [...promptMarkers, ...controlMarkers].sort((left: AnyRecord, right: AnyRecord) => + Number(left.ms) - Number(right.ms) + || String(left.nodeId).localeCompare(String(right.nodeId)) + || String(left.id).localeCompare(String(right.id))); + return { markers, arrows: controlArrows, sourceMarkerByCommand }; +} + function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) { if (!details) { return h("span", { className: "muted" }, "点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。"); @@ -1346,40 +2678,89 @@ function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) { ); } -function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, onRunChange, onIntervalSelect, selection, onRaw }: AnyRecord) { - const [autoHideIdle, setAutoHideIdle] = useState(true); - const [visibleRange, setVisibleRange] = useState({ startMs: 0, endMs: 0 }); +function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) { + const [autoHideIdle, setAutoHideIdle] = useState(pipelineDefaultGanttAutoHideIdle); + const [visibleRange, setVisibleRange] = useState({ startY: 0, endY: 0, 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 timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale); + const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes); + const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null; + const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null; + const backendLayout = pipelineGanttBackendLayout(backendGantt); + const hasBackendLayout = Boolean(backendLayout); + const intervals = hasBackendLayout + ? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId })) + : fallbackIntervals; + const bounds = hasBackendLayout ? { + startMs: Number(backendLayout?.startMs), + endMs: Number(backendLayout?.endMs), + durationMs: Math.max(1, Number(backendLayout?.durationMs ?? Number(backendLayout?.endMs) - Number(backendLayout?.startMs))), + } : pipelineRunTimeBounds(activeRun, intervals); + const backendScaleValue = hasBackendLayout ? Number(backendLayout?.scale ?? timeScale) : timeScale; + const fallbackScaleConfig = pipelineGanttScaleConfig(backendScaleValue); + const timeScaleConfig = hasBackendLayout ? { + ...fallbackScaleConfig, + pxPerMinute: Number(backendLayout?.pxPerMinute ?? fallbackScaleConfig.pxPerMinute), + } : pipelineGanttScaleConfig(timeScale); + const chartHeight = hasBackendLayout ? Math.round(Number(backendLayout?.chartHeight || 360)) : pipelineGanttHeight(bounds, timeScale); + const ganttSignals = hasBackendLayout ? pipelineBackendGanttSignals(runDetailPayload) : runDetailPayload ? pipelineRunGanttSignals(runDetailPayload, activeRun) : { markers: [], arrows: [] }; + const allMarkers = asArray(ganttSignals.markers); + const graphNodeOrder = pipelineGraphNodeOrder(activePipeline, pipelineNodes, Array.isArray(pipelineEdges) ? pipelineEdges : []); const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean); + const layoutNodeIds = hasBackendLayout ? asArray(backendLayout?.nodeOrder).map((nodeId: any) => String(nodeId || "")).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 markerNodeIds = allMarkers.map((marker: AnyRecord) => String(marker.nodeId || "")).filter(Boolean); + const allNodeIds = Array.from(new Set([ + ...graphNodeOrder, + ...layoutNodeIds, + ...configuredNodeIds, + ...intervalNodeIds, + ...markerNodeIds, + ])); + const defaultVisibleRange = { startY: 0, endY: chartHeight, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) }; + const safeRange = Number(visibleRange?.endY || 0) > 0 ? visibleRange : defaultVisibleRange; + const intervalIsVisible = (interval: AnyRecord): boolean => { + if (hasBackendLayout) { + return pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout) <= Number(safeRange.endY) + && pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) >= Number(safeRange.startY); + } + return intervalOverlaps(interval, safeRange); + }; + const markerIsVisible = (marker: AnyRecord): boolean => { + if (hasBackendLayout) { + const y = pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout); + return y >= Number(safeRange.startY) && y <= Number(safeRange.endY); + } + return Number(marker.ms) >= Number(safeRange.startMs) && Number(marker.ms) <= Number(safeRange.endMs); + }; + const activeNodeIds = new Set(allNodeIds.filter((nodeId) => + intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalIsVisible(interval)) + || allMarkers.some((marker: AnyRecord) => marker.nodeId === nodeId && markerIsVisible(marker)))); 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 gridTemplateColumns = `${pipelineGanttTimeAxisWidth}px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => `${pipelineGanttNodeColumnWidth}px`).join(" ") : "minmax(160px, 1fr)"}`; + const backendTicks = hasBackendLayout ? asArray(backendLayout?.ticks).filter(isRecord) : []; + const ticks = backendTicks.length > 0 ? backendTicks : pipelineGanttTicks(bounds, Math.max(5, Math.min(18, Math.round(chartHeight / 150)))); + const selectedIntervalKey = String(selection?.mode === "interval" ? selection?.interval?.procedureRunId || "" : ""); + const selectedMarkerKey = String(selection?.mode === "event" ? selection?.marker?.id || "" : ""); const updateVisibleRange = () => { const element = viewportRef.current as HTMLElement | null; if (!element) { - setVisibleRange({ startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) }); + setVisibleRange(defaultVisibleRange); 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, - }); + const topY = Math.max(0, element.scrollTop - pipelineGanttHeaderHeight); + const visiblePx = Math.max(120, element.clientHeight - pipelineGanttHeaderHeight); + const bottomY = Math.min(chartHeight, topY + visiblePx); + const nextRange: AnyRecord = { startY: topY, endY: bottomY, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) }; + if (!hasBackendLayout) { + const startRatio = Math.max(0, Math.min(1, topY / chartHeight)); + const endRatio = Math.max(startRatio, Math.min(1, bottomY / chartHeight)); + const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs)); + nextRange.startMs = Number(bounds.startMs) + duration * startRatio; + nextRange.endMs = Number(bounds.startMs) + duration * endRatio; + } + setVisibleRange(nextRange); }; useEffect(() => { const element = viewportRef.current as HTMLElement | null; @@ -1391,8 +2772,20 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, element?.removeEventListener("scroll", updateVisibleRange); window.removeEventListener("resize", updateVisibleRange); }; - }, [activeRunId, bounds.startMs, bounds.endMs, chartHeight]); + }, [activeRunId, bounds.startMs, bounds.endMs, chartHeight, hasBackendLayout]); const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length); + const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) => + visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id))); + const markerById = new Map(allMarkers.map((marker: AnyRecord) => [String(marker.id), marker])); + const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => + visibleMarkerIds.has(String(arrow.sourceMarkerId || "")) && visibleMarkerIds.has(String(arrow.targetMarkerId || ""))); + const boardMinWidth = pipelineGanttTimeAxisWidth + Math.max(1, visibleNodeIds.length) * pipelineGanttNodeColumnWidth; + const setScaleFromSlider = (event: any) => { + const nextScale = clampPipelineGanttScale(event.target.value); + if (typeof onGanttScaleChange === "function") onGanttScaleChange(nextScale); + window.setTimeout(updateVisibleRange, 0); + }; + const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null; return h(Panel, { title: "Epoch 甘特图", eyebrow: `${activePipeline?.id || "pipeline"} / ${epochs.length} epochs`, @@ -1407,6 +2800,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, h("label", { className: "pipeline-gantt-toggle" }, h("input", { type: "checkbox", + "data-testid": "pipeline-gantt-auto-hide-idle", checked: autoHideIdle, onChange: (event: any) => { setAutoHideIdle(Boolean(event.target.checked)); @@ -1415,6 +2809,23 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, }), h("span", null, "自动隐藏空闲列"), ), + h("label", { className: "pipeline-gantt-scale" }, + h("span", null, + h("b", null, "时间尺度"), + h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`), + ), + h("input", { + type: "range", + min: 0, + max: 100, + step: 0.01, + value: timeScale, + onChange: setScaleFromSlider, + "aria-label": "调整甘特图时间尺度", + "data-testid": "pipeline-gantt-time-scale", + }), + h("small", null, h("span", null, "全局"), h("span", null, "细节")), + ), activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null, ), }, @@ -1426,36 +2837,87 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, 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, `scale ${timeScaleConfig.label} / ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`), + h("span", null, `layout ${hasBackendLayout ? "backend-y" : "fallback"}`), + diagnostics ? h("span", null, `align ${diagnostics.timeAxisAlignmentOk === false ? "check" : "ok"}`) : null, h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`), + runDetailPayload ? h("span", null, `markers ${allMarkers.length}`) : null, 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-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "" }, + h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${boardMinWidth}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 }))), + visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId, "data-testid": "pipeline-gantt-head-node", "data-node-id": 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)}`), - )), + ticks.map((tick: AnyRecord) => { + const tickY = pipelineGanttTickY(tick, bounds, chartHeight, backendLayout); + return h("div", { key: `tick-${tick.ms}-${tickY}`, className: "pipeline-gantt-tick", style: { top: `${tickY}px` } }, + h("b", null, fmtDate(tick.ms)), + h("span", null, `+${fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs)))}`), + ); + }), ), + visibleNodeIds.length > 0 ? h("svg", { + className: "pipeline-gantt-arrow-layer", + width: visibleNodeIds.length * pipelineGanttNodeColumnWidth, + height: chartHeight, + viewBox: `0 0 ${visibleNodeIds.length * pipelineGanttNodeColumnWidth} ${chartHeight}`, + style: { + left: `${pipelineGanttTimeAxisWidth}px`, + top: `${pipelineGanttHeaderHeight}px`, + width: `${visibleNodeIds.length * pipelineGanttNodeColumnWidth}px`, + height: `${chartHeight}px`, + }, + "aria-hidden": "true", + }, + h("defs", null, + h("marker", { + id: "pipeline-gantt-arrowhead", + viewBox: "0 0 10 10", + refX: 9, + refY: 5, + markerWidth: 6, + markerHeight: 6, + orient: "auto-start-reverse", + }, + h("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "context-stroke" }), + ), + ), + visibleArrows.map((arrow: AnyRecord) => { + const sourceMarker = markerById.get(String(arrow.sourceMarkerId || "")); + const targetMarker = markerById.get(String(arrow.targetMarkerId || "")); + if (!sourceMarker || !targetMarker) return null; + const sourceIndex = visibleNodeIds.indexOf(String(sourceMarker.nodeId || "")); + const targetIndex = visibleNodeIds.indexOf(String(targetMarker.nodeId || "")); + if (sourceIndex < 0 || targetIndex < 0) return null; + const sourceX = sourceIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2; + const targetX = targetIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2; + const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1) ?? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout); + const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout); + return h("path", { + key: arrow.id, + className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`, + d: pipelineControlArrowPath(sourceX, sourceY, targetX, targetY), + markerEnd: "url(#pipeline-gantt-arrowhead)", + }); + }), + ) : null, 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); + const nodeMarkers = allMarkers.filter((marker: AnyRecord) => String(marker.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 top = pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout); + const height = pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout); 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)}`, + style: { top: `${top}px`, height: `${height}px` }, + title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt || interval.startMs)} -> ${fmtDate(interval.finishedAt || interval.endMs)}`, onClick: () => onIntervalSelect(interval), "data-testid": "pipeline-gantt-line", }, @@ -1463,12 +2925,21 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, h("span", null, fmtDurationMs(interval.durationMs)), ); }), + nodeMarkers.map((marker: AnyRecord) => h("button", { + key: marker.id, + type: "button", + className: `pipeline-gantt-marker ${marker.kind} ${marker.tone || ""} ${marker.status || ""} ${selectedMarkerKey === String(marker.id) ? "selected" : ""}`, + style: { top: `${pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout)}px` }, + title: `${marker.label || "event"} / ${fmtDate(marker.timestampIso || marker.timestamp || marker.ms)}`, + onClick: () => onMarkerSelect(marker), + "data-testid": marker.kind === "prompt" ? "pipeline-gantt-prompt-marker" : "pipeline-gantt-control-marker", + })), ); }), ), ), ), - h(PipelineGanttDetailPanel, { selection, onRaw }), + h(PipelineGanttDetailPanel, { selection, runDetails, nodeDetails, onRaw }), ), ), ); @@ -1484,10 +2955,31 @@ function pipelineNodeControlState(): AnyRecord { fetchedAt: null, appendPrompt: "", guidePrompt: "", + modifyPrompt: "", + approveReason: "", redoReason: "", }; } +function pipelineGanttSelectionState(): AnyRecord { + return { + mode: "", + runId: "", + interval: null, + marker: null, + }; +} + +function pipelineRunDetailsState(): AnyRecord { + return { + runId: "", + loading: false, + error: "", + details: null, + fetchedAt: null, + }; +} + function pipelineProxyPath(apiBaseUrl: string, path: string): string { return `${apiBaseUrl}/microservices/pipeline/proxy${path}`; } @@ -1531,7 +3023,7 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu h("span", null, h("b", null, "attempts"), String(selectedNodeRuntime?.attempts ?? "--")), h("span", null, h("b", null, "updated"), fmtDate(activeRun?.updatedAt)), ), - !selectedNodeId ? h(EmptyState, { title: "未选择 node", text: "点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导或重做。" }) : null, + !selectedNodeId ? h(EmptyState, { title: "未选择 node", text: "点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。" }) : null, control.error ? h("div", { className: "form-error wide" }, control.error) : null, control.message ? h("div", { className: "form-success wide" }, control.message) : null, h("div", { className: "pipeline-control-actions" }, @@ -1557,6 +3049,28 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu "data-testid": "pipeline-node-guide-button", }, control.actionLoading === "guide" ? "下发中" : "下发 guide"), ), + h("label", null, + h("span", null, "完成后增量修改 prompt"), + h("textarea", { value: control.modifyPrompt, onChange: updateText("modifyPrompt"), placeholder: "在该 node 已完成结果基础上追加修改要求;runner 会重跑目标 node,并保留同 node 既有 OA 输出作为上下文。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-modify-input" }), + h("button", { + type: "button", + className: "ghost-btn compact", + disabled: disabled || !String(control.modifyPrompt || "").trim(), + onClick: () => onAction("modify"), + "data-testid": "pipeline-node-modify-button", + }, control.actionLoading === "modify" ? "排队中" : "增量修改 node"), + ), + h("label", null, + h("span", null, "Monitor 审核通过原因"), + h("textarea", { value: control.approveReason, onChange: updateText("approveReason"), placeholder: "当流程配置开启 monitor 审核时,记录审核通过原因并释放后续 node。", rows: 3, disabled: !selectedNodeId, "data-testid": "pipeline-node-approve-input" }), + h("button", { + type: "button", + className: "primary-btn compact", + disabled: disabled || !String(control.approveReason || "").trim(), + onClick: () => onAction("approve"), + "data-testid": "pipeline-node-approve-button", + }, control.actionLoading === "approve" ? "提交中" : "审核通过"), + ), h("label", null, h("span", null, "重做 / restart 原因"), h("textarea", { value: control.redoReason, onChange: updateText("redoReason"), placeholder: "说明为什么需要重做;runner 会重置目标 node 以及非 rework 下游 node。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-redo-input" }), @@ -1589,25 +3103,44 @@ 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 }); + const [ganttSelection, setGanttSelection] = useState(pipelineGanttSelectionState()); + const [runDetails, setRunDetails] = useState(pipelineRunDetailsState()); + const [ganttScale, setGanttScale] = useState(pipelineDefaultGanttScale); + const loadRequestRef = useRef(0); + const loadInFlightRef = useRef(false); + const runDetailsRequestRef = useRef(0); + const runDetailsInFlightRef = useRef(""); - async function load(): Promise { + async function load(options: AnyRecord = {}): Promise { + const silent = options.silent === true; if (!service) return; - setState((prev: any) => ({ ...prev, loading: true, error: "" })); + if (loadInFlightRef.current) return; + loadInFlightRef.current = true; + const requestId = loadRequestRef.current + 1; + loadRequestRef.current = requestId; + if (!silent) setState((prev: any) => ({ ...prev, loading: true, error: "" })); try { - const [health, snapshot] = await Promise.all([ - requestJson(`${apiBaseUrl}/microservices/pipeline/health`), - requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:80,runs:3`), - ]); + const snapshotQuery = `__unideskArrayLimit=registry.components:80,runs:${pipelineSnapshotRunLimit}&_=${Date.now()}`; + const snapshot = await requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }); + if (requestId !== loadRequestRef.current) return; + const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-webui snapshot" }; setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() }); } catch (err) { + if (requestId !== loadRequestRef.current) return; setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") })); + } finally { + loadInFlightRef.current = false; } } useEffect(() => { load(); - }, [service?.id, service?.runtime?.providerStatus]); + if (!service) return undefined; + const timer = window.setInterval(() => { + load({ silent: true }); + }, pipelineAutoRefreshMs); + return () => window.clearInterval(timer); + }, [service?.id, service?.runtime?.providerStatus, apiBaseUrl]); const runtime = microserviceRuntime(service); const repository = microserviceRepository(service); @@ -1653,9 +3186,60 @@ 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 }); + setGanttSelection(pipelineGanttSelectionState()); }, [selectedNodeId, pipelineNodeIds]); + async function fetchRunDetails(runId = activeRunId, options: AnyRecord = {}): Promise { + if (!runId) { + setRunDetails(pipelineRunDetailsState()); + return; + } + const scale = clampPipelineGanttScale(options.scale ?? ganttScale ?? pipelineDefaultGanttScale); + const requestKey = `${runId}:${scale}`; + if (runDetailsInFlightRef.current === requestKey) return; + runDetailsInFlightRef.current = requestKey; + const silent = options.silent === true; + const requestId = runDetailsRequestRef.current + 1; + runDetailsRequestRef.current = requestId; + setRunDetails((prev: AnyRecord) => ({ + runId, + scale, + loading: !silent || String(prev.runId || "") !== runId || !prev.details, + error: "", + details: silent && prev.runId === runId ? prev.details : prev.runId === runId ? prev.details : null, + fetchedAt: prev.runId === runId ? prev.fetchedAt : null, + })); + try { + const details = await requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }); + if (requestId !== runDetailsRequestRef.current) return; + setRunDetails({ runId, scale, loading: false, error: "", details, fetchedAt: new Date() }); + } catch (err) { + if (requestId !== runDetailsRequestRef.current) return; + setRunDetails((prev: AnyRecord) => ({ + runId, + scale, + loading: false, + error: errorMessage(err, "抓取 epoch 执行过程失败"), + details: prev.runId === runId ? prev.details : null, + fetchedAt: prev.runId === runId ? prev.fetchedAt : null, + })); + } finally { + if (runDetailsInFlightRef.current === requestKey) runDetailsInFlightRef.current = ""; + } + } + + useEffect(() => { + if (!activeRunId) { + setRunDetails(pipelineRunDetailsState()); + return undefined; + } + void fetchRunDetails(activeRunId); + const timer = window.setInterval(() => { + void fetchRunDetails(activeRunId, { silent: true }); + }, pipelineAutoRefreshMs); + return () => window.clearInterval(timer); + }, [activeRunId, apiBaseUrl, ganttScale]); + async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise { if (!runId || !nodeId) { setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" })); @@ -1673,39 +3257,49 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR async function selectGanttInterval(interval: AnyRecord): Promise { 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; - } + setGanttSelection({ mode: "interval", runId, interval, marker: null }); + if (!runId || !nodeId) 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 }); - } + void fetchRunDetails(runId, { silent: true }); + await fetchNodeDetails(runId, nodeId); } - async function postNodeAction(action: "append" | "guide" | "redo"): Promise { + async function selectGanttMarker(marker: AnyRecord): Promise { + const runId = String(marker?.runId || activeRunId || ""); + const nodeId = String(marker?.nodeId || ""); + setGanttSelection({ mode: "event", runId, interval: null, marker }); + if (!runId) return; + if (runId !== activeRunId) setSelectedRunId(runId); + void fetchRunDetails(runId, { silent: true }); + if (!nodeId) return; + setSelectedNodeId(nodeId); + setNodeControl(pipelineNodeControlState()); + await fetchNodeDetails(runId, nodeId); + } + + async function postNodeAction(action: "append" | "guide" | "modify" | "approve" | "redo"): Promise { if (!activeRunId || !selectedNodeId) { setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" })); return; } const endpoint = action === "append" ? "prompts" : action; - const text = action === "append" ? nodeControl.appendPrompt : action === "guide" ? nodeControl.guidePrompt : nodeControl.redoReason; + const text = + action === "append" ? nodeControl.appendPrompt : + action === "guide" ? nodeControl.guidePrompt : + action === "modify" ? nodeControl.modifyPrompt : + action === "approve" ? nodeControl.approveReason : + nodeControl.redoReason; if (!String(text || "").trim()) { setNodeControl((prev: AnyRecord) => ({ ...prev, error: "操作内容不能为空", message: "" })); return; } setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: action, error: "", message: "" })); try { - const body = action === "redo" - ? { reason: text, source: "unidesk-frontend" } - : { prompt: text, source: "unidesk-frontend" }; + const body = action === "redo" || action === "approve" + ? { reason: text, source: "unidesk-frontend", sourceKind: "webui" } + : { prompt: text, source: "unidesk-frontend", sourceKind: "webui" }; const result = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(activeRunId)}/nodes/${encodeURIComponent(selectedNodeId)}/${endpoint}`), { method: "POST", body: JSON.stringify(body), @@ -1717,10 +3311,18 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR fetchedAt: new Date(), appendPrompt: action === "append" ? "" : prev.appendPrompt, guidePrompt: action === "guide" ? "" : prev.guidePrompt, + modifyPrompt: action === "modify" ? "" : prev.modifyPrompt, + approveReason: action === "approve" ? "" : prev.approveReason, redoReason: action === "redo" ? "" : prev.redoReason, - message: action === "append" ? "已追加到运行中 node" : action === "guide" ? "已下发 guide,等待 runner 处理" : "已排队重做命令", + message: + action === "append" ? "已追加到运行中 node" : + action === "guide" ? "已下发 guide,等待 runner 处理" : + action === "modify" ? "已排队增量修改命令" : + action === "approve" ? "已提交审核通过决策" : + "已排队重做命令", })); await fetchNodeDetails(activeRunId, selectedNodeId); + await fetchRunDetails(activeRunId, { silent: true }); if (action !== "append") await load(); } catch (err) { setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: "", error: errorMessage(err, "node 控制操作失败") })); @@ -1795,7 +3397,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR setSelectedRunId(""); setSelectedNodeId(""); setNodeControl(pipelineNodeControlState()); - setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null }); + setGanttSelection(pipelineGanttSelectionState()); }, "data-testid": "pipeline-select", }, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))), @@ -1805,6 +3407,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR onChange: (event: any) => { setSelectedRunId(event.target.value); setNodeControl(pipelineNodeControlState()); + setGanttSelection(pipelineGanttSelectionState()); if (selectedNodeId) void fetchNodeDetails(event.target.value, selectedNodeId); }, "data-testid": "pipeline-run-select", @@ -1859,6 +3462,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR onRunChange: (runId: string) => { setSelectedRunId(runId); setNodeControl(pipelineNodeControlState()); + setGanttSelection(pipelineGanttSelectionState()); if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId); }, selectedNodeId, @@ -1885,12 +3489,18 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR activeRun, activePipeline, pipelineNodes, + pipelineEdges, selection: ganttSelection, + runDetails, + nodeDetails: nodeControl.details, + ganttScale, + onGanttScaleChange: setGanttScale, onIntervalSelect: selectGanttInterval, + onMarkerSelect: selectGanttMarker, onRunChange: (runId: string) => { setSelectedRunId(runId); setNodeControl(pipelineNodeControlState()); - setGanttSelection({ interval: null, loading: false, error: "", details: null, fetchedAt: null }); + setGanttSelection(pipelineGanttSelectionState()); if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId); }, onRaw, @@ -1902,9 +3512,15 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`, role: "button", tabIndex: 0, - onClick: () => setSelectedRunId(String(run.runId || "")), + onClick: () => { + setSelectedRunId(String(run.runId || "")); + setGanttSelection(pipelineGanttSelectionState()); + }, onKeyDown: (event: any) => { - if (event.key === "Enter" || event.key === " ") setSelectedRunId(String(run.runId || "")); + if (event.key === "Enter" || event.key === " ") { + setSelectedRunId(String(run.runId || "")); + setGanttSelection(pipelineGanttSelectionState()); + } }, }, h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")),