From 1d0046dc5046273d521a9eb99d91c868058e6dd5 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 10:33:26 +0000 Subject: [PATCH] feat: integrate todo note microservice and modularize frontend --- AGENTS.md | 16 +- TEST.md | 13 +- config.json | 54 +- docker-compose.yml | 28 + docs/reference/cli.md | 6 +- docs/reference/config.md | 4 +- docs/reference/deployment.md | 7 +- docs/reference/e2e.md | 14 +- docs/reference/frontend.md | 12 +- docs/reference/microservices.md | 38 +- docs/reference/provider-gateway.md | 4 +- docs/reference/repo-tree.md | 7 +- scripts/cli.ts | 6 +- scripts/src/config.ts | 2 + scripts/src/docker.ts | 8 +- scripts/src/e2e.ts | 103 +++- src/bun.lock | 54 ++ src/components/backend-core/src/index.ts | 54 +- src/components/frontend/package.json | 5 + src/components/frontend/public/index.html | 1 + src/components/frontend/public/style.css | 422 +++++++++++++- src/components/frontend/src/app.tsx | 356 ++---------- src/components/frontend/src/findjob.tsx | 221 ++++++++ src/components/frontend/src/index.ts | 6 + src/components/frontend/src/pipeline.tsx | 555 +++++++++++++++++++ src/components/frontend/src/todo-note.tsx | 485 ++++++++++++++++ src/components/provider-gateway/src/index.ts | 20 +- src/tsconfig.check.json | 1 + 28 files changed, 2121 insertions(+), 381 deletions(-) create mode 100644 src/components/frontend/src/findjob.tsx create mode 100644 src/components/frontend/src/pipeline.tsx create mode 100644 src/components/frontend/src/todo-note.tsx diff --git a/AGENTS.md b/AGENTS.md index 276cdd91..ba2a2b1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,29 +13,29 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts --main-server-ip `:默认通过公网 frontend 登录态远程执行调试与节点自测命令,不要求主 server SSH key,详细规范见 `docs/reference/cli.md`。 - `bun scripts/cli.ts config show`:校验并展示根目录 `config.json`,配置来源规则见 `docs/reference/config.md`。 - `bun scripts/cli.ts check`:运行配置、TypeScript、文件存在性和 Docker Compose 配置检查,测试入口见 `TEST.md`。 -- `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway,部署规则见 `docs/reference/deployment.md`。 +- `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway 和主 server microservice,部署规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 -- `bun scripts/cli.ts server rebuild `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 +- `bun scripts/cli.ts 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`:管理和验证挂载在计算节点 Docker 中的业务 microservice,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 on D601 的规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 - `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入、资源指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 +- `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入、Todo Note 迁移数据、资源指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 ## Runtime - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 -- `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含资源监控、Docker 状态、网关版本、SSH/远程更新可用性和自动更新记录,界面规则见 `docs/reference/frontend.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/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 透传开发调试,再以仓库 URL、commit id 和业务仓库自带 Dockerfile/docker-compose 引用挂载为 UniDesk microservice,规则见 `docs/reference/microservices.md`。 +- `microservices`:非 UniDesk 核心业务通常应在计算节点通过 SSH 透传开发调试;Todo Note 因数据统一迁移部署在主 server `/root/todo_note`,其余业务以仓库 URL、commit id 和业务仓库自带 Dockerfile/docker-compose 引用挂载为 UniDesk microservice,规则见 `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 的配置、代理、安全边界、FindJob/Pipeline on D601 和验证规则。 +- `docs/reference/microservices.md`:计算节点 microservice 的配置、代理、安全边界、Todo Note on main-server、FindJob/Pipeline on D601 和验证规则。 - `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。 diff --git a/TEST.md b/TEST.md index 832bb202..5215a9cb 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`、`core:internal-overview`、`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:findjob-health`、`microservice:findjob-summary`、`microservice:findjob-jobs-preview`、`microservice:pipeline-status`、`microservice:pipeline-health`、`microservice:pipeline-snapshot`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:public-provider-info-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:findjob-integrated-visible`、`frontend:pipeline-integrated-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: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 透传`、`远程更新` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -46,11 +46,11 @@ ## T11 资源节点 Docker 状态 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:docker-status` 和 `frontend:docker-status-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `Docker 状态` 子标签,确认可以像 Docker Desktop 一样看到当前节点的 Containers、Images、Volumes、Networks 指标、容器表格、镜像/卷/网络侧栏和 Docker daemon 摘要,并确认数据库命名卷 `unidesk_pgdata_10gb` 在 Volumes 区域和数据库命名卷卡片中显式可见。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:docker-status` 和 `frontend:docker-status-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `Docker 状态` 子标签,确认可以像 Docker Desktop 一样看到当前节点的 Containers、Images、Volumes、Networks 指标、容器表格、镜像/卷/网络侧栏和 Docker daemon 摘要,并确认在 `main-server` 节点下数据库命名卷 `unidesk_pgdata_10gb` 在 Volumes 区域和数据库命名卷卡片中显式可见;切换到 D518/D601 等计算节点时不应要求存在 pgdata 数据库卷,也不应显示数据库命名卷缺失告警。 ## 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 源码;运行 `bun scripts/cli.ts check`,确认 `src/components/frontend/src/app.tsx` 纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX 转译生成。 +阅读 `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 转译生成。 ## T13 资源节点任务管理器曲线 @@ -86,4 +86,9 @@ ## 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 组件矩阵、控制图、最近运行和证据日志摘要,默认没有裸 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` 后端映射和 `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 部署调试服务。 + + +## 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` 才显示原始数据。 diff --git a/config.json b/config.json index d8960961..3a8d8c09 100644 --- a/config.json +++ b/config.json @@ -88,7 +88,11 @@ "/api/" ], "healthPath": "/api/health", - "timeoutMs": 12000 + "timeoutMs": 12000, + "allowedMethods": [ + "GET", + "HEAD" + ] }, "development": { "providerId": "D601", @@ -125,7 +129,11 @@ "/api/" ], "healthPath": "/health", - "timeoutMs": 15000 + "timeoutMs": 15000, + "allowedMethods": [ + "GET", + "HEAD" + ] }, "development": { "providerId": "D601", @@ -136,6 +144,48 @@ "route": "/apps/pipeline", "integrated": true } + }, + { + "id": "todo-note", + "name": "Todo Note", + "providerId": "main-server", + "description": "Todo Note 纯后端服务,从 D518 /mnt/d/work/todo_note 迁移到主 server Docker,使用 UniDesk PostgreSQL 存储,UniDesk frontend 负责统一前端展示。", + "repository": { + "url": "https://gitee.com/Lyon1998/todo_note", + "commitId": "a14ce0eb855a685fa17b47adacd54623e72cd2ff", + "dockerfile": "Dockerfile", + "composeFile": "docker-compose.yml", + "composeService": "todo-note", + "containerName": "todo-note-backend" + }, + "backend": { + "nodeBaseUrl": "http://todo-note:4211", + "nodeBindHost": "todo-note", + "nodePort": 4211, + "proxyMode": "provider-gateway-http", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST", + "DELETE" + ], + "allowedPathPrefixes": [ + "/api/" + ], + "healthPath": "/api/health", + "timeoutMs": 12000 + }, + "development": { + "providerId": "main-server", + "sshPassthrough": true, + "worktreePath": "/root/todo_note" + }, + "frontend": { + "route": "/apps/todo-note", + "integrated": true + } } ], "paths": { diff --git a/docker-compose.yml b/docker-compose.yml index f7314f7a..9b2ad522 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,8 @@ services: PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}" HEARTBEAT_TIMEOUT_MS: "${UNIDESK_HEARTBEAT_TIMEOUT_MS}" TASK_PENDING_TIMEOUT_MS: "${UNIDESK_TASK_PENDING_TIMEOUT_MS:-600000}" + DATABASE_VOLUME_NAME: "${UNIDESK_DATABASE_VOLUME}" + DATABASE_VOLUME_SIZE: "${UNIDESK_DATABASE_VOLUME_SIZE}" MICROSERVICES_JSON: "${UNIDESK_MICROSERVICES_JSON:-[]}" LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_backend-core.jsonl" volumes: @@ -63,6 +65,32 @@ services: timeout: 3s retries: 20 + todo-note: + build: + context: /root/todo_note + dockerfile: Dockerfile + container_name: todo-note-backend + restart: unless-stopped + depends_on: + - database + expose: + - "4211" + environment: + HOST: "0.0.0.0" + PORT: "4211" + TODO_NOTE_BACKEND_ONLY: "1" + DATABASE_URL: "postgres://${UNIDESK_DATABASE_USER}:${UNIDESK_DATABASE_PASSWORD}@database:5432/${UNIDESK_DATABASE_NAME}" + TODO_NOTE_LOGS_DIR: "logs" + LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_todo-note.jsonl" + TODO_NOTE_LOG_PATH: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_todo-note.jsonl" + volumes: + - ${UNIDESK_LOG_DIR}:/var/log/unidesk + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4211/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 3s + retries: 20 + frontend: build: context: . diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 49a33d12..08729e80 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,7 +12,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。 - `server status` 查询公开端口、内部端口、Compose 容器、core/frontend/provider/database 健康检查和访问 URL。 - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。 -- `server rebuild ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程。 +- `server rebuild ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 只重建主 server 承载的 Todo Note 后端,不会重建或删除 database 命名卷。 - `ssh [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。 - `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的 microservice;`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 @@ -23,13 +23,13 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log` 与 `.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status ` 查询进度和尾部输出。 -`server rebuild` 与 `server start`、`server stop` 一样必须通过 job 状态确认结果。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status latest` 到 `succeeded`,再用 `server status` 或 `e2e run` 验证公网 frontend;不得把 `docker rm` 手工兜底当成正式交付步骤。 +`server rebuild` 与 `server start`、`server stop` 一样必须通过 job 状态确认结果。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status latest` 到 `succeeded`,再用 `server status` 或 `e2e run` 验证公网 frontend;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note` 和 `microservice proxy todo-note /api/instances` 验证。不得把 `docker rm` 手工兜底当成正式交付步骤。 ## Output Contract 每条命令的最外层 JSON 包含 `ok`、`command` 和 `data` 或 `error`。失败时 CLI 设置非零退出码,但仍然输出 JSON 错误对象;错误对象应包含 `name`、`message` 和可用的 `stack`。 -`microservice proxy` 是面向人工验证的私有后端读取入口。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;需要完整 body 时显式添加 `--raw`,或用 `--max-body-bytes ` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 +`microservice proxy` 是面向人工验证的私有后端读取入口。正式写入型 microservice 操作由 frontend 同源代理或 E2E 直接调用 backend-core 完成,并由 config 中的 `allowedMethods` 限制;CLI `proxy` 默认仍作为 GET/HEAD 读取验证入口。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;需要完整 body 时显式添加 `--raw`,或用 `--max-body-bytes ` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。 ## Debug Contract diff --git a/docs/reference/config.md b/docs/reference/config.md index 29f0fc37..91fc5c1d 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -24,7 +24,9 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 ## Microservices -`microservices` 定义挂载在计算节点 Docker 中的非核心业务后端。该数组只保存业务仓库 URL、commit id、业务仓库自身 Dockerfile/docker-compose 引用、provider 映射、节点本机后端端口和 UniDesk frontend 集成入口;不得把业务全量代码复制进 UniDesk。`backend.public` 必须为 `false`,`backend.frontendOnly` 必须为 `true`,`backend.allowedPathPrefixes` 必须限制到业务 API 前缀;浏览器只能通过 frontend 同源代理访问这些后端。详细规则见 `docs/reference/microservices.md`。 +`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` 展示型读取路径。 ## Compose Env Generation diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index e6632a76..9978d48a 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -8,6 +8,7 @@ - `backend-core` 是无状态核心服务,提供 Docker 内网 REST API、provider ingress WebSocket、任务调度入口和数据库访问层。 - `frontend` 是唯一公开 Web 控制台,提供登录、从 TSX 转译出的 React 应用资产和到 backend-core 的同源代理。 - `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,并周期性上报系统资源指标与 Docker daemon 状态;维护用 Host SSH / WSL SSH 私钥目录只读挂载到 `/run/host-ssh`,不得作为自动任务调度主路径。 +- `todo-note` 是主 server 承载的 Todo Note 纯后端 microservice,容器名 `todo-note-backend`,只在 Compose 内网暴露 `4211/tcp`,使用主 PostgreSQL 存储迁移后的 Todo Note 数据。 ## Public Exposure Boundary @@ -27,7 +28,7 @@ Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生 ## Single Service Rebuild -前端、backend-core 或本机 provider-gateway 需要重建时,统一使用 `bun scripts/cli.ts server rebuild `,其中 `` 只能是 `backend-core`、`frontend` 或 `provider-gateway`。该命令先执行目标服务镜像构建,只有构建成功后才移除旧容器,避免构建失败导致运行中的服务被提前停掉。 +前端、backend-core、本机 provider-gateway 或主 server 承载的 Todo Note microservice 需要重建时,统一使用 `bun scripts/cli.ts server rebuild `,其中 `` 只能是 `backend-core`、`frontend`、`provider-gateway` 或 `todo-note`。该命令先执行目标服务镜像构建,只有构建成功后才移除旧容器,避免构建失败导致运行中的服务被提前停掉。 单服务重建必须按 Docker Compose label 精确选择旧容器:`com.docker.compose.project` 等于 `config.json` 中的固定 project name,`com.docker.compose.service` 等于目标服务名。删除范围不得扩大到其他 Compose project、database 容器、named volume 或未匹配 label 的容器;随后必须通过 CLI 解析出的 Compose 命令执行 `up -d --no-deps ` 启动目标服务,避免因为重建 frontend 而连带重启 database 或 backend-core。 @@ -35,8 +36,10 @@ Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生 ## Health Criteria -服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`,`/api/nodes/system-status` 中出现 `main-server` 的 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中出现 `main-server` 的 Docker 快照。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 +服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,Todo Note 后端 `/api/health` 返回 `storage=postgres`,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`,`/api/nodes/system-status` 中出现 `main-server` 的 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中出现 `main-server` 的 Docker 快照。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 ## Database Volume 架构要求数据库使用 10 GB named volume;当前实现将 volume 命名为 `unidesk_pgdata_10gb` 以固定生命周期。Docker named volume 默认不强制容量上限;如需硬配额,应在主机存储层或 Docker volume driver 层配置。CLI server 控制只能使用不删除 volume 的 `down` / `up` 流程,禁止使用 `down -v`、`docker volume rm` 或删除 `unidesk_pgdata_10gb`。 + +`/api/overview` 会返回 `pgdata` 字段,frontend `态势总览 / 核心指标` 必须展示当前 PostgreSQL 数据库占用、命名卷名称和配置容量。Docker 状态页中 `unidesk_pgdata_10gb` 的命名卷检测只对 `main-server` Provider 生效,其他计算节点不需要也不应被要求存在该数据库卷。 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 06c88777..5350b3ca 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` probes must fail. -- Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true` and at least one online node. +- 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. +- 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 `findjob` and `pipeline` on `D601` with `public=false`; `/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. -- Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql` and confirms provider state is stored in PostgreSQL. -- 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, 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 状态` 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 `微服务 / 服务目录`、`微服务 / FindJob` and `微服务 / Pipeline` to verify D601、仓库引用、私有后端映射、FindJob 指标和岗位预览、Pipeline 组件矩阵和最近运行都通过 React 控件展示。 -- Microservice frontend assertions must wait for real backend data, not only the page skeleton. 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, `控制图`, and `最近运行`; loading placeholders like `--` or empty states are not sufficient for E2E success. +- 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. +- 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 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. `FindJob` must show metrics, jobs and drafts as cards/tables; `Pipeline` must show component classes, graph nodes, 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; 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 11942452..e16d660c 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -4,15 +4,17 @@ UniDesk 前端是 React 组件化工业控制台,不追求展示型大屏效 ## Source Contract -frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components/frontend` 中维护手写 `.js` / `.jsx` 应用源码。浏览器请求的 `/app.js` 只能由 frontend Bun server 从 `src/components/frontend/src/app.tsx` 转译生成;`public/` 目录只保存 HTML/CSS 等静态资产,不提交手写 `app.js`。 +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` 只做导入和路由分发。 ## Layout -左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、FindJob、Pipeline 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 +左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、Todo Note、FindJob、Pipeline 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。桌面端左侧边栏必须支持收起,只保留模块 code 和展开按钮,以便最大化主面板空间;移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。 ## Overview Task Drilldown -`态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。 +`态势总览` 中的 `待处理任务` 指标必须可点击进入任务调度的 `待处理任务` 子标签,展示具体 queued、dispatched、running 任务的状态、Provider、已等待时间、payload 摘要和显式 `查看原始JSON` 操作。总览不得只给出无法追溯的数字;当后台把超时未终态任务转为 failed 后,待处理指标应回落,历史记录仍可在任务历史和执行结果中查看。核心指标还必须展示 `PGDATA`,显示 PostgreSQL 当前数据库用量、命名卷 `unidesk_pgdata_10gb` 和配置容量,便于从总览判断数据库状态。 ## Task History Diagnostics @@ -24,7 +26,7 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components ## Resource Node Docker View -资源节点模块必须提供 `Docker 状态` 子标签,用类似 Docker Desktop 的结构展示每个 provider 节点的 Docker daemon 状态。该页面应包含节点切换、daemon 摘要、Containers/Images/Volumes/Networks 指标、容器表格、镜像/卷/网络侧栏,并通过状态徽标区分 running、paused、exited 等状态。数据库命名卷 `unidesk_pgdata_10gb` 必须在 Volumes 区域和数据库命名卷卡片中显式可见,不得因为列表截断或匿名卷排序被隐藏。 +资源节点模块必须提供 `Docker 状态` 子标签,用类似 Docker Desktop 的结构展示每个 provider 节点的 Docker daemon 状态。该页面应包含节点切换、daemon 摘要、Containers/Images/Volumes/Networks 指标、容器表格、镜像/卷/网络侧栏,并通过状态徽标区分 running、paused、exited 等状态。数据库命名卷 `unidesk_pgdata_10gb` 的检测和数据库命名卷卡片只对 `main-server` Provider 有效;D518、D601 等计算节点不要求也不应提示缺少 pgdata 数据库卷。`main-server` 的 Volumes 区域和数据库命名卷卡片必须显式展示 `unidesk_pgdata_10gb`,不得因为列表截断或匿名卷排序被隐藏。 ## Provider Gateway Version View @@ -40,7 +42,7 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components ## Microservice Frontend -`微服务` 主模块用于展示挂载在计算节点 Docker 中的业务后端。`服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要;`FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮;`Pipeline` 子标签必须把 D601 `/home/ubuntu/pipeline` 的 snapshot 后端渲染为组件矩阵、控制图节点表、最近运行卡片和证据日志摘要。该模块不得 iframe 业务旧前端或 Pipeline 自身 WebUI,不得把 D601 后端端口暴露为浏览器直连 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 控制图框图、最近运行卡片和证据日志摘要。该模块不得 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 c61f6a05..d33fa0c1 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -7,7 +7,7 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 - microservice 后端端口默认只绑定计算节点本机地址,例如 `127.0.0.1:`,不得直接暴露公网。 - 浏览器只访问 UniDesk frontend;frontend 通过同源 `/api/microservices/*` 代理到 backend-core,backend-core 再通过目标 provider-gateway 的 `microservice.http` 能力访问计算节点本机后端。 - backend-core REST API、database 和计算节点 microservice 后端都不得新增公网端口;公网入口仍只有 frontend 和 provider ingress。 -- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost` 或 `http://host.docker.internal` 这类节点本地地址,并由 backend-core 的 `allowedPathPrefixes` 限制可代理路径。 +- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这类节点本地地址;主 server 内置 microservice 可使用同一 Compose 网络内的显式服务名,例如 `todo-note:4211`。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 ## Config Contract @@ -16,7 +16,7 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 - `id`、`name`、`providerId` 和 `description`,用于 CLI、backend-core 和 frontend 统一识别。 - `repository.url` 与 `repository.commitId`,用于记录业务代码的外部权威来源;UniDesk 不 vendoring 业务全量代码。 - `repository.dockerfile`、`repository.composeFile`、`repository.composeService` 和 `repository.containerName`,用于说明部署应复用业务仓库自身维护的 Dockerfile/docker-compose。 -- `backend.nodeBaseUrl`、`backend.nodeBindHost`、`backend.nodePort`、`backend.proxyMode`、`backend.public=false`、`backend.frontendOnly=true`、`backend.allowedPathPrefixes` 和 `backend.healthPath`,用于定义计算节点端口到 UniDesk frontend-only 代理的映射。 +- `backend.nodeBaseUrl`、`backend.nodeBindHost`、`backend.nodePort`、`backend.proxyMode`、`backend.public=false`、`backend.frontendOnly=true`、`backend.allowedMethods`、`backend.allowedPathPrefixes` 和 `backend.healthPath`,用于定义计算节点端口到 UniDesk frontend-only 代理的映射。 - `development.providerId`、`development.sshPassthrough=true` 和 `development.worktreePath`,用于说明开发调试入口必须在计算节点上通过 UniDesk SSH 透传完成。 - `frontend.route` 和 `frontend.integrated=true`,用于说明该业务前端已经整合到 UniDesk React 控制台,而不是继续公开业务自身前端。 @@ -26,12 +26,34 @@ UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。 业务仓库由业务系统自己维护,包括源码、Dockerfile、docker-compose、配置模板和业务测试。UniDesk 只引用业务仓库 URL、commit id、Dockerfile/docker-compose 路径和运行容器名;不得把业务全量代码复制到 `src/components/microservices/` 形成双维护。`src/components/microservices/` 只能放通用示例或 UniDesk 自有示例,不作为业务仓库镜像。 +## Main Server Microservices + +主 server 只承载对统一入口或状态迁移有明确必要的 microservice。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示、状态写入主 PostgreSQL 的规则。 + +### Todo Note On Main Server + +当前 Todo Note 作为 `id=todo-note` 的 microservice 登记在 `config.json`: + +- 来源工作树:D518 的 `/mnt/d/work/todo_note`;主 server 工作树固定放在 `/root/todo_note`,用于 Docker build 和后端维护。 +- Provider:`main-server`,由本机 provider-gateway 通过 `microservice.http` 访问同一 Compose 网络内的 `http://todo-note:4211`。 +- 代码引用:`https://gitee.com/Lyon1998/todo_note` 与配置中的 `repository.commitId`;UniDesk 仓库只记录引用,不 vendoring Todo Note 全量业务代码。 +- 部署引用:`/root/todo_note/Dockerfile` 构建纯后端镜像,Compose service 为 `todo-note`,容器名为 `todo-note-backend`。 +- 数据库:Todo Note 不再使用 JSON 文件作为运行时权威存储;必须把 D518 `data/registry.json` 和 `data/instances/*.todo.json`、`*.history.jsonl` 迁移到主 server PostgreSQL 的 `todo_note_instances` 和 `todo_note_history` 表。 +- 代理路径:只允许 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`,用于保持 Todo Note 原有清单创建/删除、任务增删改、提醒、展开/收起、移动、撤销/重做等功能。 +- UniDesk 前端:`微服务 / Todo Note` React 页面负责展示清单列表、树形任务、筛选、提醒、拖放/上移下移、撤销/重做、字号控制和显式原始 JSON 按钮。 + +Todo Note 在 UniDesk 语境中按纯后端服务管理:不得继续公开 Todo Note 自身 Vite/Web 前端,也不得把 `4211` 映射为公网端口。浏览器只能通过 UniDesk frontend 的 `/api/microservices/todo-note/...` 同源代理访问 Todo Note 后端。 + +Todo Note 首次迁移或源 JSON 修复时,在主 server 通过 Docker 内网执行 `/root/todo_note/scripts/migrate-json-to-pg.ts`,并显式指向主 PostgreSQL:`docker run --rm --network unidesk_default -v /root/todo_note:/app -w /app -e DATABASE_URL='postgres://unidesk:unidesk_dev_password@database:5432/unidesk' oven/bun:1-alpine bun scripts/migrate-json-to-pg.ts`。迁移脚本必须输出 `importedInstances: 5`、`totalTodos: 100`、`completedTodos: 54` 这一类可审计摘要,不能只依赖前端页面观察。 + +Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/instances` 至少能看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务` 五个迁移清单,总任务数不低于源数据的 100 条;再通过代理创建临时清单、添加任务、切换完成、撤销并删除临时清单,证明写入路径走 PostgreSQL 且不会污染长期数据。 + ## D601 Microservices 当前 `D601` 同时承载以下 UniDesk microservice: - `findjob`:FindJob 纯后端服务,UniDesk frontend 渲染岗位指标、岗位预览和草稿报告。 -- `pipeline`:Pipeline v2 观测服务,UniDesk frontend 渲染组件矩阵、控制图、运行状态和证据日志摘要。 +- `pipeline`:Pipeline v2 观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、运行状态和证据日志摘要。 ### FindJob On D601 @@ -57,7 +79,7 @@ FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 ifram - 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=pipeline-webui`、`containerName=pipeline-v2-webui`。 - 节点后端:D601 上 `127.0.0.1:18082`,provider-gateway 容器内通过 `http://host.docker.internal:18082` 访问。 - 代理路径:只允许 `/health` 和 `/api/` 前缀;Pipeline 自身 WebUI 静态页面即使仍由 `pipeline-webui` 提供,也不作为 UniDesk microservice 入口使用。 -- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、pipeline 控制图、最近运行、OA/procedure 摘要和显式原始 JSON 按钮。 +- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、React Flow pipeline 控制图框图、最近运行、OA/procedure 摘要和显式原始 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:,runs:` 做展示级裁剪。 @@ -69,25 +91,27 @@ 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 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。 `debug dispatch D601 microservice.http --payload-json ...` 仅用于开发调试 provider-gateway 代理能力;正式验收和用户入口应优先使用 `microservice` 命令与 frontend 页面。 ## Frontend Rules -microservice 前端必须整合到 `src/components/frontend/src/app.tsx` 的 TypeScript + React 组件中。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、日志摘要、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。 +microservice 前端必须整合到 `src/components/frontend/src/` 下的 TypeScript + React 模块中。`app.tsx` 只做 shell/router 和导入分发,业务页面必须拆成独立 TSX,例如 `todo-note.tsx`、`findjob.tsx`、`pipeline.tsx`。默认展示必须是业务控件:指标卡、状态徽标、表格、草稿卡片、运行卡片、树形任务、表单控件、日志摘要、链接和字段摘要;只有操作员点击 `查看原始JSON` 时才允许打开原始 JSON 弹窗。 对于超大业务 JSON,backend-core 可把 `__unideskArrayLimit=:` 作为 frontend-only 代理参数传给 provider-gateway,由 provider-gateway 在返回前裁剪指定 JSON 数组并写入 `_unidesk.arrayLimits` 元数据。该参数只用于控制 UniDesk 展示预览,不能替代业务后端自身分页 API 的长期设计。CLI 的 `microservice proxy` 还会对超过默认阈值的 body 做二次有界预览,防止人工验证时输出爆炸;只有显式 `--raw` 才允许倾倒完整 body。 ## Verification -FindJob 和 Pipeline microservice 交付必须同时通过后端、CLI 和公网 frontend 验证: +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` 容器摘要可见。 - 运行 `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 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。 - 运行 `bun scripts/cli.ts e2e run`,确认 microservice 相关检查 passed,并确认 Playwright 访问的是公网 `http://74.48.78.17:18081/`。 -- 登录公网 frontend,进入 `微服务 / 服务目录`、`微服务 / FindJob` 和 `微服务 / Pipeline`,确认能看到 D601 provider、仓库引用、后端私有映射、FindJob 指标和岗位预览、Pipeline 组件矩阵和最近运行;FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数和最近运行,不能只停留在 loading 骨架;页面默认不得出现裸 JSON。 +- 登录公网 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。 diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 171ac0ca..dd02f922 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -64,7 +64,7 @@ WSL 节点还应补充一次真实调度验证:向该 `PROVIDER_ID` 下发 `do SSH 透传自测是 provider-gateway 部署验收的一部分。目标 Provider 在线后,先确认 frontend 节点清单或 `debug health` 中该节点 labels 显示 `hostSshConfigured=true`、`hostSshKeyPresent=true` 且能力包含 `host.ssh`;再运行 `bun scripts/cli.ts debug dispatch host.ssh --wait-ms 15000`,任务必须 `succeeded`,result 中 `probeLine` 必须包含 `UNIDESK_SSH_TEST` 且 `exitCode=0`;最后运行 `bun scripts/cli.ts ssh hostname`,输出必须是目标宿主或 WSL 的 hostname,进程退出码必须为 0。任何 provider 在线但不声明 `host.ssh` 的状态都只能算未完成部署。 -如果该节点承载 microservice,还必须声明 `microservice.http` capability,并通过 `bun scripts/cli.ts microservice health ` 或 remote CLI 等价命令验证 backend-core 能经 provider-gateway 访问节点本机后端。microservice 后端端口不得映射到公网;provider-gateway 只允许代理节点本地 HTTP 地址,业务 API 路径还要受 backend-core `allowedPathPrefixes` 限制。 +如果该节点承载 microservice,还必须声明 `microservice.http` capability,并通过 `bun scripts/cli.ts microservice health ` 或 remote CLI 等价命令验证 backend-core 能经 provider-gateway 访问节点本机后端。microservice 后端端口不得映射到公网;provider-gateway 只允许代理节点本地 HTTP 地址或主 server 显式 Compose 服务名,业务 API 路径和 HTTP 方法还要受 backend-core `allowedPathPrefixes` 与 `allowedMethods` 限制。 自动化验证必须使用 Playwright 访问公网 frontend,而不是在容器内直接调 core API 代替浏览器验收。标准命令是 `bun scripts/cli.ts e2e run`;该命令会让 Playwright 打开公网 `http://74.48.78.17:18081/`、登录、抓取页面中的 Provider 信息和 `查看原始JSON` 内容,并检查 Provider 自接入、资源指标、Docker 状态和 `provider.upgrade` 预检。外部新增节点的人工验收应复用同一套前端路径:先确认 Provider 信息出现在节点清单,再确认资源监控和 Docker 状态页面有该节点的数据,最后通过任务调度向该 Provider 下发 `echo`、`docker.ps` 或维护专用 `host.ssh` probe,并在任务历史中查看耗时、状态、stdout/stderr 摘要和失败原因。 @@ -78,7 +78,7 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 ## Microservice HTTP Proxy -`microservice.http` 是 provider-gateway 给 UniDesk microservice 使用的私有后端访问能力。backend-core 通过真实 WebSocket dispatch 下发目标 service id、节点本机 `targetBaseUrl`、path、query、timeout 和可选 JSON 数组裁剪参数;provider-gateway 只执行 GET/HEAD,并只允许 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 +`microservice.http` 是 provider-gateway 给 UniDesk microservice 使用的私有后端访问能力。backend-core 通过真实 WebSocket dispatch 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数;provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,但最终允许方法必须由每个 microservice 的 `backend.allowedMethods` 显式配置。provider-gateway 只允许访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址;主 server 内置 Todo Note 后端可使用 Compose 服务名 `http://todo-note:4211`。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 超大 JSON 响应可以使用 `jsonArrayLimits` 在 provider-gateway 返回前裁剪指定数组,并在响应体中写入 `_unidesk.arrayLimits` 元数据,便于 UniDesk frontend 预览列表而不展示裸 JSON。长期应优先推动业务后端提供分页 API;裁剪只是 UniDesk 集成层的展示保护。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 7ec0a6a6..c6901473 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -2,7 +2,7 @@ - AGENTS.md (Top-level agent index and `scripts/cli.ts` usage guide) - TEST.md (Manual CLI test plan following cli-spec expectations) - config.json (Single source of truth for ports, tokens, runtime, paths, and provider identity) - - docker-compose.yml (Main server orchestration for database, backend-core, frontend, provider-gateway) + - docker-compose.yml (Main server orchestration for database, backend-core, frontend, provider-gateway, and managed main-server microservices such as Todo Note) - package.json / bun.lock (Root Bun tooling for CLI checks) - .gitignore - reference -> docs/reference (Compatibility symlink for older references) @@ -52,7 +52,10 @@ - tsconfig.json - Dockerfile - src/index.ts (Bun static server, login/session handling, and same-origin internal API proxy) - - src/app.tsx (TypeScript + React browser app source; `/app.js` is generated by Bun at runtime) + - src/app.tsx (TypeScript + React browser app shell, login, global data loading, and route dispatcher; `/app.js` is generated by Bun at runtime) + - 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`) - 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/cli.ts b/scripts/cli.ts index 296776a1..564ae0f0 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -22,11 +22,11 @@ function help(): unknown { { command: "--main-server-ip ", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." }, { command: "config show", description: "Validate and print config.json as the single source of truth." }, { command: "check", description: "Run config, TypeScript, file presence, and docker-compose config checks." }, - { command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, and provider gateway." }, + { command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server microservices." }, { command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." }, { command: "server status", description: "Show fixed ports, containers, service health, and public URLs." }, { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, - { command: "server rebuild ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, + { command: "server rebuild ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, { command: "ssh [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." }, { command: "microservice list", description: "List UniDesk-managed microservices and their provider/runtime mapping." }, { command: "microservice status ", description: "Show one microservice config, repository reference, backend mapping, and runtime status." }, @@ -149,7 +149,7 @@ async function main(): Promise { } if (sub === "rebuild") { if (!isRebuildableService(third)) { - throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway"); + throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway, todo-note"); } emitJson(commandName, rebuildService(config, third)); return; diff --git a/scripts/src/config.ts b/scripts/src/config.ts index 6b163ee6..e7ce8532 100644 --- a/scripts/src/config.ts +++ b/scripts/src/config.ts @@ -60,6 +60,7 @@ export interface UniDeskMicroserviceConfig { proxyMode: string; frontendOnly: boolean; public: boolean; + allowedMethods: string[]; allowedPathPrefixes: string[]; healthPath: string; timeoutMs: number; @@ -152,6 +153,7 @@ function microserviceConfig(item: Record, index: number): UniDe proxyMode: stringField(backend, "proxyMode", `${path}.backend`), frontendOnly: booleanField(backend, "frontendOnly", `${path}.backend`), public: booleanField(backend, "public", `${path}.backend`), + allowedMethods: stringArrayField(backend, "allowedMethods", `${path}.backend`), allowedPathPrefixes: stringArrayField(backend, "allowedPathPrefixes", `${path}.backend`), healthPath: stringField(backend, "healthPath", `${path}.backend`), timeoutMs: numberField(backend, "timeoutMs", `${path}.backend`), diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 869b0e91..3eee8ef9 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -18,7 +18,7 @@ export interface ContainerStatus { ports: string; } -const rebuildableServices = ["backend-core", "frontend", "provider-gateway"] as const; +const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note"] as const; export type RebuildableService = typeof rebuildableServices[number]; export function isRebuildableService(value: string | undefined): value is RebuildableService { @@ -79,6 +79,8 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_DATABASE_USER: config.database.user, UNIDESK_DATABASE_PASSWORD: config.database.password, UNIDESK_DATABASE_NAME: config.database.name, + UNIDESK_DATABASE_VOLUME: config.database.volume, + UNIDESK_DATABASE_VOLUME_SIZE: config.database.volumeSize, UNIDESK_PROVIDER_TOKEN: config.providerGateway.token, UNIDESK_PROVIDER_ID: config.providerGateway.id, UNIDESK_PROVIDER_NAME: config.providerGateway.name, @@ -130,7 +132,7 @@ export function startStack(config: UniDeskConfig): unknown { const downCommand = [...compose, "down", "--remove-orphans"]; const upCommand = [...compose, "up", "-d", "--build"]; const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`]; - const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, and provider gateway containers"); + const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, provider gateway, and managed microservice containers"); return { job, runtimeEnv, command, ports: fixedPorts(config) }; } @@ -301,7 +303,7 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown { const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix)); const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12); const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) })); - const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main"]; + const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend"]; const docker = containerNames.map((name) => { const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot); return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) }; diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index c5f73aa3..16468a01 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -222,10 +222,12 @@ 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 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:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic); } async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { @@ -241,8 +243,28 @@ 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 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"); + const todoE2eName = `E2E Todo ${Date.now()}`; + const todoNoteCreate = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances", { method: "POST", body: { name: todoE2eName } }); + const todoCreatedId = (todoNoteCreate as { body?: { id?: string } }).body?.id ?? ""; + const todoNoteAdd = todoCreatedId + ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/actions`, { method: "POST", body: { action: { type: "addTodo", title: "E2E migrated write path" } } }) + : { ok: false, error: "missing created todo note id", todoNoteCreate }; + const todoAddedItems = (todoNoteAdd as { body?: { todos?: Array<{ id?: string }> } }).body?.todos ?? []; + const todoAddedId = todoAddedItems[0]?.id ?? ""; + const todoNoteToggle = todoCreatedId && todoAddedId + ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/actions`, { method: "POST", body: { action: { type: "toggleTodoCompleted", todoId: todoAddedId } } }) + : { ok: false, error: "missing todo id", todoNoteAdd }; + const todoNoteUndo = todoCreatedId + ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/undo`, { method: "POST", body: {} }) + : { ok: false, error: "missing created todo note id" }; + const todoNoteDelete = todoCreatedId + ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}`, { method: "DELETE" }) + : { ok: false, error: "missing created todo note id" }; const providerIngress = await fetchProbe(urls.providerIngressHealthUrl); - const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body; + const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number; pgdata?: { volumeName?: string; databaseBytes?: number } } }).body; const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record }> } }).body?.nodes ?? []; const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id); const expectedGatewayVersion = providerGatewayPackageVersion(); @@ -251,6 +273,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? []; const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id); addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); + addCheck(checks, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview); addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id)); @@ -258,8 +281,11 @@ 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 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 firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs) ? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined @@ -284,6 +310,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-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices }); addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus); addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth); addCheck(checks, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary); @@ -291,6 +318,10 @@ 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:todo-note-status", (todoNoteStatus as { ok?: boolean }).ok === true && (todoNoteStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, todoNoteStatus); + addCheck(checks, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth); + addCheck(checks, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances }); + addCheck(checks, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete }); const upgradeDispatch = dockerCoreJson("/api/dispatch", { method: "POST", body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } }, @@ -327,10 +358,27 @@ function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string { SELECT 'marker_count=' || count(*) FROM unidesk_e2e_markers; SELECT 'failed_task=' || id FROM unidesk_tasks WHERE id = '${failedTaskId}' AND status = 'failed'; SELECT 'online_main_server=' || count(*) FROM unidesk_nodes WHERE provider_id = '${config.providerGateway.id}' AND status = 'online'; + SELECT 'todo_note_instances=' || count(*) FROM todo_note_instances; + WITH RECURSIVE todo_tree(instance_id, todo) AS ( + SELECT id, jsonb_array_elements(todos) FROM todo_note_instances + UNION ALL + SELECT todo_tree.instance_id, child.value + FROM todo_tree + CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(todo_tree.todo->'children') = 'array' THEN todo_tree.todo->'children' + ELSE '[]'::jsonb + END + ) AS child(value) + ) + SELECT 'todo_note_total_todos=' || count(*) FROM todo_tree; `; const marker = runPsql(config, markerSql); + const todoNoteInstanceCount = Number(marker.stdout.match(/todo_note_instances=(\d+)/)?.[1] ?? NaN); + const todoNoteTotalTodos = Number(marker.stdout.match(/todo_note_total_todos=(\d+)/)?.[1] ?? NaN); addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker); addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker); + addCheck(checks, "database:todo-note-pg-storage", marker.ok && todoNoteInstanceCount >= 5 && todoNoteTotalTodos >= 100, { ...marker, todoNoteInstanceCount, todoNoteTotalTodos }); return markerId; } @@ -346,6 +394,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("pageerror", (error) => consoleErrors.push(error.message)); + page.on("dialog", (dialog) => dialog.accept()); await page.goto(urls.frontendUrl, { waitUntil: "domcontentloaded", timeout: 15000 }); await page.waitForSelector('[data-testid="login-screen"]', { timeout: 10000 }); await page.fill('input[name="username"]', config.auth.username); @@ -359,6 +408,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 const publicFrontendReached = landed.origin === publicOrigin && !["127.0.0.1", "localhost", "::1"].includes(landed.hostname); await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 }); await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 }); + const railWidthBefore = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); + await page.getByTestId("rail-toggle").click(); + await page.waitForTimeout(120); + const railWidthCollapsed = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); + await page.getByTestId("rail-toggle").click(); + await page.waitForTimeout(80); await page.setViewportSize({ width: 390, height: 860 }); const mobileRailHeights: number[] = []; for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) { @@ -441,7 +496,43 @@ 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-todo-note"]', { timeout: 10000 }); const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /Todo Note/ }).click(); + await page.waitForSelector('[data-testid="todo-note-page"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText; + const lower = text.toLowerCase(); + return lower.includes("todo note 工作台") + && text.includes("CONSTAR") + && text.includes("大论文") + && text.includes("找工作") + && text.includes("小论文") + && text.includes("事务") + && text.includes("仅 UniDesk frontend 代理访问"); + }, undefined, { timeout: 30000 }); + const uiTodoListName = `UI E2E ${Date.now()}`; + await page.getByLabel("新清单名称").fill(uiTodoListName); + await page.getByRole("button", { name: "创建" }).click(); + const uiTodoRow = page.locator(".todo-instance-row", { hasText: uiTodoListName }); + await uiTodoRow.waitFor({ state: "visible", timeout: 10000 }); + await uiTodoRow.click(); + await page.waitForFunction((name) => { + const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; + return active?.innerText.includes(String(name)); + }, uiTodoListName, { timeout: 10000 }); + await page.waitForSelector('[data-testid="todo-note-tree"]', { timeout: 10000 }); + await page.getByLabel("新增根任务").fill("UI E2E smoke task"); + await page.getByRole("button", { name: "新增" }).click(); + await page.waitForSelector("text=UI E2E smoke task", { timeout: 10000 }); + const todoNoteText = await page.locator('[data-testid="todo-note-page"]').innerText({ timeout: 5000 }); + await uiTodoRow.click(); + await page.waitForFunction((name) => { + const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; + return active?.innerText.includes(String(name)); + }, uiTodoListName, { timeout: 10000 }); + await page.getByRole("button", { name: "删除清单" }).click(); + await page.waitForFunction((name) => !document.body.innerText.includes(String(name)), uiTodoListName, { timeout: 10000 }); await page.getByRole("button", { name: /FindJob/ }).click(); await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 }); await page.waitForFunction(() => { @@ -458,6 +549,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 const findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: /Pipeline/ }).click(); await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="pipeline-react-flow"] .react-flow__node', { timeout: 30000 }); await page.waitForFunction(() => { const text = document.body.innerText; const lower = text.toLowerCase(); @@ -468,12 +560,16 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 && /组件\s+\d+/.test(text) && /运行记录\s+[1-9]\d*/.test(text); }, undefined, { timeout: 30000 }); + const pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count(); + const pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count(); const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase(); + const todoNoteTextLower = todoNoteText.toLowerCase(); const findjobTextLower = findjobText.toLowerCase(); const pipelineTextLower = pipelineText.toLowerCase(); addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) }); + addCheck(checks, "frontend:sidebar-collapse", railWidthBefore >= 160 && railWidthCollapsed <= 70, { railWidthBefore, railWidthCollapsed }); addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); addCheck(checks, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics }); addCheck(checks, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); @@ -485,9 +581,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 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(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:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogText.includes("D601") && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1200) }); + 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: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:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { diff --git a/src/bun.lock b/src/bun.lock index e0832de3..7d197635 100644 --- a/src/bun.lock +++ b/src/bun.lock @@ -19,12 +19,18 @@ "components/frontend": { "name": "@unidesk/frontend", "dependencies": { + "@xyflow/react": "12.10.2", "react": "18.3.1", "react-dom": "18.3.1", }, + "devDependencies": { + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + }, }, "components/provider-gateway": { "name": "@unidesk/provider-gateway", + "version": "0.2.1", }, "components/shared": { "name": "@unidesk/shared", @@ -33,8 +39,26 @@ "packages": { "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@unidesk/backend-core": ["@unidesk/backend-core@workspace:components/backend-core"], "@unidesk/frontend": ["@unidesk/frontend@workspace:components/frontend"], @@ -43,8 +67,34 @@ "@unidesk/shared": ["@unidesk/shared@workspace:components/shared"], + "@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="], + + "@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -60,5 +110,9 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], } } diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index fc0c7232..6b31245d 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -32,6 +32,8 @@ interface RuntimeConfig { providerToken: string; heartbeatTimeoutMs: number; taskPendingTimeoutMs: number; + databaseVolumeName: string; + databaseVolumeSize: string; microservices: MicroserviceConfig[]; logFile: string; } @@ -56,6 +58,7 @@ interface MicroserviceConfig { proxyMode: string; frontendOnly: boolean; public: boolean; + allowedMethods: string[]; allowedPathPrefixes: string[]; healthPath: string; timeoutMs: number; @@ -180,6 +183,7 @@ function parseMicroserviceConfig(value: unknown, index: number): MicroserviceCon proxyMode: stringFromRecord(backend, "proxyMode", `${path}.backend`), frontendOnly: booleanFromRecord(backend, "frontendOnly", `${path}.backend`), public: booleanFromRecord(backend, "public", `${path}.backend`), + allowedMethods: stringArrayFromRecord(backend, "allowedMethods", `${path}.backend`).map((method) => method.toUpperCase()), allowedPathPrefixes: stringArrayFromRecord(backend, "allowedPathPrefixes", `${path}.backend`), healthPath: stringFromRecord(backend, "healthPath", `${path}.backend`), timeoutMs: numberFromRecord(backend, "timeoutMs", `${path}.backend`), @@ -212,6 +216,8 @@ function readConfig(): RuntimeConfig { providerToken: requiredEnv("PROVIDER_TOKEN"), heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"), taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000), + databaseVolumeName: requiredEnv("DATABASE_VOLUME_NAME"), + databaseVolumeSize: requiredEnv("DATABASE_VOLUME_SIZE"), microservices: readMicroservicesEnv(), logFile: requiredEnv("LOG_FILE"), }; @@ -242,7 +248,7 @@ function jsonResponse(body: unknown, status = 200): Response { headers: { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", - "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-methods": "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "content-type,x-provider-token", }, }); @@ -832,16 +838,35 @@ async function countPendingTasks(): Promise { return Number(rows[0]?.count ?? 0); } +async function getPgdataUsage(): Promise { + const rows = await sql>` + SELECT + current_database()::text AS database_name, + pg_database_size(current_database())::bigint AS database_bytes, + pg_size_pretty(pg_database_size(current_database()))::text AS database_pretty + `; + const row = rows[0]; + return { + volumeName: config.databaseVolumeName, + volumeSize: config.databaseVolumeSize, + databaseName: row?.database_name ?? "unknown", + databaseBytes: Number(row?.database_bytes ?? 0), + databasePretty: row?.database_pretty ?? "--", + }; +} + async function getOverview(): Promise { const nodes = await getNodes(); const pendingTasks = await countPendingTasks(); const dockerStatuses = await getNodeDockerStatuses(); const systemStatuses = await getNodeSystemStatuses(1); + const pgdata = await getPgdataUsage(); const online = nodes.filter((node) => node.status === "online").length; return { service: "unidesk-core", ok: true, dbReady, + pgdata, uptimeSeconds: Math.floor((Date.now() - serviceStartedAt.getTime()) / 1000), nodeCount: nodes.length, onlineNodeCount: online, @@ -948,6 +973,10 @@ function isMicroservicePathAllowed(service: MicroserviceConfig, path: string): b return service.backend.allowedPathPrefixes.some((prefix) => path === prefix || path.startsWith(prefix)); } +function isMicroserviceMethodAllowed(service: MicroserviceConfig, method: string): boolean { + return service.backend.allowedMethods.includes(method.toUpperCase()); +} + function readMicroserviceArrayLimits(url: URL): { query: string; jsonArrayLimits: Record } { const params = new URLSearchParams(url.searchParams); const jsonArrayLimits: Record = {}; @@ -988,8 +1017,10 @@ async function microserviceRoute(req: Request, url: URL): Promise { const suffix = slashIndex === -1 ? "" : rest.slice(slashIndex + 1); const service = microserviceById(serviceId); if (service === null) return jsonResponse({ ok: false, error: `microservice not found: ${serviceId}` }, 404); - if (suffix === "" || suffix === "status") return jsonResponse({ ok: true, microservice: (await getMicroservices()).find((item) => recordValue(item, "id") === serviceId) ?? service }); - if (req.method !== "GET" && req.method !== "HEAD") return jsonResponse({ ok: false, error: "microservice frontend proxy only supports GET/HEAD" }, 405); + const method = req.method.toUpperCase(); + if ((suffix === "" || suffix === "status") && (method === "GET" || method === "HEAD")) { + return jsonResponse({ ok: true, microservice: (await getMicroservices()).find((item) => recordValue(item, "id") === serviceId) ?? service }); + } const proxyPrefix = "proxy"; const targetPath = suffix === "health" @@ -1000,6 +1031,12 @@ async function microserviceRoute(req: Request, url: URL): Promise { ? `/${suffix.slice(proxyPrefix.length + 1)}` : ""; if (targetPath.length === 0) return jsonResponse({ ok: false, error: "microservice route must be /status, /health, or /proxy/" }, 404); + if (suffix === "health" && method !== "GET" && method !== "HEAD") { + return jsonResponse({ ok: false, error: "microservice health only supports GET/HEAD" }, 405); + } + if (!isMicroserviceMethodAllowed(service, method)) { + return jsonResponse({ ok: false, error: "microservice method is not allowed", serviceId, method, allowedMethods: service.backend.allowedMethods }, 405); + } if (!isMicroservicePathAllowed(service, targetPath)) { return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403); } @@ -1007,13 +1044,22 @@ async function microserviceRoute(req: Request, url: URL): Promise { return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409); } const proxyOptions = readMicroserviceArrayLimits(url); + const bodyText = method === "GET" || method === "HEAD" ? "" : await req.text(); + if (bodyText.length > 1024 * 1024) { + return jsonResponse({ ok: false, error: "microservice request body is too large", maxBytes: 1024 * 1024 }, 413); + } + const requestHeaders: Record = {}; + const contentType = req.headers.get("content-type"); + if (contentType !== null) requestHeaders["content-type"] = contentType.slice(0, 200); const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", { source: "microservice-frontend-proxy", serviceId: service.id, - method: req.method, + method, targetBaseUrl: service.backend.nodeBaseUrl, path: targetPath, query: proxyOptions.query, + requestHeaders, + bodyText, jsonArrayLimits: proxyOptions.jsonArrayLimits, timeoutMs: service.backend.timeoutMs, }); diff --git a/src/components/frontend/package.json b/src/components/frontend/package.json index e34c5ec7..38c8e69d 100644 --- a/src/components/frontend/package.json +++ b/src/components/frontend/package.json @@ -7,7 +7,12 @@ "check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@xyflow/react": "12.10.2", "react": "18.3.1", "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7" } } diff --git a/src/components/frontend/public/index.html b/src/components/frontend/public/index.html index 07e17308..520a262a 100644 --- a/src/components/frontend/public/index.html +++ b/src/components/frontend/public/index.html @@ -4,6 +4,7 @@ UniDesk Control Plane + diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 4c34c80a..71d80c0a 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -1015,7 +1015,130 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } align-items: start; } .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; } -.pipeline-node-table table { min-width: 1080px; } +.pipeline-flow-frame { + height: min(68vh, 720px); + min-height: 520px; + border: 1px solid var(--line-soft); + background: + radial-gradient(circle at 18% 22%, rgba(78, 183, 168, 0.11), transparent 30%), + linear-gradient(135deg, rgba(215, 161, 58, 0.09), transparent 44%), + #0b1319; +} +.pipeline-flow-frame .react-flow__pane { cursor: grab; } +.pipeline-flow-frame .react-flow__controls { + border: 1px solid var(--line); + box-shadow: none; +} +.pipeline-flow-frame .react-flow__controls-button { + border-bottom-color: var(--line-soft); + background: #111a22; + color: var(--text); +} +.pipeline-flow-frame .react-flow__controls-button svg { fill: currentColor; } +.pipeline-flow-frame .react-flow__minimap { + border: 1px solid var(--line); + background: rgba(8, 13, 18, 0.88); +} +.pipeline-flow-frame .react-flow__edges { + z-index: 5; +} +.pipeline-flow-frame .react-flow__nodes { + z-index: 4; +} +.pipeline-flow-frame .react-flow__edge-path { + stroke: rgba(129, 147, 159, 0.72); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 5px rgba(78, 183, 168, 0.12)); +} +.pipeline-flow-frame .react-flow__edge { + opacity: 0.82; + z-index: 12; +} +.pipeline-flow-frame .react-flow__edge:hover, +.pipeline-flow-frame .react-flow__edge.selected { + opacity: 1; +} +.pipeline-flow-frame .react-flow__edge:hover .react-flow__edge-path, +.pipeline-flow-frame .react-flow__edge.selected .react-flow__edge-path { + stroke-width: 2.8; +} +.pipeline-flow-frame .react-flow__edge.succeeded .react-flow__edge-path { stroke: var(--accent-2); } +.pipeline-flow-frame .react-flow__edge.running .react-flow__edge-path { stroke: var(--accent); } +.pipeline-flow-frame .react-flow__edge.failed .react-flow__edge-path { stroke: var(--danger); } +.pipeline-flow-frame .pipeline-flow-handle { + width: 9px; + height: 18px; + border: 1px solid rgba(78, 183, 168, 0.8); + border-radius: 2px; + background: #071016; +} +.pipeline-flow-frame .pipeline-flow-handle.input.top, +.pipeline-flow-frame .pipeline-flow-handle.input.bottom { + width: 18px; + height: 9px; +} +.pipeline-flow-frame .pipeline-flow-handle.input.left { left: -6px; } +.pipeline-flow-frame .pipeline-flow-handle.input.top { top: -6px; } +.pipeline-flow-frame .pipeline-flow-handle.input.bottom { bottom: -6px; } +.pipeline-flow-frame .pipeline-flow-handle.output.right { + right: -6px; + border-color: rgba(215, 161, 58, 0.86); +} +.pipeline-flow-node { + width: 236px; + border: 1px solid rgba(129, 147, 159, 0.4); + background: linear-gradient(180deg, rgba(19, 29, 38, 0.98), rgba(10, 18, 24, 0.98)); + color: var(--text); + box-shadow: 0 14px 34px rgba(0,0,0,0.32); +} +.pipeline-flow-node-body { + position: relative; + padding: 10px 12px; +} +.pipeline-flow-node.succeeded { border-color: rgba(78, 183, 168, 0.72); } +.pipeline-flow-node.running { border-color: rgba(215, 161, 58, 0.82); } +.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)); } +.flow-node-label { + display: grid; + gap: 4px; + min-width: 0; + text-align: left; +} +.flow-node-label strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font-size: 12px; +} +.flow-node-label span { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.flow-node-label code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent); +} +.pipeline-flow-summary { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + color: var(--muted); +} +.pipeline-flow-summary span { + padding: 3px 7px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.03); +} .component-strata { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); @@ -1268,3 +1391,300 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; } .docker-hero, .monitor-hero { flex-direction: column; } } + +.shell.rail-collapsed { + grid-template-columns: 58px minmax(0, 1fr); +} +.rail-toggle { + margin-left: auto; + min-width: 24px; + height: 24px; + border: 1px solid var(--line-soft); + color: var(--muted); + background: rgba(0,0,0,0.18); +} +.rail-toggle:hover { color: var(--text); border-color: var(--accent); } +.rail.collapsed { + padding-inline: 8px; +} +.rail.collapsed .brand { + justify-content: center; + padding-inline: 0; +} +.rail.collapsed .brand-text, +.rail.collapsed .module > span:not(.module-code) { + display: none; +} +.rail.collapsed .rail-toggle { + position: absolute; + top: 44px; + left: 8px; + width: 40px; +} +.rail.collapsed .module { + grid-template-columns: 1fr; + justify-items: center; + min-height: 38px; + padding: 7px 4px; + margin-top: 8px; +} +.rail.collapsed .module-code { + font-size: 10px; + letter-spacing: 0.08em; +} + +.todo-note-page { + display: grid; + gap: 10px; +} +.todo-note-hero { + display: grid; + grid-template-columns: minmax(280px, 1.35fr) minmax(260px, 0.9fr) minmax(220px, 0.7fr); + gap: 8px; + align-items: stretch; +} +.todo-note-layout { + display: grid; + grid-template-columns: minmax(260px, 0.52fr) minmax(680px, 1.8fr); + gap: 10px; + align-items: start; +} +.todo-list-panel .panel-body, +.todo-main-stack .panel-body { + min-width: 0; +} +.todo-create-list, +.todo-add-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + align-items: center; +} +.todo-instance-list { + display: grid; + gap: 6px; + margin-top: 8px; + max-height: calc(100vh - 310px); + overflow: auto; +} +.todo-instance-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + padding: 8px; + border: 1px solid var(--line-soft); + color: var(--muted); + background: var(--panel-3); + text-align: left; +} +.todo-instance-row.active, +.todo-instance-row:hover { + color: var(--text); + border-color: var(--accent-2); + background: rgba(78, 183, 168, 0.09); +} +.todo-instance-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.todo-instance-row code { + grid-column: 1 / -1; + color: #bcd2d7; +} +.todo-workbench { + display: grid; + gap: 8px; + font-size: var(--todo-font-size, 13px); +} +.todo-toolbar { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto auto; + gap: 8px; + align-items: center; +} +.todo-filter-strip, +.todo-toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; +} +.todo-filter { + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--line); + color: var(--muted); + background: rgba(12, 18, 24, 0.62); +} +.todo-filter.active, +.todo-filter:hover { + color: var(--text); + border-color: var(--accent-2); + background: rgba(78, 183, 168, 0.11); +} +.todo-font-control { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); +} +.todo-font-control input { width: 86px; padding: 0; } +.todo-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.todo-root-drop { + padding: 6px 8px; + border: 1px dashed var(--line); + color: var(--muted); + background: rgba(255,255,255,0.02); +} +.todo-tree { + display: grid; + gap: 5px; + max-height: calc(100vh - 398px); + min-height: 280px; + overflow: auto; + align-content: start; +} +.todo-row-wrap { + display: grid; + gap: 5px; +} +.todo-row { + display: grid; + grid-template-columns: 28px 24px minmax(240px, 1fr) 178px minmax(360px, auto); + gap: 6px; + align-items: center; + margin-left: calc(var(--todo-depth, 0) * 18px); + padding: 6px; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.todo-row.completed { + opacity: 0.76; +} +.todo-row.completed .todo-title-cell strong { + text-decoration: line-through; + color: var(--muted); +} +.todo-row.dragging { + border-color: var(--accent); + background: rgba(215, 161, 58, 0.08); +} +.todo-expand { + min-width: 24px; + height: 24px; + border: 1px solid var(--line-soft); + color: var(--muted); + background: rgba(0,0,0,0.14); +} +.todo-expand:disabled { opacity: 0.45; cursor: default; } +.todo-title-cell { + min-width: 0; + display: grid; + gap: 3px; +} +.todo-title-cell strong { + overflow-wrap: anywhere; + color: var(--text); + font-size: var(--todo-font-size, 13px); +} +.todo-meta-line { + display: flex; + flex-wrap: wrap; + gap: 5px; + color: var(--muted); + font-size: 11px; +} +.todo-reminder { color: var(--accent); } +.todo-reminder-input { + min-width: 176px; + padding: 5px 6px; +} +.todo-row-actions, +.todo-edit-inline { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} +.todo-row-actions .ghost-btn { + min-height: 24px; + padding: 2px 6px; +} +.todo-children { + display: grid; + gap: 5px; +} + +@media (max-width: 1120px) { + .todo-note-layout, + .todo-note-hero, + .todo-toolbar { + grid-template-columns: 1fr; + } + .todo-row { + grid-template-columns: 28px 24px minmax(220px, 1fr); + } + .todo-reminder-input, + .todo-row-actions { + grid-column: 3; + } + .todo-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + .shell.rail-collapsed { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + } + .rail.collapsed { + height: 42px; + padding: 4px 6px; + } + .rail.collapsed .brand { + justify-content: flex-start; + padding: 0 8px 0 0; + } + .rail.collapsed .rail-toggle { + position: static; + width: 24px; + flex: 0 0 auto; + } + .rail.collapsed .module { + width: auto; + min-width: 88px; + min-height: 30px; + grid-template-columns: auto 1fr; + justify-items: start; + gap: 5px; + padding: 4px 8px; + margin: 0; + } + .rail.collapsed .module > span:not(.module-code) { + display: inline; + } + .todo-stats-grid, + .todo-create-list, + .todo-add-form { + grid-template-columns: 1fr; + } + .todo-tree { + max-height: none; + min-height: 220px; + } + .todo-row { + grid-template-columns: 24px minmax(0, 1fr); + margin-left: 0; + } + .todo-row > input[type="checkbox"] { grid-column: 1; } + .todo-title-cell, + .todo-reminder-input, + .todo-row-actions { + grid-column: 1 / -1; + } +} diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index cc8e7135..8e9c3b2e 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -1,15 +1,10 @@ -export {}; - -declare const React: { - createElement: (...args: any[]) => any; - useEffect: (...args: any[]) => any; - useMemo: (...args: any[]) => any; - useState: (...args: any[]) => any; -}; -declare const ReactDOM: { createRoot: (element: Element | null) => { render: (node: any) => void } }; +import React from "react"; +import { createRoot } from "react-dom/client"; +import { FindJobPage } from "./findjob"; +import { PipelinePage } from "./pipeline"; +import { TodoNotePage } from "./todo-note"; type AnyRecord = Record; -type ReactNode = any; function readClientConfig(): AnyRecord { const raw = document.getElementById("root")?.getAttribute("data-config"); @@ -24,7 +19,8 @@ function readClientConfig(): AnyRecord { const cfg: AnyRecord = readClientConfig(); const h = React.createElement; -const { useEffect, useMemo, useState } = React; +const { useEffect, useMemo } = React; +const useState: any = React.useState; function errorMessage(error: unknown, fallback = "操作失败"): string { return error instanceof Error ? error.message : String(error || fallback); @@ -52,6 +48,7 @@ const MODULES = [ ] }, { id: "apps", label: "微服务", code: "APP", tabs: [ { id: "catalog", label: "服务目录" }, + { id: "todo-note", label: "Todo Note" }, { id: "findjob", label: "FindJob" }, { id: "pipeline", label: "Pipeline" }, ] }, @@ -285,63 +282,6 @@ function microserviceRepository(service: any): AnyRecord { return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {}; } -function findjobSummaryMetric(summary: any, key: string): string { - const value = summary && typeof summary === "object" ? summary[key] : undefined; - return Number.isFinite(Number(value)) ? String(value) : "--"; -} - -function findjobJobRows(data: any): any[] { - const rows = Array.isArray(data?.jobs) ? data.jobs : []; - return rows.slice(0, 40); -} - -function findjobDraftRows(data: any): any[] { - const rows = Array.isArray(data?.drafts) ? data.drafts : []; - return rows.slice(0, 12); -} - -function pipelineSnapshotArrays(snapshot: any): { components: any[]; pipelines: any[]; runs: any[] } { - return { - components: Array.isArray(snapshot?.registry?.components) ? snapshot.registry.components : [], - pipelines: Array.isArray(snapshot?.pipelines) ? snapshot.pipelines : [], - runs: Array.isArray(snapshot?.runs) ? snapshot.runs : [], - }; -} - -function pipelineArrayCount(snapshot: any, path: string, fallback: number): number { - const meta = snapshot?._unidesk?.arrayLimits?.[path]; - const original = Number(meta?.originalLength); - return Number.isFinite(original) ? original : fallback; -} - -function pipelineComponentRef(value: any): string { - if (!value || typeof value !== "object" || Array.isArray(value)) return "--"; - return `${value.componentClass || "--"}/${value.id || "--"}`; -} - -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); - return String(node?.status || "pending"); -} - -function pipelineStatusCounts(runs: any[]): AnyRecord { - return runs.reduce((counts: AnyRecord, run: any) => { - const status = String(run?.status || "unknown").toLowerCase(); - counts[status] = (counts[status] || 0) + 1; - return counts; - }, {}); -} - -function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> { - const counts = components.reduce((memo: AnyRecord, component: any) => { - const name = String(component?.componentClass || "unknown"); - memo[name] = (memo[name] || 0) + 1; - return memo; - }, {}); - return Object.entries(counts).map(([name, count]) => ({ name, count: Number(count) })).sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)); -} - 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"); @@ -526,14 +466,19 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock } ); } -function Sidebar({ activeModule, onChange }: AnyRecord) { - return h("aside", { className: "rail", "aria-label": "主模块" }, - h("div", { className: "brand" }, h("span", { className: "brand-mark" }, "UD"), h("span", { className: "brand-text" }, "UniDesk")), +function Sidebar({ activeModule, onChange, collapsed, onToggle }: AnyRecord) { + return h("aside", { className: `rail ${collapsed ? "collapsed" : ""}`, "aria-label": "主模块" }, + h("div", { className: "brand" }, + h("span", { className: "brand-mark" }, "UD"), + h("span", { className: "brand-text" }, "UniDesk"), + h("button", { type: "button", className: "rail-toggle", onClick: onToggle, "aria-label": collapsed ? "展开左侧边栏" : "收起左侧边栏", "data-testid": "rail-toggle" }, collapsed ? "»" : "«"), + ), MODULES.map((module: any) => h("button", { key: module.id, type: "button", className: `module ${activeModule === module.id ? "active" : ""}`, onClick: () => onChange(module.id), + title: module.label, }, h("span", { className: "module-code" }, module.code), h("span", null, module.label))), ); } @@ -555,10 +500,12 @@ function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) { const pendingTasks = data.pendingTasks || data.tasks.filter(isPendingTask); const pendingCount = overview.pendingTaskCount ?? pendingTasks.length; const recentTasks = data.tasks.slice(0, 5); + const pgdata = overview.pgdata || {}; return h("div", { className: "page-grid overview-grid" }, h(Panel, { title: "核心指标", eyebrow: "Control" }, h("div", { className: "metric-grid" }, h(MetricCard, { label: "数据库", value: overview.dbReady ? "READY" : "WAIT", hint: "PostgreSQL internal network", tone: overview.dbReady ? "ok" : "warn" }), + h(MetricCard, { label: "PGDATA", value: fmtBytes(pgdata.databaseBytes), hint: `${pgdata.volumeName || "unidesk_pgdata_10gb"} / ${pgdata.databasePretty || "--"}`, tone: "ok", testId: "pgdata-usage-card" }), h(MetricCard, { label: "在线节点", value: overview.onlineNodeCount ?? 0, hint: `${overview.nodeCount ?? 0} registered`, tone: "ok" }), h(MetricCard, { label: "WebSocket", value: overview.activeSocketCount ?? 0, hint: "Provider ingress sockets" }), h(MetricCard, { label: "待处理任务", value: pendingCount, hint: pendingCount > 0 ? "点击查看具体任务" : `timeout ${fmtDuration(Math.floor((overview.taskPendingTimeoutMs ?? 0) / 1000))}`, tone: pendingCount > 0 ? "warn" : "ok", onClick: () => onNavigate("tasks", "pending"), testId: "pending-task-card" }), @@ -992,12 +939,13 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { } const status = active.dockerStatus; + const isMainServer = active.providerId === "main-server"; const counts = status?.counts || {}; const daemon = status?.daemon || {}; const containers = status?.containers || []; const images = status?.images || []; const volumes = sortVolumes(status?.volumes || []); - const databaseVolume = volumes.find(isDatabaseVolume); + const databaseVolume = isMainServer ? volumes.find(isDatabaseVolume) : null; const networks = status?.networks || []; const runningContainers = containers.filter((item: any) => item.state === "running"); const stoppedContainers = containers.filter((item: any) => item.state !== "running"); @@ -1041,10 +989,10 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { h("div", { className: "docker-metrics" }, h(MetricCard, { label: "Containers", value: counts.containers ?? containers.length, hint: `${counts.running ?? runningContainers.length} running / ${counts.stopped ?? stoppedContainers.length} stopped`, tone: "ok" }), h(MetricCard, { label: "Images", value: counts.images ?? images.length, hint: `${counts.daemonImages ?? counts.images ?? images.length} daemon images` }), - h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: databaseVolume ? "database volume visible" : "local volumes", tone: databaseVolume ? "ok" : "" }), + h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: isMainServer ? (databaseVolume ? "database volume visible" : "database volume missing") : "node local volumes", tone: databaseVolume ? "ok" : "" }), h(MetricCard, { label: "Networks", value: counts.networks ?? networks.length, hint: daemon.driver ? `driver ${daemon.driver}` : "docker networks" }), ), - h(DatabaseVolumeCard, { volume: databaseVolume, volumeCount: volumes.length }), + isMainServer ? h(DatabaseVolumeCard, { volume: databaseVolume, volumeCount: volumes.length }) : null, h("div", { className: "docker-section-head" }, h("h3", null, "Containers"), h("span", null, `updated ${fmtDate(active.dockerUpdatedAt || status.collectedAt)}`), @@ -1064,9 +1012,9 @@ function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { ), h("div", { className: "docker-side-stack" }, h(DockerSidePanel, { title: "Images", items: images, render: (image: any) => h("article", { key: `${image.id}-${image.repository}`, className: "docker-side-row" }, h("strong", null, `${image.repository}:${image.tag}`), h("span", null, image.size || "--"), h("code", null, image.id || "--")) }), - h(DockerSidePanel, { title: "Volumes", items: volumes, limit: volumes.length, render: (volume: any) => h("article", { key: volume.name, className: `docker-side-row volume-row ${isDatabaseVolume(volume) ? "database-volume" : ""}`, "data-testid": isDatabaseVolume(volume) ? "database-volume-row" : undefined }, + h(DockerSidePanel, { title: "Volumes", items: volumes, limit: volumes.length, render: (volume: any) => h("article", { key: volume.name, className: `docker-side-row volume-row ${isMainServer && isDatabaseVolume(volume) ? "database-volume" : ""}`, "data-testid": isMainServer && isDatabaseVolume(volume) ? "database-volume-row" : undefined }, h("strong", null, volume.name), - h("span", null, isDatabaseVolume(volume) ? "PostgreSQL" : isHashVolumeName(String(volume.name || "")) ? "anonymous" : "named"), + h("span", null, isMainServer && isDatabaseVolume(volume) ? "PostgreSQL" : isHashVolumeName(String(volume.name || "")) ? "anonymous" : "named"), h("code", null, volume.mountpoint || volume.driver || volume.scope || "--"), ) }), h(DockerSidePanel, { title: "Networks", items: networks, render: (network: any) => h("article", { key: network.id || network.name, className: "docker-side-row" }, h("strong", null, network.name), h("span", null, network.driver || "--"), h("code", null, network.id || "--")) }), @@ -1147,6 +1095,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord h("div", { className: "microservice-actions" }, 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, h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }), ), ), @@ -1157,247 +1106,6 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord ); } -function FindJobPage({ microservices, onRaw }: AnyRecord) { - const service = microservices.find((item: any) => item.id === "findjob") || null; - const [state, setState] = useState({ loading: false, error: "", health: null, summary: null, jobs: null, drafts: null, refreshedAt: null }); - - async function load(): Promise { - if (!service) return; - setState((prev: any) => ({ ...prev, loading: true, error: "" })); - try { - const [health, summary, jobs, drafts] = await Promise.all([ - requestJson(`${cfg.apiBaseUrl}/microservices/findjob/health`), - requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/summary`), - requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:40`), - requestJson(`${cfg.apiBaseUrl}/microservices/findjob/proxy/api/drafts`), - ]); - setState({ loading: false, error: "", health, summary, jobs, drafts, refreshedAt: new Date() }); - } catch (err) { - setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "FindJob 加载失败") })); - } - } - - useEffect(() => { - load(); - }, [service?.id, service?.runtime?.providerStatus]); - - if (!service) return h(EmptyState, { title: "FindJob 未登记", text: "请在 config.json 的 microservices 中登记 id=findjob" }); - - const runtime = microserviceRuntime(service); - const repository = microserviceRepository(service); - const backend = microserviceBackend(service); - const summary = state.summary || {}; - const jobs = findjobJobRows(state.jobs); - const drafts = findjobDraftRows(state.drafts); - const transform = state.jobs?._unidesk?.arrayLimits?.jobs; - return h("div", { className: "findjob-page", "data-testid": "findjob-page" }, - h(Panel, { - title: "FindJob 工作台", - eyebrow: "D601 Backend Microservice", - actions: h("div", { className: "panel-actions" }, - h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "findjob-refresh-button" }, state.loading ? "刷新中" : "刷新"), - h(RawButton, { title: "FindJob Microservice", data: service, onOpen: onRaw, testId: "raw-findjob-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.composeService || "--"}`), - ), - ), - state.error ? h("div", { className: "form-error wide" }, state.error) : null, - ), - h("div", { className: "findjob-grid" }, - h(Panel, { title: "岗位指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Summary" }, - h("div", { className: "metric-grid" }, - h(MetricCard, { label: "岗位总量", value: findjobSummaryMetric(summary, "totalJobs"), hint: "tracked jobs", tone: "ok" }), - h(MetricCard, { label: "原始岗位", value: findjobSummaryMetric(summary, "rawJobs"), hint: "raw queue" }), - h(MetricCard, { label: "已验证", value: findjobSummaryMetric(summary, "verifiedJobs"), hint: "verified set" }), - h(MetricCard, { label: "优先处理", value: findjobSummaryMetric(summary, "prioritizedJobs"), hint: "prioritized" }), - h(MetricCard, { label: "过期", value: findjobSummaryMetric(summary, "staleJobs"), hint: "stale jobs", tone: "warn" }), - h(MetricCard, { label: "无效", value: findjobSummaryMetric(summary, "invalidJobs"), hint: "invalid jobs", tone: "warn" }), - h(MetricCard, { label: "上海", value: findjobSummaryMetric(summary, "shanghaiJobs"), hint: "city filter" }), - h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /api/health" }), - ), - h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Summary", data: summary, onOpen: onRaw, testId: "raw-findjob-summary" })), - ), - h(Panel, { title: "近期岗位", eyebrow: transform ? `${transform.returnedLength}/${transform.originalLength} Preview` : `${jobs.length} Preview` }, - jobs.length === 0 ? h(EmptyState, { title: "暂无岗位预览", text: "等待 D601 findjob backend 返回 /api/jobs" }) : - h("div", { className: "table-wrap findjob-job-table" }, h("table", null, - h("thead", null, h("tr", null, h("th", null, "优先级"), h("th", null, "状态"), h("th", null, "单位"), h("th", null, "职位"), h("th", null, "城市"), h("th", null, "阶段"), h("th", null, "截止"), h("th", null, "证据"))), - h("tbody", null, jobs.map((job: any) => h("tr", { key: job.id }, - h("td", null, h(StatusBadge, { status: String(job.priority || "").toLowerCase() || "unknown" }, job.priority || "--")), - h("td", null, h(StatusBadge, { status: String(job.status || "").toLowerCase() || "unknown" }, job.status || "--")), - h("td", null, job.organization_name || "--", h("code", null, job.id || "--")), - h("td", null, job.display_title || job.title || "--"), - h("td", null, job.display_city || job.city || "--"), - h("td", null, job.workflow_stage || "--"), - h("td", null, job.deadline || "--"), - h("td", null, job.evidence_url ? h("a", { href: job.evidence_url, target: "_blank", rel: "noreferrer" }, "打开") : h("span", { className: "muted" }, "无")), - ))), - )), - h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Jobs Preview", data: state.jobs, onOpen: onRaw, testId: "raw-findjob-jobs" })), - ), - h(Panel, { title: "草稿与报告", eyebrow: `${drafts.length} Drafts` }, - drafts.length === 0 ? h(EmptyState, { title: "暂无草稿", text: "D601 findjob backend 未返回 drafts" }) : - h("div", { className: "draft-list" }, drafts.map((draft: any) => h("article", { key: draft.id, className: "draft-card" }, - h("div", { className: "node-card-head" }, h("strong", null, draft.id), h(StatusBadge, { status: draft.status }, draft.status || "--")), - h("div", { className: "docker-meta compact" }, - h("span", null, draft.workflow_stage || "--"), - h("span", null, `jobs ${draft.counts?.jobs ?? 0}`), - h("span", null, `reports ${draft.counts?.reports ?? 0}`), - ), - h("span", null, draft.latestReportPath || "暂无报告"), - h("code", null, fmtDate(draft.updated_at || draft.updatedAt)), - ))), - h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Drafts", data: state.drafts, onOpen: onRaw, testId: "raw-findjob-drafts" })), - ), - ), - ); -} - -function PipelinePage({ microservices, onRaw }: AnyRecord) { - const service = microservices.find((item: any) => item.id === "pipeline") || null; - const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null }); - - async function load(): Promise { - if (!service) return; - setState((prev: any) => ({ ...prev, loading: true, error: "" })); - try { - const [health, snapshot] = await Promise.all([ - requestJson(`${cfg.apiBaseUrl}/microservices/pipeline/health`), - requestJson(`${cfg.apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3`), - ]); - setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() }); - } catch (err) { - setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") })); - } - } - - useEffect(() => { - 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); - const snapshot = state.snapshot || {}; - const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot); - const activePipeline = pipelines[0] || {}; - const pipelineNodes = Array.isArray(activePipeline.nodes) ? activePipeline.nodes : []; - const pipelineEdges = Array.isArray(activePipeline.edges) ? activePipeline.edges : []; - const latestRun = runs[0] || 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); - return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" }, - h(Panel, { - title: "Pipeline v2 工作台", - eyebrow: "D601 Snapshot Microservice", - actions: h("div", { className: "panel-actions" }, - h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"), - h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }), - ), - }, - h("div", { className: "pipeline-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.composeService || "--"}`), - ), - ), - state.error ? h("div", { className: "form-error wide" }, state.error) : null, - ), - h("div", { className: "pipeline-grid" }, - h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" }, - h("div", { className: "metric-grid" }, - h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }), - h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }), - h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }), - h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }), - h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }), - h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }), - ), - h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })), - ), - h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` }, - componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) : - h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" }, - h("span", null, item.name), - h("strong", null, item.count), - ))), - h("div", { className: "pipeline-component-list" }, - components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))), - ), - ), - h(Panel, { title: "控制图", eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}` }, - pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 pipeline.nodes" }) : - h("div", { className: "table-wrap pipeline-node-table" }, h("table", null, - h("thead", null, h("tr", null, h("th", null, "节点"), h("th", null, "状态"), h("th", null, "类型"), h("th", null, "组件引用"), h("th", null, "输入摘要"))), - h("tbody", null, pipelineNodes.slice(0, 32).map((node: any) => h("tr", { key: node.id }, - h("td", null, h("strong", null, node.id || "--")), - h("td", null, h(StatusBadge, { status: pipelineRunNodeStatus(latestRun, node.id) }, pipelineRunNodeStatus(latestRun, node.id))), - h("td", null, node.kind || "--"), - h("td", null, h("code", null, pipelineComponentRef(node.componentRef))), - h("td", null, h(DataSummary, { data: node.instanceInputs, empty: "无实例输入" })), - ))), - )), - ), - h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` }, - runs.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "Pipeline .state/pipeline-runs 还没有可展示状态" }) : - h("div", { className: "pipeline-run-list" }, runs.map((run: any) => h("article", { key: run.runId, className: "pipeline-run-card" }, - h("div", { className: "node-card-head" }, h("strong", null, run.runId || "--"), h(StatusBadge, { status: run.status }, run.status || "--")), - h("div", { className: "docker-meta compact" }, - h("span", null, run.pipelineId || "--"), - h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`), - h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`), - h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`), - ), - h("p", { className: "muted paragraph" }, summarizeValue(run.task)), - h("code", null, fmtDate(run.updatedAt)), - ))), - ), - h(Panel, { title: "证据日志", eyebrow: latestRun?.runId || "latest worker tail" }, - !latestRun ? 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)), - ), - h("div", { className: "panel-actions inline-actions" }, latestRun ? h(RawButton, { title: `Pipeline Run ${latestRun.runId}`, data: latestRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null), - ), - ), - ); -} - function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) { const onlineNodes = nodes.filter((node: any) => node.status === "online"); const [providerId, setProviderId] = useState(onlineNodes[0]?.providerId || nodes[0]?.providerId || ""); @@ -1644,8 +1352,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw }); if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw }); if (activeModule === "apps" && activeTab === "catalog") return h(MicroserviceCatalogPage, { microservices: data.microservices, onRaw, onNavigate }); - if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw }); - if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw }); + 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 === "config" && activeTab === "topology") return h(TopologyPage, { data }); if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session }); if (activeModule === "config" && activeTab === "security") return h(SecurityPage); @@ -1660,6 +1369,7 @@ function Shell({ session, onLogout }: AnyRecord) { const [lastRefresh, setLastRefresh] = useState(null); const [clock, setClock] = useState(new Date()); const [raw, setRaw] = useState(null); + const [railCollapsed, setRailCollapsed] = useState(false); const module = MODULES.find((item: any) => item.id === activeModule) || MODULES[0]; const activeTab = activeTabs[activeModule] || module.tabs[0].id; @@ -1724,8 +1434,8 @@ function Shell({ session, onLogout }: AnyRecord) { setRaw({ title, data: rawData }); } - return h("div", { className: "shell", "data-testid": "app-shell" }, - h(Sidebar, { activeModule, onChange: setActiveModule }), + return h("div", { className: `shell ${railCollapsed ? "rail-collapsed" : ""}`, "data-testid": "app-shell" }, + h(Sidebar, { activeModule, onChange: setActiveModule, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }), h("main", { className: "workspace" }, h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }), h(TabBar, { module, activeTab, onChange: setTab }), @@ -1765,4 +1475,6 @@ function App() { return h(Shell, { session, onLogout: logout }); } -ReactDOM.createRoot(document.getElementById("root")).render(h(App)); +const rootElement = document.getElementById("root"); +if (rootElement === null) throw new Error("root element not found"); +createRoot(rootElement).render(h(App)); diff --git a/src/components/frontend/src/findjob.tsx b/src/components/frontend/src/findjob.tsx new file mode 100644 index 00000000..90f96d83 --- /dev/null +++ b/src/components/frontend/src/findjob.tsx @@ -0,0 +1,221 @@ +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 }); +} + +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}`; + const error = new Error(message); + (error as Error & { status?: number }).status = response.status; + throw error; + } + 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 findjobSummaryMetric(summary: any, key: string): string { + const value = summary && typeof summary === "object" ? summary[key] : undefined; + return Number.isFinite(Number(value)) ? String(value) : "--"; +} + +function findjobJobRows(data: any): any[] { + const rows = Array.isArray(data?.jobs) ? data.jobs : []; + return rows.slice(0, 40); +} + +function findjobDraftRows(data: any): any[] { + const rows = Array.isArray(data?.drafts) ? data.drafts : []; + return rows.slice(0, 12); +} + +export function FindJobPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { + const service = microservices.find((item: any) => item.id === "findjob") || null; + const [state, setState] = useState({ loading: false, error: "", health: null, summary: null, jobs: null, drafts: null, refreshedAt: null }); + + async function load(): Promise { + if (!service) return; + setState((prev: any) => ({ ...prev, loading: true, error: "" })); + try { + const [health, summary, jobs, drafts] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/findjob/health`), + requestJson(`${apiBaseUrl}/microservices/findjob/proxy/api/summary`), + requestJson(`${apiBaseUrl}/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:40`), + requestJson(`${apiBaseUrl}/microservices/findjob/proxy/api/drafts`), + ]); + setState({ loading: false, error: "", health, summary, jobs, drafts, refreshedAt: new Date() }); + } catch (err) { + setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "FindJob 加载失败") })); + } + } + + useEffect(() => { + load(); + }, [service?.id, service?.runtime?.providerStatus]); + + if (!service) return h(EmptyState, { title: "FindJob 未登记", text: "请在 config.json 的 microservices 中登记 id=findjob" }); + + const runtime = microserviceRuntime(service); + const repository = microserviceRepository(service); + const backend = microserviceBackend(service); + const summary = state.summary || {}; + const jobs = findjobJobRows(state.jobs); + const drafts = findjobDraftRows(state.drafts); + const transform = state.jobs?._unidesk?.arrayLimits?.jobs; + return h("div", { className: "findjob-page", "data-testid": "findjob-page" }, + h(Panel, { + title: "FindJob 工作台", + eyebrow: "D601 Backend Microservice", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "findjob-refresh-button" }, state.loading ? "刷新中" : "刷新"), + h(RawButton, { title: "FindJob Microservice", data: service, onOpen: onRaw, testId: "raw-findjob-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.composeService || "--"}`), + ), + ), + state.error ? h("div", { className: "form-error wide" }, state.error) : null, + ), + h("div", { className: "findjob-grid" }, + h(Panel, { title: "岗位指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Summary" }, + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "岗位总量", value: findjobSummaryMetric(summary, "totalJobs"), hint: "tracked jobs", tone: "ok" }), + h(MetricCard, { label: "原始岗位", value: findjobSummaryMetric(summary, "rawJobs"), hint: "raw queue" }), + h(MetricCard, { label: "已验证", value: findjobSummaryMetric(summary, "verifiedJobs"), hint: "verified set" }), + h(MetricCard, { label: "优先处理", value: findjobSummaryMetric(summary, "prioritizedJobs"), hint: "prioritized" }), + h(MetricCard, { label: "过期", value: findjobSummaryMetric(summary, "staleJobs"), hint: "stale jobs", tone: "warn" }), + h(MetricCard, { label: "无效", value: findjobSummaryMetric(summary, "invalidJobs"), hint: "invalid jobs", tone: "warn" }), + h(MetricCard, { label: "上海", value: findjobSummaryMetric(summary, "shanghaiJobs"), hint: "city filter" }), + h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: "D601 /api/health" }), + ), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Summary", data: summary, onOpen: onRaw, testId: "raw-findjob-summary" })), + ), + h(Panel, { title: "近期岗位", eyebrow: transform ? `${transform.returnedLength}/${transform.originalLength} Preview` : `${jobs.length} Preview` }, + jobs.length === 0 ? h(EmptyState, { title: "暂无岗位预览", text: "等待 D601 findjob backend 返回 /api/jobs" }) : + h("div", { className: "table-wrap findjob-job-table" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "优先级"), h("th", null, "状态"), h("th", null, "单位"), h("th", null, "职位"), h("th", null, "城市"), h("th", null, "阶段"), h("th", null, "截止"), h("th", null, "证据"))), + h("tbody", null, jobs.map((job: any) => h("tr", { key: job.id }, + h("td", null, h(StatusBadge, { status: String(job.priority || "").toLowerCase() || "unknown" }, job.priority || "--")), + h("td", null, h(StatusBadge, { status: String(job.status || "").toLowerCase() || "unknown" }, job.status || "--")), + h("td", null, job.organization_name || "--", h("code", null, job.id || "--")), + h("td", null, job.display_title || job.title || "--"), + h("td", null, job.display_city || job.city || "--"), + h("td", null, job.workflow_stage || "--"), + h("td", null, job.deadline || "--"), + h("td", null, job.evidence_url ? h("a", { href: job.evidence_url, target: "_blank", rel: "noreferrer" }, "打开") : h("span", { className: "muted" }, "无")), + ))), + )), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Jobs Preview", data: state.jobs, onOpen: onRaw, testId: "raw-findjob-jobs" })), + ), + h(Panel, { title: "草稿与报告", eyebrow: `${drafts.length} Drafts` }, + drafts.length === 0 ? h(EmptyState, { title: "暂无草稿", text: "D601 findjob backend 未返回 drafts" }) : + h("div", { className: "draft-list" }, drafts.map((draft: any) => h("article", { key: draft.id, className: "draft-card" }, + h("div", { className: "node-card-head" }, h("strong", null, draft.id), h(StatusBadge, { status: draft.status }, draft.status || "--")), + h("div", { className: "docker-meta compact" }, + h("span", null, draft.workflow_stage || "--"), + h("span", null, `jobs ${draft.counts?.jobs ?? 0}`), + h("span", null, `reports ${draft.counts?.reports ?? 0}`), + ), + h("span", null, draft.latestReportPath || "暂无报告"), + h("code", null, fmtDate(draft.updated_at || draft.updatedAt)), + ))), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "FindJob Drafts", data: state.drafts, onOpen: onRaw, testId: "raw-findjob-drafts" })), + ), + ), + ); +} diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 5dba81cf..6ad7413c 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -45,6 +45,11 @@ async function buildFrontendApp(): Promise { entrypoints: [join(import.meta.dir, "app.tsx")], target: "browser", format: "iife", + define: { + "process.env.NODE_ENV": "\"production\"", + "import.meta.env": "{\"MODE\":\"production\"}", + "import.meta.env.MODE": "\"production\"", + }, minify: false, sourcemap: "none", }); @@ -259,6 +264,7 @@ async function proxyApi(req: Request, url: URL): Promise { function vendorPath(pathname: string): string | null { if (pathname === "/vendor/react.production.min.js") return join(vendorDir, "react", "umd", "react.production.min.js"); if (pathname === "/vendor/react-dom.production.min.js") return join(vendorDir, "react-dom", "umd", "react-dom.production.min.js"); + if (pathname === "/vendor/xyflow.css") return join(vendorDir, "@xyflow", "react", "dist", "style.css"); return null; } diff --git a/src/components/frontend/src/pipeline.tsx b/src/components/frontend/src/pipeline.tsx new file mode 100644 index 00000000..8e4986af --- /dev/null +++ b/src/components/frontend/src/pipeline.tsx @@ -0,0 +1,555 @@ +import React from "react"; +import { Background, BaseEdge, Controls, Handle, MarkerType, MiniMap, Position, ReactFlow, type Edge, type Node } from "@xyflow/react"; + +type AnyRecord = Record; + +const h = React.createElement; +const { useEffect } = React; +const useState: any = React.useState; + +const pipelineInputPorts: AnyRecord[] = [ + { id: "in-left-top", side: "left", position: Position.Left, style: { top: "24%" } }, + { id: "in-left-mid", side: "left", position: Position.Left, style: { top: "50%" } }, + { id: "in-left-bottom", side: "left", position: Position.Left, style: { top: "76%" } }, + { id: "in-top-left", side: "top", position: Position.Top, style: { left: "28%" } }, + { id: "in-top-mid", side: "top", position: Position.Top, style: { left: "50%" } }, + { id: "in-top-right", side: "top", position: Position.Top, style: { left: "72%" } }, + { id: "in-bottom-left", side: "bottom", position: Position.Bottom, style: { left: "28%" } }, + { id: "in-bottom-mid", side: "bottom", position: Position.Bottom, style: { left: "50%" } }, + { id: "in-bottom-right", side: "bottom", position: Position.Bottom, style: { left: "72%" } }, +]; + +const pipelineOutputPorts: AnyRecord[] = [ + { id: "out-right-top", position: Position.Right, style: { top: "24%" } }, + { id: "out-right-mid", position: Position.Right, style: { top: "50%" } }, + { id: "out-right-bottom", position: Position.Right, style: { top: "76%" } }, +]; + +function PipelineFlowNode({ data }: AnyRecord) { + return h("div", { className: "pipeline-flow-node-body" }, + pipelineInputPorts.map((port) => h(Handle, { + key: port.id, + id: port.id, + type: "target", + position: port.position, + isConnectable: false, + className: `pipeline-flow-handle input ${port.side}`, + style: port.style, + })), + pipelineOutputPorts.map((port) => h(Handle, { + key: port.id, + id: port.id, + type: "source", + position: port.position, + isConnectable: false, + className: "pipeline-flow-handle output right", + style: port.style, + })), + data?.label, + ); +} + +function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosition, markerEnd, markerStart, style, data }: AnyRecord) { + const laneOffset = Number(data?.laneOffset || 0); + const forward = targetX >= sourceX; + const distance = Math.max(1, Math.abs(targetX - sourceX)); + const handleOffset = Math.max(8, Math.min(92, distance * 0.24)); + let path = ""; + if (targetPosition === Position.Top || targetPosition === Position.Bottom) { + const sourceRailX = sourceX + handleOffset; + const verticalSign = targetPosition === Position.Top ? -1 : 1; + const railY = targetY + verticalSign * (46 + Math.min(86, Math.abs(laneOffset) * 0.35)); + path = [ + `M ${sourceX},${sourceY}`, + `C ${sourceRailX},${sourceY} ${sourceRailX},${sourceY} ${sourceRailX},${sourceY}`, + `L ${sourceRailX},${railY}`, + `L ${targetX},${railY}`, + `C ${targetX},${railY} ${targetX},${targetY - verticalSign * 18} ${targetX},${targetY}`, + ].join(" "); + } else if (forward && Math.abs(laneOffset) < 4) { + path = [ + `M ${sourceX},${sourceY}`, + `C ${sourceX + handleOffset},${sourceY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`, + ].join(" "); + } else if (forward) { + const sourceRailX = sourceX + handleOffset; + const targetRailX = targetX - handleOffset; + const railY = (sourceY + targetY) / 2 + laneOffset; + path = [ + `M ${sourceX},${sourceY}`, + `C ${sourceRailX},${sourceY} ${sourceRailX},${sourceY} ${sourceRailX},${sourceY}`, + `L ${sourceRailX},${railY}`, + `L ${targetRailX},${railY}`, + `C ${targetRailX},${railY} ${targetRailX},${targetY} ${targetRailX},${targetY}`, + `C ${targetRailX},${targetY} ${targetRailX},${targetY} ${targetX},${targetY}`, + ].join(" "); + } else { + const detourX = Math.max(sourceX, targetX) + 92 + Math.min(130, Math.abs(laneOffset)); + const railY = (sourceY + targetY) / 2 + laneOffset; + path = [ + `M ${sourceX},${sourceY}`, + `C ${detourX},${sourceY} ${detourX},${railY} ${detourX},${railY}`, + `C ${detourX},${targetY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`, + ].join(" "); + } + return h(BaseEdge, { id, path, markerEnd, markerStart, style, interactionWidth: 28 }); +} + +const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge }; +const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode }; + +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 summarizeValue(value: any): string { + if (value === null || value === undefined) return "--"; + if (typeof value === "boolean") return value ? "是" : "否"; + if (typeof value === "number") return String(value); + if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}...` : value; + if (Array.isArray(value)) return `${value.length} 项`; + if (typeof value === "object") return `${Object.keys(value).length} 字段`; + return String(value); +} + +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}`; + const error = new Error(message); + (error as Error & { status?: number }).status = response.status; + throw error; + } + 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 pipelineSnapshotArrays(snapshot: any): { components: any[]; pipelines: any[]; runs: any[] } { + return { + components: Array.isArray(snapshot?.registry?.components) ? snapshot.registry.components : [], + pipelines: Array.isArray(snapshot?.pipelines) ? snapshot.pipelines : [], + runs: Array.isArray(snapshot?.runs) ? snapshot.runs : [], + }; +} + +function pipelineArrayCount(snapshot: any, path: string, fallback: number): number { + const meta = snapshot?._unidesk?.arrayLimits?.[path]; + const original = Number(meta?.originalLength); + return Number.isFinite(original) ? original : fallback; +} + +function pipelineComponentRef(value: any): string { + if (!value || typeof value !== "object" || Array.isArray(value)) return "--"; + return `${value.componentClass || "--"}/${value.id || "--"}`; +} + +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); + return String(node?.status || "pending"); +} + +function pipelineStatusCounts(runs: any[]): AnyRecord { + return runs.reduce((counts: AnyRecord, run: any) => { + const status = String(run?.status || "unknown").toLowerCase(); + counts[status] = (counts[status] || 0) + 1; + return counts; + }, {}); +} + +function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> { + const counts = components.reduce((memo: AnyRecord, component: any) => { + const name = String(component?.componentClass || "unknown"); + memo[name] = (memo[name] || 0) + 1; + return memo; + }, {}); + return Object.entries(counts).map(([name, count]) => ({ name, count: Number(count) })).sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)); +} + +function pipelineRawNodeIds(value: any): string[] { + if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item : String(item?.id || item?.nodeId || "")).filter(Boolean); + if (Array.isArray(value?.nodes)) return pipelineRawNodeIds(value.nodes); + if (Array.isArray(value?.nodeIds)) return pipelineRawNodeIds(value.nodeIds); + return []; +} + +function pipelineGraphColumns(pipeline: any): string[][] { + const rawBatches = Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; + const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0); + if (explicit.length > 0) return explicit; + + const nodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : []; + const ids: string[] = nodes.map((node: any) => String(node?.id || "")).filter(Boolean); + const idSet = new Set(ids); + const rawEdges = Array.isArray(pipeline?.edges) ? pipeline.edges : []; + const incoming: Map = new Map(ids.map((id) => [id, 0])); + const outgoing: Map = new Map(ids.map((id) => [id, []])); + for (const edge of rawEdges) { + const from = String(edge?.from || edge?.source || ""); + const to = String(edge?.to || edge?.target || ""); + if (!idSet.has(from) || !idSet.has(to)) continue; + outgoing.get(from)?.push(to); + incoming.set(to, (incoming.get(to) || 0) + 1); + } + const levels = new Map(); + const queue = ids.filter((id) => (incoming.get(id) || 0) === 0); + for (const id of queue) levels.set(id, 0); + while (queue.length > 0) { + const current = queue.shift()!; + const nextLevel = (levels.get(current) || 0) + 1; + for (const next of outgoing.get(current) || []) { + incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1)); + levels.set(next, Math.max(levels.get(next) || 0, nextLevel)); + if ((incoming.get(next) || 0) === 0) queue.push(next); + } + } + ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); }); + const maxLevel = Math.max(0, ...Array.from(levels.values())); + return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).filter((batch) => batch.length > 0); +} + +function pipelineFlowEdgeKey(edge: AnyRecord): string { + return `${edge.source}->${edge.target}-${edge.index}`; +} + +function pipelineFlowElements(pipeline: any, latestRun: any): { nodes: Node[]; edges: Edge[] } { + const pipelineNodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : []; + const nodeById: Map = new Map(pipelineNodes.map((node: any) => [String(node?.id || ""), node])); + const columns = pipelineGraphColumns(pipeline); + const flowNodes: Node[] = []; + const nodeLayout: Map = new Map(); + const columnGap = 330; + const rowGap = 122; + columns.forEach((batch, columnIndex) => { + const columnHeight = batch.length * rowGap; + batch.forEach((nodeId, rowIndex) => { + const node = nodeById.get(nodeId) || { id: nodeId }; + const status = pipelineRunNodeStatus(latestRun, nodeId).toLowerCase(); + const kind = String(node.kind || "node").toLowerCase(); + const y = rowIndex * rowGap - Math.floor(columnHeight / 2); + nodeLayout.set(nodeId, { column: columnIndex, row: rowIndex, y }); + flowNodes.push({ + id: nodeId, + type: "pipelineNode", + position: { + x: columnIndex * columnGap, + y, + }, + data: { + label: h("div", { className: "flow-node-label" }, + h("strong", null, nodeId), + h("span", null, kind), + h("code", null, pipelineComponentRef(node.componentRef)), + h(StatusBadge, { status }, status), + ), + }, + className: `pipeline-flow-node ${kind} ${status}`, + }); + }); + }); + const graphEdges = (Array.isArray(pipeline?.edges) ? pipeline.edges : []).flatMap((edge: any, index: number) => { + const source = String(edge?.from || edge?.source || ""); + const target = String(edge?.to || edge?.target || ""); + if (!nodeById.has(source) || !nodeById.has(target)) return []; + return [{ source, target, index }]; + }); + const sourceTotals = graphEdges.reduce((memo: Map, edge: AnyRecord) => memo.set(edge.source, (memo.get(edge.source) || 0) + 1), new Map()); + const targetTotals = graphEdges.reduce((memo: Map, edge: AnyRecord) => memo.set(edge.target, (memo.get(edge.target) || 0) + 1), new Map()); + const pairTotals = graphEdges.reduce((memo: Map, edge: AnyRecord) => { + const key = `${edge.source}->${edge.target}`; + return memo.set(key, (memo.get(key) || 0) + 1); + }, new Map()); + const sourceSeen = new Map(); + const targetSeen = new Map(); + const pairSeen = new Map(); + const targetHandleByEdge = new Map(); + const targetPortLoads: Map> = new Map(); + const sortedIncomingEdges = [...graphEdges].sort((left: AnyRecord, right: AnyRecord) => { + const leftSource = nodeLayout.get(left.source); + const leftTarget = nodeLayout.get(left.target); + const rightSource = nodeLayout.get(right.source); + const rightTarget = nodeLayout.get(right.target); + const leftScore = Math.abs((leftTarget?.column || 0) - (leftSource?.column || 0)) * columnGap + Math.abs((leftTarget?.y || 0) - (leftSource?.y || 0)); + const rightScore = Math.abs((rightTarget?.column || 0) - (rightSource?.column || 0)) * columnGap + Math.abs((rightTarget?.y || 0) - (rightSource?.y || 0)); + return leftScore - rightScore || left.index - right.index; + }); + sortedIncomingEdges.forEach((edge: AnyRecord) => { + const sourceLayout = nodeLayout.get(edge.source) || { column: 0, row: 0, y: 0 }; + const targetLayout = nodeLayout.get(edge.target) || { column: 0, row: 0, y: 0 }; + const span = Math.max(0, targetLayout.column - sourceLayout.column); + const verticalDelta = sourceLayout.y - targetLayout.y; + const loads = targetPortLoads.get(edge.target) || new Map(); + targetPortLoads.set(edge.target, loads); + const port = pipelineInputPorts.slice().sort((left, right) => { + const loadDiff = (loads.get(left.id) || 0) - (loads.get(right.id) || 0); + if (loadDiff !== 0) return loadDiff; + const score = (item: AnyRecord): number => { + const side = String(item.side); + let value = 0; + if (side === "left") value += span <= 1 ? 0 : 24; + if (side === "top") value += verticalDelta < -36 ? -18 : 42; + if (side === "bottom") value += verticalDelta > 36 ? -18 : 42; + if (span <= 1 && Math.abs(verticalDelta) <= 82 && side !== "left") value += 38; + if (span > 1 && side !== "left") value -= 10; + return value; + }; + return score(left) - score(right); + })[0]; + loads.set(port.id, (loads.get(port.id) || 0) + 1); + targetHandleByEdge.set(pipelineFlowEdgeKey(edge), port); + }); + const edges: Edge[] = graphEdges.map((edge: AnyRecord) => { + const targetStatus = pipelineRunNodeStatus(latestRun, edge.target).toLowerCase(); + const pairKey = `${edge.source}->${edge.target}`; + const sourceSlot = sourceSeen.get(edge.source) || 0; + const targetSlot = targetSeen.get(edge.target) || 0; + const pairSlot = pairSeen.get(pairKey) || 0; + sourceSeen.set(edge.source, sourceSlot + 1); + targetSeen.set(edge.target, targetSlot + 1); + pairSeen.set(pairKey, pairSlot + 1); + const sourceLane = sourceSlot - ((sourceTotals.get(edge.source) || 1) - 1) / 2; + const targetLane = targetSlot - ((targetTotals.get(edge.target) || 1) - 1) / 2; + const pairLane = pairSlot - ((pairTotals.get(pairKey) || 1) - 1) / 2; + const sourceLayout = nodeLayout.get(edge.source); + const targetLayout = nodeLayout.get(edge.target); + const span = Math.max(1, (targetLayout?.column || 0) - (sourceLayout?.column || 0)); + const verticalDelta = Math.abs((targetLayout?.y || 0) - (sourceLayout?.y || 0)); + const lane = pairLane * 2 + sourceLane + targetLane * 0.45; + const railDirection = lane === 0 ? (edge.index % 2 === 0 ? -1 : 1) : Math.sign(lane); + const targetPort = targetHandleByEdge.get(pipelineFlowEdgeKey(edge)) || pipelineInputPorts[1]; + const shouldScatter = span > 1 || verticalDelta > 96 || Math.abs(lane) > 0.2 || targetPort.side !== "left"; + const laneOffset = shouldScatter ? Math.max(-220, Math.min(220, lane * 34 + railDirection * Math.min(92, 22 + span * 16))) : 0; + const sourceHandleIndex = Math.max(0, Math.min(pipelineOutputPorts.length - 1, Math.round(sourceLane + (pipelineOutputPorts.length - 1) / 2))); + const sourcePort = pipelineOutputPorts[sourceHandleIndex] || pipelineOutputPorts[1]; + const edgeColor = targetStatus === "succeeded" ? "var(--accent-2)" : targetStatus === "running" ? "var(--accent)" : targetStatus === "failed" ? "var(--danger)" : "rgba(129, 147, 159, 0.78)"; + return { + id: `${edge.source}->${edge.target}-${edge.index}`, + source: edge.source, + target: edge.target, + sourceHandle: sourcePort.id, + targetHandle: targetPort.id, + type: "pipelineCurve", + zIndex: 12, + animated: targetStatus === "running", + data: { laneOffset, targetSide: targetPort.side }, + style: { stroke: edgeColor }, + markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, + className: `pipeline-flow-edge ${targetStatus}`, + }; + }); + return { nodes: flowNodes, edges }; +} + +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 }); + + async function load(): Promise { + if (!service) return; + setState((prev: any) => ({ ...prev, loading: true, error: "" })); + try { + const [health, snapshot] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/pipeline/health`), + requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3`), + ]); + setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() }); + } catch (err) { + setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") })); + } + } + + useEffect(() => { + 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); + const snapshot = state.snapshot || {}; + const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot); + const activePipeline = pipelines[0] || {}; + const pipelineNodes = Array.isArray(activePipeline.nodes) ? activePipeline.nodes : []; + const pipelineEdges = Array.isArray(activePipeline.edges) ? activePipeline.edges : []; + const latestRun = runs[0] || 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); + return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" }, + h(Panel, { + title: "Pipeline v2 工作台", + eyebrow: "D601 Snapshot Microservice", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"), + h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }), + ), + }, + h("div", { className: "pipeline-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.composeService || "--"}`), + ), + ), + state.error ? h("div", { className: "form-error wide" }, state.error) : null, + ), + h("div", { className: "pipeline-grid" }, + h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" }, + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }), + h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }), + h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }), + h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }), + h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }), + h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }), + ), + h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })), + ), + h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` }, + componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) : + h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" }, + h("span", null, item.name), + h("strong", null, item.count), + ))), + h("div", { className: "pipeline-component-list" }, + components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))), + ), + ), + h(Panel, { title: "控制图", eyebrow: `${activePipeline.id || "pipeline"} / latest run ${latestRun?.status || "--"}` }, + pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 pipeline.nodes" }) : + 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-flow-summary" }, + h("span", null, `${flow.nodes.length} nodes`), + h("span", null, `${flow.edges.length} edges`), + h("span", null, `latest ${latestRun?.runId || "--"}`), + ), + ), + h(Panel, { title: "最近运行", eyebrow: `${runs.length}/${runCount} preview` }, + runs.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "Pipeline .state/pipeline-runs 还没有可展示状态" }) : + h("div", { className: "pipeline-run-list" }, runs.map((run: any) => h("article", { key: run.runId, className: "pipeline-run-card" }, + h("div", { className: "node-card-head" }, h("strong", null, run.runId || "--"), h(StatusBadge, { status: run.status }, run.status || "--")), + h("div", { className: "docker-meta compact" }, + h("span", null, run.pipelineId || "--"), + h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`), + h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`), + h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`), + ), + h("p", { className: "muted paragraph" }, summarizeValue(run.task)), + h("code", null, fmtDate(run.updatedAt)), + ))), + ), + h(Panel, { title: "证据日志", eyebrow: latestRun?.runId || "latest worker tail" }, + !latestRun ? 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)), + ), + h("div", { className: "panel-actions inline-actions" }, latestRun ? h(RawButton, { title: `Pipeline Run ${latestRun.runId}`, data: latestRun, onOpen: onRaw, testId: "raw-pipeline-run" }) : null), + ), + ), + ); +} diff --git a/src/components/frontend/src/todo-note.tsx b/src/components/frontend/src/todo-note.tsx new file mode 100644 index 00000000..fbec8e14 --- /dev/null +++ b/src/components/frontend/src/todo-note.tsx @@ -0,0 +1,485 @@ +import React from "react"; + +type AnyRecord = Record; +type ReactNode = any; + +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 }); +} + +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}`; + const error = new Error(message); + (error as Error & { status?: number }).status = response.status; + throw error; + } + 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 safeId(value: any): string { + return String(value).replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function fmtPercent(value: number): string { + if (!Number.isFinite(value)) return "--"; + return `${value.toFixed(1)}%`; +} + +function todoApi(apiBaseUrl: string, path: string): string { + return `${apiBaseUrl}/microservices/todo-note/proxy${path}`; +} + +function todoCount(nodes: any[]): { total: number; completed: number; active: number } { + return nodes.reduce((memo, node: any) => { + const nested = todoCount(Array.isArray(node.children) ? node.children : []); + const done = Boolean(node.completed); + return { + total: memo.total + 1 + nested.total, + completed: memo.completed + (done ? 1 : 0) + nested.completed, + active: memo.active + (done ? 0 : 1) + nested.active, + }; + }, { total: 0, completed: 0, active: 0 }); +} + +function todoVisible(todo: any, filter: string): boolean { + const selfVisible = filter === "all" || (filter === "completed" ? Boolean(todo.completed) : !todo.completed); + const children = Array.isArray(todo.children) ? todo.children : []; + return selfVisible || children.some((child: any) => todoVisible(child, filter)); +} + +function todoReminderLocal(value: any): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function localReminderIso(value: string): string | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function todoRegistryRows(registry: any): any[] { + return Array.isArray(registry?.instances) ? registry.instances : []; +} + +export function TodoNotePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) { + const service = microservices.find((item: any) => item.id === "todo-note") || null; + const [health, setHealth] = useState(null); + const [registry, setRegistry] = useState(null); + const [activeId, setActiveId] = useState(""); + const [instance, setInstance] = useState(null); + const [filter, setFilter] = useState("all"); + const [fontSize, setFontSize] = useState(13); + const [newListName, setNewListName] = useState(""); + const [newTodoTitle, setNewTodoTitle] = useState(""); + const [editingId, setEditingId] = useState(""); + const [editingTitle, setEditingTitle] = useState(""); + const [dragTodoId, setDragTodoId] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [refreshedAt, setRefreshedAt] = useState(null); + + const rows = todoRegistryRows(registry); + const counts = todoCount(Array.isArray(instance?.todos) ? instance.todos : []); + const runtime = service ? microserviceRuntime(service) : {}; + const repository = service ? microserviceRepository(service) : {}; + const backend = service ? microserviceBackend(service) : {}; + + async function loadRegistry(preferId = activeId): Promise { + const [healthResult, registryResult] = await Promise.all([ + requestJson(`${apiBaseUrl}/microservices/todo-note/health`), + requestJson(todoApi(apiBaseUrl, "/api/instances")), + ]); + setHealth(healthResult); + setRegistry(registryResult); + const nextRows = todoRegistryRows(registryResult); + const nextId = nextRows.some((row: any) => row.id === preferId) ? preferId : nextRows[0]?.id || ""; + setActiveId(nextId); + return nextId; + } + + async function loadInstance(instanceId = activeId): Promise { + if (!instanceId) { + setInstance(null); + return; + } + const result = await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(instanceId)}`)); + setInstance(result); + } + + async function refresh(preferId = activeId): Promise { + if (!service) return; + setLoading(true); + setError(""); + try { + const nextId = await loadRegistry(preferId); + await loadInstance(nextId); + setRefreshedAt(new Date()); + } catch (err) { + setError(errorMessage(err, "Todo Note 加载失败")); + } finally { + setLoading(false); + } + } + + async function applyTodoAction(action: AnyRecord): Promise { + if (!activeId) return; + setError(""); + try { + const updated = await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(activeId)}/actions`), { + method: "POST", + body: JSON.stringify({ action }), + }); + setInstance(updated); + await loadRegistry(activeId); + } catch (err) { + setError(errorMessage(err, "Todo 操作失败")); + } + } + + async function createList(event: any): Promise { + event.preventDefault(); + const name = newListName.trim(); + if (!name) return; + setLoading(true); + setError(""); + try { + const created = await requestJson(todoApi(apiBaseUrl, "/api/instances"), { method: "POST", body: JSON.stringify({ name }) }); + setNewListName(""); + await refresh(created.id); + } catch (err) { + setError(errorMessage(err, "创建清单失败")); + } finally { + setLoading(false); + } + } + + async function deleteList(instanceId: string): Promise { + if (!window.confirm("确认删除这个 Todo Note 清单?")) return; + setLoading(true); + setError(""); + try { + await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(instanceId)}`), { method: "DELETE" }); + await refresh(activeId === instanceId ? "" : activeId); + } catch (err) { + setError(errorMessage(err, "删除清单失败")); + } finally { + setLoading(false); + } + } + + async function addRootTodo(event: any): Promise { + event.preventDefault(); + const title = newTodoTitle.trim(); + if (!title) return; + setNewTodoTitle(""); + await applyTodoAction({ type: "addTodo", title }); + } + + async function undoRedo(kind: "undo" | "redo"): Promise { + if (!activeId) return; + setError(""); + try { + const updated = await requestJson(todoApi(apiBaseUrl, `/api/instances/${encodeURIComponent(activeId)}/${kind}`), { method: "POST", body: JSON.stringify({}) }); + setInstance(updated); + await loadRegistry(activeId); + } catch (err) { + setError(errorMessage(err, `${kind} 失败`)); + } + } + + function beginEdit(todo: any): void { + setEditingId(todo.id); + setEditingTitle(String(todo.title || "")); + } + + async function saveEdit(todoId: string): Promise { + const title = editingTitle.trim(); + setEditingId(""); + setEditingTitle(""); + if (title) await applyTodoAction({ type: "updateTodoTitle", todoId, title }); + } + + async function addChild(todoId: string): Promise { + const title = window.prompt("新增子任务标题"); + if (title && title.trim()) await applyTodoAction({ type: "addTodo", title: title.trim(), parentId: todoId }); + } + + async function dropTodo(targetParentId: string | null, targetIndex: number): Promise { + if (!dragTodoId) return; + const payload: AnyRecord = { type: "moveTodo", todoId: dragTodoId, targetIndex }; + if (targetParentId) payload.targetParentId = targetParentId; + setDragTodoId(""); + await applyTodoAction(payload); + } + + useEffect(() => { + refresh(); + }, [service?.id, service?.runtime?.providerStatus]); + + if (!service) return h(EmptyState, { title: "Todo Note 未登记", text: "请在 config.json 的 microservices 中登记 id=todo-note" }); + + const activeSummary = rows.find((row: any) => row.id === activeId) || null; + return h("div", { className: "todo-note-page", "data-testid": "todo-note-page" }, + h(Panel, { + title: "Todo Note 工作台", + eyebrow: "Main Server Backend Microservice", + actions: h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", disabled: loading, onClick: () => refresh(activeId), "data-testid": "todo-note-refresh-button" }, loading ? "刷新中" : "刷新"), + h(RawButton, { title: "Todo Note Microservice", data: service, onOpen: onRaw, testId: "raw-todo-note-service" }), + ), + }, + h("div", { className: "todo-note-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("span", null, health?.ok ? "Health OK" : "Health --"), + ), + 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, "Main Server Docker"), h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)), + ), + error ? h("div", { className: "form-error wide" }, error) : null, + ), + h("div", { className: "todo-note-layout" }, + h(Panel, { title: "清单", eyebrow: `${rows.length} Instances`, className: "todo-list-panel" }, + h("form", { className: "todo-create-list", onSubmit: createList }, + h("input", { placeholder: "新清单名称", value: newListName, onChange: (event: any) => setNewListName(event.target.value), "aria-label": "新清单名称" }), + h("button", { type: "submit", className: "ghost-btn", disabled: loading || !newListName.trim() }, "创建"), + ), + rows.length === 0 ? h(EmptyState, { title: "暂无清单", text: "迁移或创建清单后会出现在这里" }) : + h("div", { className: "todo-instance-list" }, rows.map((row: any) => h("button", { + key: row.id, + type: "button", + className: `todo-instance-row ${activeId === row.id ? "active" : ""}`, + onClick: () => { setActiveId(row.id); loadInstance(row.id); }, + "data-testid": `todo-instance-${safeId(row.id)}`, + }, + h("strong", null, row.name), + h("span", null, `${row.completedCount ?? 0}/${row.todoCount ?? 0} 完成`), + h("code", null, row.id), + ))), + ), + h("div", { className: "todo-main-stack" }, + h(Panel, { + title: activeSummary?.name || "待选择清单", + eyebrow: refreshedAt ? `Updated ${fmtClock(refreshedAt)}` : "Todo Tree", + actions: instance ? h("div", { className: "panel-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => applyTodoAction({ type: "renameInstance", name: window.prompt("清单新名称", instance.name) || instance.name }) }, "重命名"), + h("button", { type: "button", className: "ghost-btn danger", onClick: () => deleteList(activeId) }, "删除清单"), + h(RawButton, { title: `Todo Instance ${activeId}`, data: instance, onOpen: onRaw, testId: "raw-todo-instance" }), + ) : null, + }, + !instance ? h(EmptyState, { title: "未选择清单", text: "左侧选择一个 Todo Note 清单" }) : + h("div", { className: "todo-workbench", style: { "--todo-font-size": `${fontSize}px` } }, + h("div", { className: "todo-toolbar" }, + h("form", { className: "todo-add-form", onSubmit: addRootTodo }, + h("input", { placeholder: "新增根任务", value: newTodoTitle, onChange: (event: any) => setNewTodoTitle(event.target.value), "aria-label": "新增根任务" }), + h("button", { type: "submit", className: "ghost-btn", disabled: !newTodoTitle.trim() }, "新增"), + ), + h("div", { className: "todo-filter-strip" }, ["all", "active", "completed"].map((item) => h("button", { + key: item, + type: "button", + className: `todo-filter ${filter === item ? "active" : ""}`, + onClick: () => setFilter(item), + }, item === "all" ? "全部" : item === "active" ? "未完成" : "已完成"))), + h("div", { className: "todo-toolbar-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => applyTodoAction({ type: "setAllTodosExpanded", expanded: true }) }, "全部展开"), + h("button", { type: "button", className: "ghost-btn", onClick: () => applyTodoAction({ type: "setAllTodosExpanded", expanded: false }) }, "全部收起"), + h("button", { type: "button", className: "ghost-btn", onClick: () => undoRedo("undo") }, "撤销"), + h("button", { type: "button", className: "ghost-btn", onClick: () => undoRedo("redo") }, "重做"), + h("label", { className: "todo-font-control" }, "字号", h("input", { type: "range", min: 11, max: 18, value: fontSize, onChange: (event: any) => setFontSize(Number(event.target.value)) })), + ), + ), + h("div", { className: "todo-stats-grid" }, + h(MetricCard, { label: "总任务", value: counts.total, hint: `${rows.length} lists` }), + h(MetricCard, { label: "已完成", value: counts.completed, hint: `${fmtPercent(counts.total ? counts.completed / counts.total * 100 : 0)}`, tone: "ok" }), + h(MetricCard, { label: "未完成", value: counts.active, hint: filter === "active" ? "当前筛选" : "active tasks", tone: counts.active > 0 ? "warn" : "ok" }), + h(MetricCard, { label: "历史指针", value: instance.historyPointer ?? 0, hint: "undo / redo" }), + ), + h("div", { + className: "todo-root-drop", + onDragOver: (event: any) => event.preventDefault(), + onDrop: (event: any) => { event.preventDefault(); dropTodo(null, (instance.todos || []).length); }, + }, "拖到这里可移为根任务末尾"), + h("div", { className: "todo-tree", "data-testid": "todo-note-tree" }, + (instance.todos || []).filter((todo: any) => todoVisible(todo, filter)).length === 0 + ? h(EmptyState, { title: "没有匹配任务", text: "调整筛选或新增任务" }) + : (instance.todos || []).filter((todo: any) => todoVisible(todo, filter)).map((todo: any, index: number) => h(TodoRow, { + key: todo.id, + todo, + depth: 0, + parentId: null, + index, + siblingCount: instance.todos.length, + filter, + editingId, + editingTitle, + setEditingTitle, + beginEdit, + saveEdit, + applyTodoAction, + addChild, + dragTodoId, + setDragTodoId, + dropTodo, + })), + ), + ), + ), + ), + ), + ); +} + +function TodoRow(props: AnyRecord): ReactNode { + const { todo, depth, parentId, index, siblingCount, filter, editingId, editingTitle, setEditingTitle, beginEdit, saveEdit, applyTodoAction, addChild, dragTodoId, setDragTodoId, dropTodo } = props; + const children = Array.isArray(todo.children) ? todo.children : []; + const visibleChildren = children.filter((child: any) => todoVisible(child, filter)); + const isEditing = editingId === todo.id; + const targetParentId = parentId || null; + return h("div", { className: "todo-row-wrap" }, + h("article", { + className: `todo-row ${todo.completed ? "completed" : ""} ${dragTodoId === todo.id ? "dragging" : ""}`, + style: { "--todo-depth": depth }, + draggable: true, + onDragStart: (event: any) => { setDragTodoId(todo.id); event.dataTransfer.effectAllowed = "move"; }, + onDragOver: (event: any) => event.preventDefault(), + onDrop: (event: any) => { event.preventDefault(); dropTodo(todo.id, children.length); }, + "data-testid": `todo-row-${safeId(todo.id)}`, + }, + h("button", { type: "button", className: "todo-expand", disabled: children.length === 0, onClick: () => applyTodoAction({ type: "toggleTodoExpanded", todoId: todo.id }) }, children.length === 0 ? "·" : todo.expanded ? "▾" : "▸"), + h("input", { type: "checkbox", checked: Boolean(todo.completed), onChange: () => applyTodoAction({ type: "toggleTodoCompleted", todoId: todo.id }), "aria-label": `完成 ${todo.title}` }), + h("div", { className: "todo-title-cell", onDoubleClick: () => beginEdit(todo) }, + isEditing ? h("div", { className: "todo-edit-inline" }, + h("input", { + value: editingTitle, + autoFocus: true, + onChange: (event: any) => setEditingTitle(event.target.value), + onKeyDown: (event: any) => { + if (event.key === "Enter") saveEdit(todo.id); + if (event.key === "Escape") beginEdit({ id: "", title: "" }); + }, + }), + h("button", { type: "button", className: "ghost-btn", onClick: () => saveEdit(todo.id) }, "保存"), + ) : h("strong", null, todo.title || "Untitled"), + h("div", { className: "todo-meta-line" }, + h("span", null, `子项 ${children.length}`), + h("span", null, `更新 ${fmtDate(todo.updatedAt)}`), + todo.reminderAt ? h("span", { className: "todo-reminder" }, `提醒 ${fmtDate(todo.reminderAt)}`) : h("span", null, "无提醒"), + ), + ), + h("input", { className: "todo-reminder-input", type: "datetime-local", value: todoReminderLocal(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localReminderIso(event.target.value) }) }), + h("div", { className: "todo-row-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: () => beginEdit(todo) }, "编辑"), + h("button", { type: "button", className: "ghost-btn", onClick: () => addChild(todo.id) }, "子项"), + h("button", { type: "button", className: "ghost-btn", disabled: index <= 0, onClick: () => applyTodoAction({ type: "moveTodo", todoId: todo.id, ...(targetParentId ? { targetParentId } : {}), targetIndex: index - 1 }) }, "上移"), + h("button", { type: "button", className: "ghost-btn", disabled: index >= siblingCount - 1, onClick: () => applyTodoAction({ type: "moveTodo", todoId: todo.id, ...(targetParentId ? { targetParentId } : {}), targetIndex: index + 1 }) }, "下移"), + h("button", { type: "button", className: "ghost-btn", disabled: !parentId, onClick: () => applyTodoAction({ type: "moveTodo", todoId: todo.id, targetIndex: 9999 }) }, "提升"), + h("button", { type: "button", className: "ghost-btn danger", onClick: () => applyTodoAction({ type: "deleteTodo", todoId: todo.id }) }, "删除"), + ), + ), + todo.expanded && visibleChildren.length > 0 ? h("div", { className: "todo-children" }, visibleChildren.map((child: any, childIndex: number) => h(TodoRow, { + key: child.id, + todo: child, + depth: depth + 1, + parentId: todo.id, + index: childIndex, + siblingCount: children.length, + filter, + editingId, + editingTitle, + setEditingTitle, + beginEdit, + saveEdit, + applyTodoAction, + addChild, + dragTodoId, + setDragTodoId, + dropTodo, + }))) : null, + ); +} diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index 579e11e2..2180aee5 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -1018,7 +1018,7 @@ function assertAllowedMicroserviceBase(rawBaseUrl: string): URL { const baseUrl = new URL(rawBaseUrl); if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`); const host = baseUrl.hostname.toLowerCase(); - const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal"]); + const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note"]); if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`); return baseUrl; } @@ -1060,7 +1060,9 @@ function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Rec } async function runMicroserviceHttp(payload: Record): Promise { - const method = payload.method === "HEAD" ? "HEAD" : "GET"; + const rawMethod = String(payload.method || "GET").toUpperCase(); + const allowedMethods = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]); + const method = allowedMethods.has(rawMethod) ? rawMethod : "GET"; const targetBaseUrl = payloadString(payload, "targetBaseUrl"); if (targetBaseUrl === null) throw new Error("microservice.http requires targetBaseUrl"); const path = payloadString(payload, "path") ?? "/"; @@ -1072,10 +1074,22 @@ async function runMicroserviceHttp(payload: Record): Promise< url.search = query; const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000))); const jsonArrayLimits = payloadJsonArrayLimits(payload); + const bodyText = payloadString(payload, "bodyText") ?? ""; + const requestHeaders = typeof payload.requestHeaders === "object" && payload.requestHeaders !== null && !Array.isArray(payload.requestHeaders) + ? payload.requestHeaders as Record + : {}; + const headers = new Headers(); + const contentType = typeof requestHeaders["content-type"] === "string" ? requestHeaders["content-type"] : ""; + if (contentType.length > 0) headers.set("content-type", contentType); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(url, { method, signal: controller.signal }); + const response = await fetch(url, { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : bodyText, + signal: controller.signal, + }); const rawBodyText = await response.text(); const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8"; const transformed = applyJsonArrayLimits(rawBodyText, contentType, jsonArrayLimits); diff --git a/src/tsconfig.check.json b/src/tsconfig.check.json index c9d8e624..40aa701b 100644 --- a/src/tsconfig.check.json +++ b/src/tsconfig.check.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "react", "types": ["bun", "node"], "strict": true, "noImplicitReturns": true,