feat: add provider-backed microservices
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文件是项目顶级索引,也承担 `scripts/cli.ts` 的 CLI 使用说明入口。
|
||||
|
||||
## Critical Provider Gateway Upgrade Rule
|
||||
|
||||
- 计算节点 `provider-gateway` 容器的重建/升级必须走 `provider.upgrade mode=schedule` 远程升级路径或前端等价调度;禁止通过 `bun scripts/cli.ts ssh <providerId>` 同步执行 `docker compose up --build provider-gateway` 这类自重建命令,权威规则见 `docs/reference/provider-gateway.md`。
|
||||
- Host SSH / WSL SSH 透传只能用于节点诊断、前置条件修复和升级后验证,不能作为计算节点 `provider-gateway` 自身的重建/升级通道;部署验收必须同时证明远程升级和 SSH 透传可用,测试门禁见 `TEST.md`。
|
||||
|
||||
## CLI
|
||||
|
||||
- `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `docs/reference/cli.md`。
|
||||
@@ -13,6 +18,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。
|
||||
- `bun scripts/cli.ts server rebuild <backend-core|frontend|provider-gateway>`:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。
|
||||
- `bun scripts/cli.ts ssh <providerId> [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 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`。
|
||||
@@ -24,10 +30,12 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `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`。
|
||||
- `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`。
|
||||
- `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 和验证规则。
|
||||
- `reference`:兼容旧路径的符号链接,指向 `docs/reference/`。
|
||||
|
||||
@@ -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`、`core:internal-overview`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`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` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`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`、`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 透传`、`远程更新` 和结构化控件。
|
||||
|
||||
## T9 Database 命名卷持久化
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
## T14 Provider Gateway 远程升级
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划且计划中包含 `policy: "always-enabled"`;对明确要升级的计算节点,必须再运行 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000`,确认任务成功、result 包含 updater 容器信息、节点随后重新上线。在非主 server 的计算节点上,必须使用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 做同一验证,证明该节点能通过公网 frontend remote CLI 自测自动升级,且不需要指定 `--main-server-key`。正式执行升级只能通过前端 `资源监控` 的 `执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道,也不能通过 `PROVIDER_UPGRADE_ENABLED` 或等价开关禁用远程升级。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划且计划中包含 `policy: "always-enabled"`、`--no-deps` 和 `--force-recreate`;对明确要升级或重建 `provider-gateway` 容器的计算节点,必须再运行 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000`,确认任务成功、result 包含 updater 容器信息、节点随后重新上线。在非主 server 的计算节点上,必须使用 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 做同一验证,证明该节点能通过公网 frontend remote CLI 自测自动升级,且不需要指定 `--main-server-key`。正式执行计算节点 `provider-gateway` 重建/升级只能通过前端 `资源监控` 的 `执行升级` 或等价的 `provider.upgrade mode=schedule` 显式调度完成,不能通过 `bun scripts/cli.ts ssh <PROVIDER_ID>` 或 Host SSH 维护桥同步执行自重建命令,也不能通过 `PROVIDER_UPGRADE_ENABLED` 或等价开关禁用远程升级。
|
||||
|
||||
## T15 待处理任务可追溯
|
||||
|
||||
@@ -79,3 +79,11 @@
|
||||
## T19 前端单服务重建
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server rebuild frontend`,确认命令立即返回 `server_rebuild` job id;随后运行 `bun scripts/cli.ts job status latest` 直到状态为 `succeeded`,stdout 中必须能看到先 build、再按 `frontend` 服务容器 label 移除、最后 `--no-deps frontend` 启动的过程。重建后运行 `bun scripts/cli.ts server status` 和 `bun scripts/cli.ts e2e run`,确认公网 frontend 恢复健康、Playwright 登录通过、database 命名卷未被删除;正式验收不得要求人工执行 `docker rm` 作为兜底。
|
||||
|
||||
## T20 D601 FindJob Microservice
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `findjob` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL、commit id、`127.0.0.1:3254` 后端映射和 `findjob-server` 容器摘要;运行 `bun scripts/cli.ts microservice health findjob` 和 `bun scripts/cli.ts microservice proxy findjob /api/summary`,确认链路通过 backend-core、D601 provider-gateway 和 D601 本机 FindJob 后端;运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 microservice health findjob`,确认非主 server 也能通过公网 frontend remote CLI 验证同一链路且不需要 `--main-server-key`。随后运行 `bun scripts/cli.ts e2e run`,确认 microservice 和 frontend FindJob 检查全部 passed;再登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / 服务目录` 和 `微服务 / FindJob`,确认页面以 React 控件显示 D601、仓库引用、私有后端映射、FindJob 指标、岗位预览和草稿报告,默认没有裸 JSON,只有点击 `查看原始JSON` 才显示原始数据。FindJob 业务代码开发和调试必须用 `bun scripts/cli.ts ssh D601 ...` 进入 D601 的 `/home/ubuntu/findjob`,不得把 findjob 全量代码复制进 UniDesk 仓库,也不得占用主 server 部署调试服务。
|
||||
|
||||
## 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 部署调试服务。
|
||||
|
||||
+75
@@ -63,6 +63,81 @@
|
||||
"composeFile": "docker-compose.yml",
|
||||
"projectName": "unidesk"
|
||||
},
|
||||
"microservices": [
|
||||
{
|
||||
"id": "findjob",
|
||||
"name": "FindJob",
|
||||
"providerId": "D601",
|
||||
"description": "FindJob 纯后端服务,部署在 D601 Docker 中,UniDesk frontend 负责统一前端展示。",
|
||||
"repository": {
|
||||
"url": "https://gitee.com/Lyon1998/findjob",
|
||||
"commitId": "2d43212c5f474df5d87820985a6c75a8c2e7ac42",
|
||||
"dockerfile": "Dockerfile",
|
||||
"composeFile": "docker-compose.yml",
|
||||
"composeService": "server",
|
||||
"containerName": "findjob-server"
|
||||
},
|
||||
"backend": {
|
||||
"nodeBaseUrl": "http://host.docker.internal:3254",
|
||||
"nodeBindHost": "127.0.0.1",
|
||||
"nodePort": 3254,
|
||||
"proxyMode": "provider-gateway-http",
|
||||
"frontendOnly": true,
|
||||
"public": false,
|
||||
"allowedPathPrefixes": [
|
||||
"/api/"
|
||||
],
|
||||
"healthPath": "/api/health",
|
||||
"timeoutMs": 12000
|
||||
},
|
||||
"development": {
|
||||
"providerId": "D601",
|
||||
"sshPassthrough": true,
|
||||
"worktreePath": "/home/ubuntu/findjob"
|
||||
},
|
||||
"frontend": {
|
||||
"route": "/apps/findjob",
|
||||
"integrated": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pipeline",
|
||||
"name": "Pipeline v2",
|
||||
"providerId": "D601",
|
||||
"description": "Pipeline v2 观测后端部署在 D601 Docker 中,UniDesk frontend 负责渲染组件矩阵、运行状态和证据摘要。",
|
||||
"repository": {
|
||||
"url": "https://github.com/pikasTech/pipeline",
|
||||
"commitId": "87811a8d43edf216a4f4d8efa55bbb96bad8df14",
|
||||
"dockerfile": "Dockerfile",
|
||||
"composeFile": "docker-compose.yml",
|
||||
"composeService": "pipeline-webui",
|
||||
"containerName": "pipeline-v2-webui"
|
||||
},
|
||||
"backend": {
|
||||
"nodeBaseUrl": "http://host.docker.internal:18082",
|
||||
"nodeBindHost": "127.0.0.1",
|
||||
"nodePort": 18082,
|
||||
"proxyMode": "provider-gateway-http",
|
||||
"frontendOnly": true,
|
||||
"public": false,
|
||||
"allowedPathPrefixes": [
|
||||
"/health",
|
||||
"/api/"
|
||||
],
|
||||
"healthPath": "/health",
|
||||
"timeoutMs": 15000
|
||||
},
|
||||
"development": {
|
||||
"providerId": "D601",
|
||||
"sshPassthrough": true,
|
||||
"worktreePath": "/home/ubuntu/pipeline"
|
||||
},
|
||||
"frontend": {
|
||||
"route": "/apps/pipeline",
|
||||
"integrated": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"stateDir": ".state",
|
||||
"logsDir": "logs",
|
||||
|
||||
@@ -53,6 +53,7 @@ services:
|
||||
PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"
|
||||
HEARTBEAT_TIMEOUT_MS: "${UNIDESK_HEARTBEAT_TIMEOUT_MS}"
|
||||
TASK_PENDING_TIMEOUT_MS: "${UNIDESK_TASK_PENDING_TIMEOUT_MS:-600000}"
|
||||
MICROSERVICES_JSON: "${UNIDESK_MICROSERVICES_JSON:-[]}"
|
||||
LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_backend-core.jsonl"
|
||||
volumes:
|
||||
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
|
||||
|
||||
@@ -14,9 +14,10 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
- `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。
|
||||
- `server rebuild <backend-core|frontend|provider-gateway>` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程。
|
||||
- `ssh <providerId> [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/` 文件系统状态,是异步命令的可观测入口。
|
||||
- `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。
|
||||
- `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁。
|
||||
- `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁;CLI 默认输出 check 状态摘要,完整诊断写入 `resultPath`。
|
||||
|
||||
## Async Job State
|
||||
|
||||
@@ -28,9 +29,11 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
|
||||
每条命令的最外层 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 <N>` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。
|
||||
|
||||
## Debug Contract
|
||||
|
||||
`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade`、`host.ssh` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`provider.upgrade` 默认使用 `mode: "plan"` 预检;需要验证一键升级时必须显式加 `--mode schedule`,并通过 `--wait-ms` 或 `debug task` 确认任务进入 `succeeded`、result 中包含 updater 容器信息和 `policy: "always-enabled"`。`host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms` 和 `debug task` 查看 stdout、stderr、exitCode 与 probeLine。
|
||||
`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade`、`host.ssh`、`microservice.http` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`provider.upgrade` 默认使用 `mode: "plan"` 预检;需要验证一键升级时必须显式加 `--mode schedule`,并通过 `--wait-ms` 或 `debug task` 确认任务进入 `succeeded`、result 中包含 updater 容器信息和 `policy: "always-enabled"`。`host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms` 和 `debug task` 查看 stdout、stderr、exitCode 与 probeLine。`microservice.http` 只用于开发调试 provider-gateway 私有 HTTP 代理,正式用户入口应使用 `microservice` CLI 或 frontend 页面。
|
||||
|
||||
## SSH Command
|
||||
|
||||
@@ -44,7 +47,7 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传
|
||||
|
||||
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
|
||||
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task` 和 `ssh <PROVIDER_ID> <remote-command>`。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy` 和 `ssh <PROVIDER_ID> <remote-command>`。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
|
||||
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和
|
||||
|
||||
`sshForwarding` 定义 provider-gateway 维护专用 Host SSH / WSL SSH 桥的显式配置。CLI 会把 `sshForwarding.keyDir` 写入 `.state/docker-compose.env` 的 `UNIDESK_HOST_SSH_KEY_DIR`,Compose 将该目录只读挂载到 provider-gateway 的 `/run/host-ssh`,并把 `sshForwarding.host`、`sshForwarding.port`、`sshForwarding.user` 映射为 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`。目录中必须存在 `id_ed25519` 私钥且权限收紧,provider-gateway 才会把 `hostSshKeyPresent` 上报为 true,并允许 `host.ssh` 维护探测;该桥只用于故障诊断和 WSL 维护,不替代 Docker socket 调度。
|
||||
|
||||
## 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`。
|
||||
|
||||
## Compose Env Generation
|
||||
|
||||
Docker Compose 本身不读取 JSON,因此 CLI 会从 `config.json` 生成 `.state/docker-compose.env`。该文件是派生状态,不应手写;如需改端口、token、provider 标签、登录凭据或主机名,应修改 `config.json` 后重新运行 CLI。CLI 会在保留当前日志前缀的同时刷新新增配置键,避免旧 env 文件遗漏字段。
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
Docker Compose 只能向公网暴露两个接口:frontend host port 和 provider ingress host port。backend-core REST API 和 PostgreSQL database 必须只在 Docker 内部网络中可达,不允许映射到宿主机公网端口;浏览器访问 core API 必须通过 frontend 的同源代理完成。
|
||||
|
||||
计算节点上的 microservice 后端也遵守同一边界:业务容器端口只绑定节点本机地址,并由 provider-gateway 主动连出 WebSocket 后接受 backend-core 的 `microservice.http` 调度访问。主 server 不为 microservice 新增公网反向代理端口;最终用户只通过 UniDesk frontend 的 React 页面访问结构化业务控件。
|
||||
|
||||
## Docker Compose Runtime
|
||||
|
||||
CLI 会优先使用 `docker compose` v2 plugin;当 v2 plugin 不存在时才回退到 `docker-compose` v1。主 server 可以通过安装系统包形式的 Compose v2 plugin 完成无中断升级,因为该动作只增加 Docker CLI plugin,不要求重启 Docker daemon,也不要求重建或停止已有容器。若 Compose v2 build 提示 buildx plugin 不存在,应同样以系统包安装 buildx CLI plugin,而不是重启 Docker 或重建业务容器。
|
||||
|
||||
@@ -10,14 +10,16 @@ UniDesk delivery is not complete until the public frontend, public provider ingr
|
||||
|
||||
## Automated E2E Scope
|
||||
|
||||
`bun scripts/cli.ts e2e run` validates the following URLs and internal checks derived from `config.json`:
|
||||
`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 and public database probes must fail.
|
||||
- Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core、public database and known private microservice ports such as FindJob `3254` 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.
|
||||
- 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`, then opens `网关版本` and verifies the provider-gateway version, SSH 透传可用性、远程更新可用性 plus structured automatic update records for `provider.upgrade`.
|
||||
- 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.
|
||||
|
||||
## Frontend JSON Rule
|
||||
|
||||
@@ -27,6 +29,8 @@ 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.
|
||||
|
||||
## Public Boundary Rule
|
||||
|
||||
The public frontend URL and provider ingress URL are the only public network interfaces. backend-core REST API and PostgreSQL database are Docker-internal only; E2E must prove the historical public core/database ports are not reachable.
|
||||
@@ -42,11 +46,11 @@ Before claiming delivery, run these checks and keep their JSON output or screens
|
||||
1. `bun scripts/cli.ts check`
|
||||
2. `bun scripts/cli.ts server start`, then `bun scripts/cli.ts job status latest` until `succeeded`
|
||||
3. `bun scripts/cli.ts server status`
|
||||
4. `bun scripts/cli.ts e2e run`
|
||||
4. `bun scripts/cli.ts e2e run` and inspect `failedChecks` or the emitted `resultPath` if any check fails
|
||||
5. a database persistence marker check across at least one CLI-controlled restart
|
||||
|
||||
## Provider Upgrade Gate
|
||||
|
||||
When delivery explicitly includes upgrading a compute node such as D601 or D518, the automated E2E plan check is not sufficient. The operator must first bootstrap any legacy provider manually if it cannot yet schedule upgrades, then run `provider.upgrade` with `mode: "schedule"` against that Provider ID, confirm the task succeeds, confirm the node reconnects in the public frontend, and finally verify any required `host.ssh` capability with `bun scripts/cli.ts ssh <PROVIDER_ID> hostname`. This schedule check is a node-upgrade gate, not a replacement for the standard public frontend Playwright E2E gate.
|
||||
When delivery explicitly includes upgrading or rebuilding a compute-node `provider-gateway` such as D601 or D518, the automated E2E plan check is not sufficient. The operator must first bootstrap any legacy provider only from a node-local terminal, node-owned web terminal, systemd, scheduled task, or detached shell if it cannot yet schedule upgrades; SSH passthrough carried by the same provider-gateway must not be used for synchronous self-rebuilds. Then run `provider.upgrade` with `mode: "schedule"` against that Provider ID, confirm the task succeeds, confirm the node reconnects in the public frontend, and finally verify any required `host.ssh` capability with `bun scripts/cli.ts ssh <PROVIDER_ID> hostname`. This schedule check is a node-upgrade gate, not a replacement for the standard public frontend Playwright E2E gate.
|
||||
|
||||
External compute nodes should run that schedule check through the remote main-server passthrough form: `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000`. The default remote transport logs in to the public frontend and does not require a main server SSH key; this proves the node can validate itself without direct access to backend-core REST or PostgreSQL.
|
||||
|
||||
@@ -8,7 +8,7 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
|
||||
|
||||
## Layout
|
||||
|
||||
左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。
|
||||
左侧边栏只切换主模块:运行总览、资源节点、任务调度、微服务、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,微服务下的服务目录、FindJob、Pipeline 只属于微服务,和运行总览、任务调度、系统配置没有重复或共享语义。移动端左侧边栏会转为顶部横向主模块条,但高度必须在不同主模块之间保持一致,并保持窄条、单行、不换行;主内容区无论内容多少都必须从顶部向下排列,空状态也不得上下居中制造大块留白。
|
||||
|
||||
## Overview Task Drilldown
|
||||
|
||||
@@ -38,6 +38,10 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
|
||||
|
||||
`资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id、摘要和当前节点的自动更新记录,完整升级计划必须通过 `查看原始JSON` 显式查看。
|
||||
|
||||
## 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 裸铺在页面上。
|
||||
|
||||
## Component Data Rendering
|
||||
|
||||
前端必须把 backend-core 返回的 JSON 渲染为合适的控件:状态徽标、指标卡、表格列、标签 chip、字段摘要、任务结果卡、日志行和表单控件。默认页面禁止暴露裸 JSON、`pre` JSON 或整段 `JSON.stringify` 文本;只有用户明确点击 `查看原始JSON` 按钮后,才允许在弹窗或高级编辑区展示原始 JSON。
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# UniDesk Microservices Reference
|
||||
|
||||
UniDesk microservice 是挂载到主 server 控制面的非核心业务后端。业务容器运行在计算节点 Docker 中,主 server 只保存仓库引用、commit id、Dockerfile/docker-compose 引用、provider 映射和前端集成配置,不把业务仓库整体复制进 UniDesk。
|
||||
|
||||
## Boundary
|
||||
|
||||
- microservice 后端端口默认只绑定计算节点本机地址,例如 `127.0.0.1:<port>`,不得直接暴露公网。
|
||||
- 浏览器只访问 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` 限制可代理路径。
|
||||
|
||||
## Config Contract
|
||||
|
||||
`config.json` 的 `microservices` 是 microservice 的唯一登记来源。每个条目必须包含:
|
||||
|
||||
- `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 代理的映射。
|
||||
- `development.providerId`、`development.sshPassthrough=true` 和 `development.worktreePath`,用于说明开发调试入口必须在计算节点上通过 UniDesk SSH 透传完成。
|
||||
- `frontend.route` 和 `frontend.integrated=true`,用于说明该业务前端已经整合到 UniDesk React 控制台,而不是继续公开业务自身前端。
|
||||
|
||||
## Compute-Node Development Convention
|
||||
|
||||
非 UniDesk 核心功能开发不得默认占用主 server 有限主机资源。涉及 findjob 这类业务功能时,应通过 `bun scripts/cli.ts ssh <PROVIDER_ID> ...` 或 remote CLI SSH 透传进入计算节点,在计算节点本地业务仓库中开发、构建和调试;开发完成后,只把业务服务以 microservice 形式登记到 UniDesk。
|
||||
|
||||
业务仓库由业务系统自己维护,包括源码、Dockerfile、docker-compose、配置模板和业务测试。UniDesk 只引用业务仓库 URL、commit id、Dockerfile/docker-compose 路径和运行容器名;不得把业务全量代码复制到 `src/components/microservices/` 形成双维护。`src/components/microservices/` 只能放通用示例或 UniDesk 自有示例,不作为业务仓库镜像。
|
||||
|
||||
## D601 Microservices
|
||||
|
||||
当前 `D601` 同时承载以下 UniDesk microservice:
|
||||
|
||||
- `findjob`:FindJob 纯后端服务,UniDesk frontend 渲染岗位指标、岗位预览和草稿报告。
|
||||
- `pipeline`:Pipeline v2 观测服务,UniDesk frontend 渲染组件矩阵、控制图、运行状态和证据日志摘要。
|
||||
|
||||
### FindJob On D601
|
||||
|
||||
当前 FindJob 作为 `id=findjob` 的 microservice 登记在 `config.json`:
|
||||
|
||||
- Provider:`D601`。
|
||||
- 开发工作树:`/home/ubuntu/findjob`,开发和调试必须通过 UniDesk SSH 透传进入 D601。
|
||||
- 代码引用:`https://gitee.com/Lyon1998/findjob` 与配置中的 `repository.commitId`。
|
||||
- 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=server`、`containerName=findjob-server`。
|
||||
- 节点后端:D601 上 `127.0.0.1:3254`,provider-gateway 容器内通过 `http://host.docker.internal:3254` 访问。
|
||||
- 代理路径:只允许 `/api/` 前缀;`/` 上的业务旧前端即使仍存在,也不作为 UniDesk microservice 入口使用。
|
||||
- UniDesk 前端:`微服务 / FindJob` React 页面负责展示指标、岗位预览、草稿报告和原始 JSON 显式查看按钮。
|
||||
|
||||
FindJob 在 UniDesk 语境中按纯后端服务管理:默认页面不得 iframe 或跳转到 findjob 自身前端,也不得直接暴露 D601 的 `3254` 到公网。UniDesk frontend 只能通过 `/api/microservices/findjob/health` 和 `/api/microservices/findjob/proxy/api/...` 访问 FindJob 后端。
|
||||
|
||||
### Pipeline On D601
|
||||
|
||||
当前 Pipeline v2 作为 `id=pipeline` 的 microservice 登记在 `config.json`:
|
||||
|
||||
- Provider:`D601`。
|
||||
- 开发工作树:`/home/ubuntu/pipeline`,开发和调试必须通过 UniDesk SSH 透传进入 D601。
|
||||
- 代码引用:`https://github.com/pikasTech/pipeline` 与配置中的 `repository.commitId`。
|
||||
- 部署引用:业务仓库自身 `Dockerfile`、`docker-compose.yml`、`composeService=pipeline-webui`、`containerName=pipeline-v2-webui`。
|
||||
- 节点后端:D601 上 `127.0.0.1:18082`,provider-gateway 容器内通过 `http://host.docker.internal:18082` 访问。
|
||||
- 代理路径:只允许 `/health` 和 `/api/` 前缀;Pipeline 自身 WebUI 静态页面即使仍由 `pipeline-webui` 提供,也不作为 UniDesk microservice 入口使用。
|
||||
- UniDesk 前端:`微服务 / Pipeline` React 页面负责展示 health、组件数量、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:<limit>,runs:<limit>` 做展示级裁剪。
|
||||
|
||||
## CLI
|
||||
|
||||
- `bun scripts/cli.ts microservice list`:列出全部 microservice、provider 映射、仓库引用、后端映射和运行态容器摘要。
|
||||
- `bun scripts/cli.ts microservice status findjob`:查看单个 microservice 的配置与运行态。
|
||||
- `bun scripts/cli.ts microservice health findjob`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 FindJob `/api/health`。
|
||||
- `bun scripts/cli.ts microservice proxy findjob /api/summary`:通过同一私有代理读取业务 API,适合人工验证,不用于公开业务端口。
|
||||
- `bun scripts/cli.ts microservice health pipeline`:通过 backend-core -> provider-gateway -> D601 本机后端链路探测 Pipeline `/health`。
|
||||
- `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`:读取 Pipeline snapshot 的有界预览,适合人工验证,不用于公开业务端口;若 body 仍超过 CLI 阈值,默认只输出 `bodyPreview`,需要完整 body 时显式追加 `--raw`。
|
||||
- `bun scripts/cli.ts --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 弹窗。
|
||||
|
||||
对于超大业务 JSON,backend-core 可把 `__unideskArrayLimit=<path>:<limit>` 作为 frontend-only 代理参数传给 provider-gateway,由 provider-gateway 在返回前裁剪指定 JSON 数组并写入 `_unidesk.arrayLimits` 元数据。该参数只用于控制 UniDesk 展示预览,不能替代业务后端自身分页 API 的长期设计。CLI 的 `microservice proxy` 还会对超过默认阈值的 body 做二次有界预览,防止人工验证时输出爆炸;只有显式 `--raw` 才允许倾倒完整 body。
|
||||
|
||||
## Verification
|
||||
|
||||
FindJob 和 Pipeline 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 后端。
|
||||
- 在 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。
|
||||
@@ -6,6 +6,14 @@ Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴
|
||||
|
||||
当前主 server 也运行一个 provider-gateway,`providerId` 固定来自 `config.json` 的 `providerGateway.id`。这让单机环境也能验证完整的分布式调度闭环:frontend 发起任务,core 写数据库并通过 provider ingress WebSocket 下发,provider gateway 执行后回传状态。
|
||||
|
||||
## Upgrade Safety Gate
|
||||
|
||||
计算节点 `provider-gateway` 容器的重建和升级权威路径是 `provider.upgrade` 的 `mode: "schedule"`,或 frontend 中等价的显式升级调度。该路径由在线 provider 通过本地 Docker socket 启动 detached updater 容器,让升级动作脱离当前 WebSocket 与 SSH 透传会话的生命周期;重建目标只能是 `provider-gateway` service,并且必须带 `--no-deps` 与 `--force-recreate`,不得牵连 database、backend-core、frontend 或业务 microservice,也不得因为镜像 tag 未变而 no-op。
|
||||
|
||||
禁止通过 UniDesk 自己的 Host SSH / WSL SSH 透传同步执行 `docker compose up -d --build provider-gateway`、`docker compose restart provider-gateway`、`docker rm -f <provider-gateway>` 后再启动等自重建命令。原因是这条 SSH 透传连接正由被重建的旧 `provider-gateway` 容器承载;旧容器停止后会切断控制通道,可能把节点留在旧容器已停、新容器未起的不可达状态。SSH 透传只允许用于诊断、修复升级前置条件、查看本地状态和升级后验证,不允许作为计算节点 `provider-gateway` 正式重建/升级通道。
|
||||
|
||||
只有旧节点尚不支持 `provider.upgrade mode=schedule` 时,才允许使用节点本地终端、节点自有 Web terminal、systemd、计划任务或 detached shell 做一次 bootstrap;bootstrap 完成后必须立刻回到 `provider.upgrade mode=schedule` 做真实升级验证,并通过公网 frontend 或 remote CLI 确认节点重新上线、远程更新可用性和 SSH 透传可用性。
|
||||
|
||||
## Deployment Method
|
||||
|
||||
当前主 server 公网 IP 是 `74.48.78.17`,`config.json` 中的 `network.publicHost` 必须保持为该地址;公网 frontend 入口是 `http://74.48.78.17:18081/`,provider gateway 对外接入入口是 `ws://74.48.78.17:18082/ws/provider`,provider ingress 健康检查是 `http://74.48.78.17:18082/health`。主 server 本机 provider 由根目录 `docker-compose.yml` 的 `provider-gateway` 服务启动,容器内使用 Docker 内网地址 `ws://backend-core:8081/ws/provider` 自接入;外部计算节点部署 provider-gateway 时必须改用公网 provider ingress URL,并复用 `config.json` / `.state/docker-compose.env` 中的 provider token、心跳间隔和重连参数。
|
||||
@@ -14,7 +22,7 @@ Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴
|
||||
|
||||
## Mandatory SSH Passthrough Bundle
|
||||
|
||||
新增或重建 provider-gateway 时,Docker socket、远程升级和 SSH 透传必须作为同一个部署包验收,不能只让 provider 在线就认为完成。节点侧应先启动目标宿主或 WSL 的 sshd,把维护公钥写入目标用户 `authorized_keys`,再用只读目录挂载私钥到 provider-gateway 容器内 `/run/host-ssh`;provider-gateway 环境变量必须包含 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`、`HOST_SSH_KEY` 和 `HOST_REMOTE_CWD`。注册成功后,主 server 看到的 labels 必须同时满足 `hostSshConfigured=true`、`hostSshKeyPresent=true`、`hostSshTarget` 指向目标 sshd,且 `unideskCapabilities` 包含 `host.ssh`。
|
||||
新增 provider-gateway 或 legacy bootstrap 后,Docker socket、远程升级和 SSH 透传必须作为同一个部署包验收,不能只让 provider 在线就认为完成。节点侧应先启动目标宿主或 WSL 的 sshd,把维护公钥写入目标用户 `authorized_keys`,再用只读目录挂载私钥到 provider-gateway 容器内 `/run/host-ssh`;provider-gateway 环境变量必须包含 `HOST_SSH_HOST`、`HOST_SSH_PORT`、`HOST_SSH_USER`、`HOST_SSH_KEY` 和 `HOST_REMOTE_CWD`。注册成功后,主 server 看到的 labels 必须同时满足 `hostSshConfigured=true`、`hostSshKeyPresent=true`、`hostSshTarget` 指向目标 sshd,且 `unideskCapabilities` 包含 `host.ssh`。
|
||||
|
||||
计算节点镜像必须包含 `ssh` 客户端,否则 provider 虽然可以在线并执行 Docker 任务,但 `host.ssh` 调度会在节点侧失败。标准 `src/components/provider-gateway/Dockerfile` 已安装 `openssh-client`;如果节点使用本地兜底镜像或复制 Bun/Docker CLI 的自定义镜像,也必须同时安装或复制可用的 OpenSSH client,并在容器内通过 `ssh -V`、`test -r /run/host-ssh/id_ed25519`、`ssh -i /run/host-ssh/id_ed25519 ... <target> hostname` 做本地烟测后再接入主 server。
|
||||
|
||||
@@ -56,6 +64,8 @@ WSL 节点还应补充一次真实调度验证:向该 `PROVIDER_ID` 下发 `do
|
||||
|
||||
SSH 透传自测是 provider-gateway 部署验收的一部分。目标 Provider 在线后,先确认 frontend 节点清单或 `debug health` 中该节点 labels 显示 `hostSshConfigured=true`、`hostSshKeyPresent=true` 且能力包含 `host.ssh`;再运行 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000`,任务必须 `succeeded`,result 中 `probeLine` 必须包含 `UNIDESK_SSH_TEST` 且 `exitCode=0`;最后运行 `bun scripts/cli.ts ssh <PROVIDER_ID> hostname`,输出必须是目标宿主或 WSL 的 hostname,进程退出码必须为 0。任何 provider 在线但不声明 `host.ssh` 的状态都只能算未完成部署。
|
||||
|
||||
如果该节点承载 microservice,还必须声明 `microservice.http` capability,并通过 `bun scripts/cli.ts microservice health <id>` 或 remote CLI 等价命令验证 backend-core 能经 provider-gateway 访问节点本机后端。microservice 后端端口不得映射到公网;provider-gateway 只允许代理节点本地 HTTP 地址,业务 API 路径还要受 backend-core `allowedPathPrefixes` 限制。
|
||||
|
||||
自动化验证必须使用 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 摘要和失败原因。
|
||||
|
||||
## Provider Ingress
|
||||
@@ -66,6 +76,12 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前
|
||||
|
||||
自动任务执行只允许走本地 Docker socket。Compose 将 `/var/run/docker.sock` 挂入 provider-gateway,provider 标签会报告 `dockerSocketPresent`,`docker.ps` 调试任务会通过该 socket 查询宿主 Docker 容器。
|
||||
|
||||
## 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。
|
||||
|
||||
超大 JSON 响应可以使用 `jsonArrayLimits` 在 provider-gateway 返回前裁剪指定数组,并在响应体中写入 `_unidesk.arrayLimits` 元数据,便于 UniDesk frontend 预览列表而不展示裸 JSON。长期应优先推动业务后端提供分页 API;裁剪只是 UniDesk 集成层的展示保护。
|
||||
|
||||
## Gateway Version Metadata
|
||||
|
||||
provider-gateway 必须从自身 `package.json` 读取版本号,并在 register 与 heartbeat labels 中上报 `providerGatewayName`、`providerGatewayVersion`、`providerGatewayStartedAt` 和 `providerGatewayUpgradePolicy`。backend-core 将这些 labels 合并到 `unidesk_nodes.labels`,frontend 在节点清单、资源监控和 `资源节点 / 网关版本` 中展示;旧节点缺少这些字段时只能显示版本未知,不能用猜测值替代。`unideskCapabilities`、`hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget` 也是 WebUI 运维可用性徽标的数据源,用于直接显示每个计算节点的 SSH 透传可用性与远程更新可用性。
|
||||
@@ -80,7 +96,7 @@ provider-gateway 连接成功后必须周期性上报节点 CPU、内存和硬
|
||||
|
||||
## Remote Provider Upgrade
|
||||
|
||||
backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provider.upgrade`。`mode: "plan"` 只返回升级计划,用于 E2E 和人工预检;`mode: "schedule"` 会要求 provider-gateway 通过本地 Docker socket 启动一个 detached updater 容器,由 updater 在节点本地执行 `docker compose up -d --no-deps --build provider-gateway`。`--no-deps` 是强制要求,升级 provider-gateway 时不得重建或停止 database、backend-core、frontend。升级执行路径使用 Docker socket 和只读仓库挂载,不使用 Host SSH 维护桥作为自动调度通道。
|
||||
backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provider.upgrade`。`mode: "plan"` 只返回升级计划,用于 E2E 和人工预检;`mode: "schedule"` 会要求 provider-gateway 通过本地 Docker socket 启动一个 detached updater 容器。updater 的固定策略是先执行 `docker compose build provider-gateway`,构建成功后只按 `com.docker.compose.project` 与 `com.docker.compose.service=provider-gateway` label 删除旧 provider-gateway 容器,最后执行 `docker compose up -d --no-deps --force-recreate provider-gateway`。`--no-deps` 是强制要求,`--force-recreate` 用于保证 provider 重新注册能力标签并避免 compose no-op;升级 provider-gateway 时不得重建或停止 database、backend-core、frontend。对 D518、D601 这类计算节点,`mode: "schedule"` 是正式重建/升级 `provider-gateway` 容器的唯一标准路径;升级执行路径使用 Docker socket 和只读仓库挂载,不使用 Host SSH 维护桥作为自动调度通道。
|
||||
|
||||
远程升级策略固定为 always-enabled:只要 provider-gateway 在线并声明 `provider.upgrade`,`mode: "schedule"` 就必须真正调度升级容器,不允许被 `PROVIDER_UPGRADE_ENABLED=false`、前端隐藏按钮或服务端特殊名单禁用。升级能力的安全边界不是开关,而是显式 `PROVIDER_UPGRADE_*` 配置、Docker socket 权限、只读仓库挂载、固定 Compose service 和 `--no-deps` 约束。升级计划中必须展示 `policy: "always-enabled"`、updater 容器名、runner image、workspace、Compose project/service、env file、compose file 和实际 `docker run` 命令,方便前端任务历史与 CLI debug 直接诊断。
|
||||
|
||||
@@ -90,9 +106,9 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi
|
||||
|
||||
## Manual Upgrade Maintenance
|
||||
|
||||
手动升级只用于把旧节点 bootstrap 到支持 always-enabled 远程升级的版本;bootstrap 完成后,常规升级必须回到 `provider.upgrade schedule`。节点侧维护步骤是:进入节点本地 UniDesk 仓库,执行 `git pull --ff-only` 获取主 server 已推送版本;确认 `.state/provider-<ID>.env` 中存在 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider`、`PROVIDER_ID=<ID>`、`PROVIDER_NAME=<ID>`、`PROVIDER_TOKEN`、`PROVIDER_LABELS_JSON`、`PROVIDER_UPGRADE_HOST_PROJECT_ROOT=/home/ubuntu/unidesk`、`PROVIDER_UPGRADE_WORKSPACE_PATH=/workspace`、`PROVIDER_UPGRADE_COMPOSE_FILE`、`PROVIDER_UPGRADE_ENV_FILE`、`PROVIDER_UPGRADE_COMPOSE_PROJECT`、`PROVIDER_UPGRADE_SERVICE=provider-gateway`、`PROVIDER_UPGRADE_RUNNER_IMAGE=unidesk_provider-gateway:<id>`、`DOCKER_SOCKET_PATH=/var/run/docker.sock`、`MONITOR_DISK_PATH=/`、心跳和重连参数。旧 env 文件中如果还残留 `PROVIDER_UPGRADE_ENABLED`,新版 provider-gateway 会忽略它;长期文档和新部署不得再依赖这个键。
|
||||
手动升级只用于把旧节点 bootstrap 到支持 always-enabled 远程升级的版本;bootstrap 完成后,常规重建/升级必须回到 `provider.upgrade mode=schedule`,不得再用 SSH 透传同步重建 `provider-gateway`。节点侧维护步骤是:进入节点本地 UniDesk 仓库,执行 `git pull --ff-only` 获取主 server 已推送版本;确认 `.state/provider-<ID>.env` 中存在 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider`、`PROVIDER_ID=<ID>`、`PROVIDER_NAME=<ID>`、`PROVIDER_TOKEN`、`PROVIDER_LABELS_JSON`、`PROVIDER_UPGRADE_HOST_PROJECT_ROOT=/home/ubuntu/unidesk`、`PROVIDER_UPGRADE_WORKSPACE_PATH=/workspace`、`PROVIDER_UPGRADE_COMPOSE_FILE`、`PROVIDER_UPGRADE_ENV_FILE`、`PROVIDER_UPGRADE_COMPOSE_PROJECT`、`PROVIDER_UPGRADE_SERVICE=provider-gateway`、`PROVIDER_UPGRADE_RUNNER_IMAGE=unidesk_provider-gateway:<id>`、`DOCKER_SOCKET_PATH=/var/run/docker.sock`、`MONITOR_DISK_PATH=/`、心跳和重连参数。旧 env 文件中如果还残留 `PROVIDER_UPGRADE_ENABLED`,新版 provider-gateway 会忽略它;长期文档和新部署不得再依赖这个键。
|
||||
|
||||
如果节点已有专用 Compose,优先用节点本地 Compose 手动重建一次:`docker compose --env-file .state/provider-<ID>.env -f <compose-file> -p <compose-project> up -d --no-deps --build provider-gateway`。老版 `docker-compose` 可能在重建已存在容器时因为 `ContainerConfig` 兼容问题失败;此时只能移除目标 provider-gateway 容器后重新 `up -d --no-deps provider-gateway`,不得执行 `down -v`、`docker volume rm` 或任何会影响 database 命名卷的命令。如果节点当前只有 `docker run` 部署,则先构建镜像 `docker build -f src/components/provider-gateway/Dockerfile -t unidesk_provider-gateway:<id> .`,再以固定容器名重建:挂载 `/var/run/docker.sock:/var/run/docker.sock`、`/home/ubuntu/unidesk:/workspace:ro`、节点日志目录到 `/var/log/unidesk`,如需 WSL SSH 维护桥还要把只读私钥目录挂载到 `/run/host-ssh`,并使用同一个 `.state/provider-<ID>.env` 启动。无论 Compose 还是 `docker run`,容器名和镜像 tag 都必须带 Provider ID,便于 Docker 状态页、任务历史和节点本地排障互相对应。
|
||||
如果节点已有专用 Compose,优先用节点本地 Compose 手动重建一次:`docker compose --env-file .state/provider-<ID>.env -f <compose-file> -p <compose-project> up -d --no-deps --build --force-recreate provider-gateway`。这条命令必须在节点本地终端、节点自有 Web terminal、系统计划任务或 detached shell 中执行;不得通过正在被重建的 UniDesk provider-gateway 自己提供的 SSH 透传同步执行,否则旧 provider 容器停止时会切断 SSH client,可能导致重建中断在旧容器已停、新容器未起的状态。若只能通过 UniDesk 触达该节点,必须使用 `provider.upgrade mode=schedule` 的 detached updater,或先用节点本地 `nohup`/systemd 启动一个不依赖当前 provider 容器生命周期的重建脚本。老版 `docker-compose` 可能在重建已存在容器时因为 `ContainerConfig` 兼容问题失败;此时只能移除目标 provider-gateway 容器后重新 `up -d --no-deps provider-gateway`,不得执行 `down -v`、`docker volume rm` 或任何会影响 database 命名卷的命令。如果节点当前只有 `docker run` 部署,则先构建镜像 `docker build -f src/components/provider-gateway/Dockerfile -t unidesk_provider-gateway:<id> .`,再以固定容器名重建:挂载 `/var/run/docker.sock:/var/run/docker.sock`、`/home/ubuntu/unidesk:/workspace:ro`、节点日志目录到 `/var/log/unidesk`,如需 WSL SSH 维护桥还要把只读私钥目录挂载到 `/run/host-ssh`,并使用同一个 `.state/provider-<ID>.env` 启动。无论 Compose 还是 `docker run`,容器名和镜像 tag 都必须带 Provider ID,便于 Docker 状态页、任务历史和节点本地排障互相对应。
|
||||
|
||||
手动升级完成后的判定标准固定为主 server 可观测结果,而不是节点容器 `running`:访问公网 frontend `http://74.48.78.17:18081/`,确认该 Provider 在线;随后在任意装有本仓库且 `config.json` 含正确 frontend 登录凭据的计算节点上执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000`,确认任务 `succeeded` 且 result 包含 updater 容器信息;最后再次查看 frontend 或执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`,确认节点重连、指标恢复、labels 中 `host.ssh` 能力存在。每个 provider-gateway 手动升级后都必须用 remote CLI 再执行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname`,验证维护桥没有在升级后丢失;该 remote CLI 默认走公网 frontend,不需要指定 `--main-server-key`。
|
||||
|
||||
|
||||
+11
-1
@@ -7,6 +7,7 @@ import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
|
||||
import { runChecks } from "./src/check";
|
||||
import { runSsh } from "./src/ssh";
|
||||
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
||||
import { runMicroserviceCommand } from "./src/microservices";
|
||||
|
||||
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
||||
const args = remoteOptions.args;
|
||||
@@ -27,10 +28,14 @@ function help(): unknown {
|
||||
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
|
||||
{ command: "server rebuild <backend-core|frontend|provider-gateway>", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." },
|
||||
{ command: "ssh <providerId> [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 <id>", description: "Show one microservice config, repository reference, backend mapping, and runtime status." },
|
||||
{ command: "microservice health <id>", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." },
|
||||
{ command: "microservice proxy <id> <path> [--raw] [--max-body-bytes N]", description: "GET a private microservice backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." },
|
||||
{ command: "job list", description: "List async jobs from .state/jobs." },
|
||||
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
|
||||
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
|
||||
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
|
||||
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
|
||||
{ command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." },
|
||||
{ command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." },
|
||||
],
|
||||
@@ -151,6 +156,11 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (top === "microservice") {
|
||||
emitJson(commandName, await runMicroserviceCommand(config, args.slice(1)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "job") {
|
||||
if (sub === "list") {
|
||||
emitJson(commandName, { jobs: listJobs() });
|
||||
|
||||
@@ -35,10 +35,46 @@ export interface UniDeskConfig {
|
||||
};
|
||||
};
|
||||
docker: { composeFile: string; projectName: string };
|
||||
microservices: UniDeskMicroserviceConfig[];
|
||||
paths: { stateDir: string; logsDir: string; docsReferenceDir: string };
|
||||
sshForwarding: { mode: string; keyDir: string; host: string; port: number; user: string };
|
||||
}
|
||||
|
||||
export interface UniDeskMicroserviceConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
description: string;
|
||||
repository: {
|
||||
url: string;
|
||||
commitId: string;
|
||||
dockerfile: string;
|
||||
composeFile: string;
|
||||
composeService: string;
|
||||
containerName: string;
|
||||
};
|
||||
backend: {
|
||||
nodeBaseUrl: string;
|
||||
nodeBindHost: string;
|
||||
nodePort: number;
|
||||
proxyMode: string;
|
||||
frontendOnly: boolean;
|
||||
public: boolean;
|
||||
allowedPathPrefixes: string[];
|
||||
healthPath: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
development: {
|
||||
providerId: string;
|
||||
sshPassthrough: boolean;
|
||||
worktreePath: string;
|
||||
};
|
||||
frontend: {
|
||||
route: string;
|
||||
integrated: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
export const repoRoot = join(moduleDir, "..", "..");
|
||||
|
||||
@@ -70,6 +106,68 @@ function portPair(obj: Record<string, unknown>, key: string): { port: number; co
|
||||
return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) };
|
||||
}
|
||||
|
||||
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalArray(value: unknown, name: string): Record<string, unknown>[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
||||
return value.map((item, index) => asRecord(item, `${name}[${index}]`));
|
||||
}
|
||||
|
||||
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
||||
const value = obj[key];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) {
|
||||
throw new Error(`${path}.${key} must be an array of non-empty strings`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function microserviceConfig(item: Record<string, unknown>, index: number): UniDeskMicroserviceConfig {
|
||||
const path = `microservices[${index}]`;
|
||||
const repository = asRecord(item.repository, `${path}.repository`);
|
||||
const backend = asRecord(item.backend, `${path}.backend`);
|
||||
const development = asRecord(item.development, `${path}.development`);
|
||||
const frontend = asRecord(item.frontend, `${path}.frontend`);
|
||||
return {
|
||||
id: stringField(item, "id", path),
|
||||
name: stringField(item, "name", path),
|
||||
providerId: stringField(item, "providerId", path),
|
||||
description: stringField(item, "description", path),
|
||||
repository: {
|
||||
url: stringField(repository, "url", `${path}.repository`),
|
||||
commitId: stringField(repository, "commitId", `${path}.repository`),
|
||||
dockerfile: stringField(repository, "dockerfile", `${path}.repository`),
|
||||
composeFile: stringField(repository, "composeFile", `${path}.repository`),
|
||||
composeService: stringField(repository, "composeService", `${path}.repository`),
|
||||
containerName: stringField(repository, "containerName", `${path}.repository`),
|
||||
},
|
||||
backend: {
|
||||
nodeBaseUrl: stringField(backend, "nodeBaseUrl", `${path}.backend`),
|
||||
nodeBindHost: stringField(backend, "nodeBindHost", `${path}.backend`),
|
||||
nodePort: numberField(backend, "nodePort", `${path}.backend`),
|
||||
proxyMode: stringField(backend, "proxyMode", `${path}.backend`),
|
||||
frontendOnly: booleanField(backend, "frontendOnly", `${path}.backend`),
|
||||
public: booleanField(backend, "public", `${path}.backend`),
|
||||
allowedPathPrefixes: stringArrayField(backend, "allowedPathPrefixes", `${path}.backend`),
|
||||
healthPath: stringField(backend, "healthPath", `${path}.backend`),
|
||||
timeoutMs: numberField(backend, "timeoutMs", `${path}.backend`),
|
||||
},
|
||||
development: {
|
||||
providerId: stringField(development, "providerId", `${path}.development`),
|
||||
sshPassthrough: booleanField(development, "sshPassthrough", `${path}.development`),
|
||||
worktreePath: stringField(development, "worktreePath", `${path}.development`),
|
||||
},
|
||||
frontend: {
|
||||
route: stringField(frontend, "route", `${path}.frontend`),
|
||||
integrated: booleanField(frontend, "integrated", `${path}.frontend`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readConfig(): UniDeskConfig {
|
||||
const configPath = rootPath("config.json");
|
||||
if (!existsSync(configPath)) throw new Error(`config.json not found at ${configPath}`);
|
||||
@@ -82,6 +180,7 @@ export function readConfig(): UniDeskConfig {
|
||||
const auth = asRecord(parsed.auth, "auth");
|
||||
const providerGateway = asRecord(parsed.providerGateway, "providerGateway");
|
||||
const docker = asRecord(parsed.docker, "docker");
|
||||
const microservices = optionalArray(parsed.microservices, "microservices").map(microserviceConfig);
|
||||
const paths = asRecord(parsed.paths, "paths");
|
||||
const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding");
|
||||
const labels = asRecord(providerGateway.labels, "providerGateway.labels");
|
||||
@@ -129,6 +228,7 @@ export function readConfig(): UniDeskConfig {
|
||||
},
|
||||
},
|
||||
docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") },
|
||||
microservices,
|
||||
paths: {
|
||||
stateDir: stringField(paths, "stateDir", "paths"),
|
||||
logsDir: stringField(paths, "logsDir", "paths"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
|
||||
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "echo"] as const;
|
||||
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "microservice.http", "echo"] as const;
|
||||
export type DebugDispatchCommand = typeof dispatchCommands[number];
|
||||
|
||||
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
|
||||
|
||||
@@ -69,6 +69,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
chmodSync(logDir, 0o777);
|
||||
const labels = JSON.stringify(config.providerGateway.labels);
|
||||
const microservices = JSON.stringify(config.microservices);
|
||||
const lines = {
|
||||
UNIDESK_PUBLIC_HOST: config.network.publicHost,
|
||||
UNIDESK_CORE_PORT: String(config.network.core.port),
|
||||
@@ -82,6 +83,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_PROVIDER_ID: config.providerGateway.id,
|
||||
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
|
||||
UNIDESK_PROVIDER_LABELS_JSON: labels,
|
||||
UNIDESK_MICROSERVICES_JSON: microservices,
|
||||
UNIDESK_AUTH_USERNAME: config.auth.username,
|
||||
UNIDESK_AUTH_PASSWORD: config.auth.password,
|
||||
UNIDESK_SESSION_SECRET: config.auth.sessionSecret,
|
||||
|
||||
+114
-4
@@ -1,9 +1,10 @@
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { boundedJsonDetail } from "./preview";
|
||||
|
||||
type CheckStatus = "passed" | "failed";
|
||||
|
||||
@@ -67,7 +68,16 @@ function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise<unknown
|
||||
}
|
||||
|
||||
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
|
||||
checks.push({ name, status: passed ? "passed" : "failed", detail });
|
||||
checks.push({
|
||||
name,
|
||||
status: passed ? "passed" : "failed",
|
||||
detail: boundedJsonDetail(detail, passed ? 4_000 : 24_000, {
|
||||
maxDepth: passed ? 3 : 5,
|
||||
maxArrayItems: passed ? 3 : 8,
|
||||
maxObjectKeys: passed ? 12 : 40,
|
||||
maxStringLength: passed ? 600 : 1600,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function safeTestId(value: string): string {
|
||||
@@ -211,9 +221,11 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
|
||||
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
|
||||
@@ -221,6 +233,14 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
const coreNodes = dockerCoreJson("/api/nodes");
|
||||
const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24");
|
||||
const dockerStatus = dockerCoreJson("/api/nodes/docker-status");
|
||||
const microservices = dockerCoreJson("/api/microservices");
|
||||
const findjobStatus = dockerCoreJson("/api/microservices/findjob/status");
|
||||
const findjobHealth = dockerCoreJson("/api/microservices/findjob/health");
|
||||
const findjobSummary = dockerCoreJson("/api/microservices/findjob/proxy/api/summary");
|
||||
const findjobJobsPreview = dockerCoreJson("/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5");
|
||||
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 providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
|
||||
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
|
||||
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record<string, unknown> }> } }).body?.nodes ?? [];
|
||||
@@ -235,6 +255,42 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null });
|
||||
addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
|
||||
addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id));
|
||||
const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? [];
|
||||
const findjob = microserviceList.find((service) => service.id === "findjob");
|
||||
const pipeline = microserviceList.find((service) => service.id === "pipeline");
|
||||
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 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
|
||||
: undefined;
|
||||
const pipelineSnapshotDetail = {
|
||||
ok: (pipelineSnapshot as { ok?: boolean }).ok,
|
||||
status: (pipelineSnapshot as { status?: number }).status,
|
||||
body: {
|
||||
ok: pipelineSnapshotBody?.ok,
|
||||
registryOk: pipelineSnapshotBody?.registry?.ok,
|
||||
componentPreviewCount: pipelineSnapshotBody?.registry?.components?.length ?? 0,
|
||||
pipelinePreviewCount: pipelineSnapshotBody?.pipelines?.length ?? 0,
|
||||
runPreviewCount: pipelineSnapshotBody?.runs?.length ?? 0,
|
||||
firstRun: firstPipelineRun === undefined ? null : {
|
||||
runId: firstPipelineRun.runId,
|
||||
pipelineId: firstPipelineRun.pipelineId,
|
||||
status: firstPipelineRun.status,
|
||||
updatedAt: firstPipelineRun.updatedAt,
|
||||
},
|
||||
arrayLimits: pipelineSnapshotBody?._unidesk?.arrayLimits,
|
||||
},
|
||||
};
|
||||
addCheck(checks, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices });
|
||||
addCheck(checks, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices });
|
||||
addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus);
|
||||
addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth);
|
||||
addCheck(checks, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary);
|
||||
addCheck(checks, "microservice:findjob-jobs-preview", (findjobJobsPreview as { ok?: boolean }).ok === true && Array.isArray(findjobJobs?.jobs) && (findjobJobs.jobs.length ?? 0) > 0 && (findjobJobs._unidesk?.arrayLimits?.jobs?.returnedLength ?? 0) <= 5, findjobJobsPreview);
|
||||
addCheck(checks, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus);
|
||||
addCheck(checks, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth);
|
||||
addCheck(checks, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail);
|
||||
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
|
||||
method: "POST",
|
||||
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
|
||||
@@ -305,7 +361,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 });
|
||||
await page.setViewportSize({ width: 390, height: 860 });
|
||||
const mobileRailHeights: number[] = [];
|
||||
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "系统配置"]) {
|
||||
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) {
|
||||
await page.getByRole("button", { name: new RegExp(moduleLabel) }).click();
|
||||
await page.waitForTimeout(80);
|
||||
const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height));
|
||||
@@ -381,6 +437,41 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
const gatewayTextLower = gatewayText.toLowerCase();
|
||||
const sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText));
|
||||
const upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText));
|
||||
await page.getByRole("button", { name: /微服务/ }).click();
|
||||
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 });
|
||||
const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 });
|
||||
await page.getByRole("button", { name: /FindJob/ }).click();
|
||||
await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.body.innerText.toLowerCase();
|
||||
const originalText = document.body.innerText;
|
||||
return text.includes("findjob 工作台".toLowerCase())
|
||||
&& text.includes("岗位总量")
|
||||
&& text.includes("d601")
|
||||
&& text.includes("近期岗位")
|
||||
&& /岗位总量\s+\d+/.test(originalText)
|
||||
&& /health\s+ok/i.test(originalText)
|
||||
&& /[1-9]\d*\/[1-9]\d*\s+preview/i.test(originalText);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 });
|
||||
await page.getByRole("button", { name: /Pipeline/ }).click();
|
||||
await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.body.innerText;
|
||||
const lower = text.toLowerCase();
|
||||
return lower.includes("pipeline v2 工作台")
|
||||
&& text.includes("控制图")
|
||||
&& text.includes("最近运行")
|
||||
&& /Health\s+OK/i.test(text)
|
||||
&& /组件\s+\d+/.test(text)
|
||||
&& /运行记录\s+[1-9]\d*/.test(text);
|
||||
}, undefined, { timeout: 30000 });
|
||||
const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 });
|
||||
const microserviceCatalogTextLower = microserviceCatalogText.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:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights });
|
||||
@@ -394,6 +485,9 @@ 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: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:no-console-errors", consoleErrors.length === 0, { consoleErrors });
|
||||
return { screenshotPath, bodyText, consoleErrors };
|
||||
} finally {
|
||||
@@ -409,11 +503,27 @@ export async function runE2E(config: UniDeskConfig): Promise<unknown> {
|
||||
const markerId = databaseChecks(config, checks);
|
||||
const frontend = await frontendCheck(config, urls, checks);
|
||||
const ok = checks.every((check) => check.status === "passed");
|
||||
return {
|
||||
const fullResult = {
|
||||
ok,
|
||||
urls,
|
||||
markerId,
|
||||
screenshotPath: frontend.screenshotPath,
|
||||
checks,
|
||||
};
|
||||
const resultPath = rootPath(".state", "e2e", `${markerId}_result.json`);
|
||||
writeFileSync(resultPath, `${JSON.stringify(fullResult, null, 2)}\n`, "utf8");
|
||||
return {
|
||||
ok,
|
||||
urls,
|
||||
markerId,
|
||||
screenshotPath: frontend.screenshotPath,
|
||||
resultPath,
|
||||
checkCounts: {
|
||||
total: checks.length,
|
||||
passed: checks.filter((check) => check.status === "passed").length,
|
||||
failed: checks.filter((check) => check.status === "failed").length,
|
||||
},
|
||||
checks: checks.map((check) => ({ name: check.name, status: check.status })),
|
||||
failedChecks: checks.filter((check) => check.status === "failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { jsonByteLength, previewJson } from "./preview";
|
||||
|
||||
function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
|
||||
const code = `
|
||||
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
|
||||
method: init?.method ?? "GET",
|
||||
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
})});
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
|
||||
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
|
||||
`;
|
||||
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
|
||||
if (result.exitCode !== 0) {
|
||||
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout.trim()) as unknown;
|
||||
} catch {
|
||||
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
|
||||
}
|
||||
}
|
||||
|
||||
function requireId(value: string | undefined, command: string): string {
|
||||
if (value === undefined || value.length === 0) throw new Error(`${command} requires microservice id`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireProxyPath(value: string | undefined): string {
|
||||
if (value === undefined || value.length === 0) throw new Error("microservice proxy requires upstream path, for example /api/summary");
|
||||
if (!value.startsWith("/")) throw new Error("microservice proxy upstream path must start with /");
|
||||
return value;
|
||||
}
|
||||
|
||||
function encodeId(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function numberOption(args: string[], name: string, defaultValue: number): number {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return defaultValue;
|
||||
const raw = args[index + 1];
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown {
|
||||
if (args.includes("--raw")) return response;
|
||||
const maxBodyBytes = numberOption(args, "--max-body-bytes", 60_000);
|
||||
if (typeof response !== "object" || response === null || Array.isArray(response)) return response;
|
||||
const record = response as Record<string, unknown>;
|
||||
if (!("body" in record)) return response;
|
||||
const bodyBytes = jsonByteLength(record.body);
|
||||
if (bodyBytes <= maxBodyBytes) return response;
|
||||
const rest = { ...record };
|
||||
delete rest.body;
|
||||
return {
|
||||
...rest,
|
||||
bodyOmitted: true,
|
||||
bodyBytes,
|
||||
bodyMaxBytes: maxBodyBytes,
|
||||
bodyPreview: previewJson(record.body, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }),
|
||||
rawHint: "Re-run with --raw for the full body, or add/tighten __unideskArrayLimit=<path>:<limit> in the proxied path.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMicroserviceCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "list", idArg, pathArg] = args;
|
||||
if (action === "list") return coreInternalFetch("/api/microservices");
|
||||
if (action === "status") {
|
||||
const id = requireId(idArg, "microservice status");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/status`);
|
||||
}
|
||||
if (action === "health") {
|
||||
const id = requireId(idArg, "microservice health");
|
||||
return coreInternalFetch(`/api/microservices/${encodeId(id)}/health`);
|
||||
}
|
||||
if (action === "proxy") {
|
||||
const id = requireId(idArg, "microservice proxy");
|
||||
const path = requireProxyPath(pathArg);
|
||||
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`), args);
|
||||
}
|
||||
throw new Error("microservice command must be one of: list, status, health, proxy");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface PreviewOptions {
|
||||
maxDepth?: number;
|
||||
maxArrayItems?: number;
|
||||
maxObjectKeys?: number;
|
||||
maxStringLength?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_OPTIONS: Required<PreviewOptions> = {
|
||||
maxDepth: 4,
|
||||
maxArrayItems: 6,
|
||||
maxObjectKeys: 32,
|
||||
maxStringLength: 1000,
|
||||
};
|
||||
|
||||
function optionsWithDefaults(options: PreviewOptions = {}): Required<PreviewOptions> {
|
||||
return {
|
||||
maxDepth: options.maxDepth ?? DEFAULT_PREVIEW_OPTIONS.maxDepth,
|
||||
maxArrayItems: options.maxArrayItems ?? DEFAULT_PREVIEW_OPTIONS.maxArrayItems,
|
||||
maxObjectKeys: options.maxObjectKeys ?? DEFAULT_PREVIEW_OPTIONS.maxObjectKeys,
|
||||
maxStringLength: options.maxStringLength ?? DEFAULT_PREVIEW_OPTIONS.maxStringLength,
|
||||
};
|
||||
}
|
||||
|
||||
export function jsonByteLength(value: unknown): number {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
} catch {
|
||||
return Buffer.byteLength(String(value), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function previewJson(value: unknown, options: PreviewOptions = {}, depth = 0): unknown {
|
||||
const limits = optionsWithDefaults(options);
|
||||
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
if (value.length <= limits.maxStringLength) return value;
|
||||
return {
|
||||
_previewType: "string",
|
||||
length: value.length,
|
||||
text: value.slice(0, limits.maxStringLength),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
if (typeof value !== "object") return String(value);
|
||||
if (depth >= limits.maxDepth) {
|
||||
return {
|
||||
_previewType: Array.isArray(value) ? "array" : "object",
|
||||
truncated: true,
|
||||
...(Array.isArray(value) ? { length: value.length } : { keys: Object.keys(value as Record<string, unknown>).length }),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
_previewType: "array",
|
||||
length: value.length,
|
||||
items: value.slice(0, limits.maxArrayItems).map((item) => previewJson(item, limits, depth + 1)),
|
||||
truncatedItems: Math.max(0, value.length - limits.maxArrayItems),
|
||||
};
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const entries = Object.entries(record);
|
||||
const preview: Record<string, unknown> = {};
|
||||
for (const [key, item] of entries.slice(0, limits.maxObjectKeys)) {
|
||||
preview[key] = previewJson(item, limits, depth + 1);
|
||||
}
|
||||
if (entries.length > limits.maxObjectKeys) {
|
||||
preview._preview = {
|
||||
truncatedKeys: entries.length - limits.maxObjectKeys,
|
||||
totalKeys: entries.length,
|
||||
};
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
export function boundedJsonDetail(value: unknown, maxBytes: number, options: PreviewOptions = {}): unknown {
|
||||
const originalBytes = jsonByteLength(value);
|
||||
if (originalBytes <= maxBytes) return value;
|
||||
return {
|
||||
_truncated: true,
|
||||
originalBytes,
|
||||
maxBytes,
|
||||
preview: previewJson(value, options),
|
||||
};
|
||||
}
|
||||
+29
-1
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { parseSshArgs } from "./ssh";
|
||||
|
||||
export interface RemoteCliOptions {
|
||||
@@ -354,6 +355,29 @@ async function remoteDebugTask(session: FrontendSession, args: string[]): Promis
|
||||
return { transport: "frontend", tasksResponse, taskId, task: task ?? null };
|
||||
}
|
||||
|
||||
async function remoteMicroservice(session: FrontendSession, args: string[]): Promise<unknown> {
|
||||
const action = args[1] ?? "list";
|
||||
const id = args[2];
|
||||
const path = args[3];
|
||||
if (action === "list") {
|
||||
return { transport: "frontend", response: await frontendJson(session, "/api/microservices", undefined, 12_000) };
|
||||
}
|
||||
if ((action === "status" || action === "health") && id !== undefined) {
|
||||
return {
|
||||
transport: "frontend",
|
||||
response: await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/${action}`, undefined, 18_000),
|
||||
};
|
||||
}
|
||||
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
|
||||
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000);
|
||||
return {
|
||||
transport: "frontend",
|
||||
response: summarizeMicroserviceProxyResponse(response, args),
|
||||
};
|
||||
}
|
||||
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | proxy <id> <path>");
|
||||
}
|
||||
|
||||
async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
||||
const parsed = parseSshArgs(args);
|
||||
@@ -399,7 +423,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
baseUrl: session.baseUrl,
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -415,6 +439,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, await remoteDebugTask(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "microservice") {
|
||||
emitRemoteJson(name, await remoteMicroservice(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "ssh") {
|
||||
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
||||
}
|
||||
|
||||
@@ -32,9 +32,45 @@ interface RuntimeConfig {
|
||||
providerToken: string;
|
||||
heartbeatTimeoutMs: number;
|
||||
taskPendingTimeoutMs: number;
|
||||
microservices: MicroserviceConfig[];
|
||||
logFile: string;
|
||||
}
|
||||
|
||||
interface MicroserviceConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
description: string;
|
||||
repository: {
|
||||
url: string;
|
||||
commitId: string;
|
||||
dockerfile: string;
|
||||
composeFile: string;
|
||||
composeService: string;
|
||||
containerName: string;
|
||||
};
|
||||
backend: {
|
||||
nodeBaseUrl: string;
|
||||
nodeBindHost: string;
|
||||
nodePort: number;
|
||||
proxyMode: string;
|
||||
frontendOnly: boolean;
|
||||
public: boolean;
|
||||
allowedPathPrefixes: string[];
|
||||
healthPath: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
development: {
|
||||
providerId: string;
|
||||
sshPassthrough: boolean;
|
||||
worktreePath: string;
|
||||
};
|
||||
frontend: {
|
||||
route: string;
|
||||
integrated: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
providerId?: string;
|
||||
channel?: "provider" | "ssh";
|
||||
@@ -86,6 +122,88 @@ function readOptionalNumberEnv(name: string, fallback: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, name: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringFromRecord(value: Record<string, unknown>, key: string, path: string): string {
|
||||
const field = value[key];
|
||||
if (typeof field !== "string" || field.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function numberFromRecord(value: Record<string, unknown>, key: string, path: string): number {
|
||||
const field = value[key];
|
||||
if (typeof field !== "number" || !Number.isFinite(field) || field <= 0) throw new Error(`${path}.${key} must be a positive number`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function booleanFromRecord(value: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const field = value[key];
|
||||
if (typeof field !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function stringArrayFromRecord(value: Record<string, unknown>, key: string, path: string): string[] {
|
||||
const field = value[key];
|
||||
if (!Array.isArray(field) || field.some((item) => typeof item !== "string" || item.length === 0)) {
|
||||
throw new Error(`${path}.${key} must be an array of non-empty strings`);
|
||||
}
|
||||
return field as string[];
|
||||
}
|
||||
|
||||
function parseMicroserviceConfig(value: unknown, index: number): MicroserviceConfig {
|
||||
const path = `MICROSERVICES_JSON[${index}]`;
|
||||
const item = asRecord(value, path);
|
||||
const repository = asRecord(item.repository, `${path}.repository`);
|
||||
const backend = asRecord(item.backend, `${path}.backend`);
|
||||
const development = asRecord(item.development, `${path}.development`);
|
||||
const frontend = asRecord(item.frontend, `${path}.frontend`);
|
||||
return {
|
||||
id: stringFromRecord(item, "id", path),
|
||||
name: stringFromRecord(item, "name", path),
|
||||
providerId: stringFromRecord(item, "providerId", path),
|
||||
description: stringFromRecord(item, "description", path),
|
||||
repository: {
|
||||
url: stringFromRecord(repository, "url", `${path}.repository`),
|
||||
commitId: stringFromRecord(repository, "commitId", `${path}.repository`),
|
||||
dockerfile: stringFromRecord(repository, "dockerfile", `${path}.repository`),
|
||||
composeFile: stringFromRecord(repository, "composeFile", `${path}.repository`),
|
||||
composeService: stringFromRecord(repository, "composeService", `${path}.repository`),
|
||||
containerName: stringFromRecord(repository, "containerName", `${path}.repository`),
|
||||
},
|
||||
backend: {
|
||||
nodeBaseUrl: stringFromRecord(backend, "nodeBaseUrl", `${path}.backend`),
|
||||
nodeBindHost: stringFromRecord(backend, "nodeBindHost", `${path}.backend`),
|
||||
nodePort: numberFromRecord(backend, "nodePort", `${path}.backend`),
|
||||
proxyMode: stringFromRecord(backend, "proxyMode", `${path}.backend`),
|
||||
frontendOnly: booleanFromRecord(backend, "frontendOnly", `${path}.backend`),
|
||||
public: booleanFromRecord(backend, "public", `${path}.backend`),
|
||||
allowedPathPrefixes: stringArrayFromRecord(backend, "allowedPathPrefixes", `${path}.backend`),
|
||||
healthPath: stringFromRecord(backend, "healthPath", `${path}.backend`),
|
||||
timeoutMs: numberFromRecord(backend, "timeoutMs", `${path}.backend`),
|
||||
},
|
||||
development: {
|
||||
providerId: stringFromRecord(development, "providerId", `${path}.development`),
|
||||
sshPassthrough: booleanFromRecord(development, "sshPassthrough", `${path}.development`),
|
||||
worktreePath: stringFromRecord(development, "worktreePath", `${path}.development`),
|
||||
},
|
||||
frontend: {
|
||||
route: stringFromRecord(frontend, "route", `${path}.frontend`),
|
||||
integrated: booleanFromRecord(frontend, "integrated", `${path}.frontend`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readMicroservicesEnv(): MicroserviceConfig[] {
|
||||
const raw = process.env.MICROSERVICES_JSON;
|
||||
if (raw === undefined || raw.length === 0) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error("MICROSERVICES_JSON must be an array");
|
||||
return parsed.map(parseMicroserviceConfig);
|
||||
}
|
||||
|
||||
function readConfig(): RuntimeConfig {
|
||||
return {
|
||||
port: readNumberEnv("PORT"),
|
||||
@@ -94,6 +212,7 @@ function readConfig(): RuntimeConfig {
|
||||
providerToken: requiredEnv("PROVIDER_TOKEN"),
|
||||
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
|
||||
taskPendingTimeoutMs: readOptionalNumberEnv("TASK_PENDING_TIMEOUT_MS", 10 * 60 * 1000),
|
||||
microservices: readMicroservicesEnv(),
|
||||
logFile: requiredEnv("LOG_FILE"),
|
||||
};
|
||||
}
|
||||
@@ -735,20 +854,54 @@ async function getOverview(): Promise<JsonValue> {
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchTask(req: Request): Promise<Response> {
|
||||
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
|
||||
const providerId = typeof body.providerId === "string" ? body.providerId : "";
|
||||
const command = isProviderDispatchCommand(body.command) ? body.command : null;
|
||||
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
|
||||
if (!providerId) {
|
||||
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
||||
}
|
||||
if (command === null) {
|
||||
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, echo" }, 400);
|
||||
}
|
||||
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
|
||||
}
|
||||
function microserviceById(id: string): MicroserviceConfig | null {
|
||||
return config.microservices.find((service) => service.id === id) ?? null;
|
||||
}
|
||||
|
||||
function dockerStatusRecord(status: JsonValue | null): Record<string, unknown> {
|
||||
return typeof status === "object" && status !== null && !Array.isArray(status) ? status as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function findContainer(status: JsonValue | null, containerName: string): JsonValue | null {
|
||||
const containers = dockerStatusRecord(status).containers;
|
||||
if (!Array.isArray(containers)) return null;
|
||||
const container = containers.find((item) => {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) return false;
|
||||
return (item as Record<string, unknown>).name === containerName;
|
||||
});
|
||||
return container === undefined ? null : container as JsonValue;
|
||||
}
|
||||
|
||||
async function getMicroservices(): Promise<JsonValue[]> {
|
||||
const nodes = await getNodes();
|
||||
const dockerStatuses = await getNodeDockerStatuses();
|
||||
return config.microservices.map((service) => {
|
||||
const node = nodes.find((item) => item.providerId === service.providerId) ?? null;
|
||||
const docker = dockerStatuses.find((item) => item.providerId === service.providerId) ?? null;
|
||||
const container = findContainer(docker?.dockerStatus ?? null, service.repository.containerName);
|
||||
return {
|
||||
...service,
|
||||
runtime: {
|
||||
providerStatus: node?.status ?? "missing",
|
||||
providerName: node?.name ?? service.providerId,
|
||||
providerLastHeartbeat: node?.lastHeartbeat ?? null,
|
||||
container,
|
||||
backendPortMapping: {
|
||||
providerId: service.providerId,
|
||||
node: `${service.backend.nodeBindHost}:${service.backend.nodePort}`,
|
||||
mainServerAccess: "frontend-only backend proxy",
|
||||
public: service.backend.public,
|
||||
},
|
||||
},
|
||||
} satisfies JsonValue;
|
||||
});
|
||||
}
|
||||
|
||||
async function createAndSendTask(
|
||||
providerId: string,
|
||||
command: CoreDispatchMessage["command"],
|
||||
payload: Record<string, JsonValue>,
|
||||
): Promise<{ taskId: string; providerOnline: boolean }> {
|
||||
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
await sql`
|
||||
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
|
||||
@@ -757,7 +910,7 @@ async function dispatchTask(req: Request): Promise<Response> {
|
||||
const socket = activeProviders.get(providerId);
|
||||
if (!socket) {
|
||||
await recordEvent("task_queued_provider_offline", providerId, { taskId, providerId, command });
|
||||
return jsonResponse({ ok: true, taskId, status: "queued", providerOnline: false });
|
||||
return { taskId, providerOnline: false };
|
||||
}
|
||||
const dispatch: CoreDispatchMessage = { type: "dispatch", taskId, command, payload };
|
||||
socket.send(JSON.stringify(dispatch));
|
||||
@@ -767,7 +920,127 @@ async function dispatchTask(req: Request): Promise<Response> {
|
||||
WHERE id = ${taskId} AND status = 'queued'
|
||||
`;
|
||||
await recordEvent("task_dispatched", providerId, { taskId, providerId, command });
|
||||
return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true });
|
||||
return { taskId, providerOnline: true };
|
||||
}
|
||||
|
||||
async function rawTask(taskId: string): Promise<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string } | null> {
|
||||
const rows = await sql<Array<{ id: string; provider_id: string; command: string; status: string; payload: JsonValue; result: JsonValue | null; updated_at: Date | string }>>`
|
||||
SELECT id, provider_id, command, status, payload, result, updated_at
|
||||
FROM unidesk_tasks
|
||||
WHERE id = ${taskId}
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function waitForTaskTerminal(taskId: string, timeoutMs: number): Promise<ReturnType<typeof rawTask> extends Promise<infer T> ? T : never> {
|
||||
const started = Date.now();
|
||||
let latest = await rawTask(taskId);
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
latest = await rawTask(taskId);
|
||||
if (latest !== null && (latest.status === "succeeded" || latest.status === "failed")) return latest;
|
||||
await Bun.sleep(200);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function isMicroservicePathAllowed(service: MicroserviceConfig, path: string): boolean {
|
||||
return service.backend.allowedPathPrefixes.some((prefix) => path === prefix || path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function readMicroserviceArrayLimits(url: URL): { query: string; jsonArrayLimits: Record<string, JsonValue> } {
|
||||
const params = new URLSearchParams(url.searchParams);
|
||||
const jsonArrayLimits: Record<string, JsonValue> = {};
|
||||
const rawLimits = params.getAll("__unideskArrayLimit");
|
||||
params.delete("__unideskArrayLimit");
|
||||
for (const raw of rawLimits) {
|
||||
for (const entry of raw.split(",")) {
|
||||
const [path = "", limitText = ""] = entry.split(":");
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
|
||||
const limit = Number(limitText);
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 500) jsonArrayLimits[path] = limit;
|
||||
}
|
||||
}
|
||||
const search = params.toString();
|
||||
return { query: search.length > 0 ? `?${search}` : "", jsonArrayLimits };
|
||||
}
|
||||
|
||||
function responseFromMicroserviceResult(task: Awaited<ReturnType<typeof rawTask>>): Response {
|
||||
if (task === null) return jsonResponse({ ok: false, error: "microservice proxy task missing" }, 502);
|
||||
if (task.status !== "succeeded") return jsonResponse({ ok: false, error: "microservice proxy task failed", task }, 502);
|
||||
const result = dockerStatusRecord(task.result);
|
||||
const status = Number(result.status);
|
||||
const contentType = typeof result.contentType === "string" ? result.contentType : "application/json; charset=utf-8";
|
||||
const bodyText = typeof result.bodyText === "string" ? result.bodyText : "";
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
||||
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", task }, 502);
|
||||
}
|
||||
return new Response(bodyText, {
|
||||
status,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
}
|
||||
|
||||
async function microserviceRoute(req: Request, url: URL): Promise<Response> {
|
||||
const rest = url.pathname.slice("/api/microservices/".length);
|
||||
const slashIndex = rest.indexOf("/");
|
||||
const serviceId = decodeURIComponent(slashIndex === -1 ? rest : rest.slice(0, slashIndex));
|
||||
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 proxyPrefix = "proxy";
|
||||
const targetPath = suffix === "health"
|
||||
? service.backend.healthPath
|
||||
: suffix === proxyPrefix
|
||||
? "/"
|
||||
: suffix.startsWith(`${proxyPrefix}/`)
|
||||
? `/${suffix.slice(proxyPrefix.length + 1)}`
|
||||
: "";
|
||||
if (targetPath.length === 0) return jsonResponse({ ok: false, error: "microservice route must be /status, /health, or /proxy/<path>" }, 404);
|
||||
if (!isMicroservicePathAllowed(service, targetPath)) {
|
||||
return jsonResponse({ ok: false, error: "microservice path is not allowed", serviceId, targetPath }, 403);
|
||||
}
|
||||
if (!(await providerSupports(service.providerId, "microservice.http"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409);
|
||||
}
|
||||
const proxyOptions = readMicroserviceArrayLimits(url);
|
||||
const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", {
|
||||
source: "microservice-frontend-proxy",
|
||||
serviceId: service.id,
|
||||
method: req.method,
|
||||
targetBaseUrl: service.backend.nodeBaseUrl,
|
||||
path: targetPath,
|
||||
query: proxyOptions.query,
|
||||
jsonArrayLimits: proxyOptions.jsonArrayLimits,
|
||||
timeoutMs: service.backend.timeoutMs,
|
||||
});
|
||||
if (!providerOnline) return jsonResponse({ ok: false, error: `provider is offline: ${service.providerId}`, taskId }, 503);
|
||||
const task = await waitForTaskTerminal(taskId, service.backend.timeoutMs + 3000);
|
||||
return responseFromMicroserviceResult(task);
|
||||
}
|
||||
|
||||
async function dispatchTask(req: Request): Promise<Response> {
|
||||
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
|
||||
const providerId = typeof body.providerId === "string" ? body.providerId : "";
|
||||
const command = isProviderDispatchCommand(body.command) ? body.command : null;
|
||||
const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record<string, JsonValue>) : {};
|
||||
if (!providerId) {
|
||||
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
||||
}
|
||||
if (command === null) {
|
||||
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, microservice.http, echo" }, 400);
|
||||
}
|
||||
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
|
||||
}
|
||||
if (command === "microservice.http" && !(await providerSupports(providerId, "microservice.http"))) {
|
||||
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${providerId}` }, 409);
|
||||
}
|
||||
const { taskId, providerOnline } = await createAndSendTask(providerId, command, payload);
|
||||
return jsonResponse({ ok: true, taskId, status: providerOnline ? "dispatched" : "queued", providerOnline });
|
||||
}
|
||||
|
||||
function numberFromUnknown(value: unknown, fallback: number, min: number, max: number): number {
|
||||
@@ -889,6 +1162,8 @@ async function route(req: Request, server: Server<WsData>): Promise<Response | u
|
||||
if (url.pathname === "/api/nodes/docker-status") return jsonResponse({ ok: true, dockerStatuses: await getNodeDockerStatuses() });
|
||||
if (url.pathname === "/api/events") return jsonResponse({ ok: true, events: await getEvents(readLimit(url, 100)) });
|
||||
if (url.pathname === "/api/tasks") return jsonResponse({ ok: true, tasks: await getTasks(readLimit(url, 100), url.searchParams.get("status") ?? "all") });
|
||||
if (url.pathname === "/api/microservices") return jsonResponse({ ok: true, microservices: await getMicroservices() });
|
||||
if (url.pathname.startsWith("/api/microservices/")) return microserviceRoute(req, url);
|
||||
if (url.pathname === "/api/dispatch" && req.method === "POST") return dispatchTask(req);
|
||||
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) });
|
||||
if (url.pathname === "/favicon.ico") return textResponse("", 204);
|
||||
|
||||
@@ -401,6 +401,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.status-badge.offline, .status-badge.failed { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); }
|
||||
.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
|
||||
.status-badge.queued, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
|
||||
.status-badge.private, .status-badge.p1, .status-badge.prioritized, .status-badge.verified { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
|
||||
.status-badge.stale, .status-badge.invalid, .status-badge.abandoned { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
|
||||
|
||||
.docker-page {
|
||||
display: grid;
|
||||
@@ -837,6 +839,8 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
||||
.node-list-table { min-width: 1180px; }
|
||||
.task-history-table { min-width: 1080px; }
|
||||
.microservice-table { min-width: 1320px; }
|
||||
.findjob-job-table table { min-width: 1180px; }
|
||||
th, td {
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
@@ -961,6 +965,117 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.result-card dd { margin: 0; }
|
||||
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
|
||||
.microservice-page, .findjob-page, .pipeline-page {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.microservice-actions, .inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.findjob-hero, .pipeline-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.4fr) minmax(260px, 0.9fr) minmax(220px, 0.7fr);
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.microservice-ref-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.microservice-ref-card span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.microservice-ref-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.findjob-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.9fr) minmax(560px, 1.35fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.findjob-grid .panel:nth-child(3) { grid-column: 1 / -1; }
|
||||
.pipeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.9fr) minmax(520px, 1.25fr);
|
||||
gap: 10px;
|
||||
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; }
|
||||
.component-strata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
.component-stratum, .pipeline-run-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
.component-stratum span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.component-stratum strong {
|
||||
color: var(--accent-2);
|
||||
font-size: 18px;
|
||||
}
|
||||
.pipeline-component-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.pipeline-run-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.pipeline-log-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.pipeline-log-list code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: #b9d5d8;
|
||||
}
|
||||
.draft-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.draft-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--panel-3);
|
||||
}
|
||||
|
||||
.endpoint-list article { grid-template-columns: 150px minmax(220px, 1fr) auto; }
|
||||
.policy-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.policy-grid article { grid-template-columns: 1fr; align-items: start; }
|
||||
@@ -1051,7 +1166,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dispatch-form { grid-template-columns: 1fr 1fr; }
|
||||
.dispatch-actions { align-items: center; }
|
||||
.page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero { grid-template-columns: 1fr; }
|
||||
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1; }
|
||||
.gateway-record-grid { grid-template-columns: 1fr; }
|
||||
.overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; }
|
||||
}
|
||||
@@ -1149,6 +1265,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid { grid-template-columns: 1fr; }
|
||||
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route { grid-template-columns: 1fr; align-items: start; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ const MODULES = [
|
||||
{ id: "history", label: "任务历史" },
|
||||
{ id: "results", label: "执行结果" },
|
||||
] },
|
||||
{ id: "apps", label: "微服务", code: "APP", tabs: [
|
||||
{ id: "catalog", label: "服务目录" },
|
||||
{ id: "findjob", label: "FindJob" },
|
||||
{ id: "pipeline", label: "Pipeline" },
|
||||
] },
|
||||
{ id: "config", label: "系统配置", code: "CFG", tabs: [
|
||||
{ id: "topology", label: "连接拓扑" },
|
||||
{ id: "auth", label: "认证策略" },
|
||||
@@ -147,6 +152,14 @@ function summarizeValue(value: any): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function summarizeFieldValue(key: string, value: any): string {
|
||||
if (key === "bodyText" && typeof value === "string") {
|
||||
const kind = /^\s*[{[]/.test(value) ? "JSON" : "HTTP";
|
||||
return `${kind} body ${value.length} chars`;
|
||||
}
|
||||
return summarizeValue(value);
|
||||
}
|
||||
|
||||
function objectEntries(value: any): Array<[string, any]> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
return Object.entries(value);
|
||||
@@ -260,6 +273,75 @@ function latestScheduledUpgradeTask(records: any[]): any | null {
|
||||
return records.find((task) => taskUpgradeMode(task) === "schedule") || records[0] || null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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<any> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
@@ -387,7 +469,7 @@ function DataSummary({ data, empty = "无数据" }: AnyRecord) {
|
||||
const entries = Object.entries(data).slice(0, 5);
|
||||
if (entries.length === 0) return h("span", { className: "muted" }, empty);
|
||||
return h("div", { className: "summary-grid" }, entries.map(([key, value]) =>
|
||||
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeValue(value))),
|
||||
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeFieldValue(key, value))),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1031,6 +1113,291 @@ function DockerSidePanel({ title, items, render, limit }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord) {
|
||||
const privateServices = microservices.filter((service: any) => microserviceBackend(service).public === false);
|
||||
return h("div", { className: "microservice-page", "data-testid": "microservice-catalog-page" },
|
||||
h(Panel, { title: "Microservice 目录", eyebrow: "Provider Mounted Backends" },
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "服务总数", value: microservices.length, hint: "config.json microservices" }),
|
||||
h(MetricCard, { label: "私有后端", value: privateServices.length, hint: "不直接暴露公网", tone: "ok" }),
|
||||
h(MetricCard, { label: "D601 服务", value: microservices.filter((service: any) => service.providerId === "D601").length, hint: "compute-node docker" }),
|
||||
h(MetricCard, { label: "集成前端", value: microservices.filter((service: any) => service.frontend?.integrated).length, hint: "UniDesk React 页面" }),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "服务映射", eyebrow: "Repo Reference + Runtime" },
|
||||
microservices.length === 0 ? h(EmptyState, { title: "暂无 Microservice", text: "在 config.json 的 microservices 中登记 provider、仓库引用和后端映射" }) :
|
||||
h("div", { className: "table-wrap" }, h("table", { className: "microservice-table" },
|
||||
h("thead", null, h("tr", null, h("th", null, "服务"), h("th", null, "Provider"), h("th", null, "代码引用"), h("th", null, "Docker 引用"), h("th", null, "后端映射"), h("th", null, "开发入口"), h("th", null, "运行态"), h("th", null, "操作"))),
|
||||
h("tbody", null, microservices.map((service: any) => {
|
||||
const runtime = microserviceRuntime(service);
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
return h("tr", { key: service.id, "data-testid": `microservice-row-${safeId(service.id)}` },
|
||||
h("td", null, h("strong", null, service.name), h("code", null, service.id)),
|
||||
h("td", null, h("strong", null, runtime.providerName || service.providerId), h("code", null, service.providerId)),
|
||||
h("td", null, h("span", null, repository.url || "--"), h("code", null, repository.commitId || "--")),
|
||||
h("td", null, h("span", null, repository.composeFile || "--"), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)),
|
||||
h("td", null,
|
||||
h(StatusBadge, { status: backend.public ? "warn" : "online" }, backend.public ? "public" : "private"),
|
||||
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"} -> ${backend.proxyMode || "--"}`),
|
||||
),
|
||||
h("td", null, h("span", null, service.development?.sshPassthrough ? "SSH 透传" : "未配置"), h("code", null, service.development?.worktreePath || "--")),
|
||||
h("td", null, h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"), h(DataSummary, { data: runtime.container, empty: "容器快照未上报" })),
|
||||
h("td", null,
|
||||
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,
|
||||
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
|
||||
),
|
||||
),
|
||||
);
|
||||
})),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 || "");
|
||||
@@ -1085,6 +1452,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
|
||||
h("label", null, "Command", h("select", { value: command, onChange: (event: any) => setCommand(event.target.value) },
|
||||
h("option", { value: "docker.ps" }, "docker.ps"),
|
||||
h("option", { value: "host.ssh" }, "host.ssh"),
|
||||
h("option", { value: "microservice.http" }, "microservice.http"),
|
||||
h("option", { value: "echo" }, "echo"),
|
||||
)),
|
||||
h("label", null, "来源", h("input", { value: source, onChange: (event: any) => setSource(event.target.value) })),
|
||||
@@ -1275,6 +1643,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
if (activeModule === "tasks" && activeTab === "pending") return h(TaskPendingPage, { tasks: data.pendingTasks, onRaw });
|
||||
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 === "config" && activeTab === "topology") return h(TopologyPage, { data });
|
||||
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
|
||||
if (activeModule === "config" && activeTab === "security") return h(SecurityPage);
|
||||
@@ -1283,8 +1654,8 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
|
||||
|
||||
function Shell({ session, onLogout }: AnyRecord) {
|
||||
const [activeModule, setActiveModule] = useState("ops");
|
||||
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" });
|
||||
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], events: [], tasks: [], pendingTasks: [], logs: [] });
|
||||
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", apps: "catalog", config: "topology" });
|
||||
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], microservices: [], events: [], tasks: [], pendingTasks: [], logs: [] });
|
||||
const [connection, setConnection] = useState({ ok: false, text: "连接中" });
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
const [clock, setClock] = useState(new Date());
|
||||
@@ -1295,11 +1666,12 @@ function Shell({ session, onLogout }: AnyRecord) {
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, pendingTasks, logs] = await Promise.all([
|
||||
const [overview, nodes, systemStatuses, dockerStatuses, microservices, events, tasks, pendingTasks, logs] = await Promise.all([
|
||||
requestJson(`${cfg.apiBaseUrl}/overview`),
|
||||
requestJson(`${cfg.apiBaseUrl}/nodes`),
|
||||
requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`),
|
||||
requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`),
|
||||
requestJson(`${cfg.apiBaseUrl}/microservices`),
|
||||
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
|
||||
requestJson(`${cfg.apiBaseUrl}/tasks?limit=300`),
|
||||
requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`),
|
||||
@@ -1310,6 +1682,7 @@ function Shell({ session, onLogout }: AnyRecord) {
|
||||
nodes: nodes.nodes || [],
|
||||
systemStatuses: systemStatuses.systemStatuses || [],
|
||||
dockerStatuses: dockerStatuses.dockerStatuses || [],
|
||||
microservices: microservices.microservices || [],
|
||||
events: events.events || [],
|
||||
tasks: tasks.tasks || [],
|
||||
pendingTasks: pendingTasks.tasks || [],
|
||||
|
||||
@@ -193,7 +193,7 @@ function sendJson(value: unknown): void {
|
||||
}
|
||||
|
||||
function sendRegister(): void {
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"];
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "echo"];
|
||||
if (isHostSshConfigured()) capabilities.push("host.ssh");
|
||||
sendJson({
|
||||
type: "register",
|
||||
@@ -609,6 +609,45 @@ function defaultHostSshProbeCommand(): string {
|
||||
return "printf 'UNIDESK_SSH_TEST user=%s host=%s bridge=%s cwd=%s\\n' \"$(whoami)\" \"$(hostname)\" \"${UNIDESK_BRIDGE:-}\" \"$(pwd)\"";
|
||||
}
|
||||
|
||||
function normalizeShellCommand(command: string): string {
|
||||
return command.replace(/\\\r?\n/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hostSshSelfMutationReason(command: string, cwd: string | null): string | null {
|
||||
const normalized = normalizeShellCommand(command);
|
||||
const currentContainerName = `unidesk-provider-gateway-${safeDockerName(config.providerId)}`.toLowerCase();
|
||||
const composeProject = config.upgradeComposeProject.toLowerCase();
|
||||
const composeService = config.upgradeService.toLowerCase();
|
||||
const composeFile = config.upgradeComposeFile.toLowerCase();
|
||||
const composeEnvFile = config.upgradeEnvFile.toLowerCase();
|
||||
const hostProjectRoot = config.upgradeHostProjectRoot.toLowerCase();
|
||||
const currentCwd = (cwd ?? "").toLowerCase();
|
||||
const mutatesCompose = /\bdocker\s+compose\b|\bdocker-compose\b/.test(normalized)
|
||||
&& /\b(up|build|restart|stop|rm|down|kill)\b/.test(normalized);
|
||||
const mentionsCurrentProvider = normalized.includes(currentContainerName)
|
||||
|| normalized.includes(composeProject)
|
||||
|| normalized.includes(composeService)
|
||||
|| normalized.includes(composeFile)
|
||||
|| normalized.includes(composeEnvFile)
|
||||
|| normalized.includes(hostProjectRoot)
|
||||
|| (currentCwd.length > 0 && currentCwd.startsWith(hostProjectRoot));
|
||||
if (mutatesCompose && mentionsCurrentProvider) {
|
||||
return "docker compose mutation targets the current provider-gateway deployment";
|
||||
}
|
||||
const mutatesContainer = /\bdocker\s+(container\s+)?(rm|stop|restart|kill)\b/.test(normalized);
|
||||
if (mutatesContainer && normalized.includes(currentContainerName)) {
|
||||
return "docker container mutation targets the current provider-gateway container";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertHostSshCommandAllowed(command: string, cwd: string | null): void {
|
||||
const reason = hostSshSelfMutationReason(command, cwd);
|
||||
if (reason !== null) {
|
||||
throw new Error(`blocked unsafe host.ssh self-mutation: ${reason}; use provider.upgrade mode=schedule or a detached local node shell instead`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||
if (!isHostSshConfigured()) {
|
||||
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
|
||||
@@ -629,6 +668,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
|
||||
throw new Error(`host SSH command is too long: ${command.length} bytes`);
|
||||
}
|
||||
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
|
||||
if (mode === "exec") assertHostSshCommandAllowed(command, cwd);
|
||||
const scriptParts = [
|
||||
"set -e",
|
||||
cwd === null ? null : `cd ${shellQuote(cwd)}`,
|
||||
@@ -682,6 +722,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
|
||||
function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: number, rows?: number): string {
|
||||
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
|
||||
const requestedCwd = cwd ?? fallbackCwd;
|
||||
if (command !== null && command.length > 0) assertHostSshCommandAllowed(command, requestedCwd);
|
||||
const loginShell = config.hostLoginShell ?? "/bin/bash";
|
||||
const resize = Number.isFinite(cols) && Number.isFinite(rows)
|
||||
? `stty rows ${Math.max(8, Math.min(120, Math.floor(rows ?? 30)))} cols ${Math.max(20, Math.min(300, Math.floor(cols ?? 100)))} 2>/dev/null || true`
|
||||
@@ -846,7 +887,7 @@ function safeDockerName(value: string): string {
|
||||
|
||||
function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||
const workspace = config.upgradeWorkspacePath;
|
||||
const composeCommand = [
|
||||
const composeBaseCommand = [
|
||||
"docker",
|
||||
"compose",
|
||||
"--env-file",
|
||||
@@ -855,14 +896,39 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||
`${workspace}/${config.upgradeComposeFile}`,
|
||||
"-p",
|
||||
config.upgradeComposeProject,
|
||||
];
|
||||
const composeBuildCommand = [
|
||||
...composeBaseCommand,
|
||||
"build",
|
||||
config.upgradeService,
|
||||
];
|
||||
const listServiceContainersCommand = [
|
||||
"docker",
|
||||
"ps",
|
||||
"-aq",
|
||||
"--filter",
|
||||
`label=com.docker.compose.project=${config.upgradeComposeProject}`,
|
||||
"--filter",
|
||||
`label=com.docker.compose.service=${config.upgradeService}`,
|
||||
];
|
||||
const composeUpCommand = [
|
||||
...composeBaseCommand,
|
||||
"up",
|
||||
"-d",
|
||||
"--no-deps",
|
||||
"--build",
|
||||
"--force-recreate",
|
||||
config.upgradeService,
|
||||
];
|
||||
const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`;
|
||||
const script = `set -eu; sleep 2; cd ${shellQuote(workspace)}; ${composeCommand.map(shellQuote).join(" ")}`;
|
||||
const script = [
|
||||
"set -eu",
|
||||
"sleep 2",
|
||||
`cd ${shellQuote(workspace)}`,
|
||||
composeBuildCommand.map(shellQuote).join(" "),
|
||||
`ids=$(${listServiceContainersCommand.map(shellQuote).join(" ")})`,
|
||||
`if [ -n "$ids" ]; then docker rm -f $ids; fi`,
|
||||
composeUpCommand.map(shellQuote).join(" "),
|
||||
].join("; ");
|
||||
const dockerRunCommand = [
|
||||
"docker",
|
||||
"run",
|
||||
@@ -894,7 +960,19 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||
composeFile: config.upgradeComposeFile,
|
||||
envFile: config.upgradeEnvFile,
|
||||
},
|
||||
composeCommand,
|
||||
composeCommand: composeUpCommand,
|
||||
composeBuildCommand,
|
||||
listServiceContainersCommand,
|
||||
composeUpCommand,
|
||||
replacementStrategy: {
|
||||
buildBeforeRemove: true,
|
||||
removeScope: {
|
||||
projectLabel: config.upgradeComposeProject,
|
||||
serviceLabel: config.upgradeService,
|
||||
},
|
||||
noDeps: true,
|
||||
namedVolumesPreserved: true,
|
||||
},
|
||||
dockerRunCommand,
|
||||
};
|
||||
}
|
||||
@@ -917,6 +995,118 @@ async function runProviderUpgrade(taskId: string, payload: Record<string, JsonVa
|
||||
};
|
||||
}
|
||||
|
||||
function payloadNumber(payload: Record<string, JsonValue>, key: string, fallback: number): number {
|
||||
const raw = payload[key];
|
||||
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function payloadJsonArrayLimits(payload: Record<string, JsonValue>): Record<string, number> {
|
||||
const raw = payload.jsonArrayLimits;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
||||
const limits: Record<string, number> = {};
|
||||
for (const [path, value] of Object.entries(raw)) {
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
|
||||
const limit = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 500) limits[path] = limit;
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
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"]);
|
||||
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
function arrayAtPath(value: unknown, path: string): JsonValue[] | null {
|
||||
let current: unknown = value;
|
||||
for (const part of path.split(".")) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return Array.isArray(current) ? current as JsonValue[] : null;
|
||||
}
|
||||
|
||||
function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Record<string, number>): { bodyText: string; transform: JsonValue } {
|
||||
const entries = Object.entries(limits);
|
||||
if (entries.length === 0) return { bodyText, transform: { applied: false } };
|
||||
if (!contentType.toLowerCase().includes("json")) {
|
||||
return { bodyText, transform: { applied: false, reason: "content-type is not json" } };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return { bodyText, transform: { applied: false, reason: "json root is not an object" } };
|
||||
}
|
||||
const root = parsed as Record<string, unknown>;
|
||||
const applied: Record<string, JsonValue> = {};
|
||||
for (const [path, limit] of entries) {
|
||||
const array = arrayAtPath(root, path);
|
||||
if (array === null) continue;
|
||||
const originalLength = array.length;
|
||||
if (array.length > limit) array.splice(limit);
|
||||
applied[path] = { limit, originalLength, returnedLength: array.length };
|
||||
}
|
||||
root._unidesk = { arrayLimits: applied };
|
||||
return { bodyText: JSON.stringify(parsed), transform: { applied: Object.keys(applied).length > 0, arrayLimits: applied } };
|
||||
} catch (error) {
|
||||
return { bodyText, transform: { applied: false, error: error instanceof Error ? error.message : String(error) } };
|
||||
}
|
||||
}
|
||||
|
||||
async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||
const method = payload.method === "HEAD" ? "HEAD" : "GET";
|
||||
const targetBaseUrl = payloadString(payload, "targetBaseUrl");
|
||||
if (targetBaseUrl === null) throw new Error("microservice.http requires targetBaseUrl");
|
||||
const path = payloadString(payload, "path") ?? "/";
|
||||
const query = payloadString(payload, "query") ?? "";
|
||||
if (!path.startsWith("/")) throw new Error("microservice.http path must start with /");
|
||||
if (query.length > 0 && !query.startsWith("?")) throw new Error("microservice.http query must start with ?");
|
||||
const baseUrl = assertAllowedMicroserviceBase(targetBaseUrl);
|
||||
const url = new URL(path, baseUrl);
|
||||
url.search = query;
|
||||
const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000)));
|
||||
const jsonArrayLimits = payloadJsonArrayLimits(payload);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { method, 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);
|
||||
return {
|
||||
ok: true,
|
||||
serviceId: payloadString(payload, "serviceId") ?? "unknown",
|
||||
method,
|
||||
url: url.toString(),
|
||||
status: response.status,
|
||||
upstreamOk: response.ok,
|
||||
contentType,
|
||||
bodyText: truncateText(transformed.bodyText, 1024 * 1024),
|
||||
upstreamBodyBytes: rawBodyText.length,
|
||||
returnedBodyBytes: Math.min(transformed.bodyText.length, 1024 * 1024),
|
||||
truncated: transformed.bodyText.length > 1024 * 1024,
|
||||
transform: transformed.transform,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
serviceId: payloadString(payload, "serviceId") ?? "unknown",
|
||||
method,
|
||||
url: url.toString(),
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
|
||||
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
|
||||
@@ -941,6 +1131,15 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
await sendTaskStatus(message.taskId, "succeeded", "host SSH command completed", result);
|
||||
return;
|
||||
}
|
||||
if (message.command === "microservice.http") {
|
||||
const result = await runMicroserviceHttp(message.payload);
|
||||
if ((result as { ok?: unknown }).ok !== true) {
|
||||
await sendTaskStatus(message.taskId, "failed", "microservice HTTP proxy failed", result);
|
||||
return;
|
||||
}
|
||||
await sendTaskStatus(message.taskId, "succeeded", "microservice HTTP proxy completed", result);
|
||||
return;
|
||||
}
|
||||
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface ProviderTaskStatusMessage {
|
||||
result?: JsonValue;
|
||||
}
|
||||
|
||||
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "echo";
|
||||
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "microservice.http" | "echo";
|
||||
|
||||
export interface CoreDispatchMessage {
|
||||
type: "dispatch";
|
||||
@@ -271,7 +271,7 @@ export function parseJsonObject(value: string, name: string): Record<string, Jso
|
||||
}
|
||||
|
||||
export function isProviderDispatchCommand(value: unknown): value is ProviderDispatchCommand {
|
||||
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "echo";
|
||||
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "microservice.http" || value === "echo";
|
||||
}
|
||||
|
||||
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {
|
||||
|
||||
Reference in New Issue
Block a user