diff --git a/AGENTS.md b/AGENTS.md index 164632aa..913ffa43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,15 +12,15 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.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`:通过 Docker 内网 core、真实 HTTP 和 WebSocket 流程调试健康检查与任务下发,调试规则见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 +- `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标和 Docker 状态流程调试健康检查与任务下发,调试规则见 `docs/reference/cli.md`。 +- `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入、资源指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 ## Runtime - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 - `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端采用 React 组件、高信息密度、左侧主模块和顶部子模块标签的工业化控制台设计,界面规则见 `docs/reference/frontend.md`。 -- `src/components/provider-gateway`:当前主 server 也作为 provider gateway 接入 UniDesk,节点接入规则和公网 provider ingress 见 `docs/reference/provider-gateway.md`。 +- `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含任务管理器风格资源监控与 Docker Desktop 风格状态页,界面规则见 `docs/reference/frontend.md`。 +- `src/components/provider-gateway`:当前主 server 也作为 provider gateway 接入 UniDesk,并周期性上报系统资源指标和 Docker daemon 状态,支持 `provider.upgrade` 预检/调度,节点接入规则和公网 provider ingress 见 `docs/reference/provider-gateway.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。 ## Architecture Docs diff --git a/TEST.md b/TEST.md index 540ce9ba..7f76690f 100644 --- a/TEST.md +++ b/TEST.md @@ -10,7 +10,7 @@ ## T3 主 server 自接入 Provider Gateway -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server status` 和 `bun scripts/cli.ts debug health`,确认公网只监听 frontend 与 provider ingress,backend-core 和 database 显示为 Docker 内部端口,`/api/nodes` 中存在 `main-server` provider,状态为 `online`,且 provider 标签中能看到 Docker socket 可用性。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server status` 和 `bun scripts/cli.ts debug health`,确认公网只监听 frontend 与 provider ingress,backend-core 和 database 显示为 Docker 内部端口,`/api/nodes` 中存在 `main-server` provider,状态为 `online`,`/api/nodes/system-status` 中存在 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中存在 `main-server` 的 Docker 快照,且 provider 标签中能看到 Docker socket 可用性。 ## T4 前端控制台连通 @@ -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-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:no-naked-json-before-click`、`frontend:raw-json-explicit-button` 全部 passed;打开输出的 screenshotPath,确认页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 +阅读 `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:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible` 全部 passed;打开输出的 screenshotPath,确认页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -43,3 +43,19 @@ ## T10 前端 JSON 控件化展示 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `frontend:no-naked-json-before-click` passed;再用浏览器登录 frontend,确认节点标签、事件 payload、任务 payload/result 都渲染为徽标、字段摘要、表格或卡片,页面初始状态没有裸 JSON,只有点击 `查看原始JSON` 后才出现原始 JSON 弹窗或高级编辑区。 + +## T11 资源节点 Docker 状态 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:docker-status` 和 `frontend:docker-status-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `Docker 状态` 子标签,确认可以像 Docker Desktop 一样看到当前节点的 Containers、Images、Volumes、Networks 指标、容器表格、镜像/卷/网络侧栏和 Docker daemon 摘要。 + +## T12 前端 TypeScript + React 源码约束 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `find src/components/frontend -type f \\( -name '*.js' -o -name '*.jsx' \\) -print`,确认没有手写 frontend JS/JSX 源码;运行 `bun scripts/cli.ts check`,确认 `src/components/frontend/src/app.tsx` 纳入 TypeScript 检查,且浏览器请求 `/app.js` 由 frontend Bun server 从 TSX 转译生成。 + +## T13 资源节点任务管理器曲线 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:system-status` 和 `frontend:system-monitor-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `资源监控` 子标签,确认可以像 Windows 任务管理器一样看到 CPU、Memory、Disk 当前用量和历史曲线,并能执行 `Provider Gateway 升级` 的 `预检升级`。 + +## 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` 成功返回升级计划;正式执行升级只能通过前端 `资源监控` 的 `执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道。 diff --git a/config.json b/config.json index 71be610d..caff420c 100644 --- a/config.json +++ b/config.json @@ -45,7 +45,20 @@ }, "heartbeatIntervalMs": 15000, "reconnectBaseMs": 1000, - "reconnectMaxMs": 30000 + "reconnectMaxMs": 30000, + "metrics": { + "diskPath": "/" + }, + "upgrade": { + "enabled": true, + "hostProjectRoot": "/root/unidesk", + "workspacePath": "/workspace", + "composeFile": "docker-compose.yml", + "composeEnvFile": ".state/docker-compose.env", + "composeProject": "unidesk", + "service": "provider-gateway", + "runnerImage": "unidesk_provider-gateway" + } }, "docker": { "composeFile": "docker-compose.yml", diff --git a/docker-compose.yml b/docker-compose.yml index 5be04a10..8f3b6cbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,7 @@ services: retries: 20 provider-gateway: + image: unidesk_provider-gateway build: context: . dockerfile: src/components/provider-gateway/Dockerfile @@ -109,6 +110,15 @@ services: RECONNECT_BASE_MS: "${UNIDESK_RECONNECT_BASE_MS}" RECONNECT_MAX_MS: "${UNIDESK_RECONNECT_MAX_MS}" DOCKER_SOCKET_PATH: "/var/run/docker.sock" + MONITOR_DISK_PATH: "${UNIDESK_MONITOR_DISK_PATH}" + PROVIDER_UPGRADE_ENABLED: "${UNIDESK_PROVIDER_UPGRADE_ENABLED}" + PROVIDER_UPGRADE_HOST_PROJECT_ROOT: "${UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT}" + PROVIDER_UPGRADE_WORKSPACE_PATH: "${UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH}" + PROVIDER_UPGRADE_COMPOSE_FILE: "${UNIDESK_PROVIDER_UPGRADE_COMPOSE_FILE}" + PROVIDER_UPGRADE_ENV_FILE: "${UNIDESK_PROVIDER_UPGRADE_ENV_FILE}" + PROVIDER_UPGRADE_COMPOSE_PROJECT: "${UNIDESK_PROVIDER_UPGRADE_COMPOSE_PROJECT}" + PROVIDER_UPGRADE_SERVICE: "${UNIDESK_PROVIDER_UPGRADE_SERVICE}" + PROVIDER_UPGRADE_RUNNER_IMAGE: "${UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE}" LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_provider-gateway.jsonl" HOST_SSH_HOST: "${UNIDESK_HOST_SSH_HOST}" HOST_SSH_PORT: "${UNIDESK_HOST_SSH_PORT}" @@ -118,6 +128,7 @@ services: HOST_LOGIN_SHELL: "/bin/bash" volumes: - /var/run/docker.sock:/var/run/docker.sock + - ${UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT}:${UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH}:ro - ${UNIDESK_LOG_DIR}:/var/log/unidesk extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 73a4d12a..00b6e2e7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -12,8 +12,8 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `server status` 查询公开端口、内部端口、Compose 容器、core/frontend/provider/database 健康检查和访问 URL。 - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。 - `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。 -- `debug health` 与 `debug dispatch` 走真实内部 core、WebSocket、数据库和 provider 流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 -- `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection 和 Playwright 前端页面,是交付前的自动化 E2E 门禁。 +- `debug health` 与 `debug dispatch` 走真实内部 core、WebSocket、数据库、provider、系统指标和 Docker 状态流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 +- `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection、系统指标曲线、Docker 状态快照、provider.upgrade 预检和 Playwright 前端页面,是交付前的自动化 E2E 门禁。 ## Async Job State @@ -25,4 +25,4 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 ## Debug Contract -`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。 +`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status` 和 `/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将 `docker.ps`、`provider.upgrade` 或 `echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。 diff --git a/docs/reference/config.md b/docs/reference/config.md index 6fdf9bef..7ead8c3a 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -14,6 +14,10 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 `auth.username` 和 `auth.password` 是 frontend 登录凭据,默认值分别为 `admin` 和 `Liang6516.`。`auth.sessionSecret` 用于签名 frontend HttpOnly Cookie,`auth.sessionTtlSeconds` 控制登录会话有效期;修改后必须重新启动 Docker 栈以刷新派生环境变量。 +## Provider Gateway Metrics And Upgrade + +`providerGateway.metrics.diskPath` 指定资源监控页的硬盘采样路径,默认是 `/`。`providerGateway.upgrade` 定义远程升级 provider-gateway 所需的 Compose project、service、仓库挂载路径、派生 env 文件和 updater runner 镜像;这些字段由 CLI 写入 `.state/docker-compose.env`,provider-gateway 只通过 WebSocket 接受 `provider.upgrade` 调度,不从隐藏环境或默认值静默补齐。 + ## Compose Env Generation Docker Compose 本身不读取 JSON,因此 CLI 会从 `config.json` 生成 `.state/docker-compose.env`。该文件是派生状态,不应手写;如需改端口、token、provider 标签、登录凭据或主机名,应修改 `config.json` 后重新运行 CLI。CLI 会在保留当前日志前缀的同时刷新新增配置键,避免旧 env 文件遗漏字段。 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index c66fe088..1b741905 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -6,8 +6,8 @@ - `database` 使用 `postgres:16-alpine`,数据保存到 named volume `unidesk_pgdata_10gb`,初始化 SQL 位于 `src/components/database/init/`。 - `backend-core` 是无状态核心服务,提供 Docker 内网 REST API、provider ingress WebSocket、任务调度入口和数据库访问层。 -- `frontend` 是唯一公开 Web 控制台,提供登录、React 静态资产和到 backend-core 的同源代理。 -- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,并挂载 `/var/run/docker.sock` 作为自动任务执行主路径。 +- `frontend` 是唯一公开 Web 控制台,提供登录、从 TSX 转译出的 React 应用资产和到 backend-core 的同源代理。 +- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,并周期性上报系统资源指标与 Docker daemon 状态。 ## Public Exposure Boundary @@ -19,7 +19,7 @@ Docker Compose 只能向公网暴露两个接口:frontend host port 和 provid ## Health Criteria -服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 +服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`,`/api/nodes/system-status` 中出现 `main-server` 的 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中出现 `main-server` 的 Docker 快照。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 ## Database Volume diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index fd822171..ec925909 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -14,9 +14,10 @@ UniDesk delivery is not complete until the public frontend, public provider ingr - Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core and public database 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`; public provider ingress `/health` must return ok. +- Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`; 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. - 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 opens the public frontend URL, logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, confirms no raw JSON is visible before clicking `查看原始JSON`, then clicks the explicit raw JSON button and verifies the raw payload appears. +- Frontend: Playwright opens the public frontend URL, logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, confirms no raw JSON is visible before clicking `查看原始JSON`, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, then opens `Docker 状态` and verifies the Docker Desktop-style container view. ## Frontend JSON Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index d7c1b3a9..140a13d3 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -2,10 +2,26 @@ UniDesk 前端是 React 组件化工业控制台,不追求展示型大屏效果。设计目标是高信息密度、低装饰、低字号、低间距,并让调度、节点、事件和配置入口在单屏内快速切换。 +## Source Contract + +frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components/frontend` 中维护手写 `.js` / `.jsx` 应用源码。浏览器请求的 `/app.js` 只能由 frontend Bun server 从 `src/components/frontend/src/app.tsx` 转译生成;`public/` 目录只保存 HTML/CSS 等静态资产,不提交手写 `app.js`。 + ## Layout 左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。 +## Resource Node Monitor View + +资源节点模块必须提供 `资源监控` 子标签,用类似 Windows 任务管理器的性能页展示每个 provider 节点的 CPU、内存和硬盘用量历史曲线。该页面应包含节点切换、当前用量摘要、CPU/Memory/Disk 三条曲线、采样说明和 `Provider Gateway 升级` 控制区;曲线数据来自 backend-core 的 `/api/nodes/system-status`,不得在页面默认展示原始 JSON。 + +## Resource Node Docker View + +资源节点模块必须提供 `Docker 状态` 子标签,用类似 Docker Desktop 的结构展示每个 provider 节点的 Docker daemon 状态。该页面应包含节点切换、daemon 摘要、Containers/Images/Volumes/Networks 指标、容器表格、镜像/卷/网络侧栏,并通过状态徽标区分 running、paused、exited 等状态。 + +## Provider Gateway Upgrade Control + +`资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id 和摘要,完整升级计划必须通过 `查看原始JSON` 显式查看。 + ## Component Data Rendering 前端必须把 backend-core 返回的 JSON 渲染为合适的控件:状态徽标、指标卡、表格列、标签 chip、字段摘要、任务结果卡、日志行和表单控件。默认页面禁止暴露裸 JSON、`pre` JSON 或整段 `JSON.stringify` 文本;只有用户明确点击 `查看原始JSON` 按钮后,才允许在弹窗或高级编辑区展示原始 JSON。 diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 50ef0c35..8b448c42 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -14,6 +14,18 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 自动任务执行只允许走本地 Docker socket。Compose 将 `/var/run/docker.sock` 挂入 provider-gateway,provider 标签会报告 `dockerSocketPresent`,`docker.ps` 调试任务会通过该 socket 查询宿主 Docker 容器。 +## Docker Status Telemetry + +provider-gateway 连接成功后必须周期性上报 Docker daemon 状态,数据来源是本地 Docker socket 上的 `docker info`、`docker ps -a`、`docker images`、`docker volume ls` 和 `docker network ls`。backend-core 将最新快照保存到 `unidesk_node_docker_status`,frontend 的资源节点 `Docker 状态` 子标签用该快照渲染 Docker Desktop 风格视图;该能力仍然只通过 provider 主动上报,不要求主 server 反向连接计算节点。 + +## System Status Telemetry + +provider-gateway 连接成功后必须周期性上报节点 CPU、内存和硬盘用量。采集来源是节点本地 `/proc/stat`、`/proc/loadavg`、`/proc/meminfo` 与 `df -PB1`,backend-core 将最新快照保存到 `unidesk_node_system_status`,并将历史采样保存到 `unidesk_node_metric_samples` 供 frontend 绘制任务管理器风格曲线。该链路仍然由 provider 主动上报,主 server 不反向探测计算节点。 + +## 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 维护桥作为自动调度通道。 + ## Host SSH Maintenance Bridge 宿主 SSH 转发只作为应急维护辅助路径,不用于自动任务调度。实现参考 `../web-terminal` 的经验:容器内使用只读挂载的私钥,通过 `ssh -tt` 主动连接宿主 sshd,并设置 `StrictHostKeyChecking=accept-new`、`ServerAliveInterval` 和 `ServerAliveCountMax`。本仓库保留 `src/components/provider-gateway/scripts/host-ssh-shell.sh` 作为维护桥接脚本,默认 Compose 不挂载私钥,避免把 SSH 路径误用为调度通道。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 5a61beb5..389cc913 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -46,18 +46,19 @@ - package.json - tsconfig.json - Dockerfile - - src/index.ts (Internal REST API, public provider ingress WebSocket, scheduler, database access) + - src/index.ts (Internal REST API, public provider ingress WebSocket, scheduler, database access, system/Docker status storage API) - frontend/ (Frontend web application container) - package.json - tsconfig.json - Dockerfile - src/index.ts (Bun static server, login/session handling, and same-origin internal API proxy) - - public/ (React HTML/CSS/JS assets for the compact industrial console) + - src/app.tsx (TypeScript + React browser app source; `/app.js` is generated by Bun at runtime) + - public/ (HTML/CSS static assets for the compact industrial console; no handwritten app JS) - provider-gateway/ (Compute node Provider Gateway container) - package.json - tsconfig.json - Dockerfile - - src/index.ts (WebSocket client, heartbeat, Docker adapter) + - src/index.ts (WebSocket client, heartbeat, system/Docker telemetry, Docker adapter, provider.upgrade handler) - scripts/host-ssh-shell.sh (Optional maintenance-only SSH bridge) - database/ (PostgreSQL initialization and configuration) - config/postgresql.conf diff --git a/scripts/cli.ts b/scripts/cli.ts index 05434250..369df5f4 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -23,8 +23,8 @@ function help(): unknown { { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, { command: "job list", description: "List async jobs from .state/jobs." }, { command: "job status [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." }, - { command: "debug health", description: "Probe internal core, overview, nodes, frontend, provider ingress, and public boundary." }, - { command: "debug dispatch [providerId] [docker.ps|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." }, + { 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|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." }, { command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." }, ], }; @@ -110,7 +110,7 @@ async function main(): Promise { } if (sub === "dispatch") { const providerId = third ?? config.providerGateway.id; - const dispatchCommand = fourth === "docker.ps" || fourth === "echo" ? fourth : "docker.ps"; + const dispatchCommand = fourth === "docker.ps" || fourth === "provider.upgrade" || fourth === "echo" ? fourth : "docker.ps"; emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand)); return; } diff --git a/scripts/src/config.ts b/scripts/src/config.ts index 5501f31d..45651911 100644 --- a/scripts/src/config.ts +++ b/scripts/src/config.ts @@ -23,6 +23,17 @@ export interface UniDeskConfig { heartbeatIntervalMs: number; reconnectBaseMs: number; reconnectMaxMs: number; + metrics: { diskPath: string }; + upgrade: { + enabled: boolean; + hostProjectRoot: string; + workspacePath: string; + composeFile: string; + composeEnvFile: string; + composeProject: string; + service: string; + runnerImage: string; + }; }; docker: { composeFile: string; projectName: string }; paths: { stateDir: string; logsDir: string; docsReferenceDir: string }; @@ -55,6 +66,12 @@ function numberField(obj: Record, key: string, path: string): n return value; } +function booleanField(obj: Record, 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 portPair(obj: Record, key: string): { port: number; containerPort: number } { const value = asRecord(obj[key], `network.${key}`); return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) }; @@ -75,6 +92,8 @@ export function readConfig(): UniDeskConfig { const paths = asRecord(parsed.paths, "paths"); const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding"); const labels = asRecord(providerGateway.labels, "providerGateway.labels"); + const providerMetrics = asRecord(providerGateway.metrics, "providerGateway.metrics"); + const providerUpgrade = asRecord(providerGateway.upgrade, "providerGateway.upgrade"); const typescript = stringField(runtime, "typescript", "runtime"); if (typescript !== "bun") throw new Error("runtime.typescript must be bun"); return { @@ -103,6 +122,19 @@ export function readConfig(): UniDeskConfig { heartbeatIntervalMs: numberField(providerGateway, "heartbeatIntervalMs", "providerGateway"), reconnectBaseMs: numberField(providerGateway, "reconnectBaseMs", "providerGateway"), reconnectMaxMs: numberField(providerGateway, "reconnectMaxMs", "providerGateway"), + metrics: { + diskPath: stringField(providerMetrics, "diskPath", "providerGateway.metrics"), + }, + upgrade: { + enabled: booleanField(providerUpgrade, "enabled", "providerGateway.upgrade"), + hostProjectRoot: stringField(providerUpgrade, "hostProjectRoot", "providerGateway.upgrade"), + workspacePath: stringField(providerUpgrade, "workspacePath", "providerGateway.upgrade"), + composeFile: stringField(providerUpgrade, "composeFile", "providerGateway.upgrade"), + composeEnvFile: stringField(providerUpgrade, "composeEnvFile", "providerGateway.upgrade"), + composeProject: stringField(providerUpgrade, "composeProject", "providerGateway.upgrade"), + service: stringField(providerUpgrade, "service", "providerGateway.upgrade"), + runnerImage: stringField(providerUpgrade, "runnerImage", "providerGateway.upgrade"), + }, }, docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") }, paths: { diff --git a/scripts/src/debug.ts b/scripts/src/debug.ts index ea358a66..faad2916 100644 --- a/scripts/src/debug.ts +++ b/scripts/src/debug.ts @@ -38,11 +38,93 @@ function coreInternalFetch(path: string, init?: { method?: string; body?: unknow } } +function coreDockerStatusSummary(): unknown { + const code = ` + const res = await fetch('http://127.0.0.1:8080/api/nodes/docker-status'); + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { body = { text }; } + const dockerStatuses = (body?.dockerStatuses || []).map((item) => { + const status = item.dockerStatus || {}; + return { + providerId: item.providerId, + name: item.name, + nodeStatus: item.nodeStatus, + updatedAt: item.updatedAt, + dockerStatus: { + ok: status.ok, + socketPresent: status.socketPresent, + collectedAt: status.collectedAt, + counts: status.counts, + daemon: status.daemon, + containersPreview: (status.containers || []).slice(0, 8).map((container) => ({ + id: container.id, + name: container.name, + image: container.image, + state: container.state, + status: container.status, + ports: container.ports, + })), + }, + }; + }); + console.log(JSON.stringify({ ok: res.ok, status: res.status, body: { ok: body?.ok === true, dockerStatuses } })); + `; + 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 coreSystemStatusSummary(): unknown { + const code = ` + const res = await fetch('http://127.0.0.1:8080/api/nodes/system-status?limit=24'); + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { body = { text }; } + const systemStatuses = (body?.systemStatuses || []).map((item) => { + const current = item.current || {}; + return { + providerId: item.providerId, + name: item.name, + nodeStatus: item.nodeStatus, + updatedAt: item.updatedAt, + current: item.current ? { + ok: current.ok, + collectedAt: current.collectedAt, + cpu: current.cpu, + memory: current.memory, + disk: current.disk, + } : null, + historyPreview: (item.history || []).slice(-8), + historyCount: (item.history || []).length, + }; + }); + console.log(JSON.stringify({ ok: res.ok, status: res.status, body: { ok: body?.ok === true, systemStatuses } })); + `; + 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) }; + } +} + export async function debugHealth(config: UniDeskConfig): Promise { return { coreInternal: await coreInternalFetch("/health"), overviewInternal: await coreInternalFetch("/api/overview"), nodesInternal: await coreInternalFetch("/api/nodes"), + systemStatusInternal: coreSystemStatusSummary(), + dockerStatusInternal: coreDockerStatusSummary(), frontendPublic: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`), providerIngressPublic: await readJson(`http://127.0.0.1:${config.network.providerIngress.port}/health`), publicExposureBoundary: { @@ -52,9 +134,9 @@ export async function debugHealth(config: UniDeskConfig): Promise { }; } -export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise { +export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "provider.upgrade" | "echo"): Promise { return coreInternalFetch("/api/dispatch", { method: "POST", - body: { providerId, command, payload: { source: "cli-debug" } }, + body: { providerId, command, payload: command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" } }, }); } diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index cbe54be9..0a85b9dd 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -83,6 +83,15 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000", UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs), UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs), + UNIDESK_MONITOR_DISK_PATH: config.providerGateway.metrics.diskPath, + UNIDESK_PROVIDER_UPGRADE_ENABLED: String(config.providerGateway.upgrade.enabled), + UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT: config.providerGateway.upgrade.hostProjectRoot, + UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH: config.providerGateway.upgrade.workspacePath, + UNIDESK_PROVIDER_UPGRADE_COMPOSE_FILE: config.providerGateway.upgrade.composeFile, + UNIDESK_PROVIDER_UPGRADE_ENV_FILE: config.providerGateway.upgrade.composeEnvFile, + UNIDESK_PROVIDER_UPGRADE_COMPOSE_PROJECT: config.providerGateway.upgrade.composeProject, + UNIDESK_PROVIDER_UPGRADE_SERVICE: config.providerGateway.upgrade.service, + UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE: config.providerGateway.upgrade.runnerImage, UNIDESK_LOG_DIR: logDir, UNIDESK_LOG_PREFIX: logPrefix, UNIDESK_HOST_SSH_HOST: config.sshForwarding.host, diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index ba350bad..19fac4be 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -71,9 +71,13 @@ function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: str return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode }; } -function dockerCoreJson(path: string): unknown { +function dockerCoreJson(path: string, init?: { method?: string; body?: unknown }): unknown { const code = ` - const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}); + 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 }; } @@ -88,6 +92,20 @@ function dockerCoreJson(path: string): unknown { } } +async function waitForTaskStatus(taskId: string, expected: string, timeoutMs = 10_000): Promise { + const started = Date.now(); + let latest: unknown = null; + while (Date.now() - started < timeoutMs) { + latest = dockerCoreJson("/api/tasks?limit=40"); + const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? []; + const task = tasks.find((item) => item.id === taskId); + if (task?.status === expected) return { ok: true, task }; + if (task?.status === "failed") return { ok: false, task }; + await Bun.sleep(500); + } + return { ok: false, timeoutMs, latest }; +} + function dockerPortSummary(): unknown { const result = runCommand([ "docker", @@ -108,6 +126,54 @@ function dockerPortSummary(): unknown { }; } +function dockerStatusCheckDetail(dockerStatus: unknown, providerId: string): unknown { + const response = dockerStatus as { ok?: boolean; status?: number; body?: { dockerStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; dockerStatus?: { ok?: boolean; socketPresent?: boolean; collectedAt?: string; counts?: unknown; daemon?: unknown; containers?: Array<{ id?: string; name?: string; image?: string; state?: string; status?: string; ports?: string }> } }> } }; + const item = response.body?.dockerStatuses?.find((entry) => entry.providerId === providerId); + return { + ok: response.ok, + status: response.status, + providerId, + nodeStatus: item?.nodeStatus, + updatedAt: item?.updatedAt, + dockerStatus: item?.dockerStatus === undefined ? null : { + ok: item.dockerStatus.ok, + socketPresent: item.dockerStatus.socketPresent, + collectedAt: item.dockerStatus.collectedAt, + counts: item.dockerStatus.counts, + daemon: item.dockerStatus.daemon, + containersPreview: (item.dockerStatus.containers ?? []).slice(0, 8).map((container) => ({ + id: container.id, + name: container.name, + image: container.image, + state: container.state, + status: container.status, + ports: container.ports, + })), + }, + }; +} + +function systemStatusCheckDetail(systemStatus: unknown, providerId: string): unknown { + const response = systemStatus as { ok?: boolean; status?: number; body?: { systemStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; current?: { ok?: boolean; collectedAt?: string; cpu?: unknown; memory?: unknown; disk?: unknown }; history?: unknown[] }> } }; + const item = response.body?.systemStatuses?.find((entry) => entry.providerId === providerId); + return { + ok: response.ok, + status: response.status, + providerId, + nodeStatus: item?.nodeStatus, + updatedAt: item?.updatedAt, + current: item?.current === undefined ? null : { + ok: item.current.ok, + collectedAt: item.current.collectedAt, + cpu: item.current.cpu, + memory: item.current.memory, + disk: item.current.disk, + }, + historyCount: item?.history?.length ?? 0, + historyPreview: (item?.history ?? []).slice(-8), + }; +} + async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> }; const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n"); @@ -137,11 +203,27 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { const coreOverview = dockerCoreJson("/api/overview"); const coreNodes = dockerCoreJson("/api/nodes"); + const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24"); + const dockerStatus = dockerCoreJson("/api/nodes/docker-status"); 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 }> } }).body?.nodes ?? []; + const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number }; disk?: { percent?: number } }; history?: unknown[] }> } }).body?.systemStatuses ?? []; + const mainSystem = systemStatuses.find((item) => item.providerId === config.providerGateway.id); + const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? []; + const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id); addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); + 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) && 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 upgradeDispatch = dockerCoreJson("/api/dispatch", { + method: "POST", + body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } }, + }); + const upgradeTaskId = (upgradeDispatch as { body?: { taskId?: string } }).body?.taskId ?? ""; + const upgradeTask = upgradeTaskId ? await waitForTaskStatus(upgradeTaskId, "succeeded") : { ok: false, error: "missing taskId", upgradeDispatch }; + const taskResult = (upgradeTask as { task?: { result?: { plan?: unknown; mode?: string } }; ok?: boolean }).task?.result; + addCheck(checks, "provider:upgrade-plan", (upgradeDispatch as { ok?: boolean }).ok === true && (upgradeTask as { ok?: boolean }).ok === true && taskResult?.mode === "plan" && taskResult.plan !== undefined, { upgradeDispatch, upgradeTask }); addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress); } @@ -192,9 +274,35 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click(); await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 }); const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: "关闭" }).click(); + await page.getByRole("button", { name: /资源节点/ }).click(); + await page.getByRole("button", { name: /资源监控/ }).click(); + await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText.toLowerCase(); + return text.includes("任务管理器视图") && text.includes("cpu") && text.includes("memory") && text.includes("disk"); + }, undefined, { timeout: 10000 }); + const monitorText = await page.locator('[data-testid="node-monitor-page"]').innerText({ timeout: 5000 }); + await page.getByTestId("upgrade-plan-button").click(); + await page.waitForFunction(() => document.body.innerText.includes("预检升级 已下发"), undefined, { timeout: 10000 }); + const upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /Docker 状态/ }).click(); + await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 }); + await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText.toLowerCase(); + return text.includes("docker desktop 视图") && text.includes("containers"); + }, undefined, { timeout: 10000 }); + const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); + addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk"), { monitorTextPreview: monitorText.slice(0, 800) }); + addCheck(checks, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发"), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) }); + addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) }); addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index eb2ca8d2..503b2927 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -5,6 +5,8 @@ import postgres from "postgres"; import { type ApiEvent, type ApiNode, + type ApiNodeDockerStatus, + type ApiNodeSystemStatus, type ApiTask, type CoreDispatchMessage, type JsonValue, @@ -146,6 +148,36 @@ async function initDatabase(client: SqlClient): Promise { updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `; + await client` + CREATE TABLE IF NOT EXISTS unidesk_node_docker_status ( + provider_id TEXT PRIMARY KEY, + status JSONB NOT NULL DEFAULT '{}'::jsonb, + collected_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await client` + CREATE TABLE IF NOT EXISTS unidesk_node_system_status ( + provider_id TEXT PRIMARY KEY, + status JSONB NOT NULL DEFAULT '{}'::jsonb, + collected_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await client` + CREATE TABLE IF NOT EXISTS unidesk_node_metric_samples ( + id BIGSERIAL PRIMARY KEY, + provider_id TEXT NOT NULL, + collected_at TIMESTAMPTZ NOT NULL, + cpu_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + sample JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await client`CREATE INDEX IF NOT EXISTS idx_unidesk_node_system_status_updated_at ON unidesk_node_system_status(updated_at DESC)`; + await client`CREATE INDEX IF NOT EXISTS idx_unidesk_node_metric_samples_provider_time ON unidesk_node_metric_samples(provider_id, collected_at DESC)`; dbReady = true; logger("info", "database_init_complete"); } @@ -242,6 +274,57 @@ async function touchHeartbeat(providerId: string, labels: ProviderLabels): Promi `; } +async function upsertDockerStatus(providerId: string, status: JsonValue, collectedAt: string): Promise { + await sql` + INSERT INTO unidesk_node_docker_status (provider_id, status, collected_at, updated_at) + VALUES (${providerId}, ${sql.json(status)}, ${collectedAt}, now()) + ON CONFLICT (provider_id) DO UPDATE SET + status = EXCLUDED.status, + collected_at = EXCLUDED.collected_at, + updated_at = now() + `; +} + +function recordValue(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return (value as Record)[key]; +} + +function nestedNumber(value: unknown, objectKey: string, numberKey: string): number { + const parsed = Number(recordValue(recordValue(value, objectKey), numberKey)); + return Number.isFinite(parsed) ? parsed : 0; +} + +async function upsertSystemStatus(providerId: string, status: JsonValue, collectedAt: string): Promise { + const cpuPercent = nestedNumber(status, "cpu", "percent"); + const memoryPercent = nestedNumber(status, "memory", "percent"); + const diskPercent = nestedNumber(status, "disk", "percent"); + await sql.begin(async (tx) => { + await tx` + INSERT INTO unidesk_node_system_status (provider_id, status, collected_at, updated_at) + VALUES (${providerId}, ${tx.json(status)}, ${collectedAt}, now()) + ON CONFLICT (provider_id) DO UPDATE SET + status = EXCLUDED.status, + collected_at = EXCLUDED.collected_at, + updated_at = now() + `; + await tx` + INSERT INTO unidesk_node_metric_samples (provider_id, collected_at, cpu_percent, memory_percent, disk_percent, sample) + VALUES (${providerId}, ${collectedAt}, ${cpuPercent}, ${memoryPercent}, ${diskPercent}, ${tx.json(status)}) + `; + await tx` + DELETE FROM unidesk_node_metric_samples + WHERE provider_id = ${providerId} + AND id NOT IN ( + SELECT id FROM unidesk_node_metric_samples + WHERE provider_id = ${providerId} + ORDER BY collected_at DESC + LIMIT 720 + ) + `; + }); +} + async function markProviderOffline(providerId: string): Promise { activeProviders.delete(providerId); if (!dbReady) return; @@ -302,9 +385,42 @@ async function handleProviderMessage(ws: ProviderSocket, raw: string | Buffer): return; } + if (message.type === "system_status") { + await upsertSystemStatus(message.providerId, message.status as unknown as JsonValue, message.status.collectedAt); + logger("debug", "provider_system_status", { + providerId: message.providerId, + cpuPercent: message.status.cpu.percent, + memoryPercent: message.status.memory.percent, + diskPercent: message.status.disk.percent, + ok: message.status.ok, + }); + return; + } + + if (message.type === "docker_status") { + await upsertDockerStatus(message.providerId, message.status as unknown as JsonValue, message.status.collectedAt); + logger("debug", "provider_docker_status", { providerId: message.providerId, counts: message.status.counts, ok: message.status.ok }); + return; + } + await sql` + WITH incoming AS ( + SELECT ${message.status}::text AS status, ${sql.json(message.result ?? { message: message.message })}::jsonb AS result + ) UPDATE unidesk_tasks - SET status = ${message.status}, result = ${sql.json(message.result ?? { message: message.message })}, updated_at = now() + SET + status = CASE + WHEN unidesk_tasks.status IN ('succeeded', 'failed') AND incoming.status NOT IN ('succeeded', 'failed') THEN unidesk_tasks.status + WHEN unidesk_tasks.status = 'running' AND incoming.status = 'accepted' THEN unidesk_tasks.status + ELSE incoming.status + END, + result = CASE + WHEN unidesk_tasks.status IN ('succeeded', 'failed') AND incoming.status NOT IN ('succeeded', 'failed') THEN unidesk_tasks.result + WHEN unidesk_tasks.status = 'running' AND incoming.status = 'accepted' THEN unidesk_tasks.result + ELSE incoming.result + END, + updated_at = now() + FROM incoming WHERE id = ${message.taskId} `; await recordEvent("task_status", message.providerId, { @@ -332,6 +448,74 @@ async function getNodes(): Promise { })); } +async function getNodeDockerStatuses(): Promise { + const rows = await sql>>` + SELECT n.provider_id, n.name, n.status AS node_status, d.status AS docker_status, d.updated_at + FROM unidesk_nodes n + LEFT JOIN unidesk_node_docker_status d ON d.provider_id = n.provider_id + ORDER BY n.status DESC, n.provider_id ASC + `; + return rows.map((row) => ({ + providerId: String(row.provider_id), + name: String(row.name), + nodeStatus: row.node_status === "online" ? "online" : "offline", + dockerStatus: row.docker_status === null || row.docker_status === undefined ? null : (row.docker_status as JsonValue), + updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at === null || row.updated_at === undefined ? null : String(row.updated_at), + })); +} + +function metricPointFromSample(sample: unknown, collectedAt: string): JsonValue { + return { + at: collectedAt, + cpuPercent: nestedNumber(sample, "cpu", "percent"), + memoryPercent: nestedNumber(sample, "memory", "percent"), + diskPercent: nestedNumber(sample, "disk", "percent"), + memoryUsedBytes: nestedNumber(sample, "memory", "usedBytes"), + memoryTotalBytes: nestedNumber(sample, "memory", "totalBytes"), + diskUsedBytes: nestedNumber(sample, "disk", "usedBytes"), + diskTotalBytes: nestedNumber(sample, "disk", "totalBytes"), + load1: nestedNumber(sample, "cpu", "load1"), + }; +} + +async function getNodeSystemStatuses(limit: number): Promise { + const currentRows = await sql>>` + SELECT n.provider_id, n.name, n.status AS node_status, s.status AS system_status, s.updated_at + FROM unidesk_nodes n + LEFT JOIN unidesk_node_system_status s ON s.provider_id = n.provider_id + ORDER BY n.status DESC, n.provider_id ASC + `; + const sampleRows = await sql>>` + SELECT provider_id, collected_at, sample + FROM ( + SELECT provider_id, collected_at, sample, + row_number() OVER (PARTITION BY provider_id ORDER BY collected_at DESC) AS rn + FROM unidesk_node_metric_samples + ) ranked + WHERE rn <= ${limit} + ORDER BY provider_id ASC, collected_at ASC + `; + const historyByProvider = new Map(); + for (const row of sampleRows) { + const providerId = String(row.provider_id); + const collectedAt = row.collected_at instanceof Date ? row.collected_at.toISOString() : String(row.collected_at); + const history = historyByProvider.get(providerId) ?? []; + history.push(metricPointFromSample(row.sample ?? {}, collectedAt)); + historyByProvider.set(providerId, history); + } + return currentRows.map((row) => { + const providerId = String(row.provider_id); + return { + providerId, + name: String(row.name), + nodeStatus: row.node_status === "online" ? "online" : "offline", + current: row.system_status === null || row.system_status === undefined ? null : (row.system_status as JsonValue), + history: historyByProvider.get(providerId) ?? [], + updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at === null || row.updated_at === undefined ? null : String(row.updated_at), + }; + }); +} + async function getEvents(limit: number): Promise { const rows = await sql>>` SELECT id, type, source, payload, created_at @@ -370,6 +554,8 @@ async function getTasks(limit: number): Promise { async function getOverview(): Promise { const nodes = await getNodes(); const tasks = await getTasks(50); + const dockerStatuses = await getNodeDockerStatuses(); + const systemStatuses = await getNodeSystemStatuses(1); const online = nodes.filter((node) => node.status === "online").length; const pendingTasks = tasks.filter((task) => task.status === "queued" || task.status === "dispatched" || task.status === "running").length; return { @@ -379,6 +565,8 @@ async function getOverview(): Promise { uptimeSeconds: Math.floor((Date.now() - serviceStartedAt.getTime()) / 1000), nodeCount: nodes.length, onlineNodeCount: online, + dockerStatusNodeCount: dockerStatuses.filter((item) => item.dockerStatus !== null).length, + systemStatusNodeCount: systemStatuses.filter((item) => item.current !== null).length, pendingTaskCount: pendingTasks, activeSocketCount: activeProviders.size, heartbeatTimeoutMs: config.heartbeatTimeoutMs, @@ -388,7 +576,7 @@ async function getOverview(): Promise { async function dispatchTask(req: Request): Promise { const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown }; const providerId = typeof body.providerId === "string" ? body.providerId : ""; - const command = body.command === "docker.ps" || body.command === "echo" ? body.command : "echo"; + const command = body.command === "docker.ps" || body.command === "provider.upgrade" || body.command === "echo" ? body.command : "echo"; const payload = typeof body.payload === "object" && body.payload !== null ? (body.payload as Record) : {}; if (!providerId) { return jsonResponse({ ok: false, error: "providerId is required" }, 400); @@ -405,7 +593,11 @@ async function dispatchTask(req: Request): Promise { } const dispatch: CoreDispatchMessage = { type: "dispatch", taskId, command, payload }; socket.send(JSON.stringify(dispatch)); - await sql`UPDATE unidesk_tasks SET status = 'dispatched', updated_at = now() WHERE id = ${taskId}`; + await sql` + UPDATE unidesk_tasks + SET status = 'dispatched', updated_at = now() + WHERE id = ${taskId} AND status = 'queued' + `; await recordEvent("task_dispatched", providerId, { taskId, providerId, command }); return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true }); } @@ -423,6 +615,8 @@ async function route(req: Request): Promise { } if (url.pathname === "/api/overview") return jsonResponse(await getOverview()); if (url.pathname === "/api/nodes") return jsonResponse({ ok: true, nodes: await getNodes() }); + if (url.pathname === "/api/nodes/system-status") return jsonResponse({ ok: true, systemStatuses: await getNodeSystemStatuses(readLimit(url, 120)) }); + 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)) }); if (url.pathname === "/api/dispatch" && req.method === "POST") return dispatchTask(req); diff --git a/src/components/database/init/001_unidesk_init.sql b/src/components/database/init/001_unidesk_init.sql index fb32ff36..3f86c2f0 100644 --- a/src/components/database/init/001_unidesk_init.sql +++ b/src/components/database/init/001_unidesk_init.sql @@ -27,6 +27,34 @@ CREATE TABLE IF NOT EXISTS unidesk_tasks ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE TABLE IF NOT EXISTS unidesk_node_docker_status ( + provider_id TEXT PRIMARY KEY, + status JSONB NOT NULL DEFAULT '{}'::jsonb, + collected_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS unidesk_node_system_status ( + provider_id TEXT PRIMARY KEY, + status JSONB NOT NULL DEFAULT '{}'::jsonb, + collected_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS unidesk_node_metric_samples ( + id BIGSERIAL PRIMARY KEY, + provider_id TEXT NOT NULL, + collected_at TIMESTAMPTZ NOT NULL, + cpu_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + disk_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + sample JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + CREATE INDEX IF NOT EXISTS idx_unidesk_nodes_status ON unidesk_nodes(status); CREATE INDEX IF NOT EXISTS idx_unidesk_events_created_at ON unidesk_events(created_at DESC); CREATE INDEX IF NOT EXISTS idx_unidesk_tasks_provider_status ON unidesk_tasks(provider_id, status); +CREATE INDEX IF NOT EXISTS idx_unidesk_node_docker_status_updated_at ON unidesk_node_docker_status(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_unidesk_node_system_status_updated_at ON unidesk_node_system_status(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_unidesk_node_metric_samples_provider_time ON unidesk_node_metric_samples(provider_id, collected_at DESC); diff --git a/src/components/frontend/public/index.html b/src/components/frontend/public/index.html index d4fe91fc..07e17308 100644 --- a/src/components/frontend/public/index.html +++ b/src/components/frontend/public/index.html @@ -5,10 +5,9 @@ UniDesk Control Plane - -
+
diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 0ac6e654..2f803794 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -302,6 +302,279 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } .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); } +.docker-page { + display: grid; + gap: 10px; +} + +.docker-node-strip { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 8px; +} + +.docker-node-tile { + display: grid; + grid-template-columns: auto 1fr; + gap: 3px 8px; + align-items: center; + padding: 8px; + border: 1px solid var(--line); + color: var(--muted); + background: rgba(12, 18, 24, 0.62); + text-align: left; +} +.docker-node-tile.active, .docker-node-tile:hover { + color: var(--text); + border-color: var(--accent-2); + background: rgba(78, 183, 168, 0.1); +} +.docker-node-tile code, .docker-node-tile span:last-child { + grid-column: 2; + color: var(--muted); +} + +.docker-layout { + display: grid; + grid-template-columns: minmax(620px, 1.65fr) minmax(280px, 0.75fr); + gap: 10px; + align-items: start; +} + +.docker-hero { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px; + margin-bottom: 8px; + border: 1px solid var(--line-soft); + background: + linear-gradient(90deg, rgba(78, 183, 168, 0.12), transparent 42%), + var(--panel-3); +} +.docker-hero h3 { + margin: 0; + font-size: 20px; + letter-spacing: 0.04em; +} +.docker-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 7px; + color: var(--muted); +} +.docker-meta span { + padding: 2px 6px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.03); +} + +.docker-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 8px; +} + +.docker-section-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + height: 34px; + color: var(--muted); +} +.docker-section-head h3 { + margin: 0; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text); +} + +.docker-container-table { + max-height: calc(100vh - 348px); +} + +.docker-side-stack { + display: grid; + gap: 10px; +} +.docker-side-panel .panel-body { + padding: 8px; +} +.docker-side-list { + display: grid; + gap: 6px; + max-height: 230px; + overflow: auto; +} +.docker-side-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 8px; + padding: 7px; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.docker-side-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.docker-side-row span { + color: var(--muted); +} +.docker-side-row code { + grid-column: 1 / -1; + color: #bcd2d7; + overflow: hidden; + text-overflow: ellipsis; +} + +.monitor-page { + display: grid; + gap: 10px; +} + +.monitor-layout { + display: grid; + grid-template-columns: minmax(680px, 1.6fr) minmax(310px, 0.75fr); + gap: 10px; + align-items: start; +} + +.monitor-hero { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px; + margin-bottom: 8px; + border: 1px solid var(--line-soft); + background: + linear-gradient(90deg, rgba(215, 161, 58, 0.12), transparent 40%), + linear-gradient(180deg, rgba(78, 183, 168, 0.08), transparent), + var(--panel-3); +} +.monitor-hero h3 { + margin: 0; + font-size: 20px; + letter-spacing: 0.04em; +} + +.monitor-chart-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.metric-chart { + min-width: 0; + padding: 8px; + border: 1px solid var(--line-soft); + background: #0d161d; +} +.metric-chart-head, .metric-chart-foot { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.metric-chart-head span { + display: block; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; +} +.metric-chart-head strong { + font-size: 24px; + letter-spacing: 0.03em; +} +.metric-chart-head code { + color: var(--faint); +} +.metric-chart svg { + width: 100%; + height: 116px; + margin: 8px 0; + border: 1px solid var(--line-soft); + background: + linear-gradient(180deg, transparent 48%, rgba(255,255,255,0.05) 49%, transparent 50%), + repeating-linear-gradient(90deg, rgba(255,255,255,0.04) 0, rgba(255,255,255,0.04) 1px, transparent 1px, transparent 12px), + #091015; +} +.metric-chart polygon { + fill: rgba(78, 183, 168, 0.16); +} +.metric-chart polyline { + fill: none; + stroke: var(--accent-2); + stroke-width: 1.8; + vector-effect: non-scaling-stroke; +} +.metric-chart line { + stroke: rgba(255,255,255,0.12); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} +.metric-chart.cpu polyline { stroke: var(--accent); } +.metric-chart.cpu polygon { fill: rgba(215, 161, 58, 0.16); } +.metric-chart.disk polyline { stroke: #9db7ff; } +.metric-chart.disk polygon { fill: rgba(157, 183, 255, 0.15); } +.metric-chart-foot { + color: var(--muted); + font-size: 11px; +} + +.monitor-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; +} + +.monitor-side-stack { + display: grid; + gap: 10px; +} +.monitor-note-list, .upgrade-control { + display: grid; + gap: 8px; +} +.monitor-note-list article { + display: grid; + gap: 3px; + padding: 7px; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.monitor-note-list span, .upgrade-control p { + margin: 0; + color: var(--muted); +} + +.upgrade-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.upgrade-result { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 8px; + align-items: center; + padding: 7px; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.upgrade-result code { + grid-column: 1 / -1; + color: #bcd2d7; +} + .chip-row, .summary-grid { display: flex; flex-wrap: wrap; @@ -492,10 +765,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } } @media (max-width: 1120px) { - .metric-grid, .policy-grid, .security-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .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 { grid-template-columns: 1fr; } + .page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; } .overview-grid .panel:nth-child(3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; } } @@ -517,7 +790,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .workspace { padding: 10px; } .topbar { align-items: flex-start; flex-direction: column; } .status-strip { flex-wrap: wrap; white-space: normal; } - .metric-grid, .policy-grid, .security-board, .dispatch-form { grid-template-columns: 1fr; } + .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: 1fr; } .compact-row, .heartbeat-row, .log-row, .endpoint-list article { grid-template-columns: 1fr; align-items: start; } + .docker-hero, .monitor-hero { flex-direction: column; } .tab { min-width: 104px; } } diff --git a/src/components/frontend/public/app.js b/src/components/frontend/src/app.tsx similarity index 53% rename from src/components/frontend/public/app.js rename to src/components/frontend/src/app.tsx index 40b23282..992a81e7 100644 --- a/src/components/frontend/public/app.js +++ b/src/components/frontend/src/app.tsx @@ -1,7 +1,35 @@ -const cfg = window.UNIDESK_CONFIG || { apiBaseUrl: "/api", authUsername: "admin" }; +export {}; + +declare const React: { + createElement: (...args: any[]) => any; + useEffect: (...args: any[]) => any; + useMemo: (...args: any[]) => any; + useState: (...args: any[]) => any; +}; +declare const ReactDOM: { createRoot: (element: Element | null) => { render: (node: any) => void } }; + +type AnyRecord = Record; +type ReactNode = any; + +function readClientConfig(): AnyRecord { + const raw = document.getElementById("root")?.getAttribute("data-config"); + if (!raw) return { apiBaseUrl: "/api", authUsername: "admin" }; + try { + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : {}; + } catch { + return { apiBaseUrl: "/api", authUsername: "admin" }; + } +} + +const cfg: AnyRecord = readClientConfig(); const h = React.createElement; const { useEffect, useMemo, useState } = React; +function errorMessage(error: unknown, fallback = "操作失败"): string { + return error instanceof Error ? error.message : String(error || fallback); +} + const MODULES = [ { id: "ops", label: "运行总览", code: "OPS", tabs: [ { id: "status", label: "态势总览" }, @@ -10,6 +38,8 @@ const MODULES = [ ] }, { id: "nodes", label: "资源节点", code: "NODE", tabs: [ { id: "list", label: "节点清单" }, + { id: "monitor", label: "资源监控" }, + { id: "docker", label: "Docker 状态" }, { id: "labels", label: "资源标签" }, { id: "heartbeats", label: "心跳状态" }, ] }, @@ -25,25 +55,48 @@ const MODULES = [ ] }, ]; -function fmtDate(value) { +function fmtDate(value: any): string { if (!value) return "--"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "--"; return date.toLocaleString("zh-CN", { hour12: false }); } -function fmtClock(value) { +function fmtClock(value: Date): string { return value.toLocaleTimeString("zh-CN", { hour12: false }); } -function fmtDuration(seconds) { +function fmtDuration(seconds: number): string { if (!Number.isFinite(seconds)) return "--"; if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } -function summarizeValue(value) { +function fmtBytes(value: any): string { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return "--"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let current = bytes; + let index = 0; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function fmtPercent(value: any): string { + const number = Number(value); + return Number.isFinite(number) ? `${Math.max(0, Math.min(100, number)).toFixed(1)}%` : "--"; +} + +function asNumber(value: any, fallback = 0): number { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function summarizeValue(value: any): string { if (value === null || value === undefined) return "--"; if (typeof value === "boolean") return value ? "是" : "否"; if (typeof value === "number") return String(value); @@ -53,16 +106,16 @@ function summarizeValue(value) { return String(value); } -function objectEntries(value) { +function objectEntries(value: any): Array<[string, any]> { if (!value || typeof value !== "object" || Array.isArray(value)) return []; return Object.entries(value); } -function safeId(value) { +function safeId(value: any): string { return String(value).replace(/[^a-zA-Z0-9_-]/g, "_"); } -async function requestJson(path, options = {}) { +async function requestJson(path: string, options: AnyRecord = {}): Promise { const headers = new Headers(options.headers || {}); if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); const response = await fetch(path, { credentials: "same-origin", ...options, headers }); @@ -76,18 +129,18 @@ async function requestJson(path, options = {}) { if (!response.ok || body?.ok === false) { const message = body?.error?.message || body?.error || `HTTP ${response.status}`; const error = new Error(message); - error.status = response.status; + (error as Error & { status?: number }).status = response.status; throw error; } return body; } -function StatusBadge({ status, children }) { +function StatusBadge({ status, children }: AnyRecord) { const normalized = String(status || "unknown").toLowerCase(); return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); } -function MetricCard({ label, value, hint, tone }) { +function MetricCard({ label, value, hint, tone }: AnyRecord) { return h("article", { className: `metric-card ${tone || ""}` }, h("div", { className: "metric-label" }, label), h("div", { className: "metric-value" }, value), @@ -95,7 +148,7 @@ function MetricCard({ label, value, hint, tone }) { ); } -function Panel({ title, eyebrow, actions, children, className }) { +function Panel({ title, eyebrow, actions, children, className }: AnyRecord) { return h("section", { className: `panel ${className || ""}` }, h("div", { className: "panel-head" }, h("div", null, @@ -108,7 +161,7 @@ function Panel({ title, eyebrow, actions, children, className }) { ); } -function RawButton({ title, data, onOpen, testId }) { +function RawButton({ title, data, onOpen, testId }: AnyRecord) { return h("button", { type: "button", className: "ghost-btn", @@ -117,7 +170,7 @@ function RawButton({ title, data, onOpen, testId }) { }, "查看原始JSON"); } -function RawDialog({ raw, onClose }) { +function RawDialog({ raw, onClose }: AnyRecord) { if (!raw) return null; return h("div", { className: "modal-backdrop", role: "presentation" }, h("section", { className: "raw-dialog", role: "dialog", "aria-modal": "true", "aria-label": raw.title }, @@ -130,7 +183,7 @@ function RawDialog({ raw, onClose }) { ); } -function LabelChips({ labels, limit = 8 }) { +function LabelChips({ labels, limit = 8 }: AnyRecord) { const entries = objectEntries(labels).slice(0, limit); if (entries.length === 0) return h("span", { className: "muted" }, "无标签"); return h("div", { className: "chip-row" }, @@ -141,7 +194,7 @@ function LabelChips({ labels, limit = 8 }) { ); } -function DataSummary({ data, empty = "无数据" }) { +function DataSummary({ data, empty = "无数据" }: AnyRecord) { if (data === null || data === undefined) return h("span", { className: "muted" }, empty); if (typeof data !== "object") return h("span", { className: "summary-value" }, summarizeValue(data)); if (Array.isArray(data)) return h("span", { className: "summary-value" }, `${data.length} 项列表`); @@ -152,17 +205,17 @@ function DataSummary({ data, empty = "无数据" }) { )); } -function EmptyState({ title, text }) { +function EmptyState({ title, text }: AnyRecord) { return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text)); } -function LoginScreen({ onLogin }) { +function LoginScreen({ onLogin }: AnyRecord) { const [username, setUsername] = useState(cfg.authUsername || "admin"); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); - async function submit(event) { + async function submit(event: any) { event.preventDefault(); setBusy(true); setError(""); @@ -170,7 +223,7 @@ function LoginScreen({ onLogin }) { const session = await requestJson("/login", { method: "POST", body: JSON.stringify({ username, password }) }); onLogin(session); } catch (err) { - setError(err.message || "登录失败"); + setError(errorMessage(err, "登录失败")); } finally { setBusy(false); } @@ -180,8 +233,8 @@ function LoginScreen({ onLogin }) { h("section", { className: "login-card" }, h("div", { className: "login-brand" }, h("span", { className: "brand-mark" }, "UD"), h("div", null, h("h1", null, "UniDesk"), h("p", null, "Control Plane Login"))), h("form", { className: "login-form", onSubmit: submit }, - h("label", null, "账号", h("input", { name: "username", autoComplete: "username", value: username, onChange: (event) => setUsername(event.target.value) })), - h("label", null, "密码", h("input", { name: "password", type: "password", autoComplete: "current-password", value: password, onChange: (event) => setPassword(event.target.value) })), + h("label", null, "账号", h("input", { name: "username", autoComplete: "username", value: username, onChange: (event: any) => setUsername(event.target.value) })), + h("label", null, "密码", h("input", { name: "password", type: "password", autoComplete: "current-password", value: password, onChange: (event: any) => setPassword(event.target.value) })), error ? h("div", { className: "form-error" }, error) : null, h("button", { type: "submit", disabled: busy }, busy ? "登录中" : "登录"), ), @@ -190,7 +243,7 @@ function LoginScreen({ onLogin }) { ); } -function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }) { +function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }: AnyRecord) { return h("header", { className: "topbar" }, h("div", null, h("p", { className: "eyebrow" }, "Distributed Work Platform"), h("h1", null, "UniDesk 控制平面")), h("div", { className: "status-strip" }, @@ -205,10 +258,10 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock } ); } -function Sidebar({ activeModule, onChange }) { +function Sidebar({ activeModule, onChange }: AnyRecord) { return h("aside", { className: "rail", "aria-label": "主模块" }, h("div", { className: "brand" }, h("span", { className: "brand-mark" }, "UD"), h("span", { className: "brand-text" }, "UniDesk")), - MODULES.map((module) => h("button", { + MODULES.map((module: any) => h("button", { key: module.id, type: "button", className: `module ${activeModule === module.id ? "active" : ""}`, @@ -217,9 +270,9 @@ function Sidebar({ activeModule, onChange }) { ); } -function TabBar({ module, activeTab, onChange }) { +function TabBar({ module, activeTab, onChange }: AnyRecord) { return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` }, - module.tabs.map((tab) => h("button", { + module.tabs.map((tab: any) => h("button", { key: tab.id, type: "button", className: `tab ${activeTab === tab.id ? "active" : ""}`, @@ -228,9 +281,9 @@ function TabBar({ module, activeTab, onChange }) { ); } -function OverviewPage({ data, onRaw }) { +function OverviewPage({ data, onRaw }: AnyRecord) { const overview = data.overview || {}; - const onlineNodes = data.nodes.filter((node) => node.status === "online"); + const onlineNodes = data.nodes.filter((node: any) => node.status === "online"); const recentTasks = data.tasks.slice(0, 5); return h("div", { className: "page-grid overview-grid" }, h(Panel, { title: "核心指标", eyebrow: "Control" }, @@ -243,16 +296,16 @@ function OverviewPage({ data, onRaw }) { ), h(Panel, { title: "本机 Provider", eyebrow: "Self Connected" }, onlineNodes.length === 0 ? h(EmptyState, { title: "暂无在线节点", text: "provider-gateway 未完成自接入" }) : - h("div", { className: "node-card-list" }, onlineNodes.slice(0, 4).map((node) => h(NodeCard, { key: node.providerId, node, onRaw }))), + h("div", { className: "node-card-list" }, onlineNodes.slice(0, 4).map((node: any) => h(NodeCard, { key: node.providerId, node, onRaw }))), ), h(Panel, { title: "最近任务", eyebrow: "Dispatch" }, recentTasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "可以在任务调度模块发起 docker.ps 或 echo" }) : - h("div", { className: "compact-list" }, recentTasks.map((task) => h(TaskCompactRow, { key: task.id, task, onRaw }))), + h("div", { className: "compact-list" }, recentTasks.map((task: any) => h(TaskCompactRow, { key: task.id, task, onRaw }))), ), ); } -function NodeCard({ node, onRaw }) { +function NodeCard({ node, onRaw }: AnyRecord) { return h("article", { className: "node-card" }, h("div", { className: "node-card-head" }, h("div", null, h("strong", null, node.name), h("code", null, node.providerId)), @@ -266,12 +319,12 @@ function NodeCard({ node, onRaw }) { ); } -function EventsPage({ events, onRaw }) { +function EventsPage({ events, onRaw }: AnyRecord) { return h(Panel, { title: "事件摘要", eyebrow: "Latest 100" }, events.length === 0 ? h(EmptyState, { title: "暂无事件", text: "Provider 注册、心跳超时和任务状态会写入事件流" }) : h("div", { className: "table-wrap" }, h("table", null, h("thead", null, h("tr", null, h("th", null, "ID"), h("th", null, "类型"), h("th", null, "来源"), h("th", null, "摘要"), h("th", null, "时间"), h("th", null, "操作"))), - h("tbody", null, events.map((event) => h("tr", { key: event.id }, + h("tbody", null, events.map((event: any) => h("tr", { key: event.id }, h("td", null, h("code", null, event.id)), h("td", null, h(StatusBadge, { status: event.type }, event.type)), h("td", null, h("code", null, event.source)), @@ -283,10 +336,10 @@ function EventsPage({ events, onRaw }) { ); } -function LogsPage({ logs, onRaw }) { +function LogsPage({ logs, onRaw }: AnyRecord) { return h(Panel, { title: "服务日志", eyebrow: "Core Recent" }, logs.length === 0 ? h(EmptyState, { title: "暂无日志", text: "backend-core 内存日志会在请求和 provider 事件后出现" }) : - h("div", { className: "log-list" }, logs.slice(-80).reverse().map((log, index) => h("article", { key: index, className: `log-row ${log.level || "info"}` }, + h("div", { className: "log-list" }, logs.slice(-80).reverse().map((log: any, index: any) => h("article", { key: index, className: `log-row ${log.level || "info"}` }, h("span", null, fmtDate(log.ts)), h("b", null, log.level || "info"), h("strong", null, log.message || "log"), @@ -296,12 +349,12 @@ function LogsPage({ logs, onRaw }) { ); } -function NodeListPage({ nodes, onRaw }) { +function NodeListPage({ nodes, onRaw }: AnyRecord) { return h(Panel, { title: "节点清单", eyebrow: `${nodes.length} Providers` }, nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "确认 provider-gateway 已连接 provider ingress" }) : h("div", { className: "table-wrap" }, h("table", null, h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Provider"), h("th", null, "资源标签"), h("th", null, "连接时间"), h("th", null, "最后心跳"), h("th", null, "操作"))), - h("tbody", null, nodes.map((node) => h("tr", { key: node.providerId }, + h("tbody", null, nodes.map((node: any) => h("tr", { key: node.providerId }, h("td", null, h(StatusBadge, { status: node.status })), h("td", null, h("strong", null, node.name), h("code", null, node.providerId)), h("td", null, h(LabelChips, { labels: node.labels, limit: 5 })), @@ -313,7 +366,7 @@ function NodeListPage({ nodes, onRaw }) { ); } -function LabelsPage({ nodes }) { +function LabelsPage({ nodes }: AnyRecord) { const labels = useMemo(() => { const rows = []; for (const node of nodes) { @@ -323,7 +376,7 @@ function LabelsPage({ nodes }) { }, [nodes]); return h(Panel, { title: "资源标签", eyebrow: "Structured Labels" }, labels.length === 0 ? h(EmptyState, { title: "暂无标签", text: "provider-gateway 注册消息会同步资源标签" }) : - h("div", { className: "label-matrix" }, labels.map((row) => h("article", { key: `${row.providerId}-${row.key}`, className: "label-card" }, + h("div", { className: "label-matrix" }, labels.map((row: any) => h("article", { key: `${row.providerId}-${row.key}`, className: "label-card" }, h("span", null, row.key), h("strong", null, summarizeValue(row.value)), h("code", null, row.providerId), @@ -331,10 +384,10 @@ function LabelsPage({ nodes }) { ); } -function HeartbeatPage({ nodes }) { +function HeartbeatPage({ nodes }: AnyRecord) { return h(Panel, { title: "心跳状态", eyebrow: "Provider Liveness" }, nodes.length === 0 ? h(EmptyState, { title: "无心跳", text: "等待 provider 注册和 heartbeat" }) : - h("div", { className: "heartbeat-list" }, nodes.map((node) => h("article", { key: node.providerId, className: "heartbeat-row" }, + h("div", { className: "heartbeat-list" }, nodes.map((node: any) => h("article", { key: node.providerId, className: "heartbeat-row" }, h("span", { className: `pulse ${node.status}` }), h("div", null, h("strong", null, node.name), h("code", null, node.providerId)), h("div", null, h("span", null, "connected"), h("b", null, fmtDate(node.connectedAt))), @@ -343,8 +396,267 @@ function HeartbeatPage({ nodes }) { ); } -function DispatchPage({ nodes, onDispatched, onRaw }) { - const onlineNodes = nodes.filter((node) => node.status === "online"); +function NodeMonitorPage({ nodes, systemStatuses, onRaw, refresh }: AnyRecord) { + const [selectedProvider, setSelectedProvider] = useState(""); + const merged = useMemo(() => nodes.map((node: any) => { + const status = systemStatuses.find((item: any) => item.providerId === node.providerId); + return { ...node, systemCurrent: status?.current || null, systemHistory: status?.history || [], systemUpdatedAt: status?.updatedAt || null }; + }), [nodes, systemStatuses]); + const active = merged.find((node: any) => node.providerId === selectedProvider) || merged[0] || null; + useEffect(() => { + if (!selectedProvider && merged[0]) setSelectedProvider(merged[0].providerId); + }, [merged.length, selectedProvider]); + + if (!active) return h(EmptyState, { title: "暂无资源监控", text: "等待 provider 上报 CPU、内存和硬盘指标" }); + + const current = active.systemCurrent; + const history = active.systemHistory || []; + const cpu = current?.cpu || {}; + const memory = current?.memory || {}; + const disk = current?.disk || {}; + const points = history.length > 0 ? history : current ? [{ + at: current.collectedAt, + cpuPercent: asNumber(cpu.percent), + memoryPercent: asNumber(memory.percent), + diskPercent: asNumber(disk.percent), + }] : []; + + return h("div", { className: "monitor-page", "data-testid": "node-monitor-page" }, + h("div", { className: "docker-node-strip" }, + merged.map((node: any) => h("button", { + key: node.providerId, + type: "button", + className: `docker-node-tile ${active.providerId === node.providerId ? "active" : ""}`, + onClick: () => setSelectedProvider(node.providerId), + }, + h("span", { className: `pulse ${node.status}` }), + h("strong", null, node.name), + h("code", null, node.providerId), + h("span", null, node.systemCurrent ? `CPU ${fmtPercent(node.systemCurrent.cpu?.percent)} / MEM ${fmtPercent(node.systemCurrent.memory?.percent)}` : "等待指标"), + )), + ), + h("div", { className: "monitor-layout" }, + h(Panel, { + title: "任务管理器视图", + eyebrow: active.name, + className: "monitor-main-panel", + actions: current ? h(RawButton, { title: `System ${active.providerId}`, data: { current, history }, onOpen: onRaw }) : null, + }, + !current ? h(EmptyState, { title: "系统指标未上报", text: "provider-gateway 会周期性采集 /proc 与 df,并保存历史曲线" }) : + h("div", null, + h("div", { className: "monitor-hero" }, + h("div", null, + h("p", { className: "panel-eyebrow" }, "Node Performance"), + h("h3", null, active.name), + h("div", { className: "docker-meta" }, + h("span", null, `${cpu.cores || 0} CPU cores`), + h("span", null, `load ${asNumber(cpu.load1).toFixed(2)} / ${asNumber(cpu.load5).toFixed(2)} / ${asNumber(cpu.load15).toFixed(2)}`), + h("span", null, `memory ${fmtBytes(memory.usedBytes)} / ${fmtBytes(memory.totalBytes)}`), + h("span", null, `disk ${fmtBytes(disk.usedBytes)} / ${fmtBytes(disk.totalBytes)}`), + ), + ), + h(StatusBadge, { status: current.ok ? "online" : "warn" }, current.ok ? "METRICS READY" : "METRICS DEGRADED"), + ), + h("div", { className: "monitor-chart-grid" }, + h(MetricChart, { title: "CPU", metricKey: "cpuPercent", current: cpu.percent, points, detail: `${cpu.cores || 0} cores / load ${asNumber(cpu.load1).toFixed(2)}`, tone: "cpu", testId: "metric-chart-cpu" }), + h(MetricChart, { title: "Memory", metricKey: "memoryPercent", current: memory.percent, points, detail: `${fmtBytes(memory.usedBytes)} used / ${fmtBytes(memory.availableBytes)} free`, tone: "memory", testId: "metric-chart-memory" }), + h(MetricChart, { title: "Disk", metricKey: "diskPercent", current: disk.percent, points, detail: `${disk.path || "/"} mounted ${disk.mount || "--"}`, tone: "disk", testId: "metric-chart-disk" }), + ), + h("div", { className: "monitor-summary-grid" }, + h(MetricCard, { label: "CPU 当前", value: fmtPercent(cpu.percent), hint: `history ${points.length} samples`, tone: "ok" }), + h(MetricCard, { label: "内存已用", value: fmtBytes(memory.usedBytes), hint: fmtPercent(memory.percent) }), + h(MetricCard, { label: "硬盘已用", value: fmtBytes(disk.usedBytes), hint: fmtPercent(disk.percent) }), + h(MetricCard, { label: "更新时间", value: fmtDate(active.systemUpdatedAt || current.collectedAt), hint: active.providerId }), + ), + ), + ), + h("div", { className: "monitor-side-stack" }, + h(UpgradeControl, { provider: active, refresh, onRaw }), + h(Panel, { title: "采样说明", eyebrow: "Retention" }, + h("div", { className: "monitor-note-list" }, + h("article", null, h("b", null, "CPU"), h("span", null, "从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")), + h("article", null, h("b", null, "Memory"), h("span", null, "使用 MemTotal 与 MemAvailable 计算已用比例")), + h("article", null, h("b", null, "Disk"), h("span", null, "使用 df -PB1 对配置路径采样,默认监控根文件系统")), + ), + ), + ), + ), + ); +} + +function MetricChart({ title, metricKey, current, points, detail, tone, testId }: AnyRecord) { + const values = points.map((point: any) => Math.max(0, Math.min(100, asNumber(point[metricKey])))); + const chartValues = values.length > 1 ? values : [values[0] || 0, values[0] || 0]; + const step = chartValues.length <= 1 ? 100 : 100 / (chartValues.length - 1); + const linePoints = chartValues.map((value: any, index: any) => `${(index * step).toFixed(2)},${(46 - value * 0.42).toFixed(2)}`).join(" "); + const areaPoints = `0,48 ${linePoints} 100,48`; + return h("article", { className: `metric-chart ${tone}`, "data-testid": testId }, + h("div", { className: "metric-chart-head" }, + h("div", null, h("span", null, title), h("strong", null, fmtPercent(current))), + h("code", null, `${points.length} pts`), + ), + h("svg", { viewBox: "0 0 100 48", preserveAspectRatio: "none", role: "img", "aria-label": `${title} usage curve` }, + h("polygon", { points: areaPoints }), + h("polyline", { points: linePoints }), + h("line", { x1: "0", x2: "100", y1: "24", y2: "24" }), + ), + h("div", { className: "metric-chart-foot" }, h("span", null, "0%"), h("span", null, detail), h("span", null, "100%")), + ); +} + +function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) { + const [busyMode, setBusyMode] = useState(""); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + + async function run(mode: string): Promise { + setBusyMode(mode); + setError(""); + try { + const response = await requestJson(`${cfg.apiBaseUrl}/dispatch`, { + method: "POST", + body: JSON.stringify({ + providerId: provider.providerId, + command: "provider.upgrade", + payload: { mode, source: "frontend-resource-monitor", requestedAt: new Date().toISOString() }, + }), + }); + setResult({ mode, ...response }); + await refresh(); + } catch (err) { + setError(errorMessage(err, "升级命令下发失败")); + } finally { + setBusyMode(""); + } + } + + return h(Panel, { title: "Provider Gateway 升级", eyebrow: "Remote Control" }, + h("div", { className: "upgrade-control", "data-testid": "provider-upgrade-control" }, + h("p", null, "通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"), + h("div", { className: "upgrade-actions" }, + h("button", { type: "button", className: "ghost-btn", disabled: Boolean(busyMode), onClick: () => run("plan"), "data-testid": "upgrade-plan-button" }, busyMode === "plan" ? "预检中" : "预检升级"), + h("button", { type: "button", className: "ghost-btn danger", disabled: Boolean(busyMode), onClick: () => run("schedule"), "data-testid": "upgrade-schedule-button" }, busyMode === "schedule" ? "调度中" : "执行升级"), + ), + error ? h("div", { className: "form-error" }, error) : null, + result ? h("div", { className: "upgrade-result" }, + h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"), + h("span", null, `${result.mode === "schedule" ? "执行升级" : "预检升级"} 已下发`), + h("code", null, result.taskId || "--"), + h(RawButton, { title: "Provider Upgrade Dispatch", data: result, onOpen: onRaw }), + ) : h("span", { className: "muted" }, "升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。"), + ), + ); +} + +function dockerStateTone(state: string): string { + if (state === "running") return "online"; + if (state === "paused" || state === "restarting") return "warn"; + if (state === "exited" || state === "dead") return "offline"; + return "internal"; +} + +function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) { + const [selectedProvider, setSelectedProvider] = useState(""); + const merged = useMemo(() => nodes.map((node: any) => { + const status = dockerStatuses.find((item: any) => item.providerId === node.providerId); + return { ...node, dockerStatus: status?.dockerStatus || null, dockerUpdatedAt: status?.updatedAt || null }; + }), [nodes, dockerStatuses]); + const active = merged.find((node: any) => node.providerId === selectedProvider) || merged[0] || null; + useEffect(() => { + if (!selectedProvider && merged[0]) setSelectedProvider(merged[0].providerId); + }, [merged.length, selectedProvider]); + + if (!active) { + return h(EmptyState, { title: "暂无 Docker 节点", text: "等待 provider 上报 Docker daemon 状态" }); + } + + const status = active.dockerStatus; + const counts = status?.counts || {}; + const daemon = status?.daemon || {}; + const containers = status?.containers || []; + const images = status?.images || []; + const volumes = status?.volumes || []; + const networks = status?.networks || []; + const runningContainers = containers.filter((item: any) => item.state === "running"); + const stoppedContainers = containers.filter((item: any) => item.state !== "running"); + + return h("div", { className: "docker-page", "data-testid": "docker-status-page" }, + h("div", { className: "docker-node-strip" }, + merged.map((node: any) => h("button", { + key: node.providerId, + type: "button", + className: `docker-node-tile ${active.providerId === node.providerId ? "active" : ""}`, + onClick: () => setSelectedProvider(node.providerId), + }, + h("span", { className: `pulse ${node.status}` }), + h("strong", null, node.name), + h("code", null, node.providerId), + h("span", null, node.dockerStatus ? `Docker ${node.dockerStatus.ok ? "ready" : "degraded"}` : "等待上报"), + )), + ), + h("div", { className: "docker-layout" }, + h(Panel, { + title: "Docker Desktop 视图", + eyebrow: active.name, + className: "docker-main-panel", + actions: status ? h(RawButton, { title: `Docker ${active.providerId}`, data: status, onOpen: onRaw }) : null, + }, + !status ? h(EmptyState, { title: "Docker 状态未上报", text: "provider-gateway 会在连接后周期性采集 docker info / ps / images / volume / network" }) : + h("div", null, + h("div", { className: "docker-hero" }, + h("div", null, + h("p", { className: "panel-eyebrow" }, "Daemon"), + h("h3", null, daemon.name || active.providerId), + h("div", { className: "docker-meta" }, + h("span", null, daemon.serverVersion ? `Engine ${daemon.serverVersion}` : "Engine --"), + h("span", null, daemon.operatingSystem || "OS --"), + h("span", null, daemon.architecture || "arch --"), + h("span", null, `${daemon.cpus || 0} CPU / ${fmtBytes(daemon.memoryBytes)}`), + ), + ), + h(StatusBadge, { status: status.ok ? "online" : "warn" }, status.ok ? "Docker Ready" : "Docker Degraded"), + ), + h("div", { className: "docker-metrics" }, + h(MetricCard, { label: "Containers", value: counts.containers ?? containers.length, hint: `${counts.running ?? runningContainers.length} running / ${counts.stopped ?? stoppedContainers.length} stopped`, tone: "ok" }), + h(MetricCard, { label: "Images", value: counts.images ?? images.length, hint: `${counts.daemonImages ?? counts.images ?? images.length} daemon images` }), + h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: "local volumes" }), + h(MetricCard, { label: "Networks", value: counts.networks ?? networks.length, hint: daemon.driver ? `driver ${daemon.driver}` : "docker networks" }), + ), + h("div", { className: "docker-section-head" }, + h("h3", null, "Containers"), + h("span", null, `updated ${fmtDate(active.dockerUpdatedAt || status.collectedAt)}`), + ), + h("div", { className: "docker-container-table table-wrap", "data-testid": "docker-container-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("tbody", null, containers.length === 0 ? h("tr", null, h("td", { colSpan: 6 }, "暂无容器")) : containers.map((item: any) => h("tr", { key: `${item.id}-${item.name}` }, + h("td", null, h(StatusBadge, { status: dockerStateTone(item.state) }, item.state || "unknown")), + h("td", null, h("strong", null, item.name || "--"), h("code", null, item.id || "--")), + h("td", null, item.image || "--"), + h("td", null, item.ports || h("span", { className: "muted" }, "未发布")), + h("td", null, item.runningFor || item.status || "--"), + h("td", null, item.size || "--"), + ))), + )), + ), + ), + h("div", { className: "docker-side-stack" }, + h(DockerSidePanel, { title: "Images", items: images, render: (image: any) => h("article", { key: `${image.id}-${image.repository}`, className: "docker-side-row" }, h("strong", null, `${image.repository}:${image.tag}`), h("span", null, image.size || "--"), h("code", null, image.id || "--")) }), + h(DockerSidePanel, { title: "Volumes", items: volumes, render: (volume: any) => h("article", { key: volume.name, className: "docker-side-row" }, h("strong", null, volume.name), h("span", null, volume.driver || "--"), h("code", null, volume.scope || "--")) }), + h(DockerSidePanel, { title: "Networks", items: networks, render: (network: any) => h("article", { key: network.id || network.name, className: "docker-side-row" }, h("strong", null, network.name), h("span", null, network.driver || "--"), h("code", null, network.id || "--")) }), + ), + ), + ); +} + +function DockerSidePanel({ title, items, render }: AnyRecord) { + return h(Panel, { title, eyebrow: `${items.length} items`, className: "docker-side-panel" }, + items.length === 0 ? h(EmptyState, { title: `暂无 ${title}`, text: "等待 Docker 状态采集" }) : + h("div", { className: "docker-side-list" }, items.slice(0, 12).map(render)), + ); +} + +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 || ""); const [command, setCommand] = useState("docker.ps"); const [source, setSource] = useState("frontend"); @@ -360,16 +672,16 @@ function DispatchPage({ nodes, onDispatched, onRaw }) { if (!providerId && (onlineNodes[0]?.providerId || nodes[0]?.providerId)) setProviderId(onlineNodes[0]?.providerId || nodes[0].providerId); }, [nodes.length, onlineNodes.length, providerId]); - function structuredPayload() { + function structuredPayload(): AnyRecord { return { source, note, priority }; } - function revealRawPayload() { + function revealRawPayload(): void { setRawPayload(JSON.stringify(structuredPayload(), null, 2)); setRawOpen(true); } - async function submit(event) { + async function submit(event: any) { event.preventDefault(); setBusy(true); setError(""); @@ -382,7 +694,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }) { setResult(response); await onDispatched(); } catch (err) { - setError(err.message || "下发失败"); + setError(errorMessage(err, "下发失败")); } finally { setBusy(false); } @@ -391,16 +703,16 @@ function DispatchPage({ nodes, onDispatched, onRaw }) { return h("div", { className: "page-grid dispatch-grid" }, h(Panel, { title: "下发任务", eyebrow: "Real WebSocket Dispatch" }, h("form", { className: "dispatch-form", onSubmit: submit }, - h("label", null, "Provider", h("select", { value: providerId, onChange: (event) => setProviderId(event.target.value) }, - nodes.map((node) => h("option", { key: node.providerId, value: node.providerId }, `${node.name} / ${node.providerId}`)), + h("label", null, "Provider", h("select", { value: providerId, onChange: (event: any) => setProviderId(event.target.value) }, + nodes.map((node: any) => h("option", { key: node.providerId, value: node.providerId }, `${node.name} / ${node.providerId}`)), )), - h("label", null, "Command", h("select", { value: command, onChange: (event) => setCommand(event.target.value) }, + 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: "echo" }, "echo"), )), - h("label", null, "来源", h("input", { value: source, onChange: (event) => setSource(event.target.value) })), - h("label", null, "备注", h("input", { value: note, onChange: (event) => setNote(event.target.value) })), - h("label", null, "优先级", h("select", { value: priority, onChange: (event) => setPriority(event.target.value) }, + h("label", null, "来源", h("input", { value: source, onChange: (event: any) => setSource(event.target.value) })), + h("label", null, "备注", h("input", { value: note, onChange: (event: any) => setNote(event.target.value) })), + h("label", null, "优先级", h("select", { value: priority, onChange: (event: any) => setPriority(event.target.value) }, h("option", { value: "normal" }, "normal"), h("option", { value: "low" }, "low"), h("option", { value: "urgent" }, "urgent"), @@ -409,7 +721,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }) { h("button", { type: "button", className: "ghost-btn", onClick: revealRawPayload }, "查看原始JSON"), h("button", { type: "submit", disabled: busy || !providerId }, busy ? "下发中" : "下发任务"), ), - rawOpen ? h("label", { className: "raw-editor-label" }, "高级 Payload", h("textarea", { className: "raw-editor", value: rawPayload, onChange: (event) => setRawPayload(event.target.value) })) : null, + rawOpen ? h("label", { className: "raw-editor-label" }, "高级 Payload", h("textarea", { className: "raw-editor", value: rawPayload, onChange: (event: any) => setRawPayload(event.target.value) })) : null, error ? h("div", { className: "form-error wide" }, error) : null, ), ), @@ -426,7 +738,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }) { ); } -function TaskCompactRow({ task, onRaw }) { +function TaskCompactRow({ task, onRaw }: AnyRecord) { return h("article", { className: "compact-row" }, h(StatusBadge, { status: task.status }), h("div", null, h("strong", null, task.command), h("code", null, task.id)), @@ -435,12 +747,12 @@ function TaskCompactRow({ task, onRaw }) { ); } -function TaskHistoryPage({ tasks, onRaw }) { +function TaskHistoryPage({ tasks, onRaw }: AnyRecord) { return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` }, tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) : h("div", { className: "table-wrap" }, h("table", null, h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "Provider"), h("th", null, "载荷摘要"), h("th", null, "更新时间"), h("th", null, "操作"))), - h("tbody", null, tasks.map((task) => h("tr", { key: task.id }, + h("tbody", null, tasks.map((task: any) => h("tr", { key: task.id }, h("td", null, h(StatusBadge, { status: task.status })), h("td", null, h("strong", null, task.command), h("code", null, task.id)), h("td", null, h("code", null, task.providerId)), @@ -452,11 +764,11 @@ function TaskHistoryPage({ tasks, onRaw }) { ); } -function TaskResultsPage({ tasks, onRaw }) { - const finished = tasks.filter((task) => ["succeeded", "failed"].includes(task.status)); +function TaskResultsPage({ tasks, onRaw }: AnyRecord) { + const finished = tasks.filter((task: any) => ["succeeded", "failed"].includes(task.status)); return h(Panel, { title: "执行结果", eyebrow: "Finished Tasks" }, finished.length === 0 ? h(EmptyState, { title: "暂无结果", text: "任务完成后展示 provider 返回的结构化摘要" }) : - h("div", { className: "result-grid" }, finished.map((task) => h("article", { key: task.id, className: "result-card" }, + h("div", { className: "result-grid" }, finished.map((task: any) => h("article", { key: task.id, className: "result-card" }, h("div", { className: "node-card-head" }, h("strong", null, task.command), h(StatusBadge, { status: task.status })), h("code", null, task.id), h(DataSummary, { data: task.result, empty: "无执行输出" }), @@ -465,7 +777,7 @@ function TaskResultsPage({ tasks, onRaw }) { ); } -function TopologyPage({ data }) { +function TopologyPage({ data }: AnyRecord) { const overview = data.overview || {}; return h("div", { className: "page-grid topology-grid" }, h(Panel, { title: "公开入口", eyebrow: "Public" }, @@ -489,7 +801,7 @@ function TopologyPage({ data }) { ); } -function AuthPage({ session }) { +function AuthPage({ session }: AnyRecord) { return h(Panel, { title: "认证策略", eyebrow: "Frontend Login" }, h("div", { className: "policy-grid" }, h("article", null, h("span", null, "默认账号"), h("strong", null, cfg.authUsername || "admin")), @@ -511,11 +823,13 @@ function SecurityPage() { ); } -function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }) { +function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }: AnyRecord) { if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw }); if (activeModule === "ops" && activeTab === "events") return h(EventsPage, { events: data.events, onRaw }); if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, onRaw }); if (activeModule === "nodes" && activeTab === "list") return h(NodeListPage, { nodes: data.nodes, onRaw }); + if (activeModule === "nodes" && activeTab === "monitor") return h(NodeMonitorPage, { nodes: data.nodes, systemStatuses: data.systemStatuses, onRaw, refresh }); + if (activeModule === "nodes" && activeTab === "docker") return h(DockerStatusPage, { nodes: data.nodes, dockerStatuses: data.dockerStatuses, onRaw }); if (activeModule === "nodes" && activeTab === "labels") return h(LabelsPage, { nodes: data.nodes }); if (activeModule === "nodes" && activeTab === "heartbeats") return h(HeartbeatPage, { nodes: data.nodes }); if (activeModule === "tasks" && activeTab === "dispatch") return h(DispatchPage, { nodes: data.nodes, onDispatched: refresh, onRaw }); @@ -527,23 +841,25 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }) { return h(EmptyState, { title: "未找到页面", text: "请选择左侧主模块和顶部子功能标签" }); } -function Shell({ session, onLogout }) { +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: [], events: [], tasks: [], logs: [] }); + const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], events: [], tasks: [], logs: [] }); const [connection, setConnection] = useState({ ok: false, text: "连接中" }); const [lastRefresh, setLastRefresh] = useState(null); const [clock, setClock] = useState(new Date()); const [raw, setRaw] = useState(null); - const module = MODULES.find((item) => item.id === activeModule) || MODULES[0]; + const module = MODULES.find((item: any) => item.id === activeModule) || MODULES[0]; const activeTab = activeTabs[activeModule] || module.tabs[0].id; - async function refresh() { + async function refresh(): Promise { try { - const [overview, nodes, events, tasks, logs] = await Promise.all([ + const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, 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}/events?limit=100`), requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`), requestJson("/logs?limit=100"), @@ -551,6 +867,8 @@ function Shell({ session, onLogout }) { setData({ overview, nodes: nodes.nodes || [], + systemStatuses: systemStatuses.systemStatuses || [], + dockerStatuses: dockerStatuses.dockerStatuses || [], events: events.events || [], tasks: tasks.tasks || [], logs: logs.logs || [], @@ -558,8 +876,8 @@ function Shell({ session, onLogout }) { setConnection({ ok: true, text: "核心在线" }); setLastRefresh(new Date()); } catch (err) { - setConnection({ ok: false, text: err.message || "连接失败" }); - if (err.status === 401) onLogout(false); + setConnection({ ok: false, text: errorMessage(err, "连接失败") }); + if ((err as { status?: number }).status === 401) onLogout(false); } } @@ -574,11 +892,11 @@ function Shell({ session, onLogout }) { return () => clearInterval(timer); }, []); - function setTab(tab) { - setActiveTabs((prev) => ({ ...prev, [activeModule]: tab })); + function setTab(tab: string): void { + setActiveTabs((prev: any) => ({ ...prev, [activeModule]: tab })); } - function openRaw(title, rawData) { + function openRaw(title: string, rawData: any): void { setRaw({ title, data: rawData }); } @@ -597,7 +915,7 @@ function App() { const [checking, setChecking] = useState(true); const [session, setSession] = useState(null); - async function loadSession() { + async function loadSession(): Promise { setChecking(true); try { const current = await requestJson("/api/session"); @@ -609,7 +927,7 @@ function App() { } } - async function logout(callServer) { + async function logout(callServer: boolean): Promise { if (callServer) { try { await requestJson("/logout", { method: "POST" }); } catch { /* ignore logout network errors */ } } diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 80eaada1..5dba81cf 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -27,17 +27,34 @@ const config = readConfig(); const logger = createLogger("frontend", config.logFile); const publicDir = join(import.meta.dir, "..", "public"); const vendorDir = join(import.meta.dir, "..", "node_modules"); +const appBundle = await buildFrontendApp(); +const clientConfig = JSON.stringify({ + frontendPublicUrl: config.frontendPublicUrl, + providerIngressPublicUrl: config.providerIngressPublicUrl, + authUsername: config.authUsername, + sessionTtlSeconds: config.sessionTtlSeconds, + apiBaseUrl: "/api", +}); const indexHtml = readFileSync(join(publicDir, "index.html"), "utf8").replace( "__UNIDESK_CONFIG__", - JSON.stringify({ - frontendPublicUrl: config.frontendPublicUrl, - providerIngressPublicUrl: config.providerIngressPublicUrl, - authUsername: config.authUsername, - sessionTtlSeconds: config.sessionTtlSeconds, - apiBaseUrl: "/api", - }), + escapeHtmlAttribute(clientConfig), ); +async function buildFrontendApp(): Promise { + const result = await Bun.build({ + entrypoints: [join(import.meta.dir, "app.tsx")], + target: "browser", + format: "iife", + minify: false, + sourcemap: "none", + }); + if (!result.success || result.outputs.length === 0) { + const messages = result.logs.map((item) => item.message).join("; "); + throw new Error(`frontend app.tsx build failed: ${messages || "no output"}`); + } + return result.outputs[0].text(); +} + function requiredEnv(name: string): string { const value = process.env[name]; if (value === undefined || value.length === 0) { @@ -106,6 +123,14 @@ function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit): }); } +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + function signPayload(payload: string): string { return createHmac("sha256", config.sessionSecret).update(payload).digest("base64url"); } @@ -238,6 +263,9 @@ function vendorPath(pathname: string): string | null { } async function staticResponse(pathname: string): Promise { + if (pathname === "/app.js") { + return new Response(appBundle, { headers: { "content-type": "text/javascript; charset=utf-8" } }); + } const vendor = vendorPath(pathname); const filePath = vendor ?? join(publicDir, pathname.replace(/^\/+/, "")); const file = Bun.file(filePath); diff --git a/src/components/frontend/tsconfig.json b/src/components/frontend/tsconfig.json index f62969e7..28db01ce 100644 --- a/src/components/frontend/tsconfig.json +++ b/src/components/frontend/tsconfig.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "react", "types": ["bun", "node"], "strict": true, "noImplicitReturns": true, @@ -13,5 +14,5 @@ "outDir": "dist", "skipLibCheck": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/src/components/provider-gateway/Dockerfile b/src/components/provider-gateway/Dockerfile index c9a78de0..df5bc329 100644 --- a/src/components/provider-gateway/Dockerfile +++ b/src/components/provider-gateway/Dockerfile @@ -1,5 +1,5 @@ FROM oven/bun:1-alpine -RUN apk add --no-cache bash docker-cli openssh-client +RUN apk add --no-cache bash docker-cli docker-cli-compose openssh-client WORKDIR /app/src/components/provider-gateway COPY src/components/provider-gateway/package.json ./package.json RUN bun install --production diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index a0c944f4..b738da9e 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -1,10 +1,16 @@ -import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import { type CoreDispatchMessage, + type DockerContainerSummary, + type DockerImageSummary, + type DockerNetworkSummary, + type DockerStatusSnapshot, + type DockerVolumeSummary, type JsonValue, type ProviderLabels, type ProviderTaskStatusMessage, + type SystemStatusSnapshot, parseJsonObject, } from "../../shared/src/index"; @@ -18,6 +24,15 @@ interface RuntimeConfig { reconnectBaseMs: number; reconnectMaxMs: number; dockerSocketPath: string; + monitorDiskPath: string; + upgradeEnabled: boolean; + upgradeHostProjectRoot: string; + upgradeWorkspacePath: string; + upgradeComposeFile: string; + upgradeEnvFile: string; + upgradeComposeProject: string; + upgradeService: string; + upgradeRunnerImage: string; logFile: string; } @@ -26,6 +41,11 @@ const config = readConfig(); const logger = createLogger("provider-gateway", config.logFile); let socket: WebSocket | null = null; let heartbeatTimer: ReturnType | null = null; +let dockerStatusTimer: ReturnType | null = null; +let systemStatusTimer: ReturnType | null = null; +let dockerStatusRunning = false; +let systemStatusRunning = false; +let previousCpuSample: { idle: number; total: number } | null = null; let reconnectAttempt = 0; let stopping = false; @@ -46,6 +66,13 @@ function readNumberEnv(name: string): number { return parsed; } +function readBooleanEnv(name: string): boolean { + const raw = requiredEnv(name); + if (raw === "true") return true; + if (raw === "false") return false; + throw new Error(`Environment variable ${name} must be true or false, got ${raw}`); +} + function readConfig(): RuntimeConfig { return { serverUrl: requiredEnv("PROVIDER_SERVER_URL"), @@ -57,6 +84,15 @@ function readConfig(): RuntimeConfig { reconnectBaseMs: readNumberEnv("RECONNECT_BASE_MS"), reconnectMaxMs: readNumberEnv("RECONNECT_MAX_MS"), dockerSocketPath: requiredEnv("DOCKER_SOCKET_PATH"), + monitorDiskPath: requiredEnv("MONITOR_DISK_PATH"), + upgradeEnabled: readBooleanEnv("PROVIDER_UPGRADE_ENABLED"), + upgradeHostProjectRoot: requiredEnv("PROVIDER_UPGRADE_HOST_PROJECT_ROOT"), + upgradeWorkspacePath: requiredEnv("PROVIDER_UPGRADE_WORKSPACE_PATH"), + upgradeComposeFile: requiredEnv("PROVIDER_UPGRADE_COMPOSE_FILE"), + upgradeEnvFile: requiredEnv("PROVIDER_UPGRADE_ENV_FILE"), + upgradeComposeProject: requiredEnv("PROVIDER_UPGRADE_COMPOSE_PROJECT"), + upgradeService: requiredEnv("PROVIDER_UPGRADE_SERVICE"), + upgradeRunnerImage: requiredEnv("PROVIDER_UPGRADE_RUNNER_IMAGE"), logFile: requiredEnv("LOG_FILE"), }; } @@ -105,7 +141,7 @@ function sendRegister(): void { name: config.providerName, labels: currentLabels(), startedAt: startedAt.toISOString(), - capabilities: ["heartbeat", "docker.ps", "echo"], + capabilities: ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"], }); } @@ -118,6 +154,49 @@ function sendHeartbeat(): void { }); } +async function sendDockerStatus(): Promise { + if (!socket || socket.readyState !== WebSocket.OPEN || dockerStatusRunning) return; + dockerStatusRunning = true; + try { + const status = await collectDockerStatus(); + sendJson({ + type: "docker_status", + providerId: config.providerId, + at: new Date().toISOString(), + status, + }); + logger("debug", "docker_status_sent", { providerId: config.providerId, counts: status.counts }); + } catch (error) { + logger("error", "docker_status_failed", { error: error instanceof Error ? error.message : String(error) }); + } finally { + dockerStatusRunning = false; + } +} + +async function sendSystemStatus(): Promise { + if (!socket || socket.readyState !== WebSocket.OPEN || systemStatusRunning) return; + systemStatusRunning = true; + try { + const status = await collectSystemStatus(); + sendJson({ + type: "system_status", + providerId: config.providerId, + at: new Date().toISOString(), + status, + }); + logger("debug", "system_status_sent", { + providerId: config.providerId, + cpuPercent: status.cpu.percent, + memoryPercent: status.memory.percent, + diskPercent: status.disk.percent, + }); + } catch (error) { + logger("error", "system_status_failed", { error: error instanceof Error ? error.message : String(error) }); + } finally { + systemStatusRunning = false; + } +} + async function sendTaskStatus(taskId: string, status: ProviderTaskStatusMessage["status"], message: string, result?: JsonValue): Promise { sendJson({ type: "task_status", @@ -130,22 +209,274 @@ async function sendTaskStatus(taskId: string, status: ProviderTaskStatusMessage[ }); } -async function runDockerPs(): Promise { - const proc = Bun.spawn(["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], { +async function runProcessCommand(command: string, args: string[], timeoutMs = 6000): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn([command, ...args], { stdout: "pipe", stderr: "pipe", }); - const timeout = setTimeout(() => proc.kill("SIGKILL"), 5000); + const timeout = setTimeout(() => proc.kill("SIGKILL"), timeoutMs); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); clearTimeout(timeout); - if (exitCode !== 0) { - throw new Error(`docker ps failed with exit ${exitCode}: ${stderr}`); + return { ok: exitCode === 0, stdout, stderr, exitCode }; +} + +async function runDockerCommand(args: string[], timeoutMs = 6000): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> { + return runProcessCommand("docker", args, timeoutMs); +} + +function stringField(row: Record, key: string): string { + const value = row[key]; + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function parseJsonLines(stdout: string, limit: number): Array> { + const rows: Array> = []; + for (const line of stdout.split("\n")) { + const text = line.trim(); + if (!text) continue; + try { + const parsed = JSON.parse(text) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) rows.push(parsed as Record); + } catch (error) { + logger("warn", "docker_json_line_parse_failed", { line: text.slice(0, 200), error: String(error) }); + } + if (rows.length >= limit) break; } - const containers = stdout + return rows; +} + +function toContainer(row: Record): DockerContainerSummary { + return { + id: stringField(row, "ID"), + name: stringField(row, "Names"), + image: stringField(row, "Image"), + state: stringField(row, "State"), + status: stringField(row, "Status"), + ports: stringField(row, "Ports"), + createdAt: stringField(row, "CreatedAt"), + runningFor: stringField(row, "RunningFor"), + size: stringField(row, "Size"), + networks: stringField(row, "Networks"), + }; +} + +function toImage(row: Record): DockerImageSummary { + return { + id: stringField(row, "ID"), + repository: stringField(row, "Repository"), + tag: stringField(row, "Tag"), + size: stringField(row, "Size"), + createdSince: stringField(row, "CreatedSince"), + containers: stringField(row, "Containers"), + }; +} + +function toVolume(row: Record): DockerVolumeSummary { + return { + name: stringField(row, "Name"), + driver: stringField(row, "Driver"), + scope: stringField(row, "Scope"), + mountpoint: stringField(row, "Mountpoint"), + }; +} + +function toNetwork(row: Record): DockerNetworkSummary { + return { + id: stringField(row, "ID"), + name: stringField(row, "Name"), + driver: stringField(row, "Driver"), + scope: stringField(row, "Scope"), + internal: stringField(row, "Internal"), + ipv4: stringField(row, "IPv4"), + ipv6: stringField(row, "IPv6"), + }; +} + +function jsonRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + return value as Record; +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, Number(value.toFixed(1)))); +} + +function readCpuSample(): { idle: number; total: number; cores: number } { + const stat = readFileSync("/proc/stat", "utf8"); + const lines = stat.split("\n"); + const aggregate = lines[0]?.trim().split(/\s+/).slice(1).map(Number) ?? []; + if (aggregate.length < 5 || aggregate.some((item) => !Number.isFinite(item))) { + throw new Error("Unable to parse /proc/stat aggregate CPU row"); + } + const idle = aggregate[3] + aggregate[4]; + const total = aggregate.reduce((sum, item) => sum + item, 0); + const cores = Math.max(1, lines.filter((line) => /^cpu\d+\s/.test(line)).length); + return { idle, total, cores }; +} + +function readLoadAverage(): { load1: number; load5: number; load15: number } { + const [load1, load5, load15] = readFileSync("/proc/loadavg", "utf8").trim().split(/\s+/).map(Number); + return { + load1: Number.isFinite(load1) ? load1 : 0, + load5: Number.isFinite(load5) ? load5 : 0, + load15: Number.isFinite(load15) ? load15 : 0, + }; +} + +function readMemInfo(): Record { + const result: Record = {}; + for (const line of readFileSync("/proc/meminfo", "utf8").split("\n")) { + const match = line.match(/^([A-Za-z_()]+):\s+(\d+)\s+kB/); + if (match) result[match[1]] = Number(match[2]) * 1024; + } + return result; +} + +async function readDiskUsage(path: string): Promise<{ mount: string; totalBytes: number; usedBytes: number; availableBytes: number; percent: number }> { + const result = await runProcessCommand("df", ["-PB1", path], 3000); + if (!result.ok) throw new Error(`df failed with exit ${result.exitCode}: ${result.stderr}`); + const lines = result.stdout.trim().split("\n").filter(Boolean); + const values = lines[1]?.trim().split(/\s+/); + if (!values || values.length < 6) throw new Error(`Unable to parse df output: ${result.stdout.slice(0, 300)}`); + const totalBytes = Number(values[1]); + const usedBytes = Number(values[2]); + const availableBytes = Number(values[3]); + if (![totalBytes, usedBytes, availableBytes].every(Number.isFinite) || totalBytes <= 0) { + throw new Error(`Invalid df numeric output: ${lines[1]}`); + } + return { + mount: values.slice(5).join(" "), + totalBytes, + usedBytes, + availableBytes, + percent: clampPercent((usedBytes / totalBytes) * 100), + }; +} + +async function collectSystemStatus(): Promise { + const collectedAt = new Date().toISOString(); + const errors: JsonValue[] = []; + + let cpu: Record = { percent: 0, cores: 0, load1: 0, load5: 0, load15: 0 }; + try { + const sample = readCpuSample(); + const load = readLoadAverage(); + let percent = clampPercent((load.load1 / sample.cores) * 100); + if (previousCpuSample !== null) { + const totalDelta = sample.total - previousCpuSample.total; + const idleDelta = sample.idle - previousCpuSample.idle; + if (totalDelta > 0) percent = clampPercent((1 - idleDelta / totalDelta) * 100); + } + previousCpuSample = { idle: sample.idle, total: sample.total }; + cpu = { percent, cores: sample.cores, ...load }; + } catch (error) { + errors.push({ source: "proc.stat", error: error instanceof Error ? error.message : String(error) }); + } + + let memory: Record = { totalBytes: 0, usedBytes: 0, availableBytes: 0, percent: 0 }; + try { + const mem = readMemInfo(); + const totalBytes = mem.MemTotal ?? 0; + const availableBytes = mem.MemAvailable ?? mem.MemFree ?? 0; + const usedBytes = Math.max(0, totalBytes - availableBytes); + memory = { + totalBytes, + usedBytes, + availableBytes, + percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0, + }; + } catch (error) { + errors.push({ source: "proc.meminfo", error: error instanceof Error ? error.message : String(error) }); + } + + let disk: Record = { path: config.monitorDiskPath, mount: "", totalBytes: 0, usedBytes: 0, availableBytes: 0, percent: 0 }; + try { + disk = { path: config.monitorDiskPath, ...(await readDiskUsage(config.monitorDiskPath)) }; + } catch (error) { + errors.push({ source: "df", path: config.monitorDiskPath, error: error instanceof Error ? error.message : String(error) }); + } + + return { ok: errors.length === 0, collectedAt, cpu, memory, disk, errors }; +} + +async function collectDockerStatus(): Promise { + const collectedAt = new Date().toISOString(); + const socketPresent = existsSync(config.dockerSocketPath); + const errors: JsonValue[] = []; + const [infoResult, containersResult, imagesResult, volumesResult, networksResult] = await Promise.all([ + runDockerCommand(["info", "--format", "{{json .}}"]), + runDockerCommand(["ps", "-a", "--format", "{{json .}}"]), + runDockerCommand(["images", "--format", "{{json .}}"]), + runDockerCommand(["volume", "ls", "--format", "{{json .}}"]), + runDockerCommand(["network", "ls", "--format", "{{json .}}"]), + ]); + + let info: Record = {}; + if (infoResult.ok) { + try { + info = jsonRecord(JSON.parse(infoResult.stdout) as unknown); + } catch (error) { + errors.push({ source: "docker.info", error: String(error) }); + } + } else { + errors.push({ source: "docker.info", exitCode: infoResult.exitCode, stderr: infoResult.stderr.slice(0, 500) }); + } + for (const [source, result] of Object.entries({ containers: containersResult, images: imagesResult, volumes: volumesResult, networks: networksResult })) { + if (!result.ok) errors.push({ source, exitCode: result.exitCode, stderr: result.stderr.slice(0, 500) }); + } + + const containers = containersResult.ok ? parseJsonLines(containersResult.stdout, 80).map(toContainer) : []; + const images = imagesResult.ok ? parseJsonLines(imagesResult.stdout, 80).map(toImage) : []; + const volumes = volumesResult.ok ? parseJsonLines(volumesResult.stdout, 60).map(toVolume) : []; + const networks = networksResult.ok ? parseJsonLines(networksResult.stdout, 60).map(toNetwork) : []; + const running = containers.filter((item) => item.state === "running").length; + const paused = containers.filter((item) => item.state === "paused").length; + const stopped = containers.filter((item) => item.state !== "running" && item.state !== "paused").length; + + return { + ok: errors.length === 0, + socketPresent, + collectedAt, + daemon: { + name: info.Name ?? "", + serverVersion: info.ServerVersion ?? "", + operatingSystem: info.OperatingSystem ?? "", + osType: info.OSType ?? "", + architecture: info.Architecture ?? "", + cpus: info.NCPU ?? 0, + memoryBytes: info.MemTotal ?? 0, + dockerRootDir: info.DockerRootDir ?? "", + driver: info.Driver ?? "", + }, + counts: { + containers: containers.length, + running, + paused, + stopped, + images: images.length, + volumes: volumes.length, + networks: networks.length, + daemonContainers: info.Containers ?? containers.length, + daemonImages: info.Images ?? images.length, + }, + containers, + images, + volumes, + networks, + errors, + }; +} + +async function runDockerPs(): Promise { + const result = await runDockerCommand(["ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], 5000); + if (!result.ok) { + throw new Error(`docker ps failed with exit ${result.exitCode}: ${result.stderr}`); + } + const containers = result.stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) @@ -154,7 +485,91 @@ async function runDockerPs(): Promise { const [id, name, image, status, ports] = line.split("\t"); return { id, name, image, status, ports }; }); - return { containers, count: containers.length, stderr }; + return { containers, count: containers.length, stderr: result.stderr }; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function safeDockerName(value: string): string { + return value.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 80); +} + +function upgradePlan(taskId: string): Record { + const workspace = config.upgradeWorkspacePath; + const composeCommand = [ + "docker", + "compose", + "--env-file", + `${workspace}/${config.upgradeEnvFile}`, + "-f", + `${workspace}/${config.upgradeComposeFile}`, + "-p", + config.upgradeComposeProject, + "up", + "-d", + "--no-deps", + "--build", + config.upgradeService, + ]; + const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`; + const script = `set -euo pipefail; sleep 2; cd ${shellQuote(workspace)}; ${composeCommand.map(shellQuote).join(" ")}`; + const dockerRunCommand = [ + "docker", + "run", + "-d", + "--rm", + "--name", + updaterName, + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "-v", + `${config.upgradeHostProjectRoot}:${workspace}:ro`, + "-w", + workspace, + config.upgradeRunnerImage, + "sh", + "-lc", + script, + ]; + return { + enabled: config.upgradeEnabled, + taskId, + updaterName, + runnerImage: config.upgradeRunnerImage, + hostProjectRoot: config.upgradeHostProjectRoot, + workspace, + compose: { + project: config.upgradeComposeProject, + service: config.upgradeService, + composeFile: config.upgradeComposeFile, + envFile: config.upgradeEnvFile, + }, + composeCommand, + dockerRunCommand, + }; +} + +async function runProviderUpgrade(taskId: string, payload: Record): Promise { + const mode = payload.mode === "schedule" ? "schedule" : "plan"; + const plan = upgradePlan(taskId); + if (mode === "plan") return { mode, message: "provider gateway upgrade plan generated; no container changed", plan }; + if (!config.upgradeEnabled) { + throw new Error("provider gateway remote upgrade is disabled by PROVIDER_UPGRADE_ENABLED=false"); + } + const dockerRunCommand = (plan.dockerRunCommand as JsonValue[]).map((item) => String(item)); + const result = await runDockerCommand(dockerRunCommand.slice(1), 12_000); + if (!result.ok) { + throw new Error(`provider upgrade scheduler failed with exit ${result.exitCode}: ${result.stderr}`); + } + return { + mode, + message: "provider gateway upgrade scheduled by detached updater container", + updaterContainerId: result.stdout.trim(), + plan, + stderr: result.stderr.slice(0, 500), + }; } async function handleDispatch(message: CoreDispatchMessage): Promise { @@ -167,6 +582,11 @@ async function handleDispatch(message: CoreDispatchMessage): Promise { await sendTaskStatus(message.taskId, "succeeded", "docker ps completed", result); return; } + if (message.command === "provider.upgrade") { + const result = await runProviderUpgrade(message.taskId, message.payload); + await sendTaskStatus(message.taskId, "succeeded", "provider upgrade command 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); @@ -205,14 +625,28 @@ function connect(): void { logger("info", "connect_open", { providerId: config.providerId }); sendRegister(); sendHeartbeat(); + sendSystemStatus().catch((error) => logger("error", "system_status_initial_failed", { error: String(error) })); + sendDockerStatus().catch((error) => logger("error", "docker_status_initial_failed", { error: String(error) })); if (heartbeatTimer !== null) clearInterval(heartbeatTimer); + if (systemStatusTimer !== null) clearInterval(systemStatusTimer); + if (dockerStatusTimer !== null) clearInterval(dockerStatusTimer); heartbeatTimer = setInterval(sendHeartbeat, config.heartbeatIntervalMs); + systemStatusTimer = setInterval(() => { + sendSystemStatus().catch((error) => logger("error", "system_status_interval_failed", { error: String(error) })); + }, config.heartbeatIntervalMs); + dockerStatusTimer = setInterval(() => { + sendDockerStatus().catch((error) => logger("error", "docker_status_interval_failed", { error: String(error) })); + }, config.heartbeatIntervalMs); }); socket.addEventListener("message", (event) => handleMessage(event as MessageEvent)); socket.addEventListener("close", (event) => { logger("warn", "connect_close", { code: event.code, reason: event.reason }); if (heartbeatTimer !== null) clearInterval(heartbeatTimer); + if (systemStatusTimer !== null) clearInterval(systemStatusTimer); + if (dockerStatusTimer !== null) clearInterval(dockerStatusTimer); heartbeatTimer = null; + systemStatusTimer = null; + dockerStatusTimer = null; scheduleReconnect(); }); socket.addEventListener("error", () => { @@ -224,6 +658,8 @@ process.on("SIGTERM", () => { stopping = true; logger("warn", "sigterm_received"); if (heartbeatTimer !== null) clearInterval(heartbeatTimer); + if (systemStatusTimer !== null) clearInterval(systemStatusTimer); + if (dockerStatusTimer !== null) clearInterval(dockerStatusTimer); socket?.close(1000, "provider shutdown"); process.exit(0); }); diff --git a/src/components/shared/src/index.ts b/src/components/shared/src/index.ts index 345bd5eb..8bddb2aa 100644 --- a/src/components/shared/src/index.ts +++ b/src/components/shared/src/index.ts @@ -18,6 +18,81 @@ export interface ProviderHeartbeatMessage { at: string; } +export interface SystemStatusSnapshot { + ok: boolean; + collectedAt: string; + cpu: Record; + memory: Record; + disk: Record; + errors: JsonValue[]; +} + +export interface ProviderSystemStatusMessage { + type: "system_status"; + providerId: string; + at: string; + status: SystemStatusSnapshot; +} + +export interface DockerContainerSummary { + id: string; + name: string; + image: string; + state: string; + status: string; + ports: string; + createdAt: string; + runningFor: string; + size: string; + networks: string; +} + +export interface DockerImageSummary { + id: string; + repository: string; + tag: string; + size: string; + createdSince: string; + containers: string; +} + +export interface DockerVolumeSummary { + name: string; + driver: string; + scope: string; + mountpoint: string; +} + +export interface DockerNetworkSummary { + id: string; + name: string; + driver: string; + scope: string; + internal: string; + ipv4: string; + ipv6: string; +} + +export interface DockerStatusSnapshot { + ok: boolean; + socketPresent: boolean; + collectedAt: string; + daemon: Record; + counts: Record; + containers: DockerContainerSummary[]; + images: DockerImageSummary[]; + volumes: DockerVolumeSummary[]; + networks: DockerNetworkSummary[]; + errors: JsonValue[]; +} + +export interface ProviderDockerStatusMessage { + type: "docker_status"; + providerId: string; + at: string; + status: DockerStatusSnapshot; +} + export interface ProviderTaskStatusMessage { type: "task_status"; providerId: string; @@ -31,7 +106,7 @@ export interface ProviderTaskStatusMessage { export interface CoreDispatchMessage { type: "dispatch"; taskId: string; - command: "docker.ps" | "echo"; + command: "docker.ps" | "provider.upgrade" | "echo"; payload: Record; } @@ -45,6 +120,8 @@ export interface CoreAcknowledgeMessage { export type ProviderToCoreMessage = | ProviderRegisterMessage | ProviderHeartbeatMessage + | ProviderSystemStatusMessage + | ProviderDockerStatusMessage | ProviderTaskStatusMessage; export type CoreToProviderMessage = CoreDispatchMessage | CoreAcknowledgeMessage; @@ -58,6 +135,23 @@ export interface ApiNode { lastHeartbeat: string | null; } +export interface ApiNodeDockerStatus { + providerId: string; + name: string; + nodeStatus: "online" | "offline"; + dockerStatus: JsonValue | null; + updatedAt: string | null; +} + +export interface ApiNodeSystemStatus { + providerId: string; + name: string; + nodeStatus: "online" | "offline"; + current: JsonValue | null; + history: JsonValue[]; + updatedAt: string | null; +} + export interface ApiTask { id: string; providerId: string; @@ -97,7 +191,7 @@ export function isProviderToCoreMessage(value: unknown): value is ProviderToCore if (typeof value !== "object" || value === null || !("type" in value)) return false; const msg = value as { type?: unknown; providerId?: unknown }; return ( - (msg.type === "register" || msg.type === "heartbeat" || msg.type === "task_status") && + (msg.type === "register" || msg.type === "heartbeat" || msg.type === "system_status" || msg.type === "docker_status" || msg.type === "task_status") && typeof msg.providerId === "string" && msg.providerId.length > 0 ); diff --git a/src/tsconfig.check.json b/src/tsconfig.check.json index 6c03efc6..c9d8e624 100644 --- a/src/tsconfig.check.json +++ b/src/tsconfig.check.json @@ -10,6 +10,6 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["components/**/*.ts"], + "include": ["components/**/*.ts", "components/**/*.tsx"], "exclude": ["components/**/dist/**"] }