feat: add resource monitoring and provider upgrade
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 维护桥作为自动升级通道。
|
||||
|
||||
+14
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 文件遗漏字段。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 路径误用为调度通道。
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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 <jobId|latest> [--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<void> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>, key: string, path: string): n
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function portPair(obj: Record<string, unknown>, 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: {
|
||||
|
||||
+84
-2
@@ -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<unknown> {
|
||||
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<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise<unknown> {
|
||||
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "provider.upgrade" | "echo"): Promise<unknown> {
|
||||
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" } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+110
-2
@@ -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<unknown> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>)[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<void> {
|
||||
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<void> {
|
||||
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<ApiNode[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
async function getNodeDockerStatuses(): Promise<ApiNodeDockerStatus[]> {
|
||||
const rows = await sql<Array<Record<string, unknown>>>`
|
||||
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<ApiNodeSystemStatus[]> {
|
||||
const currentRows = await sql<Array<Record<string, unknown>>>`
|
||||
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<Array<Record<string, unknown>>>`
|
||||
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<string, JsonValue[]>();
|
||||
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<ApiEvent[]> {
|
||||
const rows = await sql<Array<Record<string, unknown>>>`
|
||||
SELECT id, type, source, payload, created_at
|
||||
@@ -370,6 +554,8 @@ async function getTasks(limit: number): Promise<ApiTask[]> {
|
||||
async function getOverview(): Promise<JsonValue> {
|
||||
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<JsonValue> {
|
||||
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<JsonValue> {
|
||||
async function dispatchTask(req: Request): Promise<Response> {
|
||||
const body = (await req.json()) as { providerId?: unknown; command?: unknown; payload?: unknown };
|
||||
const providerId = typeof body.providerId === "string" ? body.providerId : "";
|
||||
const command = 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<string, JsonValue>) : {};
|
||||
if (!providerId) {
|
||||
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
||||
@@ -405,7 +593,11 @@ async function dispatchTask(req: Request): Promise<Response> {
|
||||
}
|
||||
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<Response> {
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UniDesk Control Plane</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script>window.UNIDESK_CONFIG = __UNIDESK_CONFIG__;</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root" data-config="__UNIDESK_CONFIG__"></div>
|
||||
<script src="/vendor/react.production.min.js" defer></script>
|
||||
<script src="/vendor/react-dom.production.min.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<string, any>;
|
||||
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<any> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (callServer) {
|
||||
try { await requestJson("/logout", { method: "POST" }); } catch { /* ignore logout network errors */ }
|
||||
}
|
||||
@@ -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<string> {
|
||||
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, "<")
|
||||
.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<Response> {
|
||||
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);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
let dockerStatusTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let systemStatusTimer: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
sendJson({
|
||||
type: "task_status",
|
||||
@@ -130,22 +209,274 @@ async function sendTaskStatus(taskId: string, status: ProviderTaskStatusMessage[
|
||||
});
|
||||
}
|
||||
|
||||
async function runDockerPs(): Promise<JsonValue> {
|
||||
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<string, unknown>, 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<Record<string, unknown>> {
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown>);
|
||||
} 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): DockerVolumeSummary {
|
||||
return {
|
||||
name: stringField(row, "Name"),
|
||||
driver: stringField(row, "Driver"),
|
||||
scope: stringField(row, "Scope"),
|
||||
mountpoint: stringField(row, "Mountpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
function toNetwork(row: Record<string, unknown>): 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<string, JsonValue> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
return value as Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
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<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
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<SystemStatusSnapshot> {
|
||||
const collectedAt = new Date().toISOString();
|
||||
const errors: JsonValue[] = [];
|
||||
|
||||
let cpu: Record<string, JsonValue> = { 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<string, JsonValue> = { 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<string, JsonValue> = { 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<DockerStatusSnapshot> {
|
||||
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<string, JsonValue> = {};
|
||||
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<JsonValue> {
|
||||
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<JsonValue> {
|
||||
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<string, JsonValue> {
|
||||
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<string, JsonValue>): Promise<JsonValue> {
|
||||
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<void> {
|
||||
@@ -167,6 +582,11 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
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<string>));
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,81 @@ export interface ProviderHeartbeatMessage {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface SystemStatusSnapshot {
|
||||
ok: boolean;
|
||||
collectedAt: string;
|
||||
cpu: Record<string, JsonValue>;
|
||||
memory: Record<string, JsonValue>;
|
||||
disk: Record<string, JsonValue>;
|
||||
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<string, JsonValue>;
|
||||
counts: Record<string, JsonValue>;
|
||||
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<string, JsonValue>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["components/**/*.ts"],
|
||||
"include": ["components/**/*.ts", "components/**/*.tsx"],
|
||||
"exclude": ["components/**/dist/**"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user