feat: initialize unidesk platform

This commit is contained in:
Codex
2026-05-04 11:09:35 +00:00
commit caa80ee5e7
56 changed files with 3273 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.state/
logs/
node_modules/
package-lock.json
npm-debug.log*
.DS_Store
.env
.env.*
!.env.example
dist/
coverage/
+30
View File
@@ -0,0 +1,30 @@
# UniDesk Agent Index
UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文件是项目顶级索引,也承担 `scripts/cli.ts` 的 CLI 使用说明入口。
## CLI
- `bun scripts/cli.ts help`:输出所有可用命令的 JSON 索引,详细规范见 `docs/reference/cli.md`
- `bun scripts/cli.ts config show`:校验并展示根目录 `config.json`,配置来源规则见 `docs/reference/config.md`
- `bun scripts/cli.ts check`:运行配置、TypeScript、文件存在性和 Docker Compose 配置检查,测试入口见 `TEST.md`
- `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway,部署规则见 `docs/reference/deployment.md`
- `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`
- `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`:通过真实 HTTP 和 WebSocket 流程调试健康检查与任务下发,调试规则见 `docs/reference/cli.md`
- `bun scripts/cli.ts e2e run`:用公开 URL 验证 core、database、frontend、provider-gateway 和 Playwright 页面可见性,验收规则见 `docs/reference/e2e.md`
## Runtime
- `bun`TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`
- `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,服务拓扑见 `docs/reference/deployment.md`
- `src/components/frontend`:前端采用高信息密度、左侧主模块和顶部子模块标签的工业化控制台设计,界面规则见 `docs/reference/frontend.md`
- `src/components/provider-gateway`:当前主 server 也作为 provider gateway 接入 UniDesk,节点接入规则见 `docs/reference/provider-gateway.md`
- `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 前端断言和数据库命名卷持久化要求。
## Architecture Docs
- `docs/reference/arch.md`:UniDesk 分布式工作平台的长期架构约束。
- `docs/reference/repo-tree.md`:仓库结构目标与组件边界。
- `reference`:兼容旧路径的符号链接,指向 `docs/reference/`
+41
View File
@@ -0,0 +1,41 @@
# UniDesk Manual Test Plan
## T1 CLI 可观测性与配置校验
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts help``bun scripts/cli.ts config show``bun scripts/cli.ts check`,确认每条命令都有 JSON 输出、失败时包含错误对象、`config.json` 是唯一配置来源,且 TypeScript 检查覆盖 `scripts/``src/components/`
## T2 Docker 栈异步启动
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server start`,确认命令立即返回 job id;随后运行 `bun scripts/cli.ts job status latest` 观察构建和启动进度,直到 job 状态为 `succeeded`,且输出包含 `.state/jobs/` 日志路径和 `logs/{YYYYMMDD}/` 服务日志路径。
## 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`,确认 backend-core、frontend、database 端口均监听,`/api/nodes` 中存在 `main-server` provider,状态为 `online`,且 provider 标签中能看到 Docker socket 可用性。
## T4 前端控制台连通
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:先用 `bun scripts/cli.ts server status` 获取 frontend URL,再用浏览器访问该 URL,确认左侧主模块、顶部子标签、核心指标、Provider 表格和事件流可见;页面布局应紧凑、信息密度高、字体不过大,且移动端宽度下左侧栏转为横向模块条。
## T5 真实任务下发链路
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server docker.ps`,随后运行 `bun scripts/cli.ts debug health` 或在前端事件流中确认任务状态经历 `accepted``running``succeeded`,并能看到 provider 通过 Docker socket 返回容器列表摘要。
## T6 日志第一现场验证
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server logs --tail-bytes 20000`,实际读取输出中列出的 `logs/{YYYYMMDD}/` 文件,确认 backend-core、frontend、provider-gateway、database 都有实时日志;日志不得只有启动行,错误日志必须包含可定位的错误消息或 stack。
## T7 停止与端口释放
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server stop`,确认立即返回 job id;等待 `bun scripts/cli.ts job status latest` 成功后运行 `bun scripts/cli.ts server status`,确认 backend-core、frontend、database 固定端口不再监听,容器状态不再运行。
## Issue 记录
测试发现的问题写入 `docs/issue/`,文件命名为 `{NN}_{issue_title}_{YYYYMMDD}.md`,其中 `NN``01` 递增;问题文档必须包含复现命令、实际输出摘要、期望行为和修复判定标准。
## 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 中 `core:public-overview``core:public-nodes``database:named-volume-write``database:public-port``frontend:public-page-provider-visible` 全部 passed;打开输出的 screenshotPath,确认页面上能看到 `main-server``Main Server Provider`、Online Nodes 指标和 Provider 表格。
## T9 Database 命名卷持久化
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:向 `unidesk_e2e_markers` 插入一个唯一 marker,运行 `bun scripts/cli.ts server start` 并等待 `job status latest``succeeded`,再用 `docker exec unidesk-database psql -U unidesk -d unidesk` 查询该 marker 仍存在;同时审查 `docker-compose.yml``scripts/src/docker.ts`,确认 CLI server 控制没有使用 `down -v` 或 volume 删除命令。
+32
View File
@@ -0,0 +1,32 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "unidesk-root",
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"playwright": "^1.59.1",
"typescript": "latest",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"project": {
"name": "unidesk",
"timezone": "Etc/UTC"
},
"runtime": {
"typescript": "bun",
"bunVersion": "1.3.13"
},
"network": {
"host": "0.0.0.0",
"publicHost": "74.48.78.17",
"core": {
"port": 18080,
"containerPort": 8080
},
"frontend": {
"port": 18081,
"containerPort": 8080
},
"database": {
"port": 15432,
"containerPort": 5432
}
},
"database": {
"user": "unidesk",
"password": "unidesk_dev_password",
"name": "unidesk",
"volume": "unidesk_pgdata_10gb",
"volumeSize": "10GB"
},
"providerGateway": {
"id": "main-server",
"name": "Main Server Provider",
"token": "unidesk-dev-token-change-me",
"labels": {
"host": "main-server",
"role": "self-provider",
"docker": true
},
"heartbeatIntervalMs": 15000,
"reconnectBaseMs": 1000,
"reconnectMaxMs": 30000
},
"docker": {
"composeFile": "docker-compose.yml",
"projectName": "unidesk"
},
"paths": {
"stateDir": ".state",
"logsDir": "logs",
"docsReferenceDir": "docs/reference"
},
"sshForwarding": {
"mode": "optional-maintenance-only",
"keyDir": "/root/.unidesk/host-ssh",
"host": "host.docker.internal",
"port": 22,
"user": "root"
}
}
+118
View File
@@ -0,0 +1,118 @@
version: "3.8"
services:
database:
image: postgres:16-alpine
container_name: unidesk-database
restart: unless-stopped
ports:
- "${UNIDESK_DATABASE_PORT}:5432"
environment:
POSTGRES_USER: "${UNIDESK_DATABASE_USER}"
POSTGRES_PASSWORD: "${UNIDESK_DATABASE_PASSWORD}"
POSTGRES_DB: "${UNIDESK_DATABASE_NAME}"
command:
- "postgres"
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
- "-c"
- "logging_collector=on"
- "-c"
- "log_directory=/var/log/unidesk"
- "-c"
- "log_filename=${UNIDESK_LOG_PREFIX}_database.log"
- "-c"
- "log_connections=on"
- "-c"
- "log_disconnections=on"
volumes:
- unidesk_pgdata_10gb:/var/lib/postgresql/data
- ./src/components/database/init:/docker-entrypoint-initdb.d:ro
- ./src/components/database/config/postgresql.conf:/etc/postgresql/postgresql.conf:ro
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${UNIDESK_DATABASE_USER} -d ${UNIDESK_DATABASE_NAME}"]
interval: 5s
timeout: 3s
retries: 20
backend-core:
build:
context: .
dockerfile: src/components/backend-core/Dockerfile
container_name: unidesk-backend-core
restart: unless-stopped
depends_on:
- database
ports:
- "${UNIDESK_CORE_PORT}:8080"
environment:
PORT: "8080"
DATABASE_URL: "postgres://${UNIDESK_DATABASE_USER}:${UNIDESK_DATABASE_PASSWORD}@database:5432/${UNIDESK_DATABASE_NAME}"
PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"
HEARTBEAT_TIMEOUT_MS: "${UNIDESK_HEARTBEAT_TIMEOUT_MS}"
LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_backend-core.jsonl"
volumes:
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 3s
retries: 20
frontend:
build:
context: .
dockerfile: src/components/frontend/Dockerfile
container_name: unidesk-frontend
restart: unless-stopped
depends_on:
- backend-core
ports:
- "${UNIDESK_FRONTEND_PORT}:8080"
environment:
PORT: "8080"
CORE_PUBLIC_URL: "http://${UNIDESK_PUBLIC_HOST}:${UNIDESK_CORE_PORT}"
LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_frontend.jsonl"
volumes:
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 3s
retries: 20
provider-gateway:
build:
context: .
dockerfile: src/components/provider-gateway/Dockerfile
container_name: unidesk-provider-gateway-main
restart: unless-stopped
depends_on:
- backend-core
environment:
PROVIDER_SERVER_URL: "ws://backend-core:8080/ws/provider"
PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}"
PROVIDER_ID: "${UNIDESK_PROVIDER_ID}"
PROVIDER_NAME: "${UNIDESK_PROVIDER_NAME}"
PROVIDER_LABELS_JSON: "${UNIDESK_PROVIDER_LABELS_JSON}"
HEARTBEAT_INTERVAL_MS: "${UNIDESK_HEARTBEAT_INTERVAL_MS}"
RECONNECT_BASE_MS: "${UNIDESK_RECONNECT_BASE_MS}"
RECONNECT_MAX_MS: "${UNIDESK_RECONNECT_MAX_MS}"
DOCKER_SOCKET_PATH: "/var/run/docker.sock"
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}"
HOST_SSH_USER: "${UNIDESK_HOST_SSH_USER}"
HOST_SSH_KEY: "/run/host-ssh/id_ed25519"
HOST_REMOTE_CWD: "/root"
HOST_LOGIN_SHELL: "/bin/bash"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
unidesk_pgdata_10gb:
name: unidesk_pgdata_10gb
+73
View File
@@ -0,0 +1,73 @@
- Requirements
- Build a distributed work platform covering research, project development, and project management
- Deploy the main entry point on a server with a public IP, providing a unified interface
- Multiple computing resource machines join the platform to execute computing tasks
- The platform must support task scheduling, state monitoring, versioned code distribution, and large file storage
- Design goals are high availability, high concurrency, centralized state management, and stateless compute nodes
- Key Assumptions
- The main server has a public IP and can be accessed from the internet
- Computing resource machines have no public IP, possibly behind NAT or firewalls
- Computing resource machines have stable outbound network connectivity (within intranet or internet)
- Computing resource machines can run Docker and support WSL (some nodes are Windows workstations)
- Users interact with the platform only through the main server entry point, never directly with compute nodes
- The main server's availability is higher than that of computing resource machines; compute nodes may go offline frequently due to hardware, network, or human factors
- Tasks prone to single points of failure are deployed on the main server first, leveraging its high-availability environment to protect the critical path
- UniDesk Distributed Work Platform Architecture
- Overview
- The main server hosts all stateless business logic as the unified entry point
- Computing resource nodes actively connect via lightweight Provider Gateway containers
- All state is stored centrally in PostgreSQL, never scattered across nodes
- Code and environments are distributed via GitHub versions; large file storage solution is to be determined
- The main server also connects itself to the platform as a compute node, using the exact same method as ordinary compute nodes
- This design allows verification of the full distributed dispatching flow on a single main server
- Main Server Components
- UniDesk Stateless Services
- Run all business microservices as Docker containers
- Includes API gateway, task scheduler, project management, and other stateless modules
- Instances can scale horizontally; failure recovery requires no state synchronization
- PostgreSQL Database
- Deployed as a Docker container with a 10 GB named volume
- Stores all task metadata, node heartbeats, resource labels, and business state
- Backed up periodically via `pg_dump`, keeping the last 7 daily snapshots
- The named volume ensures data survives container recreation or upgrades
- Code and Environment Distribution
- Code repositories and execution environment definitions may reside in multiple GitHub repositories
- When dispatching a task, five metadata items must be specified: `code_repo_url`, `code_commit_id`, `env_repo_url`, `env_commit_id`, and `dockerfile_path`
- A single env repo can contain multiple Dockerfiles defining different execution environments, distinguished by `dockerfile_path`
- Compute nodes maintain a local Git cache and only incrementally fetch the specified version each time
- Docker layer caching accelerates environment builds, making subsequent builds nearly instantaneous after the first
- Compute Node Connection Scheme
- Provider Gateway Docker
- Each computing resource machine runs a Provider Gateway container
- Acts as the node-side gateway, bridging the main server and the local execution environment
- The container houses the agent logic, implementing a WebSocket client and local scheduling
- WebSocket Persistent Connection
- Provider Gateway actively initiates a WebSocket connection to the main server
- Commands, heartbeats, and task statuses are exchanged bidirectionally over this persistent connection
- The main server never initiates connections to nodes, perfectly adapting to environments without public IP and behind NAT
- Interaction with Local Execution Environment
- The primary path for automated task dispatching and execution is via the local Docker socket
- Access to the local environment via WSL SSH is reserved solely as an auxiliary path for emergency maintenance and troubleshooting
- Automating task deployment or dispatching through the WSL SSH channel is forbidden
- Connection Management
- When registering, a node carries an authentication token to verify its identity and declares resources such as GPU/CPU
- The authentication token is pre-issued by the main server and configured at Provider Gateway startup
- Heartbeats are sent every 15 seconds; if no heartbeat arrives for 90 seconds, the node is marked offline
- Automatic reconnection on disconnect with exponential backoff to avoid a thundering herd on the main server
- Data Flow and State Management
- Task commands are delivered over WebSocket and never contain large file content
- All state changes are reported to the main server in real time by Provider Gateway
- The main server writes state updates to PostgreSQL, completing the unified closed loop
- Critical Task Deployment Principles
- Single-point components such as the database, core scheduler logic, and API gateway are deployed on the main server
- The high-availability environment of the main server ensures the critical scheduling path never breaks
- Compute nodes are only responsible for task execution; their offline status does not affect overall platform availability
- Large File Storage Solution
- The concrete implementation is to be determined, and must meet the following requirements
- Support automated pull and upload by compute nodes without human intervention
- Provide a programmable interface for the scheduler to generate temporary access credentials
- Have sufficient bandwidth so that concurrent reads/writes never become the bottleneck for training tasks
- Deployment Notes
- Use `docker-compose` on the main server to orchestrate all services uniformly
- PostgreSQL uses a named volume to guarantee data persistence
- The Provider Gateway image is built uniformly and distributed to all compute nodes in a versioned manner
+28
View File
@@ -0,0 +1,28 @@
# UniDesk CLI Reference
UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定为 `bun scripts/cli.ts <command>`。CLI 默认输出 JSON,所有成功和失败路径都必须向 stdout 写出结构化对象,避免无输出造成状态不可观测。
## Command Model
- `help` 输出命令索引,适合作为交互式入口。
- `config show` 读取并校验根目录 `config.json`,不从环境变量、默认值或隐藏文件静默补配置。
- `check` 执行配置校验、文件存在性检查、`scripts/` TypeScript 检查、`src/components/` TypeScript 检查和 Docker Compose 配置检查。
- `server start` 创建异步 job,在后台执行 Docker 构建和启动;命令本身只负责返回 job id、日志路径和启动命令。
- `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。
- `server status` 查询固定端口、Compose 容器、core/frontend 健康检查和访问 URL。
- `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。
- `job list``job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。
- `debug health``debug dispatch` 走真实 HTTP、WebSocket、数据库和 provider 流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。
- `e2e run` 使用 publicHost 派生的公开 URL 验证 core API、PostgreSQL、provider self-connection 和 Playwright 前端页面,是交付前的自动化 E2E 门禁。
## Async Job State
长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log``.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status <jobId>` 查询进度和尾部输出。
## Output Contract
每条命令的最外层 JSON 包含 `ok``command``data``error`。失败时 CLI 设置非零退出码,但仍然输出 JSON 错误对象;错误对象应包含 `name``message` 和可用的 `stack`
## Debug Contract
`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug dispatch` 会调用 core 的 `/api/dispatch`core 再通过 WebSocket 将任务下发给 provider gateway,因此它可以验证核心调度闭环。
+19
View File
@@ -0,0 +1,19 @@
# UniDesk Configuration Reference
根目录 `config.json` 是 UniDesk CLI 的唯一配置来源。CLI 启动时必须完整校验配置结构,读取失败或字段不合法时直接返回 JSON 错误,不允许静默 fallback。
## Runtime
TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 provider-gateway 都直接运行 `.ts` 入口;Docker 镜像使用 `oven/bun` 基础镜像,本机命令使用 `bun scripts/cli.ts`
## Fixed Ports
`config.json` 中固定三个对外端口:backend-core、frontend、database。`network.publicHost` 必须是浏览器和外部客户端可访问的主 server 地址;公网 E2E 不允许把它保留为 `127.0.0.1``server start` 会在启动前检查这些端口,避免因端口冲突产生多个版本混乱的服务实例。
## Compose Env Generation
Docker Compose 本身不读取 JSON,因此 CLI 会从 `config.json` 生成 `.state/docker-compose.env`。该文件是派生状态,不应手写;如需改端口、token、provider 标签或主机名,应修改 `config.json` 后重新运行 CLI。
## Secrets
当前配置面向主 server 开发部署,包含开发用数据库密码和 provider token。公网暴露前必须在 `config.json` 中修改这些值,并重新启动栈以刷新派生环境文件。
+22
View File
@@ -0,0 +1,22 @@
# UniDesk Deployment Reference
主 server 使用根目录 `docker-compose.yml` 统一编排 database、backend-core、frontend 和 provider-gateway。当前环境本身就是主 server,因此 provider-gateway 也在同一台机器上启动,用与普通计算节点相同的 WebSocket 方式接入 core。
## Services
- `database` 使用 `postgres:16-alpine`,数据保存到 named volume `unidesk_pgdata_10gb`,初始化 SQL 位于 `src/components/database/init/`
- `backend-core` 是无状态核心服务,提供 REST API、provider WebSocket、任务调度入口和数据库访问层。
- `frontend` 是独立 Web 容器,通过浏览器访问 core 的公开 API URL。
- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 backend-core,并挂载 `/var/run/docker.sock` 作为自动任务执行主路径。
## Start And Stop
`bun scripts/cli.ts server start``bun scripts/cli.ts server stop` 都是异步 job。启动 job 会先清理固定 Compose project 的旧容器,再重新构建并启动,避免主 server 上残留旧容器或旧镜像配置。启动后用 `job status latest` 观察后台命令,用 `server status` 验证端口、容器和健康检查。
## Health Criteria
服务跑通的最低标准是:backend-core `/health` 返回 okfrontend `/health` 返回 okdatabase 端口监听,`/api/nodes` 中出现 `main-server` provider 且状态为 `online``debug dispatch main-server docker.ps` 能完成真实任务下发。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。
## Database Volume
架构要求数据库使用 10 GB named volume;当前实现将 volume 命名为 `unidesk_pgdata_10gb` 以固定生命周期。Docker named volume 默认不强制容量上限;如需硬配额,应在主机存储层或 Docker volume driver 层配置。CLI server 控制只能使用不删除 volume 的 `down` / `up` 流程,禁止使用 `down -v` 或删除 `unidesk_pgdata_10gb`
+36
View File
@@ -0,0 +1,36 @@
# UniDesk E2E Reference
UniDesk delivery is not complete until the public frontend, public core API, PostgreSQL database, and local provider-gateway self-connection pass one end-to-end check. The canonical automated command is `bun scripts/cli.ts e2e run`.
## Required Preconditions
- `config.json` `network.publicHost` must be the externally reachable host name or IP of the main server, not `127.0.0.1`, when validating browser access from outside the server.
- `bunx playwright install chromium` and `bunx playwright install-deps chromium` must have been run on hosts that execute browser E2E tests.
- The Docker stack must be running through `bun scripts/cli.ts server start`, and `bun scripts/cli.ts server status` must report healthy core, frontend, database, and provider-gateway containers.
## Automated E2E Scope
`bun scripts/cli.ts e2e run` validates the following through the public URLs derived from `config.json`:
- Core API: `GET /api/overview` reports `dbReady: true` and at least one online node.
- Provider self-connection: `GET /api/nodes` contains `main-server` with `status: online`.
- Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql`, confirms provider state is stored in PostgreSQL, and probes the public PostgreSQL port with `pg_isready`.
- Frontend: Playwright opens the public frontend URL, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, checks the metrics panel, and captures a screenshot under `.state/e2e/`.
## Public Frontend Rule
The frontend must not inject `127.0.0.1` as the browser-facing core API URL for public deployments. If a loopback URL is accidentally injected and the page itself is opened from a non-loopback host, `public/app.js` rewrites the API host to `window.location.hostname` as a safety net; however the correct fix is still to set `network.publicHost` correctly in `config.json` and restart the stack.
## Database Persistence Rule
The PostgreSQL data volume is the named Docker volume `unidesk_pgdata_10gb`. CLI server control commands must never use `docker compose down -v`, `docker volume rm`, or any equivalent data-volume removal. To validate persistence, insert a marker row into `unidesk_e2e_markers`, run `bun scripts/cli.ts server start` or a full stop/start cycle, and verify the marker row still exists.
## Delivery Gate
Before claiming delivery, run these checks and keep their JSON output or screenshot path available for review:
1. `bun scripts/cli.ts check`
2. `bun scripts/cli.ts server start`, then `bun scripts/cli.ts job status latest` until `succeeded`
3. `bun scripts/cli.ts server status`
4. `bun scripts/cli.ts e2e run`
5. a database persistence marker check across at least one CLI-controlled restart
+15
View File
@@ -0,0 +1,15 @@
# UniDesk Frontend Reference
UniDesk 前端是工业化控制台,不追求展示型大屏效果。设计目标是高信息密度、低装饰、低字号、低间距,并让调度、节点、事件和配置入口在单屏内快速切换。
## Layout
左侧边栏切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签切换子模块:Overview、Live Nodes、Event Log、Dispatch。桌面端采用双列内容网格,移动端将左侧栏压缩为横向模块条。
## Visual Language
界面使用深钢蓝、炭黑、琥珀和冷青作为工业控制台色板;字体选择窄体和等宽组合,以减少横向浪费。字号、表格行高和面板间距保持克制,避免大标题和松散卡片造成信息密度下降。
## Data Flow
frontend 容器只服务静态资产和轻量 HTML 注入;浏览器根据 `CORE_PUBLIC_URL` 调用 backend-core 的 REST API。调度表单调用 `/api/dispatch`,事件表和节点表通过轮询刷新。
+15
View File
@@ -0,0 +1,15 @@
# UniDesk Observability Reference
UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker 日志和数据库状态都必须能通过短命令查询。
## CLI Logs
异步 job 的 stdout 和 stderr 位于 `.state/jobs/``job status` 会返回有限尾部,避免输出爆炸,同时保留完整日志文件路径便于继续排查。
## Service Logs
服务日志位于 `logs/{YYYYMMDD}/`,每次 `server start` 都生成新的本地时间戳前缀。backend-core、frontend 和 provider-gateway 输出 JSONL 文件;database 通过 PostgreSQL logging collector 写入同一目录。
## Log Access
`bun scripts/cli.ts server logs` 同时读取文件日志和 Docker logs 尾部。文件日志是服务崩溃时的第一现场,Docker logs 是容器启动失败和 stdout/stderr 的辅助来源。
+15
View File
@@ -0,0 +1,15 @@
# Provider Gateway Reference
Provider Gateway 是计算节点侧容器。它只主动连出到 backend-core 的 WebSocket,不要求计算节点有公网 IP,适合 NAT、内网和防火墙后的机器。
## Main Server Self Provider
当前主 server 也运行一个 provider-gateway`providerId` 固定来自 `config.json``providerGateway.id`。这让单机环境也能验证完整的分布式调度闭环:frontend 发起任务,core 写数据库并通过 WebSocket 下发,provider gateway 执行后回传状态。
## Docker Socket Path
自动任务执行只允许走本地 Docker socket。Compose 将 `/var/run/docker.sock` 挂入 provider-gatewayprovider 标签会报告 `dockerSocketPresent``docker.ps` 调试任务会通过该 socket 查询宿主 Docker 容器。
## 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 路径误用为调度通道。
+66
View File
@@ -0,0 +1,66 @@
- unidesk/ (Repository root: configuration, orchestration, CLI, and documentation)
- AGENTS.md (Top-level agent index and `scripts/cli.ts` usage guide)
- TEST.md (Manual CLI test plan following cli-spec expectations)
- config.json (Single source of truth for ports, tokens, runtime, paths, and provider identity)
- docker-compose.yml (Main server orchestration for database, backend-core, frontend, provider-gateway)
- package.json / bun.lock (Root Bun tooling for CLI checks)
- .gitignore
- reference -> docs/reference (Compatibility symlink for older references)
- scripts/ (Unified CLI and implementation modules)
- cli.ts (Single Bun CLI entry)
- tsconfig.json (TypeScript check scope for CLI)
- src/ (CLI business logic modules; `cli.ts` remains a thin router)
- config.ts (Root config loading and validation)
- docker.ts (Docker Compose env generation, start/stop/status/logs)
- jobs.ts (Fire-and-Forget job state under `.state/jobs/`)
- check.ts (Formal checks)
- debug.ts (Real-flow debug helpers)
- command.ts (Bounded command execution helpers)
- output.ts (JSON output helpers)
- e2e.ts (Public API, database, provider, and Playwright frontend E2E checks)
- logs/ (Generated service logs; ignored by git)
- .state/ (Generated job state and compose env; ignored by git)
- docs/
- issue/ (Manual test issue records)
- reference/ (Long-term reference documents)
- arch.md (Distributed work platform architecture)
- repo-tree.md (This repository structure reference)
- cli.md (CLI command model and async job contract)
- config.md (Config and runtime rules)
- deployment.md (Docker stack deployment and health criteria)
- frontend.md (Frontend layout and design rules)
- provider-gateway.md (Provider connection and host SSH maintenance bridge)
- observability.md (Logs and status visibility)
- e2e.md (Delivery gate, Playwright frontend E2E, and database persistence checks)
- src/ (TypeScript component monorepo)
- package.json (Component workspace metadata)
- bun.lock (Component dependency lockfile)
- tsconfig.base.json (Project references for component checks)
- tsconfig.check.json (No-emit TypeScript check scope for all components)
- components/
- shared/ (Shared message types and utilities)
- package.json
- tsconfig.json
- src/index.ts
- backend-core/ (UniDesk stateless core service container)
- package.json
- tsconfig.json
- Dockerfile
- src/index.ts (REST API, WebSocket provider server, scheduler, database access)
- frontend/ (Frontend web application container)
- package.json
- tsconfig.json
- Dockerfile
- src/index.ts (Bun static server and runtime config injection)
- public/ (HTML/CSS/JS assets for the compact industrial console)
- provider-gateway/ (Compute node Provider Gateway container)
- package.json
- tsconfig.json
- Dockerfile
- src/index.ts (WebSocket client, heartbeat, Docker adapter)
- scripts/host-ssh-shell.sh (Optional maintenance-only SSH bridge)
- database/ (PostgreSQL initialization and configuration)
- config/postgresql.conf
- init/001_unidesk_init.sql
- microservices/ (Reserved for future stateless microservices)
- example-service/
+17
View File
@@ -0,0 +1,17 @@
{
"name": "unidesk-root",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"scripts": {
"cli": "bun scripts/cli.ts",
"check": "bun scripts/cli.ts check",
"e2e": "bun scripts/cli.ts e2e run"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"playwright": "^1.59.1",
"typescript": "latest"
}
}
Symlink
+1
View File
@@ -0,0 +1 @@
docs/reference
+133
View File
@@ -0,0 +1,133 @@
import { readConfig } from "./src/config";
import { debugDispatch, debugHealth } from "./src/debug";
import { stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
import { runE2E } from "./src/e2e";
import { emitError, emitJson } from "./src/output";
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
import { runChecks } from "./src/check";
const args = process.argv.slice(2);
const commandName = args.join(" ") || "help";
function help(): unknown {
return {
entry: "bun scripts/cli.ts",
output: "json",
commands: [
{ command: "help", description: "List supported commands." },
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
{ command: "check", description: "Run config, TypeScript, file presence, and docker-compose config checks." },
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, and provider gateway." },
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "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 core, overview, nodes, and frontend using real HTTP endpoints." },
{ command: "debug dispatch [providerId] [docker.ps|echo]", description: "Submit a real dispatch request to core for CLI debugging." },
{ command: "e2e run", description: "Run public API, database, provider, and Playwright frontend E2E checks." },
],
};
}
function numberOption(name: string, defaultValue: number): number {
const index = args.indexOf(name);
if (index === -1) return defaultValue;
const raw = args[index + 1];
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}
function latestJobId(): string {
const jobs = listJobs();
if (jobs.length === 0) throw new Error("No jobs found");
return jobs[0].id;
}
async function main(): Promise<void> {
const [top, sub, third, fourth] = args;
if (top === undefined || top === "help" || top === "--help" || top === "-h") {
emitJson(commandName, help());
return;
}
if (top === "internal" && sub === "run-job") {
if (!third) throw new Error("internal run-job requires job id");
emitJson(commandName, await runJob(third));
return;
}
const config = readConfig();
if (top === "config" && sub === "show") {
emitJson(commandName, { config });
return;
}
if (top === "check") {
const result = runChecks(config);
emitJson(commandName, result, result.ok);
if (!result.ok) process.exitCode = 1;
return;
}
if (top === "server") {
if (sub === "start") {
emitJson(commandName, startStack(config));
return;
}
if (sub === "stop") {
emitJson(commandName, stopStack(config));
return;
}
if (sub === "status") {
emitJson(commandName, await stackStatus(config));
return;
}
if (sub === "logs") {
emitJson(commandName, stackLogs(config, numberOption("--tail-bytes", 3000)));
return;
}
}
if (top === "job") {
if (sub === "list") {
emitJson(commandName, { jobs: listJobs() });
return;
}
if (sub === "status") {
const id = third === "latest" || third === undefined ? latestJobId() : third;
emitJson(commandName, { job: jobWithTail(readJob(id), numberOption("--tail-bytes", 12000)) });
return;
}
}
if (top === "debug") {
if (sub === "health") {
emitJson(commandName, await debugHealth(config));
return;
}
if (sub === "dispatch") {
const providerId = third ?? config.providerGateway.id;
const dispatchCommand = fourth === "docker.ps" || fourth === "echo" ? fourth : "docker.ps";
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand));
return;
}
}
if (top === "e2e" && sub === "run") {
const result = await runE2E(config);
const ok = (result as { ok?: unknown }).ok === true;
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
throw new Error(`Unknown command: ${commandName}`);
}
main().catch((error) => {
emitError(commandName, error);
process.exitCode = 1;
});
+59
View File
@@ -0,0 +1,59 @@
import { existsSync } from "node:fs";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { composeConfig } from "./docker";
interface CheckItem {
name: string;
ok: boolean;
detail: unknown;
}
function fileItem(path: string): CheckItem {
const absolute = rootPath(path);
return { name: `file:${path}`, ok: existsSync(absolute), detail: absolute };
}
function commandItem(name: string, command: string[]): CheckItem {
const result = runCommand(command, repoRoot);
return {
name,
ok: result.exitCode === 0,
detail: {
command,
exitCode: result.exitCode,
stdoutTail: result.stdout.slice(-4000),
stderrTail: result.stderr.slice(-4000),
},
};
}
export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckItem[] } {
const items: CheckItem[] = [
{ name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } },
fileItem("scripts/cli.ts"),
fileItem("AGENTS.md"),
fileItem("TEST.md"),
fileItem("docker-compose.yml"),
fileItem("src/components/backend-core/src/index.ts"),
fileItem("src/components/frontend/src/index.ts"),
fileItem("src/components/provider-gateway/src/index.ts"),
fileItem("scripts/src/e2e.ts"),
commandItem("bun:version", ["bun", "--version"]),
commandItem("typescript:scripts", ["bunx", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"]),
commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"]),
];
const compose = composeConfig(config);
items.push({
name: "docker-compose:config",
ok: compose.result.exitCode === 0,
detail: {
command: compose.command,
exitCode: compose.result.exitCode,
stdoutTail: compose.result.stdout.slice(-4000),
stderrTail: compose.result.stderr.slice(-4000),
runtimeEnv: compose.runtimeEnv,
},
});
return { ok: items.every((item) => item.ok), items };
}
+55
View File
@@ -0,0 +1,55 @@
import { spawn, spawnSync } from "node:child_process";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
export interface CommandResult {
command: string[];
cwd: string;
exitCode: number | null;
stdout: string;
stderr: string;
}
export function runCommand(command: string[], cwd: string): CommandResult {
const result = spawnSync(command[0], command.slice(1), {
cwd,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 8,
});
return {
command,
cwd,
exitCode: result.status,
stdout: result.stdout ?? "",
stderr: result.stderr ?? result.error?.message ?? "",
};
}
export function commandOk(command: string[], cwd: string): boolean {
return runCommand(command, cwd).exitCode === 0;
}
export async function runCommandToFiles(command: string[], cwd: string, stdoutFile: string, stderrFile: string): Promise<number | null> {
const stdout = createWriteStream(stdoutFile, { flags: "a" });
const stderr = createWriteStream(stderrFile, { flags: "a" });
stdout.write(`$ ${command.map((part) => JSON.stringify(part)).join(" ")}\n`);
const child = spawn(command[0], command.slice(1), { cwd, env: process.env });
child.stdout.pipe(stdout, { end: false });
child.stderr.pipe(stderr, { end: false });
const exitCode = await new Promise<number | null>((resolve) => {
child.on("close", (code) => resolve(code));
child.on("error", (error) => {
stderr.write(`${error.stack ?? error.message}\n`);
resolve(127);
});
});
stdout.write(`\n[exit_code=${exitCode}]\n`);
stdout.end();
stderr.end();
return exitCode;
}
export function tailFile(path: string, maxBytes = 8192): string {
if (!existsSync(path)) return "";
const content = readFileSync(path);
return content.subarray(Math.max(0, content.length - maxBytes)).toString("utf8");
}
+117
View File
@@ -0,0 +1,117 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export interface UniDeskConfig {
project: { name: string; timezone: string };
runtime: { typescript: "bun"; bunVersion: string };
network: {
host: string;
publicHost: string;
core: { port: number; containerPort: number };
frontend: { port: number; containerPort: number };
database: { port: number; containerPort: number };
};
database: { user: string; password: string; name: string; volume: string; volumeSize: string };
providerGateway: {
id: string;
name: string;
token: string;
labels: Record<string, unknown>;
heartbeatIntervalMs: number;
reconnectBaseMs: number;
reconnectMaxMs: number;
};
docker: { composeFile: string; projectName: string };
paths: { stateDir: string; logsDir: string; docsReferenceDir: string };
sshForwarding: { mode: string; keyDir: string; host: string; port: number; user: string };
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
export const repoRoot = join(moduleDir, "..", "..");
export function rootPath(...parts: string[]): string {
return join(repoRoot, ...parts);
}
function asRecord(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${name} must be an object`);
}
return value as Record<string, unknown>;
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value;
}
function numberField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path}.${key} must be a positive number`);
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}`) };
}
export function readConfig(): UniDeskConfig {
const configPath = rootPath("config.json");
if (!existsSync(configPath)) throw new Error(`config.json not found at ${configPath}`);
const raw = readFileSync(configPath, "utf8");
const parsed = asRecord(JSON.parse(raw) as unknown, "config.json");
const project = asRecord(parsed.project, "project");
const runtime = asRecord(parsed.runtime, "runtime");
const network = asRecord(parsed.network, "network");
const database = asRecord(parsed.database, "database");
const providerGateway = asRecord(parsed.providerGateway, "providerGateway");
const docker = asRecord(parsed.docker, "docker");
const paths = asRecord(parsed.paths, "paths");
const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding");
const labels = asRecord(providerGateway.labels, "providerGateway.labels");
const typescript = stringField(runtime, "typescript", "runtime");
if (typescript !== "bun") throw new Error("runtime.typescript must be bun");
return {
project: { name: stringField(project, "name", "project"), timezone: stringField(project, "timezone", "project") },
runtime: { typescript, bunVersion: stringField(runtime, "bunVersion", "runtime") },
network: {
host: stringField(network, "host", "network"),
publicHost: stringField(network, "publicHost", "network"),
core: portPair(network, "core"),
frontend: portPair(network, "frontend"),
database: portPair(network, "database"),
},
database: {
user: stringField(database, "user", "database"),
password: stringField(database, "password", "database"),
name: stringField(database, "name", "database"),
volume: stringField(database, "volume", "database"),
volumeSize: stringField(database, "volumeSize", "database"),
},
providerGateway: {
id: stringField(providerGateway, "id", "providerGateway"),
name: stringField(providerGateway, "name", "providerGateway"),
token: stringField(providerGateway, "token", "providerGateway"),
labels,
heartbeatIntervalMs: numberField(providerGateway, "heartbeatIntervalMs", "providerGateway"),
reconnectBaseMs: numberField(providerGateway, "reconnectBaseMs", "providerGateway"),
reconnectMaxMs: numberField(providerGateway, "reconnectMaxMs", "providerGateway"),
},
docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") },
paths: {
stateDir: stringField(paths, "stateDir", "paths"),
logsDir: stringField(paths, "logsDir", "paths"),
docsReferenceDir: stringField(paths, "docsReferenceDir", "paths"),
},
sshForwarding: {
mode: stringField(sshForwarding, "mode", "sshForwarding"),
keyDir: stringField(sshForwarding, "keyDir", "sshForwarding"),
host: stringField(sshForwarding, "host", "sshForwarding"),
port: numberField(sshForwarding, "port", "sshForwarding"),
user: stringField(sshForwarding, "user", "sshForwarding"),
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import { type UniDeskConfig } from "./config";
async function readJson(url: string, init?: RequestInit): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
const text = await res.text();
return { ok: res.ok, status: res.status, body: text.length > 0 ? JSON.parse(text) : null };
} finally {
clearTimeout(timer);
}
}
export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
return {
core: await readJson(`http://127.0.0.1:${config.network.core.port}/health`),
overview: await readJson(`http://127.0.0.1:${config.network.core.port}/api/overview`),
nodes: await readJson(`http://127.0.0.1:${config.network.core.port}/api/nodes`),
frontend: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`),
};
}
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise<unknown> {
return readJson(`http://127.0.0.1:${config.network.core.port}/api/dispatch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId, command, payload: { source: "cli-debug" } }),
});
}
+213
View File
@@ -0,0 +1,213 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { commandOk, runCommand, tailFile } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { startJob } from "./jobs";
export interface ComposeRuntimeEnv {
envFile: string;
logDir: string;
logPrefix: string;
}
export interface ContainerStatus {
id: string;
name: string;
image: string;
status: string;
ports: string;
}
export function resolveComposeCommand(config: UniDeskConfig, envFile: string): string[] {
const composeFile = rootPath(config.docker.composeFile);
if (commandOk(["docker", "compose", "version"], repoRoot)) {
return ["docker", "compose", "--env-file", envFile, "-f", composeFile, "-p", config.docker.projectName];
}
if (commandOk(["docker-compose", "--version"], repoRoot)) {
return ["docker-compose", "--env-file", envFile, "-f", composeFile, "-p", config.docker.projectName];
}
throw new Error("Neither docker compose plugin nor docker-compose binary is available");
}
function pad(value: number): string {
return String(value).padStart(2, "0");
}
function localDateParts(date: Date): { day: string; stamp: string } {
const day = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
const stamp = `${day}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
return { day, stamp };
}
function envValue(value: string): string {
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
return JSON.stringify(value);
}
export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): ComposeRuntimeEnv {
const stateDir = rootPath(config.paths.stateDir);
mkdirSync(stateDir, { recursive: true });
const envFile = join(stateDir, "docker-compose.env");
if (!freshLogPrefix && existsSync(envFile)) {
const raw = readFileSync(envFile, "utf8");
const logDir = raw.match(/^UNIDESK_LOG_DIR=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? rootPath(config.paths.logsDir);
const logPrefix = raw.match(/^UNIDESK_LOG_PREFIX=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? localDateParts(new Date()).stamp;
return { envFile, logDir, logPrefix };
}
const parts = localDateParts(new Date());
const logDir = resolve(rootPath(config.paths.logsDir, parts.day));
mkdirSync(logDir, { recursive: true });
chmodSync(logDir, 0o777);
const labels = JSON.stringify(config.providerGateway.labels);
const lines = {
UNIDESK_PUBLIC_HOST: config.network.publicHost,
UNIDESK_CORE_PORT: String(config.network.core.port),
UNIDESK_FRONTEND_PORT: String(config.network.frontend.port),
UNIDESK_DATABASE_PORT: String(config.network.database.port),
UNIDESK_DATABASE_USER: config.database.user,
UNIDESK_DATABASE_PASSWORD: config.database.password,
UNIDESK_DATABASE_NAME: config.database.name,
UNIDESK_PROVIDER_TOKEN: config.providerGateway.token,
UNIDESK_PROVIDER_ID: config.providerGateway.id,
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
UNIDESK_PROVIDER_LABELS_JSON: labels,
UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs),
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
UNIDESK_LOG_DIR: logDir,
UNIDESK_LOG_PREFIX: parts.stamp,
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
};
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
return { envFile, logDir, logPrefix: parts.stamp };
}
export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType<typeof runCommand> } {
const runtimeEnv = writeComposeEnv(config, false);
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
const command = [...compose, "config", "-q"];
return { runtimeEnv, command, result: runCommand(command, repoRoot) };
}
export function startStack(config: UniDeskConfig): unknown {
const runtimeEnv = writeComposeEnv(config, true);
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
const containers = dockerContainers(config);
const occupiedPorts = fixedPorts(config).filter((item) => item.listening);
if (occupiedPorts.length > 0 && containers.length === 0) {
throw new Error(`Fixed UniDesk port is occupied before start: ${occupiedPorts.map((p) => `${p.name}:${p.port}`).join(", ")}`);
}
const downCommand = [...compose, "down", "--remove-orphans"];
const upCommand = [...compose, "up", "-d", "--build"];
const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`];
const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, and provider gateway containers");
return { job, runtimeEnv, command, ports: fixedPorts(config) };
}
export function stopStack(config: UniDeskConfig): unknown {
const runtimeEnv = writeComposeEnv(config, false);
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
const command = [...compose, "down", "--remove-orphans"];
const job = startJob("server_stop", command, "Stop all UniDesk Docker services managed by the fixed compose project");
return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) };
}
function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> {
return [
{ name: "backend-core", port: config.network.core.port, listening: isPortListening(config.network.core.port) },
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },
{ name: "database", port: config.network.database.port, listening: isPortListening(config.network.database.port) },
];
}
function shellJoin(args: string[]): string {
return args.map((arg) => `'${arg.replace(/'/g, `'\\''`)}'`).join(" ");
}
function isPortListening(port: number): boolean {
const result = runCommand(["ss", "-ltn"], repoRoot);
if (result.exitCode !== 0) return false;
return result.stdout.split("\n").some((line) => line.includes(`:${port} `) || line.includes(`:${port}\t`));
}
export function dockerContainers(config: UniDeskConfig): ContainerStatus[] {
const result = runCommand([
"docker",
"ps",
"-a",
"--filter",
`label=com.docker.compose.project=${config.docker.projectName}`,
"--format",
"{{json .}}",
], repoRoot);
if (result.exitCode !== 0 || result.stdout.trim().length === 0) return [];
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, string>)
.map((row) => ({ id: row.ID ?? "", name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "", ports: row.Ports ?? "" }));
}
async function probe(url: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
try {
const res = await fetch(url, { signal: controller.signal });
const body = await res.text();
return { ok: res.ok, status: res.status, body: body.slice(0, 1200) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
const runtimeEnv = writeComposeEnv(config, false);
return {
runtimeEnv,
ports: fixedPorts(config),
containers: dockerContainers(config),
health: {
core: await probe(`http://127.0.0.1:${config.network.core.port}/health`),
frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`),
overview: await probe(`http://127.0.0.1:${config.network.core.port}/api/overview`),
},
urls: {
core: `http://${config.network.publicHost}:${config.network.core.port}`,
frontend: `http://${config.network.publicHost}:${config.network.frontend.port}`,
database: `postgres://${config.database.user}:***@${config.network.publicHost}:${config.network.database.port}/${config.database.name}`,
},
};
}
function listLogFiles(root: string): string[] {
if (!existsSync(root)) return [];
const entries = readdirSync(root, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const path = join(root, entry.name);
if (entry.isDirectory()) files.push(...listLogFiles(path));
if (entry.isFile()) files.push(path);
}
return files.sort();
}
export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown {
const logRoot = rootPath(config.paths.logsDir);
const runtimeEnv = writeComposeEnv(config, false);
const allFiles = listLogFiles(logRoot);
const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix));
const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12);
const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) }));
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main"];
const docker = containerNames.map((name) => {
const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot);
return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) };
});
return { logRoot, runtimeEnv, files, docker };
}
+154
View File
@@ -0,0 +1,154 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
type CheckStatus = "passed" | "failed";
interface E2ECheck {
name: string;
status: CheckStatus;
detail: unknown;
}
interface PublicUrls {
frontendUrl: string;
coreUrl: string;
databaseHost: string;
databasePort: number;
}
function publicUrls(config: UniDeskConfig): PublicUrls {
return {
frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`,
coreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
databaseHost: config.network.publicHost,
databasePort: config.network.database.port,
};
}
async function fetchJson(url: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, { signal: controller.signal });
const text = await response.text();
return { ok: response.ok, status: response.status, body: text.length > 0 ? JSON.parse(text) : null };
} finally {
clearTimeout(timer);
}
}
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
checks.push({ name, status: passed ? "passed" : "failed", detail });
}
function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: string; stderr: string; exitCode: number | null } {
const result = runCommand([
"docker",
"exec",
"unidesk-database",
"psql",
"-U",
config.database.user,
"-d",
config.database.name,
"-v",
"ON_ERROR_STOP=1",
"-tA",
"-c",
sql,
], repoRoot);
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
}
async function apiChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const overview = await fetchJson(`${urls.coreUrl}/api/overview`);
const nodes = await fetchJson(`${urls.coreUrl}/api/nodes`);
addCheck(checks, "core:public-overview", (overview as { ok?: boolean; body?: { ok?: boolean; onlineNodeCount?: number } }).ok === true && (overview as { body?: { ok?: boolean; onlineNodeCount?: number } }).body?.ok === true && ((overview as { body?: { onlineNodeCount?: number } }).body?.onlineNodeCount ?? 0) >= 1, overview);
const nodeList = (nodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
addCheck(checks, "core:public-nodes", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), nodes);
}
function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): string {
const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
const markerSql = `
CREATE TABLE IF NOT EXISTS unidesk_e2e_markers (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO unidesk_e2e_markers (id, source) VALUES ('${markerId}', 'cli-e2e');
SELECT 'marker=' || id FROM unidesk_e2e_markers WHERE id = '${markerId}';
SELECT 'marker_count=' || count(*) FROM unidesk_e2e_markers;
SELECT 'online_main_server=' || count(*) FROM unidesk_nodes WHERE provider_id = '${config.providerGateway.id}' AND status = 'online';
`;
const marker = runPsql(config, markerSql);
addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker);
addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker);
const publicProbe = runCommand([
"docker",
"run",
"--rm",
"postgres:16-alpine",
"pg_isready",
"-h",
urls.databaseHost,
"-p",
String(urls.databasePort),
"-U",
config.database.user,
], repoRoot);
addCheck(checks, "database:public-port", publicProbe.exitCode === 0, {
exitCode: publicProbe.exitCode,
stdout: publicProbe.stdout.trim(),
stderr: publicProbe.stderr.trim(),
});
return markerId;
}
async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<{ screenshotPath: string; bodyText: string; consoleErrors: string[] }> {
const e2eDir = rootPath(".state", "e2e");
mkdirSync(e2eDir, { recursive: true });
const screenshotPath = join(e2eDir, `${new Date().toISOString().replace(/[-:.TZ]/g, "")}_frontend.png`);
const consoleErrors: string[] = [];
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 920 } });
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(error.message));
await page.goto(urls.frontendUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForFunction(() => document.querySelector("#conn-text")?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 });
await page.waitForSelector("text=Online Nodes", { timeout: 5000 });
const bodyText = await page.locator("body").innerText({ timeout: 5000 });
const nodeCount = await page.locator("#node-count").innerText({ timeout: 5000 });
const metricText = await page.locator("#metric-grid").innerText({ timeout: 5000 });
await page.screenshot({ path: screenshotPath, fullPage: true });
addCheck(checks, "frontend:public-page-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name), { nodeCount, metricText, screenshotPath });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {
await browser.close();
}
}
export async function runE2E(config: UniDeskConfig): Promise<unknown> {
const checks: E2ECheck[] = [];
const urls = publicUrls(config);
await apiChecks(config, urls, checks);
const markerId = databaseChecks(config, urls, checks);
const frontend = await frontendCheck(config, urls, checks);
const ok = checks.every((check) => check.status === "passed");
return {
ok,
urls,
markerId,
screenshotPath: frontend.screenshotPath,
checks,
};
}
+95
View File
@@ -0,0 +1,95 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommandToFiles, tailFile } from "./command";
export type JobStatus = "queued" | "running" | "succeeded" | "failed";
export interface JobRecord {
id: string;
name: string;
status: JobStatus;
command: string[];
cwd: string;
createdAt: string;
startedAt: string | null;
finishedAt: string | null;
exitCode: number | null;
stdoutFile: string;
stderrFile: string;
note: string;
}
function jobsDir(): string {
const dir = rootPath(".state", "jobs");
mkdirSync(dir, { recursive: true });
return dir;
}
function jobPath(id: string): string {
return join(jobsDir(), `${id}.json`);
}
function writeJob(job: JobRecord): void {
writeFileSync(jobPath(job.id), `${JSON.stringify(job, null, 2)}\n`, "utf8");
}
export function readJob(id: string): JobRecord {
const path = jobPath(id);
if (!existsSync(path)) throw new Error(`job not found: ${id}`);
return JSON.parse(readFileSync(path, "utf8")) as JobRecord;
}
export function listJobs(): JobRecord[] {
return readdirSync(jobsDir())
.filter((name) => name.endsWith(".json"))
.map((name) => JSON.parse(readFileSync(join(jobsDir(), name), "utf8")) as JobRecord)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function startJob(name: string, command: string[], note: string): JobRecord {
const id = `${name}_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(16).slice(2, 8)}`;
const stdoutFile = rootPath(".state", "jobs", `${id}.stdout.log`);
const stderrFile = rootPath(".state", "jobs", `${id}.stderr.log`);
const job: JobRecord = {
id,
name,
status: "queued",
command,
cwd: repoRoot,
createdAt: new Date().toISOString(),
startedAt: null,
finishedAt: null,
exitCode: null,
stdoutFile,
stderrFile,
note,
};
writeJob(job);
const child = spawn(process.execPath, [rootPath("scripts", "cli.ts"), "internal", "run-job", id], {
cwd: repoRoot,
detached: true,
stdio: "ignore",
env: process.env,
});
child.unref();
return job;
}
export async function runJob(id: string): Promise<JobRecord> {
const job = readJob(id);
job.status = "running";
job.startedAt = new Date().toISOString();
writeJob(job);
const exitCode = await runCommandToFiles(job.command, job.cwd, job.stdoutFile, job.stderrFile);
job.exitCode = exitCode;
job.status = exitCode === 0 ? "succeeded" : "failed";
job.finishedAt = new Date().toISOString();
writeJob(job);
return job;
}
export function jobWithTail(job: JobRecord, maxBytes = 12000): JobRecord & { stdoutTail: string; stderrTail: string } {
return { ...job, stdoutTail: tailFile(job.stdoutFile, maxBytes), stderrTail: tailFile(job.stderrFile, maxBytes) };
}
+31
View File
@@ -0,0 +1,31 @@
export interface JsonEnvelope<T> {
ok: boolean;
command: string;
data?: T;
error?: unknown;
}
export function emitJson<T>(command: string, data: T, ok = true): void {
const envelope: JsonEnvelope<T> = { ok, command, data };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
}
export function emitError(command: string, error: unknown): void {
const payload = error instanceof Error
? { name: error.name, message: error.message, stack: error.stack ?? null }
: { message: String(error) };
const envelope: JsonEnvelope<never> = { ok: false, command, error: payload };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
}
function safeStdoutWrite(text: string): void {
try {
process.stdout.write(text);
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE") {
process.exitCode = 0;
return;
}
throw error;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["cli.ts", "src/**/*.ts"]
}
+50
View File
@@ -0,0 +1,50 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@unidesk/components",
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"typescript": "latest",
},
},
"components/backend-core": {
"name": "@unidesk/backend-core",
"dependencies": {
"postgres": "latest",
},
},
"components/frontend": {
"name": "@unidesk/frontend",
},
"components/provider-gateway": {
"name": "@unidesk/provider-gateway",
},
"components/shared": {
"name": "@unidesk/shared",
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@unidesk/backend-core": ["@unidesk/backend-core@workspace:components/backend-core"],
"@unidesk/frontend": ["@unidesk/frontend@workspace:components/frontend"],
"@unidesk/provider-gateway": ["@unidesk/provider-gateway@workspace:components/provider-gateway"],
"@unidesk/shared": ["@unidesk/shared@workspace:components/shared"],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
+7
View File
@@ -0,0 +1,7 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/backend-core
COPY src/components/backend-core/package.json ./package.json
RUN bun install --production
COPY src/components/shared /app/src/components/shared
COPY src/components/backend-core/src ./src
CMD ["bun", "run", "src/index.ts"]
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@unidesk/backend-core",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"postgres": "latest"
}
}
+484
View File
@@ -0,0 +1,484 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { Server, ServerWebSocket } from "bun";
import postgres from "postgres";
import {
type ApiEvent,
type ApiNode,
type ApiTask,
type CoreDispatchMessage,
type JsonValue,
type ProviderLabels,
type ProviderToCoreMessage,
isProviderToCoreMessage,
} from "../../shared/src/index";
interface RuntimeConfig {
port: number;
databaseUrl: string;
providerToken: string;
heartbeatTimeoutMs: number;
logFile: string;
}
interface WsData {
providerId?: string;
}
type ProviderSocket = ServerWebSocket<WsData>;
type SqlClient = ReturnType<typeof postgres>;
const recentLogs: unknown[] = [];
const activeProviders = new Map<string, ProviderSocket>();
const serviceStartedAt = new Date();
const config = readConfig();
const logger = createLogger("backend-core", config.logFile);
const sql = postgres(config.databaseUrl, {
max: 8,
idle_timeout: 20,
connect_timeout: 10,
});
let dbReady = false;
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
port: readNumberEnv("PORT"),
databaseUrl: requiredEnv("DATABASE_URL"),
providerToken: requiredEnv("PROVIDER_TOKEN"),
heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"),
logFile: requiredEnv("LOG_FILE"),
};
}
function createLogger(service: string, logFile: string) {
mkdirSync(dirname(logFile), { recursive: true });
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = data === undefined
? { ts: new Date().toISOString(), service, level, message }
: { ts: new Date().toISOString(), service, level, message, data };
recentLogs.push(entry);
while (recentLogs.length > 500) recentLogs.shift();
const line = `${JSON.stringify(entry)}\n`;
try {
appendFileSync(logFile, line, "utf8");
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,POST,OPTIONS",
"access-control-allow-headers": "content-type,x-provider-token",
},
});
}
function textResponse(text: string, status = 200): Response {
return new Response(text, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
"access-control-allow-origin": "*",
},
});
}
async function initDatabase(client: SqlClient): Promise<void> {
logger("info", "database_init_start", { databaseUrl: redactDatabaseUrl(config.databaseUrl) });
await client`
CREATE TABLE IF NOT EXISTS unidesk_nodes (
provider_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL,
connected_at TIMESTAMPTZ,
last_heartbeat TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await client`
CREATE TABLE IF NOT EXISTS unidesk_events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
source TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
await client`
CREATE TABLE IF NOT EXISTS unidesk_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
command TEXT NOT NULL,
status TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`;
dbReady = true;
logger("info", "database_init_complete");
}
async function initDatabaseWithRetry(): Promise<void> {
const started = Date.now();
let attempt = 0;
while (!dbReady) {
attempt += 1;
try {
await initDatabase(sql);
return;
} catch (error) {
const elapsedMs = Date.now() - started;
logger("warn", "database_init_retry", { attempt, elapsedMs, error: errorToJson(error) });
if (elapsedMs > 90_000) {
logger("error", "database_init_failed", { attempt, elapsedMs, error: errorToJson(error) });
throw error;
}
await Bun.sleep(Math.min(1000 * attempt, 5000));
}
}
}
function redactDatabaseUrl(value: string): string {
try {
const url = new URL(value);
if (url.password) url.password = "***";
return url.toString();
} catch {
return "<invalid-url>";
}
}
function errorToJson(error: unknown): JsonValue {
if (error instanceof Error) {
return { name: error.name, message: error.message, stack: error.stack ?? null };
}
return String(error);
}
function compactJson(value: unknown, depth = 0): JsonValue {
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "string") return value.length > 600 ? `${value.slice(0, 600)}...<truncated:${value.length}>` : value;
if (Array.isArray(value)) {
const items = value.slice(0, 20).map((item) => compactJson(item, depth + 1));
if (value.length > 20) items.push({ truncatedItems: value.length - 20 });
return items;
}
if (typeof value === "object" && value !== null) {
if (depth >= 4) return "<truncated:depth>";
const entries = Object.entries(value as Record<string, unknown>).slice(0, 30);
const result: Record<string, JsonValue> = {};
for (const [key, item] of entries) result[key] = compactJson(item, depth + 1);
const total = Object.keys(value as Record<string, unknown>).length;
if (total > entries.length) result.truncatedKeys = total - entries.length;
return result;
}
return String(value);
}
async function recordEvent(type: string, source: string, payload: JsonValue): Promise<void> {
logger("info", type, { source, payload });
if (!dbReady) return;
try {
await sql`
INSERT INTO unidesk_events (type, source, payload)
VALUES (${type}, ${source}, ${sql.json(payload)})
`;
} catch (error) {
logger("error", "event_insert_failed", { type, source, error: errorToJson(error) });
}
}
async function upsertNodeOnline(providerId: string, name: string, labels: ProviderLabels): Promise<void> {
await sql`
INSERT INTO unidesk_nodes (provider_id, name, labels, status, connected_at, last_heartbeat, updated_at)
VALUES (${providerId}, ${name}, ${sql.json(labels)}, 'online', now(), now(), now())
ON CONFLICT (provider_id) DO UPDATE SET
name = EXCLUDED.name,
labels = EXCLUDED.labels,
status = 'online',
connected_at = COALESCE(unidesk_nodes.connected_at, EXCLUDED.connected_at),
last_heartbeat = now(),
updated_at = now()
`;
}
async function touchHeartbeat(providerId: string, labels: ProviderLabels): Promise<void> {
await sql`
UPDATE unidesk_nodes
SET labels = ${sql.json(labels)}, status = 'online', last_heartbeat = now(), updated_at = now()
WHERE provider_id = ${providerId}
`;
}
async function markProviderOffline(providerId: string): Promise<void> {
activeProviders.delete(providerId);
if (!dbReady) return;
await sql`
UPDATE unidesk_nodes
SET status = 'offline', updated_at = now()
WHERE provider_id = ${providerId}
`;
await recordEvent("provider_offline", providerId, { providerId });
}
async function markStaleProvidersOffline(): Promise<void> {
if (!dbReady) return;
const timeoutMs = config.heartbeatTimeoutMs;
const rows = await sql<{ provider_id: string }[]>`
UPDATE unidesk_nodes
SET status = 'offline', updated_at = now()
WHERE status = 'online'
AND last_heartbeat IS NOT NULL
AND last_heartbeat < now() - (${timeoutMs} * interval '1 millisecond')
RETURNING provider_id
`;
for (const row of rows) {
activeProviders.delete(row.provider_id);
await recordEvent("provider_heartbeat_timeout", row.provider_id, { providerId: row.provider_id, timeoutMs });
}
}
function parseMessage(raw: string | Buffer): ProviderToCoreMessage {
const text = typeof raw === "string" ? raw : raw.toString("utf8");
const parsed = JSON.parse(text) as unknown;
if (!isProviderToCoreMessage(parsed)) {
throw new Error(`Unsupported provider message: ${text.slice(0, 200)}`);
}
return parsed;
}
async function handleProviderMessage(ws: ProviderSocket, raw: string | Buffer): Promise<void> {
const message = parseMessage(raw);
ws.data.providerId = message.providerId;
activeProviders.set(message.providerId, ws);
if (message.type === "register") {
await upsertNodeOnline(message.providerId, message.name, message.labels);
await recordEvent("provider_registered", message.providerId, {
providerId: message.providerId,
name: message.name,
labels: message.labels,
capabilities: message.capabilities,
});
ws.send(JSON.stringify({ type: "ack", requestId: "register", ok: true, message: "registered" }));
return;
}
if (message.type === "heartbeat") {
await touchHeartbeat(message.providerId, message.labels);
logger("debug", "provider_heartbeat", { providerId: message.providerId, labels: message.labels });
return;
}
await sql`
UPDATE unidesk_tasks
SET status = ${message.status}, result = ${sql.json(message.result ?? { message: message.message })}, updated_at = now()
WHERE id = ${message.taskId}
`;
await recordEvent("task_status", message.providerId, {
providerId: message.providerId,
taskId: message.taskId,
status: message.status,
message: message.message,
result: message.result ?? null,
});
}
async function getNodes(): Promise<ApiNode[]> {
const rows = await sql<Array<Record<string, unknown>>>`
SELECT provider_id, name, labels, status, connected_at, last_heartbeat
FROM unidesk_nodes
ORDER BY status DESC, provider_id ASC
`;
return rows.map((row) => ({
providerId: String(row.provider_id),
name: String(row.name),
status: row.status === "online" ? "online" : "offline",
labels: (row.labels ?? {}) as ProviderLabels,
connectedAt: row.connected_at instanceof Date ? row.connected_at.toISOString() : row.connected_at === null ? null : String(row.connected_at),
lastHeartbeat: row.last_heartbeat instanceof Date ? row.last_heartbeat.toISOString() : row.last_heartbeat === null ? null : String(row.last_heartbeat),
}));
}
async function getEvents(limit: number): Promise<ApiEvent[]> {
const rows = await sql<Array<Record<string, unknown>>>`
SELECT id, type, source, payload, created_at
FROM unidesk_events
ORDER BY id DESC
LIMIT ${limit}
`;
return rows.map((row) => ({
id: Number(row.id),
type: String(row.type),
source: String(row.source),
payload: compactJson(row.payload ?? {}),
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
}));
}
async function getTasks(limit: number): Promise<ApiTask[]> {
const rows = await sql<Array<Record<string, unknown>>>`
SELECT id, provider_id, command, status, payload, result, created_at, updated_at
FROM unidesk_tasks
ORDER BY updated_at DESC
LIMIT ${limit}
`;
return rows.map((row) => ({
id: String(row.id),
providerId: String(row.provider_id),
command: String(row.command),
status: String(row.status),
payload: compactJson(row.payload ?? {}),
result: compactJson(row.result ?? null),
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
}));
}
async function getOverview(): Promise<JsonValue> {
const nodes = await getNodes();
const tasks = await getTasks(50);
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 {
service: "unidesk-core",
ok: true,
dbReady,
uptimeSeconds: Math.floor((Date.now() - serviceStartedAt.getTime()) / 1000),
nodeCount: nodes.length,
onlineNodeCount: online,
pendingTaskCount: pendingTasks,
activeSocketCount: activeProviders.size,
heartbeatTimeoutMs: config.heartbeatTimeoutMs,
};
}
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 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);
}
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await sql`
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
VALUES (${taskId}, ${providerId}, ${command}, 'queued', ${sql.json(payload)}, NULL)
`;
const socket = activeProviders.get(providerId);
if (!socket) {
await recordEvent("task_queued_provider_offline", providerId, { taskId, providerId, command });
return jsonResponse({ ok: true, taskId, status: "queued", providerOnline: false });
}
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 recordEvent("task_dispatched", providerId, { taskId, providerId, command });
return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true });
}
async function route(req: Request, server: Server<WsData>): Promise<Response | undefined> {
const url = new URL(req.url);
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
if (url.pathname === "/ws/provider") {
const token = url.searchParams.get("token") ?? req.headers.get("x-provider-token");
if (token !== config.providerToken) {
await recordEvent("provider_auth_failed", "unknown", { remote: req.headers.get("x-forwarded-for") ?? null });
return jsonResponse({ ok: false, error: "invalid provider token" }, 401);
}
const upgraded = server.upgrade(req, { data: {} satisfies WsData });
return upgraded ? undefined : jsonResponse({ ok: false, error: "websocket upgrade failed" }, 400);
}
try {
if (url.pathname === "/" || url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-core", dbReady, startedAt: serviceStartedAt.toISOString() });
}
if (!dbReady && url.pathname.startsWith("/api/")) {
return jsonResponse({ ok: false, error: "database not ready" }, 503);
}
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/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);
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-readLimit(url, 100)) });
if (url.pathname === "/favicon.ico") return textResponse("", 204);
return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404);
} catch (error) {
logger("error", "request_failed", { path: url.pathname, error: errorToJson(error) });
return jsonResponse({ ok: false, error: errorToJson(error) }, 500);
}
}
function readLimit(url: URL, defaultLimit: number): number {
const raw = url.searchParams.get("limit");
if (raw === null) return defaultLimit;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) return defaultLimit;
return Math.min(parsed, 500);
}
await initDatabaseWithRetry();
const server = Bun.serve<WsData>({
port: config.port,
hostname: "0.0.0.0",
fetch: route,
websocket: {
open(ws) {
logger("info", "provider_socket_open", { remoteAddress: ws.remoteAddress });
},
message(ws, raw) {
handleProviderMessage(ws, raw).catch((error) => {
logger("error", "provider_message_failed", { providerId: ws.data.providerId ?? null, error: errorToJson(error) });
ws.send(JSON.stringify({ type: "ack", requestId: "message", ok: false, message: String(error) }));
});
},
close(ws) {
const providerId = ws.data.providerId;
logger("warn", "provider_socket_close", { providerId: providerId ?? null });
if (providerId !== undefined) {
markProviderOffline(providerId).catch((error) => logger("error", "provider_offline_mark_failed", { providerId, error: errorToJson(error) }));
}
},
},
});
setInterval(() => {
markStaleProvidersOffline().catch((error) => logger("error", "heartbeat_sweep_failed", { error: errorToJson(error) }));
}, 10_000);
logger("info", "server_listening", { url: `http://0.0.0.0:${server.port}`, logFile: config.logFile });
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}
@@ -0,0 +1,6 @@
# UniDesk keeps PostgreSQL mostly stock; runtime logging options are injected by docker-compose.yml.
shared_buffers = '256MB'
work_mem = '16MB'
maintenance_work_mem = '64MB'
max_connections = 100
listen_addresses = '*'
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS unidesk_nodes (
provider_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL,
connected_at TIMESTAMPTZ,
last_heartbeat TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS unidesk_events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
source TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS unidesk_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
command TEXT NOT NULL,
status TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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);
+7
View File
@@ -0,0 +1,7 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/frontend
COPY src/components/frontend/package.json ./package.json
RUN bun install --production
COPY src/components/frontend/src ./src
COPY src/components/frontend/public ./public
CMD ["bun", "run", "src/index.ts"]
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@unidesk/frontend",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
}
}
+153
View File
@@ -0,0 +1,153 @@
const rawCfg = window.UNIDESK_CONFIG || { coreApiUrl: "http://127.0.0.1:18080", corePort: "18080" };
const cfg = { ...rawCfg, coreApiUrl: resolveCoreApiUrl(rawCfg) };
const state = { overview: null, nodes: [], events: [], tasks: [], activeModule: "ops", activeTab: "overview", lastRefresh: null };
const $ = (id) => document.getElementById(id);
const fmtTime = (value) => value ? new Date(value).toLocaleTimeString() : "--";
const compactJson = (value) => JSON.stringify(value ?? {}, null, 0);
function resolveCoreApiUrl(config) {
const api = new URL(config.coreApiUrl, window.location.href);
const pageHost = window.location.hostname;
const apiIsLoopback = api.hostname === "127.0.0.1" || api.hostname === "localhost";
const pageIsLoopback = pageHost === "127.0.0.1" || pageHost === "localhost";
if (apiIsLoopback && !pageIsLoopback) {
api.hostname = pageHost;
api.port = String(config.corePort || api.port || "18080");
}
return api.origin;
}
function setConnection(ok, text) {
const dot = $("conn-dot");
dot.className = `dot ${ok ? "ok" : "fail"}`;
$("conn-text").textContent = text;
}
async function api(path, options) {
const res = await fetch(`${cfg.coreApiUrl}${path}`, options);
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(json.error?.message || json.error || `HTTP ${res.status}`);
return json;
}
function metric(label, value, hint) {
return `<article class="metric"><div class="label">${label}</div><div class="value">${value}</div><div class="hint">${hint}</div></article>`;
}
function renderMetrics() {
const o = state.overview || {};
$("metric-grid").innerHTML = [
metric("DB Ready", o.dbReady ? "YES" : "NO", "PostgreSQL central state"),
metric("Online Nodes", o.onlineNodeCount ?? 0, `${o.nodeCount ?? 0} registered`),
metric("Active Sockets", o.activeSocketCount ?? 0, "Provider WebSocket"),
metric("Pending Tasks", o.pendingTaskCount ?? 0, "queued / running"),
].join("");
$("refresh-age").textContent = state.lastRefresh ? `刷新 ${fmtTime(state.lastRefresh)}` : "--";
}
function renderNodes() {
$("node-count").textContent = `${state.nodes.length} nodes`;
$("nodes-body").innerHTML = state.nodes.map((node) => `
<tr>
<td><span class="badge ${node.status}">${node.status}</span></td>
<td><div>${node.name}</div><div class="code">${node.providerId}</div></td>
<td class="code">${compactJson(node.labels)}</td>
<td>${fmtTime(node.lastHeartbeat)}</td>
</tr>
`).join("") || `<tr><td colspan="4">暂无 Provider 节点</td></tr>`;
if (state.nodes[0] && !$("dispatch-provider").value) $("dispatch-provider").value = state.nodes[0].providerId;
}
function renderEvents() {
$("events-body").innerHTML = state.events.map((event) => `
<tr>
<td class="code">${event.id}</td>
<td>${event.type}</td>
<td class="code">${event.source}</td>
<td class="code">${compactJson(event.payload)}</td>
<td>${fmtTime(event.createdAt)}</td>
</tr>
`).join("") || `<tr><td colspan="5">暂无事件</td></tr>`;
}
function render() {
renderMetrics();
renderNodes();
renderEvents();
document.querySelectorAll("[data-panel]").forEach((panel) => {
const key = panel.getAttribute("data-panel");
panel.style.display = state.activeTab === "overview" ? "" : key === state.activeTab ? "" : key === "overview" && state.activeModule === "ops" ? "" : "none";
});
}
async function refresh() {
try {
const [overview, nodes, events] = await Promise.all([
api("/api/overview"),
api("/api/nodes"),
api("/api/events?limit=100"),
]);
state.overview = overview;
state.nodes = nodes.nodes || [];
state.events = events.events || [];
state.lastRefresh = new Date();
setConnection(true, "核心在线");
render();
} catch (error) {
setConnection(false, error.message);
}
}
function bindNav() {
document.querySelectorAll(".module").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".module").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
state.activeModule = btn.dataset.module;
if (state.activeModule === "nodes") setTab("nodes");
if (state.activeModule === "tasks") setTab("dispatch");
if (state.activeModule === "config") setTab("events");
if (state.activeModule === "ops") setTab("overview");
});
});
document.querySelectorAll(".tab").forEach((btn) => btn.addEventListener("click", () => setTab(btn.dataset.tab)));
}
function setTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".tab").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
render();
}
function bindDispatch() {
$("dispatch-form").addEventListener("submit", async (event) => {
event.preventDefault();
const providerId = $("dispatch-provider").value.trim();
const command = $("dispatch-command").value;
const payloadText = $("dispatch-payload").value.trim();
try {
const payload = payloadText ? JSON.parse(payloadText) : {};
const result = await api("/api/dispatch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId, command, payload }),
});
$("dispatch-result").textContent = JSON.stringify(result, null, 2);
await refresh();
} catch (error) {
$("dispatch-result").textContent = `ERROR: ${error.message}`;
}
});
}
function tick() {
$("clock").textContent = new Date().toLocaleTimeString();
}
bindNav();
bindDispatch();
tick();
setInterval(tick, 1000);
refresh();
setInterval(refresh, 5000);
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<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 class="shell">
<aside class="rail" aria-label="Main modules">
<div class="brand">
<span class="brand-mark">UD</span>
<span class="brand-text">UniDesk</span>
</div>
<button class="module active" data-module="ops">运行总览</button>
<button class="module" data-module="nodes">资源节点</button>
<button class="module" data-module="tasks">任务调度</button>
<button class="module" data-module="config">系统配置</button>
</aside>
<main class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">Distributed Work Platform</p>
<h1>控制平面</h1>
</div>
<div class="status-strip">
<span id="conn-dot" class="dot warn"></span>
<span id="conn-text">连接中</span>
<span id="clock">--:--:--</span>
</div>
</header>
<nav class="tabs" aria-label="Sub modules">
<button class="tab active" data-tab="overview">Overview</button>
<button class="tab" data-tab="nodes">Live Nodes</button>
<button class="tab" data-tab="events">Event Log</button>
<button class="tab" data-tab="dispatch">Dispatch</button>
</nav>
<section class="content-grid">
<section class="panel metrics-panel" data-panel="overview">
<div class="panel-head"><h2>核心指标</h2><span id="refresh-age">--</span></div>
<div class="metric-grid" id="metric-grid"></div>
</section>
<section class="panel table-panel" data-panel="nodes">
<div class="panel-head"><h2>Provider 节点</h2><span id="node-count">0</span></div>
<div class="table-wrap"><table><thead><tr><th>状态</th><th>Provider</th><th>标签</th><th>最后心跳</th></tr></thead><tbody id="nodes-body"></tbody></table></div>
</section>
<section class="panel table-panel" data-panel="events">
<div class="panel-head"><h2>事件流</h2><span>最近 100 条</span></div>
<div class="table-wrap"><table><thead><tr><th>ID</th><th>类型</th><th>来源</th><th>载荷</th><th>时间</th></tr></thead><tbody id="events-body"></tbody></table></div>
</section>
<section class="panel dispatch-panel" data-panel="dispatch">
<div class="panel-head"><h2>调度试运行</h2><span>真实 WebSocket 下发</span></div>
<form id="dispatch-form" class="dispatch-form">
<label>Provider ID<input id="dispatch-provider" placeholder="main-server" /></label>
<label>Command<select id="dispatch-command"><option value="docker.ps">docker.ps</option><option value="echo">echo</option></select></label>
<label class="wide">Payload JSON<textarea id="dispatch-payload">{"source":"frontend"}</textarea></label>
<button type="submit">下发任务</button>
</form>
<pre id="dispatch-result" class="result-block">等待操作</pre>
</section>
</section>
</main>
</div>
<script src="/app.js" defer></script>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
:root {
--bg: #111820;
--panel: #17212b;
--panel-2: #1d2a35;
--line: #30404d;
--line-soft: #24323e;
--text: #dce7ec;
--muted: #8496a3;
--accent: #e2a329;
--accent-2: #4bb7aa;
--danger: #d86b55;
--ok: #6fbe73;
--rail: #0c1218;
--shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
font-family: "DIN Condensed", "Aptos Narrow", "Liberation Sans Narrow", "Noto Sans", sans-serif;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background:
linear-gradient(135deg, rgba(226, 163, 41, 0.08), transparent 28%),
linear-gradient(315deg, rgba(75, 183, 170, 0.08), transparent 30%),
repeating-linear-gradient(90deg, rgba(255,255,255,0.025) 0, rgba(255,255,255,0.025) 1px, transparent 1px, transparent 36px),
var(--bg);
font-size: 13px;
letter-spacing: 0.01em;
}
button, input, select, textarea {
font: inherit;
}
.shell {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
min-height: 100vh;
}
.rail {
position: sticky;
top: 0;
height: 100vh;
padding: 12px 10px;
border-right: 1px solid var(--line);
background: linear-gradient(180deg, #0a1015, var(--rail));
}
.brand {
display: flex;
align-items: center;
gap: 9px;
height: 40px;
padding: 0 5px 12px;
border-bottom: 1px solid var(--line-soft);
margin-bottom: 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 26px;
border: 1px solid var(--accent);
color: var(--accent);
font-weight: 800;
letter-spacing: 0.08em;
}
.brand-text {
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.module, .tab, .dispatch-form button {
border: 1px solid transparent;
color: var(--muted);
background: transparent;
cursor: pointer;
}
.module {
display: block;
width: 100%;
padding: 8px 10px;
margin: 4px 0;
text-align: left;
border-left: 3px solid transparent;
}
.module:hover, .module.active {
color: var(--text);
background: rgba(255,255,255,0.045);
border-left-color: var(--accent);
}
.workspace {
min-width: 0;
padding: 12px 14px 16px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
min-height: 54px;
padding: 0 0 10px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
margin: 0 0 2px;
color: var(--accent);
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
}
h1, h2 { margin: 0; font-weight: 650; }
h1 { font-size: 22px; letter-spacing: 0.08em; }
h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.09em; }
.status-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 1px solid var(--line);
background: rgba(0,0,0,0.14);
color: var(--muted);
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 2px rgba(255,255,255,0.06);
}
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--accent); }
.dot.fail { background: var(--danger); }
.tabs {
display: flex;
gap: 6px;
padding: 10px 0;
overflow-x: auto;
}
.tab {
padding: 7px 12px;
border-color: var(--line);
background: rgba(12, 18, 24, 0.58);
color: var(--muted);
min-width: 108px;
}
.tab.active, .tab:hover {
color: var(--text);
border-color: var(--accent-2);
background: rgba(75, 183, 170, 0.12);
}
.content-grid {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(520px, 1.6fr);
gap: 10px;
align-items: stretch;
}
.panel {
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.015)), var(--panel);
box-shadow: var(--shadow);
min-width: 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 10px;
border-bottom: 1px solid var(--line);
color: var(--muted);
}
.metrics-panel { grid-column: 1 / 2; }
.table-panel[data-panel="nodes"] { grid-column: 2 / 3; }
.table-panel[data-panel="events"], .dispatch-panel { grid-column: 1 / -1; }
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 10px;
}
.metric {
padding: 10px;
min-height: 74px;
border: 1px solid var(--line-soft);
background: var(--panel-2);
}
.metric .label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.metric .value {
margin-top: 8px;
color: var(--text);
font-size: 24px;
font-weight: 720;
}
.metric .hint {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
}
.table-wrap { overflow: auto; max-height: 46vh; }
table { width: 100%; border-collapse: collapse; min-width: 680px; }
th, td {
padding: 7px 9px;
border-bottom: 1px solid var(--line-soft);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: #121b24;
color: var(--accent);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.11em;
z-index: 1;
}
td { color: var(--text); }
.code, code {
font-family: "Cascadia Mono", "IBM Plex Mono", "Liberation Mono", monospace;
font-size: 12px;
color: #bfd7dc;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border: 1px solid var(--line);
color: var(--muted);
background: rgba(0,0,0,0.18);
}
.badge.online { color: var(--ok); border-color: rgba(111, 190, 115, 0.45); }
.badge.offline { color: var(--danger); border-color: rgba(216, 107, 85, 0.45); }
.dispatch-panel { min-height: 230px; }
.dispatch-form {
display: grid;
grid-template-columns: 1fr 180px auto;
gap: 8px;
padding: 10px;
align-items: end;
}
.dispatch-form label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.dispatch-form .wide { grid-column: 1 / -1; }
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
color: var(--text);
background: #0d151c;
padding: 7px 8px;
outline: none;
}
textarea { min-height: 76px; resize: vertical; font-family: "Cascadia Mono", "IBM Plex Mono", monospace; }
.dispatch-form button {
height: 33px;
padding: 0 14px;
border-color: var(--accent);
color: #130f08;
background: var(--accent);
font-weight: 700;
}
.result-block {
margin: 0 10px 10px;
padding: 8px;
max-height: 170px;
overflow: auto;
border: 1px solid var(--line-soft);
background: #0d151c;
color: #bfd7dc;
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.rail {
position: static;
height: auto;
display: flex;
align-items: center;
gap: 6px;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.brand { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; flex: 0 0 auto; }
.module { width: auto; white-space: nowrap; border-left: 0; border-bottom: 2px solid transparent; }
.module.active, .module:hover { border-bottom-color: var(--accent); }
.content-grid { grid-template-columns: 1fr; }
.metrics-panel, .table-panel[data-panel="nodes"], .table-panel[data-panel="events"], .dispatch-panel { grid-column: 1; }
.dispatch-form { grid-template-columns: 1fr; }
}
+98
View File
@@ -0,0 +1,98 @@
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
interface RuntimeConfig {
port: number;
corePublicUrl: string;
logFile: string;
}
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
const config = readConfig();
const logger = createLogger("frontend", config.logFile);
const publicDir = join(import.meta.dir, "..", "public");
const indexHtml = readFileSync(join(publicDir, "index.html"), "utf8").replace(
"__UNIDESK_CONFIG__",
JSON.stringify({ coreApiUrl: config.corePublicUrl, corePort: new URL(config.corePublicUrl).port }),
);
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
port: readNumberEnv("PORT"),
corePublicUrl: requiredEnv("CORE_PUBLIC_URL"),
logFile: requiredEnv("LOG_FILE"),
};
}
function createLogger(service: string, logFile: string) {
mkdirSync(dirname(logFile), { recursive: true });
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = { ts: new Date().toISOString(), service, level, message, data };
const line = `${JSON.stringify(entry)}\n`;
try {
appendFileSync(logFile, line, "utf8");
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function contentType(pathname: string): string {
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
if (pathname.endsWith(".svg")) return "image/svg+xml";
return "text/plain; charset=utf-8";
}
function jsonResponse(body: JsonValue, status = 200): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
const server = Bun.serve({
port: config.port,
hostname: "0.0.0.0",
async fetch(req) {
const url = new URL(req.url);
logger("debug", "request", { path: url.pathname });
if (url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-frontend", coreApiUrl: config.corePublicUrl });
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } });
}
const safePath = url.pathname.replace(/^\/+/, "");
if (safePath.includes("..")) {
return jsonResponse({ ok: false, error: "invalid path" }, 400);
}
const file = Bun.file(join(publicDir, safePath));
if (!(await file.exists())) {
return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404);
}
return new Response(file, { headers: { "content-type": contentType(url.pathname) } });
},
});
logger("info", "server_listening", { url: `http://0.0.0.0:${server.port}`, coreApiUrl: config.corePublicUrl, logFile: config.logFile });
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,3 @@
FROM oven/bun:1-alpine
WORKDIR /app
CMD ["bun", "--version"]
@@ -0,0 +1,5 @@
{
"name": "@unidesk/example-service",
"private": true,
"type": "module"
}
@@ -0,0 +1 @@
export const reserved = true;
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,10 @@
FROM oven/bun:1-alpine
RUN apk add --no-cache bash docker-cli openssh-client
WORKDIR /app/src/components/provider-gateway
COPY src/components/provider-gateway/package.json ./package.json
RUN bun install --production
COPY src/components/shared /app/src/components/shared
COPY src/components/provider-gateway/src ./src
COPY src/components/provider-gateway/scripts/host-ssh-shell.sh /usr/local/bin/unidesk-host-ssh-shell
RUN chmod +x /usr/local/bin/unidesk-host-ssh-shell
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,9 @@
{
"name": "@unidesk/provider-gateway",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
host="${HOST_SSH_HOST}"
port="${HOST_SSH_PORT}"
user="${HOST_SSH_USER}"
key="${HOST_SSH_KEY}"
fallback_cwd="${HOST_REMOTE_CWD}"
requested_cwd="${UNIDESK_REQUESTED_CWD:-$fallback_cwd}"
login_shell="${HOST_LOGIN_SHELL}"
quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\''/g")"
}
q_requested=$(quote "$requested_cwd")
q_fallback=$(quote "$fallback_cwd")
q_shell=$(quote "$login_shell")
remote_cmd="cd $q_requested 2>/dev/null || cd $q_fallback 2>/dev/null || cd; export UNIDESK_BRIDGE=host-ssh; exec $q_shell -l"
exec ssh -tt \
-i "$key" \
-p "$port" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile=/tmp/unidesk-host-known-hosts \
-o ServerAliveInterval=20 \
-o ServerAliveCountMax=3 \
"$user@$host" "$remote_cmd"
@@ -0,0 +1,231 @@
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import {
type CoreDispatchMessage,
type JsonValue,
type ProviderLabels,
type ProviderTaskStatusMessage,
parseJsonObject,
} from "../../shared/src/index";
interface RuntimeConfig {
serverUrl: string;
token: string;
providerId: string;
providerName: string;
labels: ProviderLabels;
heartbeatIntervalMs: number;
reconnectBaseMs: number;
reconnectMaxMs: number;
dockerSocketPath: string;
logFile: string;
}
const startedAt = new Date();
const config = readConfig();
const logger = createLogger("provider-gateway", config.logFile);
let socket: WebSocket | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let reconnectAttempt = 0;
let stopping = false;
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function readNumberEnv(name: string): number {
const raw = requiredEnv(name);
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
}
return parsed;
}
function readConfig(): RuntimeConfig {
return {
serverUrl: requiredEnv("PROVIDER_SERVER_URL"),
token: requiredEnv("PROVIDER_TOKEN"),
providerId: requiredEnv("PROVIDER_ID"),
providerName: requiredEnv("PROVIDER_NAME"),
labels: parseJsonObject(requiredEnv("PROVIDER_LABELS_JSON"), "PROVIDER_LABELS_JSON"),
heartbeatIntervalMs: readNumberEnv("HEARTBEAT_INTERVAL_MS"),
reconnectBaseMs: readNumberEnv("RECONNECT_BASE_MS"),
reconnectMaxMs: readNumberEnv("RECONNECT_MAX_MS"),
dockerSocketPath: requiredEnv("DOCKER_SOCKET_PATH"),
logFile: requiredEnv("LOG_FILE"),
};
}
function createLogger(service: string, logFile: string) {
mkdirSync(dirname(logFile), { recursive: true });
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
const entry = data === undefined
? { ts: new Date().toISOString(), service, level, message }
: { ts: new Date().toISOString(), service, level, message, data };
const line = `${JSON.stringify(entry)}\n`;
try {
appendFileSync(logFile, line, "utf8");
} catch (error) {
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
}
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
consoleMethod(line.trimEnd());
};
}
function withToken(rawUrl: string, token: string): string {
const url = new URL(rawUrl);
url.searchParams.set("token", token);
return url.toString();
}
function currentLabels(): ProviderLabels {
return {
...config.labels,
dockerSocketPresent: existsSync(config.dockerSocketPath),
runtime: "bun",
gatewayUptimeSeconds: Math.floor((Date.now() - startedAt.getTime()) / 1000),
};
}
function sendJson(value: unknown): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify(value));
}
function sendRegister(): void {
sendJson({
type: "register",
providerId: config.providerId,
name: config.providerName,
labels: currentLabels(),
startedAt: startedAt.toISOString(),
capabilities: ["heartbeat", "docker.ps", "echo"],
});
}
function sendHeartbeat(): void {
sendJson({
type: "heartbeat",
providerId: config.providerId,
labels: currentLabels(),
at: new Date().toISOString(),
});
}
async function sendTaskStatus(taskId: string, status: ProviderTaskStatusMessage["status"], message: string, result?: JsonValue): Promise<void> {
sendJson({
type: "task_status",
providerId: config.providerId,
taskId,
status,
message,
at: new Date().toISOString(),
result,
});
}
async function runDockerPs(): Promise<JsonValue> {
const proc = Bun.spawn(["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], {
stdout: "pipe",
stderr: "pipe",
});
const timeout = setTimeout(() => proc.kill("SIGKILL"), 5000);
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}`);
}
const containers = stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.slice(0, 50)
.map((line) => {
const [id, name, image, status, ports] = line.split("\t");
return { id, name, image, status, ports };
});
return { containers, count: containers.length, stderr };
}
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
try {
await sendTaskStatus(message.taskId, "running", "provider running task");
if (message.command === "docker.ps") {
const result = await runDockerPs();
await sendTaskStatus(message.taskId, "succeeded", "docker ps 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);
logger("error", "dispatch_failed", { taskId: message.taskId, error: text });
await sendTaskStatus(message.taskId, "failed", text, { error: text });
}
}
function handleMessage(raw: MessageEvent<string>): void {
try {
const parsed = JSON.parse(raw.data) as { type?: unknown };
if (parsed.type === "dispatch") {
handleDispatch(parsed as CoreDispatchMessage).catch((error) => logger("error", "dispatch_handler_failed", { error: String(error) }));
return;
}
logger("debug", "core_message", parsed as JsonValue);
} catch (error) {
logger("error", "core_message_parse_failed", { error: String(error) });
}
}
function scheduleReconnect(): void {
if (stopping) return;
reconnectAttempt += 1;
const delayMs = Math.min(config.reconnectMaxMs, config.reconnectBaseMs * 2 ** Math.min(reconnectAttempt, 8));
logger("warn", "reconnect_scheduled", { reconnectAttempt, delayMs });
setTimeout(connect, delayMs);
}
function connect(): void {
const url = withToken(config.serverUrl, config.token);
logger("info", "connect_start", { serverUrl: config.serverUrl, providerId: config.providerId });
socket = new WebSocket(url);
socket.addEventListener("open", () => {
reconnectAttempt = 0;
logger("info", "connect_open", { providerId: config.providerId });
sendRegister();
sendHeartbeat();
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(sendHeartbeat, 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);
heartbeatTimer = null;
scheduleReconnect();
});
socket.addEventListener("error", () => {
logger("error", "connect_error", { providerId: config.providerId });
});
}
process.on("SIGTERM", () => {
stopping = true;
logger("warn", "sigterm_received");
if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
socket?.close(1000, "provider shutdown");
process.exit(0);
});
connect();
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../shared" }]
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@unidesk/shared",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}
+104
View File
@@ -0,0 +1,104 @@
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type ProviderLabels = Record<string, JsonValue>;
export interface ProviderRegisterMessage {
type: "register";
providerId: string;
name: string;
labels: ProviderLabels;
startedAt: string;
capabilities: string[];
}
export interface ProviderHeartbeatMessage {
type: "heartbeat";
providerId: string;
labels: ProviderLabels;
at: string;
}
export interface ProviderTaskStatusMessage {
type: "task_status";
providerId: string;
taskId: string;
status: "accepted" | "running" | "succeeded" | "failed";
message: string;
at: string;
result?: JsonValue;
}
export interface CoreDispatchMessage {
type: "dispatch";
taskId: string;
command: "docker.ps" | "echo";
payload: Record<string, JsonValue>;
}
export interface CoreAcknowledgeMessage {
type: "ack";
requestId: string;
ok: boolean;
message: string;
}
export type ProviderToCoreMessage =
| ProviderRegisterMessage
| ProviderHeartbeatMessage
| ProviderTaskStatusMessage;
export type CoreToProviderMessage = CoreDispatchMessage | CoreAcknowledgeMessage;
export interface ApiNode {
providerId: string;
name: string;
status: "online" | "offline";
labels: ProviderLabels;
connectedAt: string | null;
lastHeartbeat: string | null;
}
export interface ApiTask {
id: string;
providerId: string;
command: string;
status: string;
payload: JsonValue;
result: JsonValue;
createdAt: string;
updatedAt: string;
}
export interface ApiEvent {
id: number;
type: string;
source: string;
payload: JsonValue;
createdAt: string;
}
export interface ServiceLogEntry {
ts: string;
service: string;
level: "debug" | "info" | "warn" | "error";
message: string;
data?: JsonValue;
}
export function parseJsonObject(value: string, name: string): Record<string, JsonValue> {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name} must be a JSON object`);
}
return parsed as Record<string, JsonValue>;
}
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {
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") &&
typeof msg.providerId === "string" &&
msg.providerId.length > 0
);
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@unidesk/components",
"private": true,
"type": "module",
"workspaces": [
"components/*"
],
"scripts": {
"check": "tsc -b tsconfig.base.json"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "latest",
"typescript": "latest"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"files": [],
"references": [
{ "path": "components/shared" },
{ "path": "components/backend-core" },
{ "path": "components/provider-gateway" },
{ "path": "components/frontend" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["components/**/*.ts"],
"exclude": ["components/**/dist/**"]
}