feat: improve met project tree details
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
|
||||
## T8 Playwright 公网前端 E2E
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `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`、`core:internal-overview`、`core:pgdata-usage`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-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:todo-note-health`、`microservice:todo-note-migrated-data`、`microservice:todo-note-write-path`、`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`、`database:named-volume-write`、`database:todo-note-pg-storage`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:sidebar-collapse`、`frontend:overview-pgdata-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible`、`frontend:gateway-duration-subsecond-visible`、`frontend:provider-operation-availability-visible`、`frontend:microservice-catalog-visible`、`frontend:todo-note-integrated-visible`、`frontend:findjob-integrated-visible`、`frontend:pipeline-integrated-visible`、`frontend:pipeline-react-flow-visible`、`frontend:met-nonlinear-integrated-visible` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`MET Nonlinear`、`SSH 透传`、`远程更新` 和结构化控件。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `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`、`core:internal-overview`、`core:pgdata-usage`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-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:todo-note-health`、`microservice:todo-note-migrated-data`、`microservice:todo-note-write-path`、`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`、`database:named-volume-write`、`database:todo-note-pg-storage`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:sidebar-collapse`、`frontend:overview-pgdata-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible`、`frontend:gateway-duration-subsecond-visible`、`frontend:provider-operation-availability-visible`、`frontend:microservice-catalog-visible`、`frontend:todo-note-integrated-visible`、`frontend:findjob-integrated-visible`、`frontend:pipeline-integrated-visible`、`frontend:pipeline-react-flow-visible`、`frontend:met-nonlinear-integrated-visible`、`frontend:met-nonlinear-project-tree-detail`、`frontend:met-nonlinear-queue-detail-speed` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`MET Nonlinear`、`SSH 透传`、`远程更新` 和结构化控件。
|
||||
|
||||
## T9 Database 命名卷持久化
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
## T21 D601 Pipeline Microservice
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射和 `pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline` 和 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registry 和 Pipeline run 预览;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、最近运行和证据日志摘要,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/pipeline`、commit id、`127.0.0.1:18082` 后端映射、`allowedMethods` 包含 `GET/HEAD/POST` 和 `pipeline-v2-webui` 容器摘要;运行 `bun scripts/cli.ts microservice health pipeline` 和 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 Pipeline 后端,snapshot 返回 `ok=true`、组件 registry 和 Pipeline run 预览;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health pipeline`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend Pipeline 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / Pipeline`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、Pipeline 组件矩阵、React Flow 控制图框图、最近运行和证据日志摘要,点击控制图中的 node 后会打开 node 精细控制面板,能通过“抓取过程”读取 node 执行过程,并显示 append prompt、guide 和 redo/restart 操作入口;默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。Pipeline 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/pipeline`,不得把 pipeline 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。
|
||||
|
||||
|
||||
## T22 Main Server Todo Note Microservice
|
||||
@@ -95,4 +95,4 @@
|
||||
|
||||
## T23 MET Nonlinear D601 GPU Microservice
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `~/met_nonlinear` 中存在 `docker-compose.unidesk.yml`、`docker/unidesk/Dockerfile.ml`、`unidesk/server/src/index.ts` 和 `docs/reference/unidesk_microservice.md`;运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、`127.0.0.1:3288` 后端映射和 `met-nonlinear-ts` 容器摘要;运行 `bun scripts/cli.ts microservice health met-nonlinear`、`bun scripts/cli.ts microservice proxy met-nonlinear /api/queue`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=20'` 和 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 TS 后端;最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / MET Nonlinear`,通过 UI 选择已有 source Project,设置训练轮数和最大并发,使用 `Fork Project` 创建新的 `projects/unidesk_forks/` Project,确认新 Project 被自动勾选但不会直接训练,再点击 `加入待启动队列` 和 `启动队列`;完整验收可用 UI 输入 `Fork 数量=10`、`训练轮数=200`、`最大并发=3`,但这个规模只能由输入框配置,不能作为硬编码按钮。确认最多按 UI 设置的并发数运行、目标 GPU 是 2080Ti、显存余量低于 20% 时自动限制并发、任务最终进入已完成或失败诊断标签且训练容器自动销毁。页面必须以 React 控件显示项目库、待启动/排队/训练中、已完成、失败诊断、GPU/镜像、训练进度、ETA 和历史记录,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;前端不得再提供 `创建10个10轮任务` 这类硬编码测试按钮。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D601 `~/met_nonlinear` 中存在 `docker-compose.unidesk.yml`、`docker/unidesk/Dockerfile.ml`、`unidesk/server/src/index.ts` 和 `docs/reference/unidesk_microservice.md`;运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、`127.0.0.1:3288` 后端映射和 `met-nonlinear-ts` 容器摘要;运行 `bun scripts/cli.ts microservice health met-nonlinear`、`bun scripts/cli.ts microservice proxy met-nonlinear /api/queue`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=500'`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=ex_projects&limit=500'`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects/config?path=projects/<name>' --raw` 和 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 TS 后端,项目详情包含 `config`、`progress`、`data`、`model`、`metrics` 字段;最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / MET Nonlinear`,确认项目库按 `projects/` 和 `ex_projects/` 文件树层级展示且文件夹 Project 数与后端返回数量一致,点击项目行能看到结构化 `config.json`、`data/` 训练状态、模型参数量和指标;通过 UI 选择已有 source Project,设置训练轮数和最大并发,使用 `Fork Project` 创建新的 `projects/unidesk_forks/` Project,确认新 Project 被自动勾选但不会直接训练,再点击 `加入待启动队列` 和 `启动队列`;完整验收可用 UI 输入 `Fork 数量=10`、`训练轮数=200`、`最大并发=3`,但这个规模只能由输入框配置,不能作为硬编码按钮。确认最多按 UI 设置的并发数运行、目标 GPU 是 2080Ti、显存余量低于 20% 时自动限制并发、任务最终进入已完成或失败诊断标签且训练容器自动销毁。页面必须以 React 控件显示项目库、待启动/排队/训练中、已完成、失败诊断、GPU/镜像、训练进度、ETA、`epoch/h` 训练速度和历史记录;项目库、当前队列、已完成和失败列表中的项目必须可点击打开详情;默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据;前端不得再提供 `创建10个10轮任务` 这类硬编码测试按钮。
|
||||
|
||||
+3
-2
@@ -132,7 +132,8 @@
|
||||
"timeoutMs": 15000,
|
||||
"allowedMethods": [
|
||||
"GET",
|
||||
"HEAD"
|
||||
"HEAD",
|
||||
"POST"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
@@ -152,7 +153,7 @@
|
||||
"description": "MET Nonlinear 训练编排微服务,TS 后端部署在 D601 Docker 中,按需拉起 TensorFlow 2.6 GPU 训练容器并由 UniDesk frontend 展示队列、进度和历史记录。",
|
||||
"repository": {
|
||||
"url": "https://github.com/pikasTech/met_nonlinear",
|
||||
"commitId": "6b7ca7a796bf90221fec19442426748b44cafee3",
|
||||
"commitId": "9fcdfc0b505e52cc88cf51b196543dc055da2334",
|
||||
"dockerfile": "docker/unidesk/Dockerfile.ml",
|
||||
"composeFile": "docker-compose.unidesk.yml",
|
||||
"composeService": "met-nonlinear-ts",
|
||||
|
||||
@@ -26,7 +26,7 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和
|
||||
|
||||
`microservices` 定义挂载在计算节点或主 server Docker 中的非核心业务后端。该数组只保存业务仓库 URL、commit id、业务仓库自身 Dockerfile/docker-compose 引用、provider 映射、节点后端端口和 UniDesk frontend 集成入口;不得把业务全量代码复制进 UniDesk。`backend.public` 必须为 `false`,`backend.frontendOnly` 必须为 `true`,`backend.allowedPathPrefixes` 必须限制到业务 API 前缀,`backend.allowedMethods` 必须显式列出允许代理的 HTTP 方法;浏览器只能通过 frontend 同源代理访问这些后端。详细规则见 `docs/reference/microservices.md`。
|
||||
|
||||
主 server 承载的 Todo Note microservice 使用 `providerId=main-server`、`nodeBaseUrl=http://todo-note:4211` 和 `allowedMethods=["GET","HEAD","POST","DELETE"]`,数据库使用主 PostgreSQL;D601 的 FindJob/Pipeline 只允许 `GET/HEAD` 展示型读取路径。
|
||||
主 server 承载的 Todo Note microservice 使用 `providerId=main-server`、`nodeBaseUrl=http://todo-note:4211` 和 `allowedMethods=["GET","HEAD","POST","DELETE"]`,数据库使用主 PostgreSQL;D601 的 FindJob 只允许 `GET/HEAD` 展示型读取路径;D601 的 Pipeline 允许 `GET/HEAD/POST`,其中 `POST` 只用于 Pipeline 后端 `/api/node-control/...` 的 append prompt、guide 和 redo/restart 等受控 node 操作。
|
||||
|
||||
## Compose Env Generation
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ UniDesk delivery is not complete until the public frontend, public provider ingr
|
||||
- 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; 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=20` and `/api/microservices/met-nonlinear/proxy/api/images` must return the D601 TS backend health, queue/GPU policy, project preview and ready `met-nonlinear-ml:tf26` image status.
|
||||
- Microservices: internal `/api/microservices` must include `todo-note` on `main-server` plus `findjob`, `pipeline` and `met-nonlinear` on `D601` with `public=false`; `/api/microservices/todo-note/health` must report `storage=postgres`, `/api/microservices/todo-note/proxy/api/instances` must expose the migrated Todo Note lists, and a temporary Todo Note list create/add/toggle/undo/delete cycle must succeed through the real provider-gateway proxy; `/api/microservices/findjob/health` and `/api/microservices/findjob/proxy/api/summary` must succeed through the real provider-gateway proxy; `/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5` must return a bounded preview with `_unidesk.arrayLimits` metadata; `/api/microservices/pipeline/health` and `/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3` must return Pipeline health, registry and run previews; `/api/microservices/met-nonlinear/health`, `/api/microservices/met-nonlinear/proxy/api/queue`, `/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects?root=ex_projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects/config?path=<projectPath>` and `/api/microservices/met-nonlinear/proxy/api/images` must return the D601 TS backend health, queue/GPU policy, full project tree inputs, structured project detail and ready `met-nonlinear-ml:tf26` image status.
|
||||
- Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql`, confirms provider state is stored in PostgreSQL, and checks Todo Note rows exist in `todo_note_instances` using the same named volume.
|
||||
- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, verifies desktop sidebar collapse and `PGDATA` overview metric, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, opens `Docker 状态`, switches to `main-server`, and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured automatic update records for `provider.upgrade`, then opens `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / FindJob`、`微服务 / Pipeline` and `微服务 / MET Nonlinear` to verify 主 server Todo Note、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Task history and provider upgrade records must not display a real sub-second duration as `0s`; MET Nonlinear running rows must show an ETA derived from backend progress or from `startedAt` plus epoch progress.
|
||||
- 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. 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 and provider upgrade precheck dispatch, opens `Docker 状态`, switches to `main-server`, and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured automatic update records for `provider.upgrade`, then opens `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / FindJob`、`微服务 / Pipeline` and `微服务 / MET Nonlinear` to verify 主 server Todo Note、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行、MET Nonlinear 项目库/Fork/待启动队列/当前队列/已完成/失败诊断/GPU/镜像都通过 React 控件展示。Task history and provider upgrade records must not display a real sub-second duration as `0s`; MET Nonlinear running rows must show an ETA derived from backend progress or from `startedAt` plus epoch progress, and queue/completed rows must show training speed as `epoch/h`.
|
||||
- Microservice frontend assertions must wait for real backend data, not only the page skeleton. For Todo Note this means the page must show the migrated lists `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务`, support creating a temporary list and task through the frontend, and delete that temporary list afterwards. The temporary list must be selected again by its unique generated name before deletion so E2E never deletes a migrated source list by accident. For FindJob this means the page must show a numeric `岗位总量`, `HEALTH OK`, and a non-empty `PREVIEW` count such as `40/1463 PREVIEW`; for Pipeline this means the page must show `Pipeline v2 工作台`, `Health OK`, a numeric component count, a non-empty React Flow control graph, `控制图`, and `最近运行`; for MET Nonlinear this means the page must show `MET Nonlinear 训练编排`, `Health OK`, `Fork Project`, `加入待启动队列`, `启动队列`, `当前队列`, 最大并发设置、task queue and GPU/image panels, and must not show the removed hard-coded `创建10个10轮任务` frontend entry. The MET Nonlinear project library must render `projects/` and `ex_projects/` as a true path tree with folder Project counts; clicking a project row must open a structured detail panel containing `config.json`, `data/ 训练状态`, `模型参数`, `指标` and a parameter count such as `Total Params`; clicking a completed/current/failed job row must open a structured job detail and both the row and detail must show `epoch/h`. Full MET Nonlinear acceptance is driven by public frontend controls: choose a visible source Project, set batch size, epochs and max concurrency in inputs, fork into `projects/unidesk_forks/`, stage the selected forks, start the queue, and verify completed rows plus automatic `metnl-train-*` container removal; loading placeholders like `--` or empty states are not sufficient for E2E success.
|
||||
|
||||
## Frontend JSON Rule
|
||||
|
||||
@@ -29,7 +29,7 @@ Automatic update records in the frontend are covered by the same rule: `provider
|
||||
|
||||
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, project config preview, progress bars, ETA 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 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.
|
||||
|
||||
## Public Boundary Rule
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ 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` 的 snapshot 后端渲染为组件矩阵、React Flow 控制图框图、最近运行卡片和证据日志摘要;`MET Nonlinear` 子标签必须把 D601 `/home/ubuntu/met_nonlinear` 的训练编排后端渲染为下载器式工作台,包括项目库选择、从已有 Project fork 新 Project、加入待启动队列、启动队列、最大并发设置、当前队列、已完成、失败诊断、GPU/镜像、训练进度、ETA、历史记录和显式原始 JSON 按钮;运行中训练若后端未直接给出 ETA,前端必须用 `startedAt`、当前 epoch 和目标 epoch 做可解释的剩余时间估算;不得提供硬编码的固定数量/固定轮数测试按钮。该模块不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。
|
||||
`微服务` 主模块用于展示挂载在计算节点或主 server Docker 中的业务后端。`服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要;`Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮;`FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮;`Pipeline` 子标签必须把 D601 `/home/ubuntu/pipeline` 后端渲染为组件矩阵、React Flow 控制图框图、最近运行卡片、证据日志摘要和 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 裸铺在页面上。
|
||||
|
||||
## Component Data Rendering
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/insta
|
||||
当前 `D601` 同时承载以下 UniDesk microservice:
|
||||
|
||||
- `findjob`:FindJob 纯后端服务,UniDesk frontend 渲染岗位指标、岗位预览和草稿报告。
|
||||
- `pipeline`:Pipeline v2 观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、运行状态和证据日志摘要。
|
||||
- `pipeline`:Pipeline v2 控制与观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、运行状态、证据日志摘要和 node 精细控制面板。
|
||||
- `met-nonlinear`:MET Nonlinear 训练编排服务,UniDesk frontend 渲染 GPU/镜像、训练队列、Project config 预览、训练进度、ETA 和历史记录。
|
||||
|
||||
### FindJob On D601
|
||||
@@ -79,10 +79,10 @@ FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 ifram
|
||||
- 代码引用:`https://github.com/pikasTech/pipeline` 与配置中的 `repository.commitId`。
|
||||
- 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=pipeline-webui`、`containerName=pipeline-v2-webui`。
|
||||
- 节点后端:D601 上 `127.0.0.1:18082`,provider-gateway 容器内通过 `http://host.docker.internal:18082` 访问。
|
||||
- 代理路径:只允许 `/health` 和 `/api/` 前缀;Pipeline 自身 WebUI 静态页面即使仍由 `pipeline-webui` 提供,也不作为 UniDesk microservice 入口使用。
|
||||
- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、最近运行、OA/procedure 摘要和显式原始 JSON 按钮。
|
||||
- 代理路径:只允许 `/health` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`,其中 `POST` 仅用于 `/api/node-control/...` 这类 node 控制动作;Pipeline 自身 WebUI 静态页面即使仍由 `pipeline-webui` 提供,也不作为 UniDesk microservice 入口使用。
|
||||
- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、最近运行、OA/procedure 摘要、证据日志、点击 node 后的执行过程抓取、append prompt、guide 和 redo/restart 控件,以及显式原始 JSON 按钮。
|
||||
|
||||
Pipeline 在 UniDesk 语境中按观测后端服务管理:默认页面不得 iframe 或跳转到 Pipeline 自身 WebUI,也不得直接暴露 D601 的 `18082` 到公网。UniDesk frontend 只能通过 `/api/microservices/pipeline/health` 和 `/api/microservices/pipeline/proxy/api/snapshot?...` 访问 Pipeline 后端;超大 snapshot 必须使用 `__unideskArrayLimit=registry.components:<limit>,runs:<limit>` 做展示级裁剪。
|
||||
Pipeline 在 UniDesk 语境中按控制与观测后端服务管理:默认页面不得 iframe 或跳转到 Pipeline 自身 WebUI,也不得直接暴露 D601 的 `18082` 到公网。UniDesk frontend 只能通过 `/api/microservices/pipeline/health`、`/api/microservices/pipeline/proxy/api/snapshot?...` 和 `/api/microservices/pipeline/proxy/api/node-control/...` 访问 Pipeline 后端;超大 snapshot 必须使用 `__unideskArrayLimit=registry.components:<limit>,runs:<limit>` 做展示级裁剪。node 控制入口必须走 Pipeline 后端 HTTP API,前端不得直接写 `.state`、runner prompt 文件或命令队列。
|
||||
|
||||
### MET Nonlinear On D601
|
||||
|
||||
@@ -95,11 +95,11 @@ Pipeline 在 UniDesk 语境中按观测后端服务管理:默认页面不得 i
|
||||
- 部署引用:业务仓库内 `docker-compose.unidesk.yml`、`docker/unidesk/Dockerfile.server`、`docker/unidesk/Dockerfile.ml`、`composeService=met-nonlinear-ts`、`containerName=met-nonlinear-ts`。
|
||||
- 节点后端:D601 上 `127.0.0.1:3288`,provider-gateway 容器内通过 `http://host.docker.internal:3288` 访问。
|
||||
- 代理路径:只允许 `/health` 和 `/api/` 前缀;允许 `GET`、`HEAD`、`POST`、`PUT`,用于读取队列/历史、从已有 Project fork 新 Project、保存队列设置、加入待启动队列和启动队列。
|
||||
- UniDesk 前端:`微服务 / MET Nonlinear` React 页面采用类似下载器的工作台交互,负责从项目库选择已有 Project、fork 新 Project、加入待启动队列、启动队列、调整最大并发、分标签展示当前队列/已完成/失败诊断/GPU 与镜像,并展示训练进度、ETA、历史训练记录和显式原始 JSON 按钮。
|
||||
- UniDesk 前端:`微服务 / MET Nonlinear` React 页面采用类似下载器的工作台交互,负责从项目库选择已有 Project、fork 新 Project、加入待启动队列、启动队列、调整最大并发、分标签展示当前队列/已完成/失败诊断/GPU 与镜像,并展示训练进度、ETA、训练速度 `epoch/h`、历史训练记录和显式原始 JSON 按钮。项目库必须按 `projects/`、`ex_projects/` 的真实目录层级渲染文件树,文件夹计数等于子树 Project 数;项目库和任务列表行都必须可点击打开结构化详情,详情以控件展示 `config.json` 与 `data/` 中的训练状态、模型参数量、模型层和指标,不默认展示裸 JSON。
|
||||
|
||||
MET Nonlinear 的长期服务边界写在业务仓库 `~/met_nonlinear/docs/reference/unidesk_microservice.md`:`met-nonlinear-ts` 是长驻 Bun TypeScript 编排后端,`met-nonlinear-ml:tf26` 是按需训练镜像,每个训练任务用一个 `docker run --rm` 容器执行 `python cli.py -t <projectPath>`,训练完成后容器自动销毁。训练镜像 Dockerfile 必须使用中国大陆可达的软件源;当前固定使用 Huawei Cloud mirror 的 `nvidia/cuda:11.2.2-cudnn8-runtime-ubuntu20.04`、Aliyun apt mirror、Tsinghua PyPI mirror、Ubuntu Python 3.8 和 `tensorflow==2.6.0`,避免官方 TensorFlow 2.6 GPU 镜像 Python 3.6 与业务源码类型注解不兼容。
|
||||
|
||||
MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成:选择已有 source Project,设置训练轮数和最大并发,使用 `Fork Project` 创建新的 `projects/unidesk_forks/` Project,确认新 Project 只是被选中而不会直接训练,再加入待启动队列并点击 `启动队列`。验收时必须确认待启动、排队中、训练中、已完成和失败诊断分标签可见,最大并发按 UI 设置生效,运行中行显示训练进度和 ETA,目标 GPU 为 2080Ti,2080Ti 显存余量低于 20% 时自动限制并发,并确认训练容器结束后不残留。批量规模由 UI 输入框决定,完整验收可以通过输入 `Fork 数量=10`、`训练轮数=200`、`最大并发=3` 执行,但不得把该规模做成专用硬编码按钮。CLI `/api/queue/server-test` 仅保留为后端兼容入口,不作为 frontend 操作入口。
|
||||
MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成:选择已有 source Project,设置训练轮数和最大并发,使用 `Fork Project` 创建新的 `projects/unidesk_forks/` Project,确认新 Project 只是被选中而不会直接训练,再加入待启动队列并点击 `启动队列`。验收时必须确认项目库的 `projects/` 与 `ex_projects/` 按文件树层级展开、文件夹 Project 计数与后端返回数量一致;点击项目行后详情显示 `config.json`、`data/` 训练状态、模型参数量和指标;待启动、排队中、训练中、已完成和失败诊断分标签可见;训练队列和已完成行显示 `epoch/h` 训练速度且可点击打开任务详情。最大并发必须按 UI 设置生效,运行中行显示训练进度和 ETA,目标 GPU 为 2080Ti,2080Ti 显存余量低于 20% 时自动限制并发,并确认训练容器结束后不残留。批量规模由 UI 输入框决定,完整验收可以通过输入 `Fork 数量=10`、`训练轮数=200`、`最大并发=3` 执行,但不得把该规模做成专用硬编码按钮。CLI `/api/queue/server-test` 仅保留为后端兼容入口,不作为 frontend 操作入口。
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -109,8 +109,10 @@ MET Nonlinear 验收必须通过公网 UniDesk frontend 的交互式 UI 完成
|
||||
- `bun scripts/cli.ts microservice proxy findjob /api/summary`:通过同一私有代理读取业务 API,适合人工验证,不用于公开业务端口。
|
||||
- `bun scripts/cli.ts microservice health pipeline`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 Pipeline `/health`。
|
||||
- `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`:读取 Pipeline snapshot 的有界预览,适合人工验证,不用于公开业务端口;若 body 仍超过 CLI 阈值,默认只输出 `bodyPreview`,需要完整 body 时显式追加 `--raw`。
|
||||
- Pipeline node 控制写入由 UniDesk frontend 调用同源 `/api/microservices/pipeline/proxy/api/node-control/...` 完成;通用 CLI `microservice proxy` 仍主要作为读取验证入口,不作为人工批量写入工具。
|
||||
- `bun scripts/cli.ts microservice health met-nonlinear`:通过 backend-core -> provider-gateway -> D601 本机 TS 编排后端链路探测 MET Nonlinear `/health`。
|
||||
- `bun scripts/cli.ts microservice proxy met-nonlinear /api/queue` 与 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`:读取 MET Nonlinear 队列、GPU 策略和训练镜像状态,适合人工验证,不用于公开业务端口。
|
||||
- `bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=500'` 与 `bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects/config?path=projects/<name>' --raw`:验证项目库文件树输入和结构化项目详情;详情应包含 config、progress、data、model、metrics 字段,供前端渲染训练状态、模型参数量和指标。
|
||||
- `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`:验证主 server Todo Note 后端、PostgreSQL 存储和本机 provider-gateway 私有代理链路。
|
||||
- `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health findjob`:在计算节点或其他非主 server 主机上通过公网 frontend remote CLI 进行同一验证,不需要主 server SSH key。
|
||||
|
||||
|
||||
+37
-4
@@ -603,12 +603,43 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
&& text.includes("仅 UniDesk frontend 代理访问")
|
||||
&& /Health\s+OK/i.test(text);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const metNonlinearText = await page.locator('[data-testid="met-nonlinear-page"]').innerText({ timeout: 5000 });
|
||||
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 metNonlinearTextLower = metNonlinearText.toLowerCase();
|
||||
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 });
|
||||
@@ -621,7 +652,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存"), { monitorTextPreview: monitorText.slice(0, 800) });
|
||||
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("自动更新记录") && gatewayText.includes("Gateway 版本") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayText.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
|
||||
addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayTextLower.includes("gateway 版本") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayTextLower.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
|
||||
addCheck(checks, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) });
|
||||
addCheck(checks, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts });
|
||||
addCheck(checks, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) });
|
||||
@@ -630,7 +661,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addCheck(checks, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
|
||||
addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("最近运行") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
|
||||
addCheck(checks, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount });
|
||||
addCheck(checks, "frontend:met-nonlinear-integrated-visible", metNonlinearTextLower.includes("met nonlinear 训练编排") && metNonlinearText.includes("D601") && metNonlinearText.includes("当前队列") && metNonlinearText.includes("GPU/镜像") && metNonlinearText.includes("Fork Project") && metNonlinearText.includes("加入待启动队列") && metNonlinearText.includes("启动队列") && !metNonlinearText.includes("创建10个10轮任务") && metNonlinearText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(metNonlinearText), { metNonlinearTextPreview: metNonlinearText.slice(0, 1400) });
|
||||
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 });
|
||||
return { screenshotPath, bodyText, consoleErrors };
|
||||
} finally {
|
||||
|
||||
@@ -195,6 +195,19 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
}
|
||||
.ghost-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ghost-btn.danger:hover { border-color: var(--danger); color: #ffd7cf; }
|
||||
.primary-btn, .danger-btn {
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid rgba(78, 183, 168, 0.62);
|
||||
background: linear-gradient(180deg, rgba(78, 183, 168, 0.22), rgba(78, 183, 168, 0.08));
|
||||
color: var(--text);
|
||||
}
|
||||
.danger-btn {
|
||||
border-color: rgba(207, 106, 84, 0.62);
|
||||
background: linear-gradient(180deg, rgba(207, 106, 84, 0.2), rgba(207, 106, 84, 0.08));
|
||||
color: #ffd7cf;
|
||||
}
|
||||
.ghost-btn.compact, .primary-btn.compact, .danger-btn.compact { width: max-content; }
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
@@ -1125,7 +1138,189 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line-soft);
|
||||
}
|
||||
.met-project-table { max-height: 520px; overflow: auto; }
|
||||
.met-project-table {
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.met-tree-header, .met-tree-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) 90px 72px 90px 116px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.met-tree-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.met-tree-row {
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.045);
|
||||
}
|
||||
.met-tree-row.project {
|
||||
cursor: pointer;
|
||||
}
|
||||
.met-tree-row.project:hover, .met-tree-row.project.active, .met-click-row:hover, .met-click-row.active {
|
||||
background: rgba(78, 183, 168, 0.08);
|
||||
}
|
||||
.met-tree-row.project.selected {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.met-tree-row.folder {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,0.025);
|
||||
}
|
||||
.met-tree-name {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.met-tree-name input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.met-tree-toggle {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.met-tree-count {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.met-inline-link {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.met-inline-link:hover {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.met-inline-link.project-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.met-detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(78, 183, 168, 0.07), transparent 38%),
|
||||
var(--panel-2);
|
||||
}
|
||||
.met-detail-panel.muted {
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.met-detail-panel .panel-head {
|
||||
padding: 0 0 8px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.met-detail-panel code {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
white-space: normal;
|
||||
}
|
||||
.met-detail-section {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.met-detail-section h3 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.met-detail-kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(130px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.met-detail-kv-item {
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255,255,255,0.025);
|
||||
}
|
||||
.met-detail-kv-item span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.met-detail-kv-item strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.met-detail-kv-item small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.met-layer-table {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.met-file-chip-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.met-file-chip-grid span {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.met-log-lines {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
.met-log-lines div {
|
||||
padding: 5px 7px;
|
||||
border-left: 2px solid var(--accent-2);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ghost-btn.mini {
|
||||
min-height: 22px;
|
||||
padding: 2px 6px;
|
||||
@@ -1143,6 +1338,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.pipeline-control-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(620px, 1fr) minmax(320px, 0.38fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.pipeline-flow-frame {
|
||||
height: min(68vh, 720px);
|
||||
min-height: 520px;
|
||||
@@ -1236,6 +1437,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.pipeline-flow-node.failed { border-color: rgba(207, 106, 84, 0.78); }
|
||||
.pipeline-flow-node.quality-gate { background: linear-gradient(180deg, rgba(22, 38, 34, 0.98), rgba(10, 19, 17, 0.98)); }
|
||||
.pipeline-flow-node.control-block { background: linear-gradient(180deg, rgba(36, 31, 20, 0.98), rgba(17, 15, 11, 0.98)); }
|
||||
.pipeline-flow-node.selected-control-node {
|
||||
outline: 2px solid rgba(215, 161, 58, 0.78);
|
||||
box-shadow: 0 0 0 4px rgba(215, 161, 58, 0.16), 0 20px 38px rgba(0,0,0,0.42);
|
||||
}
|
||||
.flow-node-label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -1273,6 +1478,95 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.pipeline-node-control {
|
||||
min-width: 0;
|
||||
max-height: min(68vh, 720px);
|
||||
min-height: 520px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(215, 161, 58, 0.28);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(215, 161, 58, 0.11), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.014)),
|
||||
#0a1218;
|
||||
box-shadow: inset 3px 0 0 rgba(215, 161, 58, 0.28);
|
||||
padding: 10px;
|
||||
}
|
||||
.pipeline-node-control-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.pipeline-node-control-head h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.04em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pipeline-control-runbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
align-items: end;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.pipeline-control-runbar label,
|
||||
.pipeline-control-actions label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pipeline-control-runbar select,
|
||||
.pipeline-control-actions textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(4, 9, 13, 0.64);
|
||||
color: var(--text);
|
||||
}
|
||||
.pipeline-control-runbar select { min-height: 28px; padding: 4px 7px; }
|
||||
.pipeline-control-actions textarea {
|
||||
resize: vertical;
|
||||
padding: 7px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.pipeline-control-meta,
|
||||
.pipeline-control-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pipeline-control-meta span,
|
||||
.pipeline-control-evidence-grid span {
|
||||
min-width: 0;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pipeline-control-meta b {
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pipeline-control-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.pipeline-control-evidence {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
.compact-log code { font-size: 11px; }
|
||||
.component-strata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
|
||||
@@ -1384,6 +1678,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: #ffd7cf;
|
||||
background: rgba(207,106,84,0.1);
|
||||
}
|
||||
.form-success {
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(78,183,168,0.5);
|
||||
color: #d8fff6;
|
||||
background: rgba(78,183,168,0.1);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
@@ -1423,14 +1723,24 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dispatch-form { grid-template-columns: 1fr 1fr; }
|
||||
.dispatch-actions { align-items: center; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero { grid-template-columns: 1fr; }
|
||||
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid { grid-template-columns: 1fr; }
|
||||
.pipeline-control-shell { grid-template-columns: 1fr; }
|
||||
.pipeline-node-control { max-height: none; min-height: 0; }
|
||||
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5), .met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5), .met-detail-panel { grid-column: 1; }
|
||||
.gateway-record-grid { grid-template-columns: 1fr; }
|
||||
.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
body { font-size: 12px; }
|
||||
.pipeline-control-runbar,
|
||||
.pipeline-control-meta,
|
||||
.pipeline-control-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pipeline-flow-frame {
|
||||
min-height: 430px;
|
||||
}
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -1521,8 +1831,13 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
padding: 4px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid { grid-template-columns: 1fr; }
|
||||
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv { grid-template-columns: 1fr; }
|
||||
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; }
|
||||
.met-tree-header, .met-tree-row {
|
||||
grid-template-columns: minmax(220px, 1fr) 72px 62px 76px 96px;
|
||||
min-width: 560px;
|
||||
}
|
||||
.met-project-table { overflow-x: auto; }
|
||||
.docker-hero, .monitor-hero { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,39 @@ function fmtDuration(seconds: any): string {
|
||||
return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function fmtNumber(value: any, digits = 2): string {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return value === false ? "false" : value === true ? "true" : "--";
|
||||
const abs = Math.abs(number);
|
||||
if (Number.isInteger(number) || abs >= 1000) return number.toLocaleString("zh-CN", { maximumFractionDigits: 0 });
|
||||
if (abs >= 1) return number.toLocaleString("zh-CN", { maximumFractionDigits: digits });
|
||||
return number.toLocaleString("zh-CN", { maximumFractionDigits: Math.max(digits, 6) });
|
||||
}
|
||||
|
||||
function fmtValue(value: any): string {
|
||||
if (value === null || value === undefined || value === "") return "--";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") return fmtNumber(value, 4);
|
||||
if (Array.isArray(value)) return value.map((item) => fmtValue(item)).join(" x ");
|
||||
if (typeof value === "object") return "已上报";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function fmtEpochSpeed(value: any): string {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return "--";
|
||||
const digits = number >= 100 ? 0 : number >= 10 ? 1 : 2;
|
||||
return `${number.toLocaleString("zh-CN", { maximumFractionDigits: digits })} epoch/h`;
|
||||
}
|
||||
|
||||
function safeDomId(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
function objectValue(value: any): AnyRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
@@ -84,7 +117,10 @@ function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
"data-testid": testId,
|
||||
onClick: () => onOpen(title, data),
|
||||
onClick: (event: any) => {
|
||||
event?.stopPropagation?.();
|
||||
onOpen(title, data);
|
||||
},
|
||||
}, "查看原始JSON");
|
||||
}
|
||||
|
||||
@@ -113,7 +149,11 @@ function jobRows(queue: any): any[] {
|
||||
}
|
||||
|
||||
function projectRows(projects: any): any[] {
|
||||
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 160) : [];
|
||||
return Array.isArray(projects?.projects) ? projects.projects.slice(0, 1000) : [];
|
||||
}
|
||||
|
||||
function allProjectRows(projects: any): any[] {
|
||||
return Array.isArray(projects?.projects) ? projects.projects : [];
|
||||
}
|
||||
|
||||
function gpuRows(health: any, queue: any): any[] {
|
||||
@@ -145,6 +185,18 @@ function jobEtaSeconds(job: any): number | null {
|
||||
return Math.max(0, (elapsedSeconds / currentEpoch) * (epochTarget - currentEpoch));
|
||||
}
|
||||
|
||||
function jobEpochPerHour(job: any): number | null {
|
||||
const progress = job.progress || {};
|
||||
const reported = Number(progress.epochPerHour);
|
||||
if (Number.isFinite(reported) && reported > 0) return reported;
|
||||
const startedAt = Date.parse(job.startedAt || "");
|
||||
const finishedAt = ["succeeded", "failed", "canceled"].includes(job.status) ? Date.parse(job.finishedAt || "") : Date.now();
|
||||
if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt) || finishedAt <= startedAt) return null;
|
||||
const epochs = Number(progress.currentEpoch ?? job.epochTarget);
|
||||
if (!Number.isFinite(epochs) || epochs <= 0) return null;
|
||||
return epochs / ((finishedAt - startedAt) / 3600000);
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "staged") return "待启动";
|
||||
if (status === "queued") return "排队中";
|
||||
@@ -155,12 +207,69 @@ function statusLabel(status: string): string {
|
||||
return status || "unknown";
|
||||
}
|
||||
|
||||
type ProjectTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
count: number;
|
||||
children: ProjectTreeNode[];
|
||||
project: any | null;
|
||||
};
|
||||
|
||||
function createProjectTreeNode(name: string, path: string, depth: number): ProjectTreeNode {
|
||||
return { name, path, depth, count: 0, children: [], project: null };
|
||||
}
|
||||
|
||||
function buildProjectTree(projects: any[]): ProjectTreeNode {
|
||||
const root = createProjectTreeNode("", "", -1);
|
||||
for (const project of projects) {
|
||||
const projectPath = String(project?.projectPath || "").replace(/\\/g, "/");
|
||||
const parts = projectPath.split("/").filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
let node = root;
|
||||
const cursor: string[] = [];
|
||||
for (const [index, part] of parts.entries()) {
|
||||
cursor.push(part);
|
||||
const path = cursor.join("/");
|
||||
let child = node.children.find((item) => item.path === path);
|
||||
if (!child) {
|
||||
child = createProjectTreeNode(part, path, index);
|
||||
node.children.push(child);
|
||||
}
|
||||
if (index === parts.length - 1) child.project = project;
|
||||
node = child;
|
||||
}
|
||||
}
|
||||
const fillCount = (node: ProjectTreeNode): number => {
|
||||
const childCount = node.children.reduce((sum, child) => sum + fillCount(child), 0);
|
||||
node.count = (node.project ? 1 : 0) + childCount;
|
||||
node.children.sort((a, b) => {
|
||||
if (Boolean(a.project) !== Boolean(b.project)) return a.project ? 1 : -1;
|
||||
return a.name.localeCompare(b.name, "zh-CN", { numeric: true, sensitivity: "base" });
|
||||
});
|
||||
return node.count;
|
||||
};
|
||||
fillCount(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function detailPayload(detail: AnyRecord): AnyRecord {
|
||||
const data = objectValue(detail.data);
|
||||
return objectValue(data.project).projectPath ? objectValue(data.project) : data;
|
||||
}
|
||||
|
||||
function detailJob(detail: AnyRecord): AnyRecord {
|
||||
return objectValue(objectValue(detail.data).job);
|
||||
}
|
||||
|
||||
export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "met-nonlinear") || null;
|
||||
const [state, setState] = useState({ loading: false, actionBusy: false, error: "", health: null, summary: null, queue: null, projects: null, history: null, images: null, refreshedAt: null });
|
||||
const [detail, setDetail] = useState({ loading: false, error: "", kind: "", key: "", title: "", data: null });
|
||||
const [ui, setUi] = useState(() => ({
|
||||
activeTab: "projects",
|
||||
selectedProjects: {},
|
||||
expandedProjectDirs: {},
|
||||
sourceProject: "",
|
||||
forkCount: 1,
|
||||
forkEpochs: 200,
|
||||
@@ -178,14 +287,23 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
if (!service) return;
|
||||
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
|
||||
try {
|
||||
const [health, summary, queue, projects, history, images] = await Promise.all([
|
||||
const [health, summary, queue, projectsRoot, exProjectsRoot, history, images] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/met-nonlinear/health`),
|
||||
requestJson(metApi(apiBaseUrl, "/api/summary")),
|
||||
requestJson(metApi(apiBaseUrl, "/api/queue")),
|
||||
requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=160")),
|
||||
requestJson(metApi(apiBaseUrl, "/api/projects?root=projects&limit=500")),
|
||||
requestJson(metApi(apiBaseUrl, "/api/projects?root=ex_projects&limit=500")),
|
||||
requestJson(metApi(apiBaseUrl, "/api/history")),
|
||||
requestJson(metApi(apiBaseUrl, "/api/images")),
|
||||
]);
|
||||
const projects = {
|
||||
ok: projectsRoot?.ok !== false && exProjectsRoot?.ok !== false,
|
||||
roots: [
|
||||
{ root: "projects", count: allProjectRows(projectsRoot).length },
|
||||
{ root: "ex_projects", count: allProjectRows(exProjectsRoot).length },
|
||||
],
|
||||
projects: [...allProjectRows(projectsRoot), ...allProjectRows(exProjectsRoot)],
|
||||
};
|
||||
setState({ loading: false, actionBusy: false, error: "", health, summary, queue, projects, history, images, refreshedAt: new Date() });
|
||||
} catch (err) {
|
||||
setState((prev: any) => ({ ...prev, loading: false, actionBusy: false, error: errorMessage(err, "MET Nonlinear 加载失败") }));
|
||||
@@ -265,6 +383,30 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
});
|
||||
}
|
||||
|
||||
async function openProjectDetail(project: any): Promise<void> {
|
||||
const projectPath = String(project?.projectPath || "");
|
||||
if (!projectPath) return;
|
||||
setDetail({ loading: true, error: "", kind: "project", key: projectPath, title: projectPath, data: null });
|
||||
try {
|
||||
const data = await requestJson(metApi(apiBaseUrl, `/api/projects/config?path=${encodeURIComponent(projectPath)}`));
|
||||
setDetail({ loading: false, error: "", kind: "project", key: projectPath, title: projectPath, data });
|
||||
} catch (err) {
|
||||
setDetail({ loading: false, error: errorMessage(err, "Project 详情加载失败"), kind: "project", key: projectPath, title: projectPath, data: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function openJobDetail(job: any): Promise<void> {
|
||||
const jobId = String(job?.id || "");
|
||||
if (!jobId) return;
|
||||
setDetail({ loading: true, error: "", kind: "job", key: jobId, title: job.projectPath || jobId, data: null });
|
||||
try {
|
||||
const data = await requestJson(metApi(apiBaseUrl, `/api/jobs/${encodeURIComponent(jobId)}`));
|
||||
setDetail({ loading: false, error: "", kind: "job", key: jobId, title: data?.job?.projectPath || job.projectPath || jobId, data });
|
||||
} catch (err) {
|
||||
setDetail({ loading: false, error: errorMessage(err, "Job 详情加载失败"), kind: "job", key: jobId, title: job.projectPath || jobId, data: null });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
@@ -280,6 +422,7 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
const image = state.images?.mlImage || state.health?.image || {};
|
||||
const jobs = jobRows(state.queue);
|
||||
const projects = projectRows(state.projects);
|
||||
const projectTree = buildProjectTree(projects);
|
||||
const sourceProject = ui.sourceProject || projects[0]?.projectPath || "";
|
||||
const currentJobs = jobs.filter((job) => ["staged", "queued", "running"].includes(job.status));
|
||||
const completedJobs = jobs.filter((job) => job.status === "succeeded");
|
||||
@@ -296,23 +439,25 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
function queueTable(rows: any[], mode: string) {
|
||||
if (rows.length === 0) return h(EmptyState, { title: mode === "current" ? "当前队列为空" : "暂无记录", text: mode === "current" ? "从项目库选择或 fork project 后先加入待启动队列,再启动队列。" : "终态任务会显示耗时、exit code 和失败诊断。" });
|
||||
return h("div", { className: "table-wrap met-job-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Project"), h("th", null, "Epoch"), h("th", null, "ETA/耗时"), h("th", null, "GPU"), h("th", null, "Exit"), h("th", null, "更新时间"), h("th", null, "操作"))),
|
||||
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Project"), h("th", null, "Epoch"), h("th", null, "速度"), h("th", null, "ETA/耗时"), h("th", null, "GPU"), h("th", null, "Exit"), h("th", null, "更新时间"), h("th", null, "操作"))),
|
||||
h("tbody", null, rows.map((job: any) => {
|
||||
const progress = job.progress || {};
|
||||
const canCancel = ["staged", "queued", "running"].includes(job.status);
|
||||
return h("tr", { key: job.id },
|
||||
const isActive = detail.kind === "job" && detail.key === job.id;
|
||||
return h("tr", { key: job.id, className: `met-click-row ${isActive ? "active" : ""}`, onClick: () => openJobDetail(job), "data-testid": `met-job-row-${safeDomId(job.id)}` },
|
||||
h("td", null, h(StatusBadge, { status: job.status }, statusLabel(job.status))),
|
||||
h("td", null, h("strong", null, job.projectPath), h("code", null, job.id)),
|
||||
h("td", null, h("button", { type: "button", className: "met-inline-link", onClick: (event: any) => { event.stopPropagation(); openJobDetail(job); } }, job.projectPath), h("code", null, job.id)),
|
||||
h("td", null,
|
||||
h("span", null, `${progress.currentEpoch ?? "--"} / ${progress.epochTarget ?? job.epochTarget ?? "--"}`),
|
||||
h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(progress.progressPercent) } })),
|
||||
),
|
||||
h("td", null, h("strong", null, fmtEpochSpeed(jobEpochPerHour(job)))),
|
||||
h("td", null, job.status === "succeeded" || job.status === "failed" || job.status === "canceled" ? jobDuration(job) : (job.status === "running" ? `ETA ${fmtDuration(jobEtaSeconds(job))}` : "--")),
|
||||
h("td", null, job.gpuName || "--"),
|
||||
h("td", null, job.exitCode ?? "--"),
|
||||
h("td", null, fmtDate(job.updatedAt)),
|
||||
h("td", null,
|
||||
canCancel ? h("button", { type: "button", className: "ghost-btn mini", onClick: () => cancelJob(job), disabled: state.actionBusy }, "取消") : null,
|
||||
canCancel ? h("button", { type: "button", className: "ghost-btn mini", onClick: (event: any) => { event.stopPropagation(); cancelJob(job); }, disabled: state.actionBusy }, "取消") : null,
|
||||
h(RawButton, { title: `MET Job ${job.id}`, data: job, onOpen: onRaw, testId: `raw-met-job-${job.id}` }),
|
||||
),
|
||||
);
|
||||
@@ -330,6 +475,188 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
);
|
||||
}
|
||||
|
||||
function projectFolderExpanded(path: string, depth: number): boolean {
|
||||
const explicit = ui.expandedProjectDirs[path];
|
||||
return explicit === undefined ? depth < 2 : Boolean(explicit);
|
||||
}
|
||||
|
||||
function toggleProjectFolder(path: string, depth: number): void {
|
||||
const expanded = projectFolderExpanded(path, depth);
|
||||
patchUi({ expandedProjectDirs: { ...ui.expandedProjectDirs, [path]: !expanded } });
|
||||
}
|
||||
|
||||
function renderProjectTreeNode(node: ProjectTreeNode): any {
|
||||
const paddingLeft = 8 + Math.max(0, node.depth) * 16;
|
||||
const isProject = Boolean(node.project);
|
||||
if (isProject) {
|
||||
const project = node.project;
|
||||
const selected = Boolean(ui.selectedProjects[project.projectPath]);
|
||||
const active = detail.kind === "project" && detail.key === project.projectPath;
|
||||
return h("div", {
|
||||
key: node.path,
|
||||
className: `met-tree-row project ${selected ? "selected" : ""} ${active ? "active" : ""}`,
|
||||
style: { paddingLeft },
|
||||
onClick: () => openProjectDetail(project),
|
||||
"data-testid": `met-project-node-${safeDomId(project.projectPath)}`,
|
||||
},
|
||||
h("div", { className: "met-tree-name" },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: selected,
|
||||
onClick: (event: any) => event.stopPropagation(),
|
||||
onChange: (event: any) => patchUi({ selectedProjects: { ...ui.selectedProjects, [project.projectPath]: event.target.checked } }),
|
||||
"data-testid": `met-project-checkbox-${safeDomId(project.projectPath)}`,
|
||||
}),
|
||||
h("button", { type: "button", className: "met-inline-link project-path", onClick: (event: any) => { event.stopPropagation(); openProjectDetail(project); } }, node.name),
|
||||
),
|
||||
h("span", null, project.useModel || "--"),
|
||||
h("span", null, project.epochTrain ?? "--"),
|
||||
h("span", null, fmtPercent(project.progress?.progressPercent)),
|
||||
h("span", null, fmtEpochSpeed(project.progress?.epochPerHour)),
|
||||
);
|
||||
}
|
||||
const expanded = projectFolderExpanded(node.path, node.depth);
|
||||
return h(React.Fragment, { key: node.path },
|
||||
h("div", { className: "met-tree-row folder", style: { paddingLeft }, "data-testid": `met-project-folder-${safeDomId(node.path)}` },
|
||||
h("button", { type: "button", className: "met-tree-toggle", onClick: () => toggleProjectFolder(node.path, node.depth), "aria-label": expanded ? `折叠 ${node.path}` : `展开 ${node.path}` }, expanded ? "-" : "+"),
|
||||
h("strong", null, node.name),
|
||||
h("span", { className: "met-tree-count" }, `${node.count} projects`),
|
||||
),
|
||||
expanded ? node.children.map((child) => renderProjectTreeNode(child)) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function renderKvGrid(items: Array<{ label: string; value: any; hint?: string }>) {
|
||||
return h("div", { className: "met-detail-kv" }, items.map((item) => h("div", { key: item.label, className: "met-detail-kv-item" },
|
||||
h("span", null, item.label),
|
||||
h("strong", null, fmtValue(item.value)),
|
||||
item.hint ? h("small", null, item.hint) : null,
|
||||
)));
|
||||
}
|
||||
|
||||
function renderMetricList(title: string, items: Array<{ label: string; value: any; hint?: string }>) {
|
||||
return h("div", { className: "met-detail-section" },
|
||||
h("h3", null, title),
|
||||
renderKvGrid(items),
|
||||
);
|
||||
}
|
||||
|
||||
function renderLayerTable(layers: any[]) {
|
||||
if (!Array.isArray(layers) || layers.length === 0) return h(EmptyState, { title: "模型层未上报", text: "等待 data/model_info.json 或 compute_analysis.json 生成。" });
|
||||
return h("div", { className: "table-wrap met-layer-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "Layer"), h("th", null, "Type"), h("th", null, "Params"), h("th", null, "Trainable"), h("th", null, "Compute"))),
|
||||
h("tbody", null, layers.slice(0, 18).map((layer: any, index: number) => h("tr", { key: `${layer.name || "layer"}-${index}` },
|
||||
h("td", null, layer.name || `#${index + 1}`),
|
||||
h("td", null, layer.type || "--"),
|
||||
h("td", null, fmtNumber(layer.num_params)),
|
||||
h("td", null, layer.trainable === undefined ? "--" : String(Boolean(layer.trainable))),
|
||||
h("td", null, fmtNumber(layer.compute?.total ?? layer.estimated_cost?.weighted_units?.total)),
|
||||
))),
|
||||
));
|
||||
}
|
||||
|
||||
function renderDataFiles(files: any[]) {
|
||||
const list = Array.isArray(files) ? files : [];
|
||||
if (list.length === 0) return h(EmptyState, { title: "data/ 暂无文件", text: "训练或评估完成后会生成 training_state、metrics、model_info 等文件。" });
|
||||
return h("div", { className: "met-file-chip-grid" }, list.slice(0, 48).map((file: string) => h("span", { key: file }, file)), list.length > 48 ? h("span", null, `+${list.length - 48}`) : null);
|
||||
}
|
||||
|
||||
function renderLogTail(text: any) {
|
||||
const lines = String(text || "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-12);
|
||||
if (lines.length === 0) return h(EmptyState, { title: "暂无日志尾部", text: "该任务未上报 logTail 或日志已轮转。" });
|
||||
return h("div", { className: "met-log-lines" }, lines.map((line, index) => h("div", { key: `${index}-${line.slice(0, 16)}` }, line)));
|
||||
}
|
||||
|
||||
function renderDetailPanel() {
|
||||
if (detail.loading) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h(EmptyState, { title: "详情加载中", text: detail.title || "正在读取 D601 data/ 和 config.json" }));
|
||||
if (detail.error) return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" }, h("div", { className: "form-error wide" }, detail.error));
|
||||
if (!detail.data) return h("section", { className: "met-detail-panel muted", "data-testid": "met-detail-panel" }, h(EmptyState, { title: "选择一个项目或任务查看详情", text: "项目库、当前队列、已完成和失败诊断中的行都可以点击;默认只展示结构化字段,原始 JSON 需显式点击按钮。" }));
|
||||
const payload = detailPayload(detail);
|
||||
const job = detailJob(detail);
|
||||
const config = objectValue(payload.config);
|
||||
const progress = objectValue(payload.progress || job.progress);
|
||||
const data = objectValue(payload.data);
|
||||
const metrics = objectValue(payload.metrics || data.metrics || progress.trainingInfo?.evaluation_metrics);
|
||||
const trainingInfo = objectValue(data.trainingInfo || progress.trainingInfo);
|
||||
const trainingState = objectValue(data.trainingState);
|
||||
const model = objectValue(payload.model || data.model);
|
||||
const layers = Array.isArray(model.modelSummary) && model.modelSummary.length > 0 ? model.modelSummary : model.computeLayers;
|
||||
const evalMetrics = objectValue(trainingInfo.evaluation_metrics);
|
||||
const title = detail.kind === "job" ? "训练任务详情" : "Project 详情";
|
||||
return h("section", { className: "met-detail-panel", "data-testid": "met-detail-panel" },
|
||||
h("div", { className: "panel-head compact" },
|
||||
h("div", null,
|
||||
h("p", { className: "panel-eyebrow" }, detail.kind === "job" ? "Job + Project Detail" : "Project Library Detail"),
|
||||
h("h2", null, title),
|
||||
h("code", null, payload.projectPath || job.projectPath || detail.title),
|
||||
),
|
||||
h("div", { className: "panel-actions" }, h(RawButton, { title: `MET ${title}`, data: detail.data, onOpen: onRaw, testId: "raw-met-detail" })),
|
||||
),
|
||||
detail.kind === "job" ? renderMetricList("任务状态", [
|
||||
{ label: "Job ID", value: job.id },
|
||||
{ label: "状态", value: statusLabel(job.status) },
|
||||
{ label: "GPU", value: job.gpuName },
|
||||
{ label: "Exit Code", value: job.exitCode },
|
||||
{ label: "耗时", value: jobDuration(job) },
|
||||
{ label: "训练速度", value: fmtEpochSpeed(jobEpochPerHour({ ...job, progress })) },
|
||||
]) : null,
|
||||
renderMetricList("config.json", [
|
||||
{ label: "use_model", value: config.use_model },
|
||||
{ label: "epoch_train", value: config.epoch_train },
|
||||
{ label: "step_per_epoch", value: config.step_per_epoch },
|
||||
{ label: "learning_rate", value: config.learning_rate },
|
||||
{ label: "using_gpu", value: config.using_gpu },
|
||||
{ label: "use_points", value: config.use_points },
|
||||
{ label: "sample_rate", value: config.sample_rate },
|
||||
{ label: "time_clipped_s", value: config.time_clipped_s },
|
||||
{ label: "H_UNITS", value: config.H_UNITS },
|
||||
{ label: "INNER_KAN_UNITS", value: config.INNER_KAN_UNITS },
|
||||
{ label: "INNER_KAN_LAYERS", value: config.INNER_KAN_LAYERS },
|
||||
{ label: "GRID_SIZE", value: config.GRID_SIZE },
|
||||
{ label: "SPLINE_ORDER", value: config.SPLINE_ORDER },
|
||||
{ label: "USE_FAST_MODEL", value: config.USE_FAST_MODEL },
|
||||
{ label: "IIR_TRAINABLE", value: config.IIR_TRAINABLE },
|
||||
]),
|
||||
renderMetricList("data/ 训练状态", [
|
||||
{ label: "Epoch", value: `${progress.currentEpoch ?? trainingState.current_epoch ?? trainingState.completed_epoch ?? "--"} / ${progress.epochTarget ?? config.epoch_train ?? "--"}` },
|
||||
{ label: "Progress", value: fmtPercent(progress.progressPercent) },
|
||||
{ label: "Last Loss", value: progress.lastLoss ?? trainingState.loss },
|
||||
{ label: "Last Val Loss", value: progress.lastValLoss ?? trainingState.val_loss },
|
||||
{ label: "Min Loss", value: trainingInfo.min_loss ?? trainingState.min_loss },
|
||||
{ label: "Min Val Loss", value: trainingInfo.min_val_loss ?? trainingState.min_val_loss },
|
||||
{ label: "Log Lines", value: progress.logLineCount },
|
||||
{ label: "ETA", value: fmtDuration(progress.etaSeconds ?? trainingState.remaining_time) },
|
||||
{ label: "训练速度", value: fmtEpochSpeed(progress.epochPerHour ?? trainingState.smoothed_speed) },
|
||||
{ label: "Training Alive", value: trainingState.training_alive },
|
||||
]),
|
||||
renderMetricList("模型参数", [
|
||||
{ label: "Model Type", value: model.modelType ?? config.use_model },
|
||||
{ label: "Total Params", value: model.totalParams, hint: model.totalParams === null || model.totalParams === undefined ? "未上报" : "data/model_info.json" },
|
||||
{ label: "Trainable", value: model.trainableParams },
|
||||
{ label: "Non-trainable", value: model.nonTrainableParams },
|
||||
{ label: "Compute Cost", value: model.computeCost },
|
||||
{ label: "Estimate Status", value: model.estimateStatus },
|
||||
{ label: "Unsupported Layers", value: model.unsupportedLayerCount },
|
||||
]),
|
||||
renderMetricList("指标", [
|
||||
{ label: "train_loss", value: metrics.train_loss ?? evalMetrics.train_loss },
|
||||
{ label: "val_loss", value: metrics.val_loss ?? evalMetrics.val_loss },
|
||||
{ label: "train_mae", value: metrics.train_mae ?? evalMetrics.train_mae },
|
||||
{ label: "val_mae", value: metrics.val_mae ?? evalMetrics.val_mae },
|
||||
{ label: "train_afmae", value: metrics.train_afmae ?? evalMetrics.train_afmae },
|
||||
{ label: "val_afmae", value: metrics.val_afmae ?? evalMetrics.val_afmae },
|
||||
{ label: "freq_drift_hz", value: metrics.freq_drift_hz },
|
||||
{ label: "sens_drift_percent", value: metrics.sens_drift_percent },
|
||||
{ label: "linearity_percent", value: metrics.linearity_percent },
|
||||
{ label: "weights_source", value: metrics.weights_source ?? evalMetrics.weights_source },
|
||||
{ label: "lr min/mean/max", value: `${fmtValue(trainingInfo.learning_rate_min)} / ${fmtValue(trainingInfo.learning_rate_mean)} / ${fmtValue(trainingInfo.learning_rate_max)}` },
|
||||
]),
|
||||
h("div", { className: "met-detail-section" }, h("h3", null, "模型层"), renderLayerTable(layers)),
|
||||
h("div", { className: "met-detail-section" }, h("h3", null, "data/ 文件"), renderDataFiles(data.files)),
|
||||
detail.kind === "job" ? h("div", { className: "met-detail-section" }, h("h3", null, "日志尾部"), renderLogTail(objectValue(detail.data).logTail)) : null,
|
||||
);
|
||||
}
|
||||
|
||||
return h("div", { className: "met-page", "data-testid": "met-nonlinear-page" },
|
||||
h(Panel, {
|
||||
title: "MET Nonlinear 训练编排",
|
||||
@@ -404,23 +731,18 @@ export function MetNonlinearPage({ microservices, onRaw, apiBaseUrl = "/api" }:
|
||||
h("p", { className: "muted paragraph" }, "Fork 只创建新 Project 并自动勾选,不会直接训练;需要在右侧确认后加入待启动队列。"),
|
||||
),
|
||||
h("div", { className: "met-project-list" },
|
||||
h("div", { className: "panel-head compact" }, h("div", null, h("p", { className: "panel-eyebrow" }, "Existing Projects"), h("h2", null, "选择已有 Project")), h("button", { type: "button", className: "ghost-btn", onClick: stageSelectedProjects, disabled: state.actionBusy || selectedProjectPaths().length === 0, "data-testid": "met-stage-selected-button" }, `加入待启动队列 (${selectedProjectPaths().length})`)),
|
||||
h("div", { className: "panel-head compact" }, h("div", null, h("p", { className: "panel-eyebrow" }, `Existing Projects · ${(state.projects?.roots || []).map((root: any) => `${root.root} ${root.count}`).join(" / ")}`), h("h2", null, "选择已有 Project")), h("button", { type: "button", className: "ghost-btn", onClick: stageSelectedProjects, disabled: state.actionBusy || selectedProjectPaths().length === 0, "data-testid": "met-stage-selected-button" }, `加入待启动队列 (${selectedProjectPaths().length})`)),
|
||||
projects.length === 0 ? h(EmptyState, { title: "暂无 project", text: "等待 D601 返回 /api/projects" }) :
|
||||
h("div", { className: "table-wrap met-project-table" }, h("table", null,
|
||||
h("thead", null, h("tr", null, h("th", null, "选择"), h("th", null, "Project"), h("th", null, "Model"), h("th", null, "Epochs"), h("th", null, "Progress"))),
|
||||
h("tbody", null, projects.map((project: any) => h("tr", { key: project.projectPath },
|
||||
h("td", null, h("input", { type: "checkbox", checked: Boolean(ui.selectedProjects[project.projectPath]), onChange: (event: any) => patchUi({ selectedProjects: { ...ui.selectedProjects, [project.projectPath]: event.target.checked } }), "data-testid": `met-project-checkbox-${project.projectPath.replace(/[^a-zA-Z0-9_-]/g, "-")}` })),
|
||||
h("td", null, h("strong", null, project.projectPath)),
|
||||
h("td", null, project.useModel || "--"),
|
||||
h("td", null, project.epochTrain ?? "--"),
|
||||
h("td", null, fmtPercent(project.progress?.progressPercent)),
|
||||
))),
|
||||
)),
|
||||
h("div", { className: "met-project-table", "data-testid": "met-project-tree" },
|
||||
h("div", { className: "met-tree-header" }, h("span", null, "文件树 Project"), h("span", null, "Model"), h("span", null, "Epochs"), h("span", null, "Progress"), h("span", null, "速度")),
|
||||
projectTree.children.map((node) => renderProjectTreeNode(node)),
|
||||
),
|
||||
),
|
||||
renderDetailPanel(),
|
||||
) : null,
|
||||
ui.activeTab === "current" ? h("div", { "data-testid": "met-current-pane" }, currentQueueSummary(), queueTable(currentJobs, "current"), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Queue", data: state.queue, onOpen: onRaw, testId: "raw-met-queue" }))) : null,
|
||||
ui.activeTab === "completed" ? h("div", { "data-testid": "met-completed-pane" }, queueTable(completedJobs.length > 0 ? completedJobs : terminalHistory.filter((job: any) => job.status === "succeeded"), "completed")) : null,
|
||||
ui.activeTab === "failed" ? h("div", { "data-testid": "met-failed-pane" }, queueTable(failedJobs.length > 0 ? failedJobs : terminalHistory.filter((job: any) => ["failed", "canceled"].includes(job.status)), "failed"), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET History", data: state.history, onOpen: onRaw, testId: "raw-met-history" }))) : null,
|
||||
ui.activeTab === "current" ? h("div", { "data-testid": "met-current-pane" }, currentQueueSummary(), queueTable(currentJobs, "current"), renderDetailPanel(), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Queue", data: state.queue, onOpen: onRaw, testId: "raw-met-queue" }))) : null,
|
||||
ui.activeTab === "completed" ? h("div", { "data-testid": "met-completed-pane" }, queueTable(completedJobs.length > 0 ? completedJobs : terminalHistory.filter((job: any) => job.status === "succeeded"), "completed"), renderDetailPanel()) : null,
|
||||
ui.activeTab === "failed" ? h("div", { "data-testid": "met-failed-pane" }, queueTable(failedJobs.length > 0 ? failedJobs : terminalHistory.filter((job: any) => ["failed", "canceled"].includes(job.status)), "failed"), renderDetailPanel(), h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET History", data: state.history, onOpen: onRaw, testId: "raw-met-history" }))) : null,
|
||||
ui.activeTab === "gpu" ? h("div", { className: "met-gpu-pane", "data-testid": "met-gpu-pane" },
|
||||
gpus.length === 0 ? h(EmptyState, { title: "暂无 GPU 上报", text: "等待 D601 met-nonlinear-ts 或 ML image 提供 nvidia-smi 数据" }) :
|
||||
h("div", { className: "table-wrap" }, h("table", null,
|
||||
|
||||
@@ -356,11 +356,15 @@ function pipelineNodeComponent(node: any, componentByRef: Map<string, AnyRecord>
|
||||
}
|
||||
|
||||
function pipelineRunNodeStatus(run: any, nodeId: string): string {
|
||||
const nodes = Array.isArray(run?.nodes) ? run.nodes : [];
|
||||
const node = nodes.find((item: any) => item?.nodeId === nodeId || item?.id === nodeId);
|
||||
const node = pipelineRunNodeRecord(run, nodeId);
|
||||
return String(node?.status || "pending");
|
||||
}
|
||||
|
||||
function pipelineRunNodeRecord(run: any, nodeId: string): AnyRecord | null {
|
||||
const nodes = Array.isArray(run?.nodes) ? run.nodes : [];
|
||||
return nodes.find((item: any) => item?.nodeId === nodeId || item?.id === nodeId) || null;
|
||||
}
|
||||
|
||||
function pipelineStatusCounts(runs: any[]): AnyRecord {
|
||||
return runs.reduce((counts: AnyRecord, run: any) => {
|
||||
const status = String(run?.status || "unknown").toLowerCase();
|
||||
@@ -789,10 +793,129 @@ function pipelineLatestRun(runs: any[], pipelineId: string): any {
|
||||
return runs.find((run) => String(run?.pipelineId || "") === pipelineId) || null;
|
||||
}
|
||||
|
||||
function pipelineNodeControlState(): AnyRecord {
|
||||
return {
|
||||
loading: false,
|
||||
actionLoading: "",
|
||||
error: "",
|
||||
message: "",
|
||||
details: null,
|
||||
fetchedAt: null,
|
||||
appendPrompt: "",
|
||||
guidePrompt: "",
|
||||
redoReason: "",
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineProxyPath(apiBaseUrl: string, path: string): string {
|
||||
return `${apiBaseUrl}/microservices/pipeline/proxy${path}`;
|
||||
}
|
||||
|
||||
function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRunChange, selectedNodeId, selectedNodeConfig, selectedNodeRuntime, control, onControlChange, onFetch, onAction, onRaw }: AnyRecord) {
|
||||
const runId = String(activeRun?.runId || "");
|
||||
const status = String(selectedNodeRuntime?.status || "pending");
|
||||
const details = control.details || {};
|
||||
const procedureRuns = Array.isArray(details.procedureRuns) ? details.procedureRuns : [];
|
||||
const latestProcedure = procedureRuns.at(-1) || {};
|
||||
const attempts = Array.isArray(latestProcedure.attempts) ? latestProcedure.attempts : [];
|
||||
const latestAttempt = attempts.at(-1) || {};
|
||||
const workerLogTail = Array.isArray(latestProcedure.workerLogTail) ? latestProcedure.workerLogTail : [];
|
||||
const controlEventsTail = Array.isArray(latestAttempt.controlEventsTail) ? latestAttempt.controlEventsTail : [];
|
||||
const disabled = !runId || !selectedNodeId || control.loading || Boolean(control.actionLoading);
|
||||
const updateText = (field: string) => (event: any) => onControlChange({ [field]: event.target.value, error: "", message: "" });
|
||||
const runOptions = pipelineRuns.length > 0 ? pipelineRuns : (activeRun ? [activeRun] : []);
|
||||
return h("aside", { className: "pipeline-node-control", "data-testid": "pipeline-node-control" },
|
||||
h("div", { className: "pipeline-node-control-head" },
|
||||
h("div", null,
|
||||
h("p", { className: "panel-eyebrow" }, "Manual Node Control"),
|
||||
h("h3", null, selectedNodeId || "点击控制图中的 node"),
|
||||
),
|
||||
selectedNodeId ? h(StatusBadge, { status }, status) : h(StatusBadge, { status: "pending" }, "idle"),
|
||||
),
|
||||
h("div", { className: "pipeline-control-runbar" },
|
||||
h("label", null,
|
||||
h("span", null, "目标 run"),
|
||||
h("select", {
|
||||
value: runId || selectedRunId,
|
||||
disabled: runOptions.length === 0,
|
||||
onChange: (event: any) => onRunChange(event.target.value),
|
||||
"data-testid": "pipeline-node-run-select",
|
||||
}, runOptions.map((run: any) => h("option", { key: run.runId, value: run.runId }, `${run.runId || "--"} / ${run.status || "--"}`))),
|
||||
),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
disabled,
|
||||
onClick: onFetch,
|
||||
"data-testid": "pipeline-node-fetch",
|
||||
}, control.loading ? "抓取中" : "抓取过程"),
|
||||
control.details ? h(RawButton, { title: `Pipeline Node ${selectedNodeId}`, data: control.details, onOpen: onRaw, testId: "raw-pipeline-node-control" }) : null,
|
||||
),
|
||||
h("div", { className: "pipeline-control-meta" },
|
||||
h("span", null, h("b", null, "kind"), String(selectedNodeConfig?.kind || "--")),
|
||||
h("span", null, h("b", null, "procedure"), String(selectedNodeRuntime?.currentProcedureRunId || "--")),
|
||||
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,
|
||||
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" },
|
||||
h("label", null,
|
||||
h("span", null, "实时追加 prompt(仅 running node)"),
|
||||
h("textarea", { value: control.appendPrompt, onChange: updateText("appendPrompt"), placeholder: "让当前执行中的 agent 继续、补充检查或调整当前步骤...", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-append-input" }),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "primary-btn compact",
|
||||
disabled: disabled || !String(control.appendPrompt || "").trim(),
|
||||
onClick: () => onAction("append"),
|
||||
"data-testid": "pipeline-node-append-button",
|
||||
}, control.actionLoading === "append" ? "追加中" : "追加到运行中 node"),
|
||||
),
|
||||
h("label", null,
|
||||
h("span", null, "下次尝试引导 prompt"),
|
||||
h("textarea", { value: control.guidePrompt, onChange: updateText("guidePrompt"), placeholder: "给该 node 下一次 attempt 的执行提示;不会立即打断当前 session。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-guide-input" }),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn compact",
|
||||
disabled: disabled || !String(control.guidePrompt || "").trim(),
|
||||
onClick: () => onAction("guide"),
|
||||
"data-testid": "pipeline-node-guide-button",
|
||||
}, control.actionLoading === "guide" ? "下发中" : "下发 guide"),
|
||||
),
|
||||
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" }),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "danger-btn compact",
|
||||
disabled: disabled || !String(control.redoReason || "").trim(),
|
||||
onClick: () => onAction("redo"),
|
||||
"data-testid": "pipeline-node-redo-button",
|
||||
}, control.actionLoading === "redo" ? "排队中" : "重做 node"),
|
||||
),
|
||||
),
|
||||
h("div", { className: "pipeline-control-evidence" },
|
||||
h("strong", null, "执行证据"),
|
||||
control.details ? h("div", { className: "pipeline-control-evidence-grid" },
|
||||
h("span", null, `${procedureRuns.length} procedure runs`),
|
||||
h("span", null, `${attempts.length} attempts`),
|
||||
h("span", null, `messages ${summarizeValue(latestAttempt.opencodeMessages?.messageCount)}`),
|
||||
h("span", null, control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched"),
|
||||
) : h("span", { className: "muted" }, "点击“抓取过程”读取 node 的 request/artifact/worker log/control prompt tail。"),
|
||||
workerLogTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, workerLogTail.slice(-8).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 18)}` }, line))) : null,
|
||||
controlEventsTail.length > 0 ? h("div", { className: "pipeline-log-list compact-log" }, controlEventsTail.slice(-6).map((line: string, index: number) => h("code", { key: `event-${index}-${line.slice(0, 18)}` }, line))) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "pipeline") || null;
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null });
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState("");
|
||||
const [selectedRunId, setSelectedRunId] = useState("");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [nodeControl, setNodeControl] = useState(pipelineNodeControlState());
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!service) return;
|
||||
@@ -812,8 +935,6 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" });
|
||||
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
@@ -824,11 +945,20 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
const pipelineNodes = pipelineConfigNodes(activePipeline);
|
||||
const pipelineEdges = pipelineConfigEdges(activePipeline);
|
||||
const latestRun = pipelineLatestRun(runs, activePipelineId);
|
||||
const pipelineRuns = runs.filter((run: any) => String(run?.pipelineId || "") === activePipelineId);
|
||||
const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
|
||||
const activeRunId = String(activeRun?.runId || "");
|
||||
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
|
||||
const selectedNodeRuntime = selectedNodeId ? pipelineRunNodeRecord(activeRun, selectedNodeId) : null;
|
||||
const statusCounts = pipelineStatusCounts(runs);
|
||||
const componentClasses = pipelineComponentClassCounts(components);
|
||||
const componentCount = Number(state.health?.components) || pipelineArrayCount(snapshot, "registry.components", components.length);
|
||||
const runCount = pipelineArrayCount(snapshot, "runs", runs.length);
|
||||
const flow = pipelineFlowElements(activePipeline, latestRun, components);
|
||||
const baseFlow = pipelineFlowElements(activePipeline, activeRun, components);
|
||||
const flow = {
|
||||
nodes: baseFlow.nodes.map((node) => node.id === selectedNodeId ? { ...node, selected: true, className: `${node.className || ""} selected-control-node` } : node),
|
||||
edges: baseFlow.edges,
|
||||
};
|
||||
const exportItems = pipelines.map((pipeline: any) => {
|
||||
const pipelineId = String(pipeline.id || "pipeline");
|
||||
const run = pipelineLatestRun(runs, pipelineId);
|
||||
@@ -837,6 +967,73 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
flow: pipelineFlowElements(pipeline, run, components),
|
||||
};
|
||||
});
|
||||
const pipelineRunIds = pipelineRuns.map((run: any) => String(run?.runId || "")).filter(Boolean).join("|");
|
||||
const pipelineNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean).join("|");
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId || pipelineRunIds.split("|").includes(selectedRunId)) return;
|
||||
setSelectedRunId("");
|
||||
}, [selectedRunId, pipelineRunIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNodeId || pipelineNodeIds.split("|").includes(selectedNodeId)) return;
|
||||
setSelectedNodeId("");
|
||||
setNodeControl(pipelineNodeControlState());
|
||||
}, [selectedNodeId, pipelineNodeIds]);
|
||||
|
||||
async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise<void> {
|
||||
if (!runId || !nodeId) {
|
||||
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
|
||||
return;
|
||||
}
|
||||
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: true, error: "", message: "" }));
|
||||
try {
|
||||
const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=160`));
|
||||
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, details, fetchedAt: new Date(), error: "" }));
|
||||
} catch (err) {
|
||||
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, error: errorMessage(err, "抓取 node 执行过程失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
async function postNodeAction(action: "append" | "guide" | "redo"): Promise<void> {
|
||||
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;
|
||||
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 result = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(activeRunId)}/nodes/${encodeURIComponent(selectedNodeId)}/${endpoint}`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setNodeControl((prev: AnyRecord) => ({
|
||||
...prev,
|
||||
actionLoading: "",
|
||||
details: result,
|
||||
fetchedAt: new Date(),
|
||||
appendPrompt: action === "append" ? "" : prev.appendPrompt,
|
||||
guidePrompt: action === "guide" ? "" : prev.guidePrompt,
|
||||
redoReason: action === "redo" ? "" : prev.redoReason,
|
||||
message: action === "append" ? "已追加到运行中 node" : action === "guide" ? "已下发 guide,等待 runner 处理" : "已排队重做命令",
|
||||
}));
|
||||
await fetchNodeDetails(activeRunId, selectedNodeId);
|
||||
if (action !== "append") await load();
|
||||
} catch (err) {
|
||||
setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: "", error: errorMessage(err, "node 控制操作失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" });
|
||||
|
||||
return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" },
|
||||
h(Panel, {
|
||||
title: "Pipeline v2 工作台",
|
||||
@@ -892,19 +1089,24 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
),
|
||||
h(Panel, {
|
||||
title: "控制图",
|
||||
eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}`,
|
||||
eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`,
|
||||
actions: h("div", { className: "pipeline-toolbar" },
|
||||
h("select", {
|
||||
value: activePipelineId,
|
||||
disabled: pipelines.length === 0,
|
||||
onChange: (event: any) => setSelectedPipelineId(event.target.value),
|
||||
onChange: (event: any) => {
|
||||
setSelectedPipelineId(event.target.value);
|
||||
setSelectedRunId("");
|
||||
setSelectedNodeId("");
|
||||
setNodeControl(pipelineNodeControlState());
|
||||
},
|
||||
"data-testid": "pipeline-select",
|
||||
}, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "ghost-btn",
|
||||
disabled: flow.nodes.length === 0,
|
||||
onClick: () => exportPipelineGraph(flow, `${activePipeline.id || "pipeline"}-${latestRun?.runId || "snapshot"}`),
|
||||
onClick: () => exportPipelineGraph(flow, `${activePipeline.id || "pipeline"}-${activeRun?.runId || "snapshot"}`),
|
||||
"data-testid": "pipeline-export-graph",
|
||||
}, "导出渲染图"),
|
||||
h("button", {
|
||||
@@ -917,32 +1119,59 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
),
|
||||
},
|
||||
pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 config.nodes / config.edges" }) :
|
||||
h("div", { className: "pipeline-flow-frame", "data-testid": "pipeline-react-flow" },
|
||||
h(ReactFlow, {
|
||||
nodes: flow.nodes,
|
||||
edges: flow.edges,
|
||||
nodeTypes: pipelineNodeTypes,
|
||||
edgeTypes: pipelineEdgeTypes,
|
||||
fitView: true,
|
||||
fitViewOptions: { padding: 0.18 },
|
||||
nodesDraggable: false,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
minZoom: 0.25,
|
||||
maxZoom: 1.4,
|
||||
proOptions: { hideAttribution: true },
|
||||
},
|
||||
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
|
||||
h(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
|
||||
h(Controls, { showInteractive: false }),
|
||||
h("div", { className: "pipeline-control-shell" },
|
||||
h("div", { className: "pipeline-flow-frame", "data-testid": "pipeline-react-flow" },
|
||||
h(ReactFlow, {
|
||||
nodes: flow.nodes,
|
||||
edges: flow.edges,
|
||||
nodeTypes: pipelineNodeTypes,
|
||||
edgeTypes: pipelineEdgeTypes,
|
||||
fitView: true,
|
||||
fitViewOptions: { padding: 0.18 },
|
||||
nodesDraggable: false,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
minZoom: 0.25,
|
||||
maxZoom: 1.4,
|
||||
proOptions: { hideAttribution: true },
|
||||
onNodeClick: (_event: any, node: Node) => {
|
||||
const nodeId = String(node.id);
|
||||
setSelectedNodeId(nodeId);
|
||||
setNodeControl(pipelineNodeControlState());
|
||||
if (activeRunId) void fetchNodeDetails(activeRunId, nodeId);
|
||||
},
|
||||
},
|
||||
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
|
||||
h(MiniMap, { pannable: true, zoomable: true, className: "pipeline-minimap" }),
|
||||
h(Controls, { showInteractive: false }),
|
||||
),
|
||||
),
|
||||
h(PipelineNodeControlPanel, {
|
||||
activeRun,
|
||||
pipelineRuns,
|
||||
selectedRunId,
|
||||
onRunChange: (runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setNodeControl(pipelineNodeControlState());
|
||||
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
|
||||
},
|
||||
selectedNodeId,
|
||||
selectedNodeConfig,
|
||||
selectedNodeRuntime,
|
||||
control: nodeControl,
|
||||
onControlChange: (patch: AnyRecord) => setNodeControl((prev: AnyRecord) => ({ ...prev, ...patch })),
|
||||
onFetch: () => fetchNodeDetails(),
|
||||
onAction: postNodeAction,
|
||||
onRaw,
|
||||
}),
|
||||
),
|
||||
h("div", { className: "pipeline-flow-summary" },
|
||||
h("span", null, `${flow.nodes.length} nodes`),
|
||||
h("span", null, `${flow.edges.length} edges`),
|
||||
h("span", null, `${pipelines.length} pipelines`),
|
||||
h("span", null, `source config+components(${components.length})`),
|
||||
h("span", null, `latest ${latestRun?.runId || "--"}`),
|
||||
h("span", null, `run ${activeRun?.runId || "--"}`),
|
||||
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` },
|
||||
@@ -959,12 +1188,12 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
|
||||
h("code", null, fmtDate(run.updatedAt)),
|
||||
))),
|
||||
),
|
||||
h(Panel, { title: "证据日志", eyebrow: latestRun?.runId || "latest worker tail" },
|
||||
!latestRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
|
||||
h(Panel, { title: "证据日志", eyebrow: activeRun?.runId || "selected worker tail" },
|
||||
!activeRun ? h(EmptyState, { title: "暂无证据", text: "没有 Pipeline run 时不会展示 worker log tail" }) :
|
||||
h("div", { className: "pipeline-log-list" },
|
||||
(Array.isArray(latestRun.workerLogTail) && latestRun.workerLogTail.length > 0 ? latestRun.workerLogTail.slice(-12) : [`${latestRun.runId} ${latestRun.status || "--"} / ${fmtDate(latestRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
|
||||
(Array.isArray(activeRun.workerLogTail) && activeRun.workerLogTail.length > 0 ? activeRun.workerLogTail.slice(-12) : [`${activeRun.runId} ${activeRun.status || "--"} / ${fmtDate(activeRun.updatedAt)}`]).map((line: string, index: number) => h("code", { key: `${index}-${line.slice(0, 24)}` }, line)),
|
||||
),
|
||||
h("div", { className: "panel-actions inline-actions" }, latestRun ? h(RawButton, { title: `Pipeline Run ${latestRun.runId}`, data: latestRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
|
||||
h("div", { className: "panel-actions inline-actions" }, activeRun ? h(RawButton, { title: `Pipeline Run ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user