From b1c717b8348ef88d92a7856300dcbde9f3cdc105 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 15:59:34 +0000 Subject: [PATCH] feat: integrate MET Nonlinear microservice --- AGENTS.md | 8 +- TEST.md | 8 +- config.json | 43 +++ docs/reference/e2e.md | 10 +- docs/reference/frontend.md | 6 +- docs/reference/microservices.md | 27 +- docs/reference/repo-tree.md | 1 + scripts/src/e2e.ts | 36 ++- src/components/frontend/public/style.css | 24 +- src/components/frontend/src/app.tsx | 4 + src/components/frontend/src/met-nonlinear.tsx | 306 ++++++++++++++++++ 11 files changed, 455 insertions(+), 18 deletions(-) create mode 100644 src/components/frontend/src/met-nonlinear.tsx diff --git a/AGENTS.md b/AGENTS.md index ba2a2b1a..c4ac6f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 - `bun scripts/cli.ts server rebuild `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 -- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Todo Note on main-server 与 FindJob/Pipeline on D601 的规则见 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 @@ -28,14 +28,14 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 - `docker-compose.yml`:主 server 统一编排 core、frontend、database、本机 provider gateway 和 Todo Note 后端,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,Todo Note、FindJob、Pipeline 等业务页必须拆到独立 TSX 模块,并采用高信息密度工业控制台设计,界面规则见 `docs/reference/frontend.md`。 +- `src/components/frontend`:前端源码固定使用 TypeScript + React,`app.tsx` 只做 shell/router,Todo Note、FindJob、Pipeline、MET Nonlinear 等业务页必须拆到独立 TSX 模块,并采用高信息密度工业控制台设计,界面规则见 `docs/reference/frontend.md`。 - `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,必须同时部署 always-enabled 远程升级和 Host SSH / WSL SSH 透传并完成自测,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`。 -- `microservices`:非 UniDesk 核心业务通常应在计算节点通过 SSH 透传开发调试;Todo Note 因数据统一迁移部署在主 server `/root/todo_note`,其余业务以仓库 URL、commit id 和业务仓库自带 Dockerfile/docker-compose 引用挂载为 UniDesk microservice,规则见 `docs/reference/microservices.md`。 +- `microservices`:主 server 本地开发边界固定为只开发 UniDesk frontend;非 UniDesk 核心业务后端、Dockerfile、GPU/训练调试必须在目标计算节点通过 SSH 透传完成,Todo Note 这类明确写入主 server 的例外需单独登记,规则见 `docs/reference/microservices.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。 ## Architecture Docs - `docs/reference/arch.md`:UniDesk 分布式工作平台的长期架构约束。 - `docs/reference/repo-tree.md`:仓库结构目标与组件边界。 -- `docs/reference/microservices.md`:计算节点 microservice 的配置、代理、安全边界、Todo Note on main-server、FindJob/Pipeline on D601 和验证规则。 +- `docs/reference/microservices.md`:计算节点 microservice 的配置、代理、安全边界、Todo Note on main-server、FindJob/Pipeline/MET Nonlinear on D601 和验证规则。 - `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。 diff --git a/TEST.md b/TEST.md index 5215a9cb..d2109788 100644 --- a/TEST.md +++ b/TEST.md @@ -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: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-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`、`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: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` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`D601`、`FindJob`、`Pipeline`、`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: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 透传`、`远程更新` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -50,7 +50,7 @@ ## T12 前端 TypeScript + React 源码约束 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `find src/components/frontend -type f \\( -name '*.js' -o -name '*.jsx' \\) -print`,确认没有手写 frontend JS/JSX 源码;确认 `src/components/frontend/src/app.tsx` 只承担 shell/router,Todo Note、FindJob、Pipeline 分别在 `src/components/frontend/src/todo-note.tsx`、`src/components/frontend/src/findjob.tsx`、`src/components/frontend/src/pipeline.tsx` 中维护;运行 `bun scripts/cli.ts check`,确认这些 TSX 模块全部纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX imports 转译生成。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `find src/components/frontend -type f \\( -name '*.js' -o -name '*.jsx' \\) -print`,确认没有手写 frontend JS/JSX 源码;确认 `src/components/frontend/src/app.tsx` 只承担 shell/router,Todo Note、FindJob、Pipeline、MET Nonlinear 分别在 `src/components/frontend/src/todo-note.tsx`、`src/components/frontend/src/findjob.tsx`、`src/components/frontend/src/pipeline.tsx`、`src/components/frontend/src/met-nonlinear.tsx` 中维护;运行 `bun scripts/cli.ts check`,确认这些 TSX 模块全部纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX imports 转译生成。 ## T13 资源节点任务管理器曲线 @@ -92,3 +92,7 @@ ## T22 Main Server Todo Note Microservice 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 D518 `/mnt/d/work/todo_note` 已复制到主 server `/root/todo_note`,运行 `bun scripts/cli.ts microservice list`,确认 `todo-note` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://gitee.com/Lyon1998/todo_note`、`todo-note:4211` 后端映射和 `todo-note-backend` 容器摘要;运行 `bun scripts/cli.ts microservice health todo-note`,确认返回 `storage=postgres`;运行 `bun scripts/cli.ts microservice proxy todo-note /api/instances --max-body-bytes 8000`,确认能看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务` 五个迁移清单,总任务数不低于 100。随后通过 backend-core 或 `bun scripts/cli.ts e2e run` 执行临时清单 create/add/toggle/undo/delete 写入循环,确认 Todo Note 写入真实经过 backend-core、main-server provider-gateway、`todo-note-backend` 和主 PostgreSQL,且删除前必须按唯一临时清单名称重新选中临时清单,不能误删迁移清单。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / Todo Note`,确认清单、树形任务、筛选、提醒、移动、撤销/重做、字号控制都以 React 控件展示,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。 + +## 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 后端;在 D601 通过 SSH 透传执行 `curl -fsS -X POST http://127.0.0.1:3288/api/queue/server-test -H 'content-type: application/json' -d '{"sourceProject":"projects/FRIKANh6u6l4","count":10,"epochs":10,"maxConcurrency":2}'`,随后轮询 `/api/queue`,确认最多 2 个训练容器并行、目标 GPU 是 2080Ti、显存余量低于 20% 时自动限制并发、10 个 `projects/server_test/` 任务最终成功且训练容器自动销毁。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / MET Nonlinear`,确认页面以 React 控件显示队列、GPU/镜像、Project config 预览、训练进度、ETA 和历史记录,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。 diff --git a/config.json b/config.json index 3a8d8c09..5b77c39d 100644 --- a/config.json +++ b/config.json @@ -145,6 +145,49 @@ "integrated": true } }, + { + "id": "met-nonlinear", + "name": "MET Nonlinear", + "providerId": "D601", + "description": "MET Nonlinear 训练编排微服务,TS 后端部署在 D601 Docker 中,按需拉起 TensorFlow 2.6 GPU 训练容器并由 UniDesk frontend 展示队列、进度和历史记录。", + "repository": { + "url": "https://github.com/pikasTech/met_nonlinear", + "commitId": "874889dfee3afcab135568b450f3d7b30b8fd3e0", + "dockerfile": "docker/unidesk/Dockerfile.ml", + "composeFile": "docker-compose.unidesk.yml", + "composeService": "met-nonlinear-ts", + "containerName": "met-nonlinear-ts" + }, + "backend": { + "nodeBaseUrl": "http://host.docker.internal:3288", + "nodeBindHost": "127.0.0.1", + "nodePort": 3288, + "proxyMode": "provider-gateway-http", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST", + "PUT" + ], + "allowedPathPrefixes": [ + "/health", + "/api/" + ], + "healthPath": "/health", + "timeoutMs": 30000 + }, + "development": { + "providerId": "D601", + "sshPassthrough": true, + "worktreePath": "/home/ubuntu/met_nonlinear" + }, + "frontend": { + "route": "/apps/met-nonlinear", + "integrated": true + } + }, { "id": "todo-note", "name": "Todo Note", diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 5350b3ca..04a2f87d 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -12,14 +12,14 @@ UniDesk delivery is not complete until the public frontend, public provider ingr `bun scripts/cli.ts e2e run` validates the following URLs and internal checks derived from `config.json`. The CLI response is intentionally bounded: it prints check names/statuses, screenshot path, counts, and `resultPath`; the full per-check diagnostics are written to `resultPath` under `.state/e2e/` so failures remain inspectable without flooding stdout. -- Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private microservice ports such as FindJob `3254` and Todo Note `4211` probes must fail. +- Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private microservice ports such as FindJob `3254`, MET Nonlinear `3288` and Todo Note `4211` probes must fail. - Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true`, `pgdata.volumeName=unidesk_pgdata_10gb`, a positive PostgreSQL database byte count, and at least one online node. - Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`, `labels.providerGatewayVersion` equal to `src/components/provider-gateway/package.json` and `labels.providerGatewayUpgradePolicy: "always-enabled"`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples; 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` and `pipeline` 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. +- 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. - 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` and `微服务 / Pipeline` to verify 主 server Todo Note、D601、仓库引用、私有后端映射、Todo Note 迁移清单和树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行都通过 React 控件展示。 -- 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 `最近运行`; 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 队列/GPU/镜像/Project config/训练历史都通过 React 控件展示。 +- 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`, `创建10个10轮任务`, task queue and GPU/image panels; 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; 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, 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. ## Public Boundary Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index e16d660c..85a47664 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -6,11 +6,11 @@ UniDesk 前端是 React 组件化工业控制台,不追求展示型大屏效 frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components/frontend` 中维护手写 `.js` / `.jsx` 应用源码。浏览器请求的 `/app.js` 只能由 frontend Bun server 从 `src/components/frontend/src/app.tsx` 及其 TSX imports 转译生成;`public/` 目录只保存 HTML/CSS 等静态资产,不提交手写 `app.js`。 -`src/components/frontend/src/app.tsx` 只承担应用 shell、登录、全局数据加载、主模块/子标签路由和通用控制台页面。业务 microservice 前端必须模块化到独立 TSX 文件,禁止继续把所有业务页面堆进 `app.tsx`。当前长期固定入口为:`todo-note.tsx` 承载 Todo Note 工作台,`findjob.tsx` 承载 FindJob 工作台,`pipeline.tsx` 承载 Pipeline 工作台;新增业务 microservice 也必须按同样规则新增独立页面模块,并由 `app.tsx` 只做导入和路由分发。 +`src/components/frontend/src/app.tsx` 只承担应用 shell、登录、全局数据加载、主模块/子标签路由和通用控制台页面。业务 microservice 前端必须模块化到独立 TSX 文件,禁止继续把所有业务页面堆进 `app.tsx`。当前长期固定入口为:`todo-note.tsx` 承载 Todo Note 工作台,`findjob.tsx` 承载 FindJob 工作台,`pipeline.tsx` 承载 Pipeline 工作台,`met-nonlinear.tsx` 承载 MET Nonlinear 训练编排工作台;新增业务 microservice 也必须按同样规则新增独立页面模块,并由 `app.tsx` 只做导入和路由分发。 ## Layout -左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 +左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline、MET Nonlinear 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 ## Overview Task Drilldown @@ -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 控制图框图、最近运行卡片和证据日志摘要。该模块不得 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` 的 snapshot 后端渲染为组件矩阵、React Flow 控制图框图、最近运行卡片和证据日志摘要;`MET Nonlinear` 子标签必须把 D601 `/home/ubuntu/met_nonlinear` 的训练编排后端渲染为队列、GPU/镜像、Project config 预览、训练进度、ETA、历史记录和显式原始 JSON 按钮。该模块不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。 ## Component Data Rendering diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index d33fa0c1..d8fad572 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -22,7 +22,7 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 ## Compute-Node Development Convention -非 UniDesk 核心功能开发不得默认占用主 server 有限主机资源。涉及 findjob 这类业务功能时,应通过 `bun scripts/cli.ts ssh ...` 或 remote CLI SSH 透传进入计算节点,在计算节点本地业务仓库中开发、构建和调试;开发完成后,只把业务服务以 microservice 形式登记到 UniDesk。 +主 server 本地开发边界固定为只开发 UniDesk frontend 与必要的 UniDesk 配置/代理登记;非 UniDesk 核心功能的后端、Dockerfile、GPU/训练容器、业务数据迁移和业务调试不得默认占用主 server 有限主机资源。涉及 findjob、pipeline、MET Nonlinear 这类业务功能时,应通过 `bun scripts/cli.ts ssh ...` 或 remote CLI SSH 透传进入计算节点,在计算节点本地业务仓库中开发、构建和调试;开发完成后,只把业务服务以 microservice 形式登记到 UniDesk。 业务仓库由业务系统自己维护,包括源码、Dockerfile、docker-compose、配置模板和业务测试。UniDesk 只引用业务仓库 URL、commit id、Dockerfile/docker-compose 路径和运行容器名;不得把业务全量代码复制到 `src/components/microservices/` 形成双维护。`src/components/microservices/` 只能放通用示例或 UniDesk 自有示例,不作为业务仓库镜像。 @@ -54,6 +54,7 @@ Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/insta - `findjob`:FindJob 纯后端服务,UniDesk frontend 渲染岗位指标、岗位预览和草稿报告。 - `pipeline`:Pipeline v2 观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、运行状态和证据日志摘要。 +- `met-nonlinear`:MET Nonlinear 训练编排服务,UniDesk frontend 渲染 GPU/镜像、训练队列、Project config 预览、训练进度、ETA 和历史记录。 ### FindJob On D601 @@ -83,6 +84,23 @@ FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 ifram Pipeline 在 UniDesk 语境中按观测后端服务管理:默认页面不得 iframe 或跳转到 Pipeline 自身 WebUI,也不得直接暴露 D601 的 `18082` 到公网。UniDesk frontend 只能通过 `/api/microservices/pipeline/health` 和 `/api/microservices/pipeline/proxy/api/snapshot?...` 访问 Pipeline 后端;超大 snapshot 必须使用 `__unideskArrayLimit=registry.components:,runs:` 做展示级裁剪。 +### MET Nonlinear On D601 + +当前 MET Nonlinear 作为 `id=met-nonlinear` 的 microservice 登记在 `config.json`: + +- Provider:`D601`。 +- 开发工作树:`/home/ubuntu/met_nonlinear`,后端、Dockerfile、训练队列和训练容器都必须通过 UniDesk SSH 透传在 D601 开发调试;主 server 本地只允许开发 UniDesk frontend 与代理登记。 +- 数据挂载:D601 的 `/mnt/f/BaiduSyncdisk/data` 挂载为训练容器内 `/data/data`,并设置 `MET_DATA_BASE=/data`,让旧配置中的 `data/M50` 解析为 `/data/data/M50`;WSL 本机不安装 TensorFlow 训练环境。 +- 代码引用:`https://github.com/pikasTech/met_nonlinear` 与配置中的 `repository.commitId`。 +- 部署引用:业务仓库内 `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`,用于读取队列/历史、创建 server_test 项目、修改 config patch 和训练任务入队。 +- UniDesk 前端:`微服务 / MET Nonlinear` React 页面负责展示队列、GPU/镜像、Project config 预览、训练进度、ETA、历史训练记录和显式原始 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 `,训练完成后容器自动销毁。训练镜像 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 验收必须在 D601 上通过 SSH 透传执行:`curl -fsS -X POST http://127.0.0.1:3288/api/queue/server-test -H 'content-type: application/json' -d '{"sourceProject":"projects/FRIKANh6u6l4","count":10,"epochs":10,"maxConcurrency":2}'`,随后轮询 `/api/queue`,确认 `projects/server_test/` 下 10 个 10 轮训练任务全部完成、最多 2 个训练容器并发、目标 GPU 为 2080Ti、2080Ti 显存余量低于 20% 时自动限制并发,并确认训练容器结束后不残留。 + ## CLI - `bun scripts/cli.ts microservice list`:列出全部 microservice、provider 映射、仓库引用、后端映射和运行态容器摘要。 @@ -91,6 +109,8 @@ Pipeline 在 UniDesk 语境中按观测后端服务管理:默认页面不得 i - `bun scripts/cli.ts microservice proxy findjob /api/summary`:通过同一私有代理读取业务 API,适合人工验证,不用于公开业务端口。 - `bun scripts/cli.ts microservice health pipeline`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 Pipeline `/health`。 - `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`:读取 Pipeline snapshot 的有界预览,适合人工验证,不用于公开业务端口;若 body 仍超过 CLI 阈值,默认只输出 `bodyPreview`,需要完整 body 时显式追加 `--raw`。 +- `bun scripts/cli.ts microservice 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 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。 @@ -108,10 +128,13 @@ microservice 交付必须同时通过后端、CLI 和公网 frontend 验证: - 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `findjob` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3254` 映射和 `findjob-server` 容器摘要可见。 - 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `pipeline` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:18082` 映射和 `pipeline-v2-webui` 容器摘要可见。 +- 在主 server 运行 `bun scripts/cli.ts microservice list`,确认 `met-nonlinear` 的 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3288` 映射和 `met-nonlinear-ts` 容器摘要可见。 - 运行 `bun scripts/cli.ts microservice health findjob` 与 `bun scripts/cli.ts microservice proxy findjob /api/summary`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 FindJob 后端。 - 运行 `bun scripts/cli.ts microservice health pipeline` 与 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 Pipeline 后端。 +- 运行 `bun scripts/cli.ts microservice health met-nonlinear`、`bun scripts/cli.ts microservice proxy met-nonlinear /api/queue`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=20'` 和 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 MET Nonlinear TS 后端。 - 运行 `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `todo-note-backend` 后端;输出中必须包含五个迁移清单和 PostgreSQL 存储健康状态。 - 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:3254/api/health` 可用;不要把调试服务部署到主 server。 - 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:18082/health` 和 `curl http://127.0.0.1:18082/api/snapshot` 可用;不要把 Pipeline 调试服务部署到主 server。 +- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试 `~/met_nonlinear`,确认 `curl http://127.0.0.1:3288/health` 可用,并按 `~/met_nonlinear/docs/reference/unidesk_microservice.md` 完成 10 个 server_test 训练任务;不要把 MET Nonlinear 后端、Docker build 或训练任务部署到主 server。 - 运行 `bun scripts/cli.ts e2e run`,确认 microservice 相关检查 passed,并确认 Playwright 访问的是公网 `http://74.48.78.17:18081/`。 -- 登录公网 frontend,进入 `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / FindJob` 和 `微服务 / Pipeline`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数和最近运行,不能只停留在 loading 骨架;页面默认不得出现裸 JSON。 +- 登录公网 frontend,进入 `微服务 / 服务目录`、`微服务 / Todo Note`、`微服务 / FindJob`、`微服务 / Pipeline` 和 `微服务 / MET Nonlinear`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图和最近运行、MET Nonlinear 队列/GPU/镜像/Project config/训练历史;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数和最近运行,MET Nonlinear 页面必须显示 `Health OK`、`创建10个10轮任务`、任务队列和 GPU/镜像面板,不能只停留在 loading 骨架;页面默认不得出现裸 JSON。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index c6901473..33a2d81d 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -56,6 +56,7 @@ - src/todo-note.tsx (Todo Note microservice React page; do not fold back into `app.tsx`) - src/findjob.tsx (FindJob microservice React page; do not fold back into `app.tsx`) - src/pipeline.tsx (Pipeline microservice React page and React Flow control graph; do not fold back into `app.tsx`) + - src/met-nonlinear.tsx (MET Nonlinear D601 training orchestration React page; do not fold back into `app.tsx`) - public/ (HTML/CSS static assets for the compact industrial console; no handwritten app JS) - provider-gateway/ (Compute node Provider Gateway container) - package.json diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 16468a01..22782d40 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -222,11 +222,13 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500); const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort); const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500); + const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500); const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500); addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); addCheck(checks, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic); addCheck(checks, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic); + addCheck(checks, "network:met-nonlinear-public-blocked", (metNonlinearPublic as { reachable?: boolean }).reachable === false, metNonlinearPublic); addCheck(checks, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic); } @@ -243,6 +245,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const pipelineStatus = dockerCoreJson("/api/microservices/pipeline/status"); const pipelineHealth = dockerCoreJson("/api/microservices/pipeline/health"); const pipelineSnapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3"); + const metNonlinearStatus = dockerCoreJson("/api/microservices/met-nonlinear/status"); + const metNonlinearHealth = dockerCoreJson("/api/microservices/met-nonlinear/health"); + const metNonlinearQueue = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/queue?__unideskArrayLimit=jobs:10"); + const metNonlinearProjects = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=20&__unideskArrayLimit=projects:20"); + const metNonlinearImages = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/images"); const todoNoteStatus = dockerCoreJson("/api/microservices/todo-note/status"); const todoNoteHealth = dockerCoreJson("/api/microservices/todo-note/health"); const todoNoteInstances = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances"); @@ -281,12 +288,17 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? []; const findjob = microserviceList.find((service) => service.id === "findjob"); const pipeline = microserviceList.find((service) => service.id === "pipeline"); + const metNonlinear = microserviceList.find((service) => service.id === "met-nonlinear"); const todoNote = microserviceList.find((service) => service.id === "todo-note"); const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body; const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body; const todoNoteRows = (todoNoteInstances as { body?: { instances?: Array<{ id?: string; name?: string; todoCount?: number; completedCount?: number }> } }).body?.instances ?? []; const todoNoteNames = todoNoteRows.map((row) => row.name); const pipelineSnapshotBody = (pipelineSnapshot as { body?: { ok?: boolean; registry?: { ok?: boolean; components?: unknown[] }; pipelines?: unknown[]; runs?: unknown[]; _unidesk?: { arrayLimits?: { "registry.components"?: { returnedLength?: number; originalLength?: number }; runs?: { returnedLength?: number; originalLength?: number } } } } }).body; + const metNonlinearHealthBody = (metNonlinearHealth as { body?: { ok?: boolean; targetGpu?: { name?: string; freeRatio?: number } | null; image?: { present?: boolean; image?: string } } }).body; + const metNonlinearQueueBody = (metNonlinearQueue as { body?: { ok?: boolean; queue?: { counts?: Record; maxConcurrency?: number; targetGpuName?: string }; jobs?: unknown[] } }).body; + const metNonlinearProjectsBody = (metNonlinearProjects as { body?: { ok?: boolean; projects?: unknown[] } }).body; + const metNonlinearImagesBody = (metNonlinearImages as { body?: { ok?: boolean; mlImage?: { present?: boolean; image?: string } } }).body; const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs) ? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined : undefined; @@ -310,6 +322,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 }; addCheck(checks, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices }); addCheck(checks, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices }); + addCheck(checks, "microservice:catalog-met-nonlinear", (microservices as { ok?: boolean }).ok === true && metNonlinear?.providerId === "D601" && metNonlinear.backend?.public === false && metNonlinear.runtime?.container?.name === "met-nonlinear-ts", { microservices }); addCheck(checks, "microservice:catalog-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices }); addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus); addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth); @@ -318,6 +331,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 addCheck(checks, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus); addCheck(checks, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth); addCheck(checks, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail); + addCheck(checks, "microservice:met-nonlinear-status", (metNonlinearStatus as { ok?: boolean }).ok === true && (metNonlinearStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", metNonlinearStatus); + addCheck(checks, "microservice:met-nonlinear-health", (metNonlinearHealth as { ok?: boolean }).ok === true && metNonlinearHealthBody?.ok === true, metNonlinearHealth); + addCheck(checks, "microservice:met-nonlinear-queue", (metNonlinearQueue as { ok?: boolean }).ok === true && metNonlinearQueueBody?.ok === true && typeof metNonlinearQueueBody.queue?.counts === "object" && metNonlinearQueueBody.queue?.targetGpuName === "2080 Ti", metNonlinearQueue); + addCheck(checks, "microservice:met-nonlinear-projects", (metNonlinearProjects as { ok?: boolean }).ok === true && metNonlinearProjectsBody?.ok === true && Array.isArray(metNonlinearProjectsBody.projects) && metNonlinearProjectsBody.projects.length > 0, metNonlinearProjects); + addCheck(checks, "microservice:met-nonlinear-image", (metNonlinearImages as { ok?: boolean }).ok === true && metNonlinearImagesBody?.ok === true && metNonlinearImagesBody.mlImage?.present === true && metNonlinearImagesBody.mlImage?.image === "met-nonlinear-ml:tf26", metNonlinearImages); addCheck(checks, "microservice:todo-note-status", (todoNoteStatus as { ok?: boolean }).ok === true && (todoNoteStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, todoNoteStatus); addCheck(checks, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth); addCheck(checks, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances }); @@ -496,6 +514,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="microservice-row-met-nonlinear"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 }); const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: /Todo Note/ }).click(); @@ -563,10 +582,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 const pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count(); const pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count(); const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /MET Nonlinear/ }).click(); + await page.waitForSelector('[data-testid="met-nonlinear-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("met nonlinear 训练编排") + && text.includes("D601") + && text.includes("任务队列") + && text.includes("GPU 与镜像") + && 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 microserviceCatalogTextLower = microserviceCatalogText.toLowerCase(); const todoNoteTextLower = todoNoteText.toLowerCase(); const findjobTextLower = findjobText.toLowerCase(); const pipelineTextLower = pipelineText.toLowerCase(); + const metNonlinearTextLower = metNonlinearText.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 }); @@ -582,11 +615,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayText.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) }); addCheck(checks, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts }); addCheck(checks, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) }); - addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1400) }); + addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1600) }); addCheck(checks, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) }); addCheck(checks, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) }); addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("最近运行") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) }); addCheck(checks, "frontend:pipeline-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("创建10个10轮任务") && metNonlinearText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(metNonlinearText), { metNonlinearTextPreview: metNonlinearText.slice(0, 1400) }); addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 71d80c0a..1bf3b037 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -965,7 +965,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .result-card dd { margin: 0; } .result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } -.microservice-page, .findjob-page, .pipeline-page { +.microservice-page, .findjob-page, .pipeline-page, .met-page { display: grid; gap: 10px; } @@ -1015,6 +1015,28 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } align-items: start; } .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; } +.met-grid { + display: grid; + grid-template-columns: minmax(420px, 0.9fr) minmax(560px, 1.25fr); + gap: 10px; + align-items: start; +} +.met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5) { grid-column: 1 / -1; } +.met-progress { + height: 6px; + margin-top: 5px; + overflow: hidden; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.met-progress span { + display: block; + height: 100%; + min-width: 2px; + max-width: 100%; + background: linear-gradient(90deg, var(--accent-2), var(--accent)); +} +.met-job-table { max-height: 460px; overflow: auto; } .pipeline-flow-frame { height: min(68vh, 720px); min-height: 520px; diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 8e9c3b2e..7c3c5606 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -1,6 +1,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { FindJobPage } from "./findjob"; +import { MetNonlinearPage } from "./met-nonlinear"; import { PipelinePage } from "./pipeline"; import { TodoNotePage } from "./todo-note"; @@ -51,6 +52,7 @@ const MODULES = [ { id: "todo-note", label: "Todo Note" }, { id: "findjob", label: "FindJob" }, { id: "pipeline", label: "Pipeline" }, + { id: "met-nonlinear", label: "MET Nonlinear" }, ] }, { id: "config", label: "系统配置", code: "CFG", tabs: [ { id: "topology", label: "连接拓扑" }, @@ -1096,6 +1098,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord service.id === "findjob" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "findjob"), "data-testid": "open-findjob-button" }, "打开") : null, service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null, service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null, + service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null, h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }), ), ), @@ -1355,6 +1358,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa if (activeModule === "apps" && activeTab === "todo-note") return h(TodoNotePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); + if (activeModule === "apps" && activeTab === "met-nonlinear") return h(MetNonlinearPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl }); if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data }); if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session }); if (activeModule === "config" && activeTab === "security") return h(SecurityPage); diff --git a/src/components/frontend/src/met-nonlinear.tsx b/src/components/frontend/src/met-nonlinear.tsx new file mode 100644 index 00000000..44409db5 --- /dev/null +++ b/src/components/frontend/src/met-nonlinear.tsx @@ -0,0 +1,306 @@ +import React from "react"; + +type AnyRecord = Record; + +const h = React.createElement; +const { useEffect } = React; +const useState: any = React.useState; + +function errorMessage(error: unknown, fallback = "操作失败"): string { + return error instanceof Error ? error.message : String(error || fallback); +} + +function fmtDate(value: any): string { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function fmtClock(value: Date): string { + return value.toLocaleTimeString("zh-CN", { hour12: false }); +} + +function fmtPercent(value: any): string { + const number = Number(value); + return Number.isFinite(number) ? `${Math.max(0, Math.min(100, number)).toFixed(1)}%` : "--"; +} + +function fmtBytes(value: any): string { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return "--"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let current = bytes; + let index = 0; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function fmtDuration(seconds: any): string { + const value = Number(seconds); + if (!Number.isFinite(value)) return "--"; + if (value < 60) return `${Math.max(0, Math.round(value))}s`; + if (value < 3600) return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`; + return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`; +} + +async function requestJson(path: string, options: AnyRecord = {}): Promise { + const headers = new Headers(options.headers || {}); + if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); + const response = await fetch(path, { credentials: "same-origin", ...options, headers }); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = { text }; + } + if (!response.ok || body?.ok === false) { + const message = body?.error?.message || body?.error || `HTTP ${response.status}`; + throw new Error(message); + } + return body; +} + +function StatusBadge({ status, children }: AnyRecord) { + const normalized = String(status || "unknown").toLowerCase(); + return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); +} + +function MetricCard({ label, value, hint, tone }: AnyRecord) { + return h("article", { className: `metric-card ${tone || ""}` }, + h("div", { className: "metric-label" }, label), + h("div", { className: "metric-value" }, value), + h("div", { className: "metric-hint" }, hint), + ); +} + +function Panel({ title, eyebrow, actions, children, className }: AnyRecord) { + return h("section", { className: `panel ${className || ""}` }, + h("div", { className: "panel-head" }, + h("div", null, + eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null, + h("h2", null, title), + ), + actions ? h("div", { className: "panel-actions" }, actions) : null, + ), + h("div", { className: "panel-body" }, children), + ); +} + +function RawButton({ title, data, onOpen, testId }: AnyRecord) { + return h("button", { + type: "button", + className: "ghost-btn", + "data-testid": testId, + onClick: () => onOpen(title, data), + }, "查看原始JSON"); +} + +function EmptyState({ title, text }: AnyRecord) { + return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text)); +} + +function microserviceRuntime(service: any): AnyRecord { + return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {}; +} + +function microserviceBackend(service: any): AnyRecord { + return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {}; +} + +function microserviceRepository(service: any): AnyRecord { + return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {}; +} + +function queueCounts(queue: any): AnyRecord { + return queue?.counts && typeof queue.counts === "object" && !Array.isArray(queue.counts) ? queue.counts : {}; +} + +function jobRows(queue: any): any[] { + return Array.isArray(queue?.jobs) ? queue.jobs.slice(0, 80) : []; +} + +function projectRows(projects: any): any[] { + return Array.isArray(projects?.projects) ? projects.projects.slice(0, 40) : []; +} + +function gpuRows(health: any, queue: any): any[] { + if (Array.isArray(health?.gpu)) return health.gpu; + if (Array.isArray(queue?.gpu)) return queue.gpu; + return []; +} + +function metApi(apiBaseUrl: string, path: string): string { + return `${apiBaseUrl}/microservices/met-nonlinear/proxy${path}`; +} + +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 }); + + async function load(): Promise { + if (!service) return; + setState((prev: any) => ({ ...prev, loading: true, error: "" })); + try { + const [health, summary, queue, projects, 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=40")), + requestJson(metApi(apiBaseUrl, "/api/history")), + requestJson(metApi(apiBaseUrl, "/api/images")), + ]); + 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 加载失败") })); + } + } + + async function enqueueServerTest(): Promise { + setState((prev: any) => ({ ...prev, actionBusy: true, error: "" })); + try { + await requestJson(metApi(apiBaseUrl, "/api/queue/server-test"), { + method: "POST", + body: JSON.stringify({ sourceProject: "projects/FRIKANh6u6l4", count: 10, epochs: 10, maxConcurrency: 2 }), + }); + await load(); + } catch (err) { + setState((prev: any) => ({ ...prev, actionBusy: false, error: errorMessage(err, "Server test 入队失败") })); + } + } + + useEffect(() => { + load(); + }, [service?.id, service?.runtime?.providerStatus]); + + if (!service) return h(EmptyState, { title: "MET Nonlinear 未登记", text: "请在 config.json 的 microservices 中登记 id=met-nonlinear" }); + + const runtime = microserviceRuntime(service); + const repository = microserviceRepository(service); + const backend = microserviceBackend(service); + const counts = queueCounts(state.queue?.queue || state.summary?.queue); + const gpus = gpuRows(state.health, state.queue); + const targetGpu = state.health?.targetGpu || state.summary?.targetGpu || gpus.find((gpu: any) => String(gpu.name || "").includes("2080")); + const image = state.images?.mlImage || state.health?.image || {}; + const jobs = jobRows(state.queue); + const projects = projectRows(state.projects); + const history = Array.isArray(state.history?.jobs) ? state.history.jobs.slice(0, 20) : []; + return h("div", { className: "met-page", "data-testid": "met-nonlinear-page" }, + h(Panel, { + title: "MET Nonlinear 训练编排", + eyebrow: "D601 GPU Microservice", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "met-refresh-button" }, state.loading ? "刷新中" : "刷新"), + h("button", { type: "button", className: "primary-btn", onClick: enqueueServerTest, disabled: state.actionBusy, "data-testid": "met-server-test-button" }, state.actionBusy ? "入队中" : "创建10个10轮任务"), + h(RawButton, { title: "MET Nonlinear Microservice", data: service, onOpen: onRaw, testId: "raw-met-service" }), + ), + }, + h("div", { className: "findjob-hero" }, + h("div", null, + h("div", { className: "node-version-line" }, + h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"), + h("span", null, service.providerId), + h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"), + ), + h("p", { className: "muted paragraph" }, service.description), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "Repo"), + h("strong", null, repository.url || "--"), + h("code", null, repository.commitId || "--"), + ), + h("div", { className: "microservice-ref-card" }, + h("span", null, "D601 Docker"), + h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), + h("code", null, `${repository.composeFile || "--"} / ${repository.containerName || "--"}`), + ), + ), + state.error ? h("div", { className: "form-error wide" }, state.error) : null, + ), + h("div", { className: "met-grid" }, + h(Panel, { title: "核心状态", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Queue + GPU" }, + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "Queued", value: counts.queued ?? 0, hint: "等待训练", tone: Number(counts.queued || 0) > 0 ? "warn" : "" }), + h(MetricCard, { label: "Running", value: counts.running ?? 0, hint: `max ${state.summary?.queue?.maxConcurrency ?? state.queue?.queue?.maxConcurrency ?? "--"}`, tone: Number(counts.running || 0) > 0 ? "ok" : "" }), + h(MetricCard, { label: "Succeeded", value: counts.succeeded ?? 0, hint: "历史成功" }), + h(MetricCard, { label: "Failed", value: counts.failed ?? 0, hint: "需要诊断", tone: Number(counts.failed || 0) > 0 ? "warn" : "" }), + h(MetricCard, { label: "2080Ti Free", value: targetGpu ? fmtPercent(Number(targetGpu.freeRatio) * 100) : "--", hint: targetGpu ? `${targetGpu.memoryFreeMiB}/${targetGpu.memoryTotalMiB} MiB` : "等待 GPU 上报" }), + h(MetricCard, { label: "ML Image", value: image.present ? "READY" : "MISSING", hint: image.image || "met-nonlinear-ml:tf26", tone: image.present ? "ok" : "warn" }), + h(MetricCard, { label: "Projects", value: projects.length, hint: "config preview" }), + h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /health" }), + ), + h("div", { className: "panel-actions inline-actions" }, + h(RawButton, { title: "MET Summary", data: state.summary, onOpen: onRaw, testId: "raw-met-summary" }), + h(RawButton, { title: "MET Images", data: state.images, onOpen: onRaw, testId: "raw-met-images" }), + ), + ), + h(Panel, { title: "GPU 与镜像", eyebrow: `${gpus.length} GPU` }, + gpus.length === 0 ? h(EmptyState, { title: "暂无 GPU 上报", text: "等待 D601 met-nonlinear-ts 或 ML image 提供 nvidia-smi 数据" }) : + h("div", { className: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "Index"), h("th", null, "Name"), h("th", null, "Free"), h("th", null, "Policy"))), + h("tbody", null, gpus.map((gpu: any) => h("tr", { key: gpu.index }, + h("td", null, gpu.index), + h("td", null, gpu.name), + h("td", null, `${gpu.memoryFreeMiB} / ${gpu.memoryTotalMiB} MiB`, h("div", { className: "met-progress" }, h("span", { style: { width: fmtPercent(Number(gpu.freeRatio) * 100) } }))), + h("td", null, String(gpu.name || "").includes("2080") ? "target 2080Ti, <20% 限制并发" : "non-target"), + ))), + )), + ), + h(Panel, { title: "任务队列", eyebrow: `${jobs.length} Jobs` }, + jobs.length === 0 ? h(EmptyState, { title: "队列为空", text: "可创建 server_test 训练任务或通过 API 添加已有 projects" }) : + 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("tbody", null, jobs.map((job: any) => { + const progress = job.progress || {}; + return h("tr", { key: job.id }, + h("td", null, h(StatusBadge, { status: job.status }, job.status)), + h("td", null, h("strong", null, 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, fmtDuration(progress.etaSeconds)), + h("td", null, job.gpuName || "--"), + h("td", null, job.exitCode ?? "--"), + h("td", null, fmtDate(job.updatedAt)), + ); + })), + )), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Queue", data: state.queue, onOpen: onRaw, testId: "raw-met-queue" })), + ), + h(Panel, { title: "Project Config 预览", eyebrow: "projects/" }, + projects.length === 0 ? h(EmptyState, { title: "暂无 project", text: "等待 D601 返回 /api/projects" }) : + h("div", { className: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "Project"), h("th", null, "Model"), h("th", null, "Epochs"), h("th", null, "Step/Epoch"), h("th", null, "GPU"), h("th", null, "Progress"))), + h("tbody", null, projects.map((project: any) => h("tr", { key: project.projectPath }, + h("td", null, project.projectPath), + h("td", null, project.useModel || "--"), + h("td", null, project.epochTrain ?? "--"), + h("td", null, project.stepPerEpoch ?? "--"), + h("td", null, project.usingGpu ? "true" : "false"), + h("td", null, fmtPercent(project.progress?.progressPercent)), + ))), + )), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET Projects", data: state.projects, onOpen: onRaw, testId: "raw-met-projects" })), + ), + h(Panel, { title: "历史训练记录", eyebrow: `${history.length} Terminal Jobs` }, + history.length === 0 ? h(EmptyState, { title: "暂无历史", text: "完成或失败的训练任务会显示耗时、exit code 和失败诊断" }) : + h("div", { className: "history-list" }, history.map((job: any) => h("article", { key: job.id, className: "result-card" }, + h("div", { className: "node-card-head" }, h("strong", null, job.projectPath), h(StatusBadge, { status: job.status }, job.status)), + h("div", { className: "docker-meta compact" }, + h("span", null, `耗时 ${job.startedAt && job.finishedAt ? fmtDuration((Date.parse(job.finishedAt) - Date.parse(job.startedAt)) / 1000) : "--"}`), + h("span", null, `exit ${job.exitCode ?? "--"}`), + h("span", null, job.gpuName || "--"), + ), + job.error ? h("p", { className: "form-error" }, job.error.slice(0, 260)) : h("p", { className: "muted" }, "无失败原因"), + h("code", null, job.id), + ))), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "MET History", data: state.history, onOpen: onRaw, testId: "raw-met-history" })), + ), + ), + ); +}