diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2e290d7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.state +logs +node_modules +**/node_modules +**/dist +**/coverage +npm-debug.log* +.env +.env.* diff --git a/AGENTS.md b/AGENTS.md index 40dd7669..164632aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,16 +12,16 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。 - `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。 - `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch`:通过真实 HTTP 和 WebSocket 流程调试健康检查与任务下发,调试规则见 `docs/reference/cli.md`。 -- `bun scripts/cli.ts e2e run`:用公开 URL 验证 core、database、frontend、provider-gateway 和 Playwright 页面可见性,验收规则见 `docs/reference/e2e.md`。 +- `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch`:通过 Docker 内网 core、真实 HTTP 和 WebSocket 流程调试健康检查与任务下发,调试规则见 `docs/reference/cli.md`。 +- `bun scripts/cli.ts e2e run`:验证公网 frontend/provider ingress、内网 core/database、provider-gateway 自接入和 Playwright 登录页面,验收规则见 `docs/reference/e2e.md`。 ## 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 前端断言和数据库命名卷持久化要求。 +- `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 +- `src/components/frontend`:前端采用 React 组件、高信息密度、左侧主模块和顶部子模块标签的工业化控制台设计,界面规则见 `docs/reference/frontend.md`。 +- `src/components/provider-gateway`:当前主 server 也作为 provider gateway 接入 UniDesk,节点接入规则和公网 provider ingress 见 `docs/reference/provider-gateway.md`。 +- `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。 ## Architecture Docs diff --git a/TEST.md b/TEST.md index aa51dd7f..540ce9ba 100644 --- a/TEST.md +++ b/TEST.md @@ -10,11 +10,11 @@ ## 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 可用性。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server status` 和 `bun scripts/cli.ts debug health`,确认公网只监听 frontend 与 provider ingress,backend-core 和 database 显示为 Docker 内部端口,`/api/nodes` 中存在 `main-server` provider,状态为 `online`,且 provider 标签中能看到 Docker socket 可用性。 ## T4 前端控制台连通 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:先用 `bun scripts/cli.ts server status` 获取 frontend URL,再用浏览器访问该 URL,确认左侧主模块、顶部子标签、核心指标、Provider 表格和事件流可见;页面布局应紧凑、信息密度高、字体不过大,且移动端宽度下左侧栏转为横向模块条。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:先用 `bun scripts/cli.ts server status` 获取 frontend URL,再用浏览器访问该 URL,使用默认账号 `admin` 和默认密码 `Liang6516.` 登录,确认左侧主模块、顶部当前模块子标签、核心指标、Provider 控件和事件流可见;页面布局应紧凑、信息密度高、字体不过大,且移动端宽度下左侧栏转为横向模块条。 ## T5 真实任务下发链路 @@ -26,7 +26,7 @@ ## 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 固定端口不再监听,容器状态不再运行。 +阅读 `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`,确认 frontend 与 provider ingress 公网端口不再监听,backend-core 与 database 没有宿主机端口映射,容器状态不再运行。 ## Issue 记录 @@ -34,8 +34,12 @@ ## 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 表格。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`core:internal-overview`、`provider:self-node-online`、`provider-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:no-naked-json-before-click`、`frontend:raw-json-explicit-button` 全部 passed;打开输出的 screenshotPath,确认页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 ## 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 删除命令。 + +## T10 前端 JSON 控件化展示 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `frontend:no-naked-json-before-click` passed;再用浏览器登录 frontend,确认节点标签、事件 payload、任务 payload/result 都渲染为徽标、字段摘要、表格或卡片,页面初始状态没有裸 JSON,只有点击 `查看原始JSON` 后才出现原始 JSON 弹窗或高级编辑区。 diff --git a/config.json b/config.json index c25dd5b2..71be610d 100644 --- a/config.json +++ b/config.json @@ -21,6 +21,10 @@ "database": { "port": 15432, "containerPort": 5432 + }, + "providerIngress": { + "port": 18082, + "containerPort": 8081 } }, "database": { @@ -58,5 +62,11 @@ "host": "host.docker.internal", "port": 22, "user": "root" + }, + "auth": { + "username": "admin", + "password": "Liang6516.", + "sessionSecret": "unidesk-dev-session-secret-change-me", + "sessionTtlSeconds": 86400 } } diff --git a/docker-compose.yml b/docker-compose.yml index 923e787d..5be04a10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,8 @@ services: image: postgres:16-alpine container_name: unidesk-database restart: unless-stopped - ports: - - "${UNIDESK_DATABASE_PORT}:5432" + expose: + - "5432" environment: POSTGRES_USER: "${UNIDESK_DATABASE_USER}" POSTGRES_PASSWORD: "${UNIDESK_DATABASE_PASSWORD}" @@ -45,9 +45,12 @@ services: depends_on: - database ports: - - "${UNIDESK_CORE_PORT}:8080" + - "${UNIDESK_PROVIDER_INGRESS_PORT}:8081" + expose: + - "8080" environment: PORT: "8080" + PROVIDER_PORT: "8081" 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}" @@ -72,7 +75,13 @@ services: - "${UNIDESK_FRONTEND_PORT}:8080" environment: PORT: "8080" - CORE_PUBLIC_URL: "http://${UNIDESK_PUBLIC_HOST}:${UNIDESK_CORE_PORT}" + CORE_INTERNAL_URL: "http://backend-core:8080" + FRONTEND_PUBLIC_URL: "http://${UNIDESK_PUBLIC_HOST}:${UNIDESK_FRONTEND_PORT}" + PROVIDER_INGRESS_PUBLIC_URL: "ws://${UNIDESK_PUBLIC_HOST}:${UNIDESK_PROVIDER_INGRESS_PORT}/ws/provider" + AUTH_USERNAME: "${UNIDESK_AUTH_USERNAME}" + AUTH_PASSWORD: "${UNIDESK_AUTH_PASSWORD}" + SESSION_SECRET: "${UNIDESK_SESSION_SECRET}" + SESSION_TTL_SECONDS: "${UNIDESK_SESSION_TTL_SECONDS}" LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_frontend.jsonl" volumes: - ${UNIDESK_LOG_DIR}:/var/log/unidesk @@ -91,7 +100,7 @@ services: depends_on: - backend-core environment: - PROVIDER_SERVER_URL: "ws://backend-core:8080/ws/provider" + PROVIDER_SERVER_URL: "ws://backend-core:8081/ws/provider" PROVIDER_TOKEN: "${UNIDESK_PROVIDER_TOKEN}" PROVIDER_ID: "${UNIDESK_PROVIDER_ID}" PROVIDER_NAME: "${UNIDESK_PROVIDER_NAME}" diff --git a/docs/reference/arch.md b/docs/reference/arch.md index 6d6a9c5a..b24178e6 100644 --- a/docs/reference/arch.md +++ b/docs/reference/arch.md @@ -23,8 +23,9 @@ - Main Server Components - UniDesk Stateless Services - Run all business microservices as Docker containers - - Includes API gateway, task scheduler, project management, and other stateless modules + - Includes frontend gateway, task scheduler, project management, provider ingress, and other stateless modules - Instances can scale horizontally; failure recovery requires no state synchronization + - Only the frontend gateway and provider ingress are public; core REST APIs and PostgreSQL remain on the Docker internal network - PostgreSQL Database - Deployed as a Docker container with a 10 GB named volume - Stores all task metadata, node heartbeats, resource labels, and business state diff --git a/docs/reference/cli.md b/docs/reference/cli.md index eb35e7b4..73a4d12a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -9,11 +9,11 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 - `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 status` 查询公开端口、内部端口、Compose 容器、core/frontend/provider/database 健康检查和访问 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 门禁。 +- `debug health` 与 `debug dispatch` 走真实内部 core、WebSocket、数据库和 provider 流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。 +- `e2e run` 使用 publicHost 派生的公开 frontend/provider ingress URL,并通过 Docker 内网验证 core API、PostgreSQL、provider self-connection 和 Playwright 前端页面,是交付前的自动化 E2E 门禁。 ## Async Job State @@ -25,4 +25,4 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定 ## Debug Contract -`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug dispatch` 会调用 core 的 `/api/dispatch`,core 再通过 WebSocket 将任务下发给 provider gateway,因此它可以验证核心调度闭环。 +`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`,core 再通过 WebSocket 将任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。 diff --git a/docs/reference/config.md b/docs/reference/config.md index 977061b6..6fdf9bef 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -6,14 +6,18 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和 provider-gateway 都直接运行 `.ts` 入口;Docker 镜像使用 `oven/bun` 基础镜像,本机命令使用 `bun scripts/cli.ts`。 -## Fixed Ports +## Network Ports -`config.json` 中固定三个对外端口:backend-core、frontend、database。`network.publicHost` 必须是浏览器和外部客户端可访问的主 server 地址;公网 E2E 不允许把它保留为 `127.0.0.1`。`server start` 会在启动前检查这些端口,避免因端口冲突产生多个版本混乱的服务实例。 +`config.json` 中保留 core、frontend、database 和 providerIngress 的端口字段,但只有 frontend 与 providerIngress 允许映射到宿主机公网端口。core 和 database 的 `port` 字段用于测试公网阻断和历史兼容,实际服务只使用 Docker 内网 `containerPort`。 + +## Auth + +`auth.username` 和 `auth.password` 是 frontend 登录凭据,默认值分别为 `admin` 和 `Liang6516.`。`auth.sessionSecret` 用于签名 frontend HttpOnly Cookie,`auth.sessionTtlSeconds` 控制登录会话有效期;修改后必须重新启动 Docker 栈以刷新派生环境变量。 ## Compose Env Generation -Docker Compose 本身不读取 JSON,因此 CLI 会从 `config.json` 生成 `.state/docker-compose.env`。该文件是派生状态,不应手写;如需改端口、token、provider 标签或主机名,应修改 `config.json` 后重新运行 CLI。 +Docker Compose 本身不读取 JSON,因此 CLI 会从 `config.json` 生成 `.state/docker-compose.env`。该文件是派生状态,不应手写;如需改端口、token、provider 标签、登录凭据或主机名,应修改 `config.json` 后重新运行 CLI。CLI 会在保留当前日志前缀的同时刷新新增配置键,避免旧 env 文件遗漏字段。 ## Secrets -当前配置面向主 server 开发部署,包含开发用数据库密码和 provider token。公网暴露前必须在 `config.json` 中修改这些值,并重新启动栈以刷新派生环境文件。 +当前配置面向主 server 开发部署,包含开发用数据库密码、provider token 和默认登录密码。公网长期运行前必须在 `config.json` 中修改这些值,并重新启动栈以刷新派生环境文件。 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index c62bf27b..c66fe088 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -5,18 +5,22 @@ ## 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` 作为自动任务执行主路径。 +- `backend-core` 是无状态核心服务,提供 Docker 内网 REST API、provider ingress WebSocket、任务调度入口和数据库访问层。 +- `frontend` 是唯一公开 Web 控制台,提供登录、React 静态资产和到 backend-core 的同源代理。 +- `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,并挂载 `/var/run/docker.sock` 作为自动任务执行主路径。 + +## Public Exposure Boundary + +Docker Compose 只能向公网暴露两个接口:frontend host port 和 provider ingress host port。backend-core REST API 和 PostgreSQL database 必须只在 Docker 内部网络中可达,不允许映射到宿主机公网端口;浏览器访问 core API 必须通过 frontend 的同源代理完成。 ## 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` 验证端口、容器和健康检查。 +`bun scripts/cli.ts server start` 与 `bun scripts/cli.ts server stop` 都是异步 job。启动 job 会先清理固定 Compose project 的旧容器,再重新构建并启动,避免主 server 上残留旧容器或旧镜像配置。启动和停止流程禁止删除 Docker named volume。 ## Health Criteria -服务跑通的最低标准是:backend-core `/health` 返回 ok,frontend `/health` 返回 ok,database 端口监听,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`,`debug dispatch main-server docker.ps` 能完成真实任务下发。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 +服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,`/api/nodes` 中出现 `main-server` provider 且状态为 `online`。交付前还必须运行 `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`。 +架构要求数据库使用 10 GB named volume;当前实现将 volume 命名为 `unidesk_pgdata_10gb` 以固定生命周期。Docker named volume 默认不强制容量上限;如需硬配额,应在主机存储层或 Docker volume driver 层配置。CLI server 控制只能使用不删除 volume 的 `down` / `up` 流程,禁止使用 `down -v`、`docker volume rm` 或删除 `unidesk_pgdata_10gb`。 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index d99782d2..fd822171 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -1,25 +1,30 @@ # 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`. +UniDesk delivery is not complete until the public frontend, public provider ingress, internal core API, PostgreSQL database, local provider-gateway self-connection, and frontend Playwright flow 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. +- The Docker stack must be running through `bun scripts/cli.ts server start`, and `bun scripts/cli.ts server status` must report healthy frontend, provider ingress, internal core, 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`: +`bun scripts/cli.ts e2e run` validates the following URLs and internal checks 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 exposure: Docker port summary must show only frontend and provider ingress host mappings; public core and public database probes must fail. +- Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true` and at least one online node. +- Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`; public provider ingress `/health` must return ok. +- Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql` and confirms provider state is stored in PostgreSQL. +- Frontend: Playwright opens the public frontend URL, logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, confirms no raw JSON is visible before clicking `查看原始JSON`, then clicks the explicit raw JSON button and verifies the raw payload appears. -## Public Frontend Rule +## Frontend JSON 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. +The frontend must render JSON data into React controls by default. Raw JSON is allowed only after an explicit `查看原始JSON` user action, and E2E must fail if the initial page exposes raw JSON text or a raw JSON block. + +## Public Boundary Rule + +The public frontend URL and provider ingress URL are the only public network interfaces. backend-core REST API and PostgreSQL database are Docker-internal only; E2E must prove the historical public core/database ports are not reachable. ## Database Persistence Rule diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 3f00f91d..d7c1b3a9 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -1,10 +1,18 @@ # UniDesk Frontend Reference -UniDesk 前端是工业化控制台,不追求展示型大屏效果。设计目标是高信息密度、低装饰、低字号、低间距,并让调度、节点、事件和配置入口在单屏内快速切换。 +UniDesk 前端是 React 组件化工业控制台,不追求展示型大屏效果。设计目标是高信息密度、低装饰、低字号、低间距,并让调度、节点、事件和配置入口在单屏内快速切换。 ## Layout -左侧边栏切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签切换子模块:Overview、Live Nodes、Event Log、Dispatch。桌面端采用双列内容网格,移动端将左侧栏压缩为横向模块条。 +左侧边栏只切换主模块:运行总览、资源节点、任务调度、系统配置。顶部标签只切换当前主模块内的子功能;例如资源节点下的节点清单、资源标签、心跳状态只属于资源节点,和运行总览、任务调度、系统配置没有重复或共享语义。 + +## Component Data Rendering + +前端必须把 backend-core 返回的 JSON 渲染为合适的控件:状态徽标、指标卡、表格列、标签 chip、字段摘要、任务结果卡、日志行和表单控件。默认页面禁止暴露裸 JSON、`pre` JSON 或整段 `JSON.stringify` 文本;只有用户明确点击 `查看原始JSON` 按钮后,才允许在弹窗或高级编辑区展示原始 JSON。 + +## Login + +frontend 提供账号密码登录,默认账号为 `admin`,默认密码为 `Liang6516.`。登录会话使用 frontend 容器签发的 HttpOnly Cookie;浏览器后续只访问同源 frontend API,frontend 再通过 Docker 内网代理 backend-core。 ## Visual Language @@ -12,4 +20,4 @@ UniDesk 前端是工业化控制台,不追求展示型大屏效果。设计目 ## Data Flow -frontend 容器只服务静态资产和轻量 HTML 注入;浏览器根据 `CORE_PUBLIC_URL` 调用 backend-core 的 REST API。调度表单调用 `/api/dispatch`,事件表和节点表通过轮询刷新。 +浏览器不直接访问 backend-core。frontend 容器服务 React 静态资产、登录接口、会话接口和同源 API 代理;代理目标是 Docker 内网 `http://backend-core:8080`。provider-gateway 的外部接入地址只作为连接拓扑信息展示,不作为浏览器数据源。 diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index 73cc6a1c..50ef0c35 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -1,10 +1,14 @@ # Provider Gateway Reference -Provider Gateway 是计算节点侧容器。它只主动连出到 backend-core 的 WebSocket,不要求计算节点有公网 IP,适合 NAT、内网和防火墙后的机器。 +Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴露的 provider ingress WebSocket,不要求计算节点有公网 IP,适合 NAT、内网和防火墙后的机器。 ## Main Server Self Provider -当前主 server 也运行一个 provider-gateway,`providerId` 固定来自 `config.json` 的 `providerGateway.id`。这让单机环境也能验证完整的分布式调度闭环:frontend 发起任务,core 写数据库并通过 WebSocket 下发,provider gateway 执行后回传状态。 +当前主 server 也运行一个 provider-gateway,`providerId` 固定来自 `config.json` 的 `providerGateway.id`。这让单机环境也能验证完整的分布式调度闭环:frontend 发起任务,core 写数据库并通过 provider ingress WebSocket 下发,provider gateway 执行后回传状态。 + +## Provider Ingress + +provider ingress 是唯一允许公网暴露的 provider 连接接口,当前由 backend-core 容器的独立端口提供 `/ws/provider` 和 `/health`。backend-core REST API 仍只在 Docker 内网开放,外部计算节点只应连接 provider ingress。 ## Docker Socket Path diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index aae6c440..5a61beb5 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -17,7 +17,7 @@ - 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) + - e2e.ts (Public frontend/provider ingress, internal core/database, and Playwright frontend E2E checks) - logs/ (Generated service logs; ignored by git) - .state/ (Generated job state and compose env; ignored by git) - docs/ @@ -46,13 +46,13 @@ - package.json - tsconfig.json - Dockerfile - - src/index.ts (REST API, WebSocket provider server, scheduler, database access) + - src/index.ts (Internal REST API, public provider ingress WebSocket, 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) + - src/index.ts (Bun static server, login/session handling, and same-origin internal API proxy) + - public/ (React HTML/CSS/JS assets for the compact industrial console) - provider-gateway/ (Compute node Provider Gateway container) - package.json - tsconfig.json diff --git a/scripts/cli.ts b/scripts/cli.ts index 4618eec6..05434250 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -23,9 +23,9 @@ function help(): unknown { { command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." }, { command: "job list", description: "List async jobs from .state/jobs." }, { command: "job status [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." }, - { command: "debug health", description: "Probe 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." }, + { command: "debug health", description: "Probe internal core, overview, nodes, frontend, provider ingress, and public boundary." }, + { command: "debug dispatch [providerId] [docker.ps|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." }, + { command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." }, ], }; } diff --git a/scripts/src/config.ts b/scripts/src/config.ts index f1a23a40..5501f31d 100644 --- a/scripts/src/config.ts +++ b/scripts/src/config.ts @@ -11,7 +11,9 @@ export interface UniDeskConfig { core: { port: number; containerPort: number }; frontend: { port: number; containerPort: number }; database: { port: number; containerPort: number }; + providerIngress: { port: number; containerPort: number }; }; + auth: { username: string; password: string; sessionSecret: string; sessionTtlSeconds: number }; database: { user: string; password: string; name: string; volume: string; volumeSize: string }; providerGateway: { id: string; @@ -67,6 +69,7 @@ export function readConfig(): UniDeskConfig { const runtime = asRecord(parsed.runtime, "runtime"); const network = asRecord(parsed.network, "network"); const database = asRecord(parsed.database, "database"); + const auth = asRecord(parsed.auth, "auth"); const providerGateway = asRecord(parsed.providerGateway, "providerGateway"); const docker = asRecord(parsed.docker, "docker"); const paths = asRecord(parsed.paths, "paths"); @@ -83,6 +86,7 @@ export function readConfig(): UniDeskConfig { core: portPair(network, "core"), frontend: portPair(network, "frontend"), database: portPair(network, "database"), + providerIngress: portPair(network, "providerIngress"), }, database: { user: stringField(database, "user", "database"), @@ -113,5 +117,11 @@ export function readConfig(): UniDeskConfig { port: numberField(sshForwarding, "port", "sshForwarding"), user: stringField(sshForwarding, "user", "sshForwarding"), }, + auth: { + username: stringField(auth, "username", "auth"), + password: stringField(auth, "password", "auth"), + sessionSecret: stringField(auth, "sessionSecret", "auth"), + sessionTtlSeconds: numberField(auth, "sessionTtlSeconds", "auth"), + }, }; } diff --git a/scripts/src/debug.ts b/scripts/src/debug.ts index 7ecb1172..ea358a66 100644 --- a/scripts/src/debug.ts +++ b/scripts/src/debug.ts @@ -1,4 +1,5 @@ -import { type UniDeskConfig } from "./config"; +import { runCommand } from "./command"; +import { type UniDeskConfig, repoRoot } from "./config"; async function readJson(url: string, init?: RequestInit): Promise { const controller = new AbortController(); @@ -7,24 +8,53 @@ async function readJson(url: string, init?: RequestInit): Promise { 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 }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { clearTimeout(timer); } } +function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown { + const code = ` + const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({ + method: init?.method ?? "GET", + headers: init?.body === undefined ? undefined : { "content-type": "application/json" }, + body: init?.body === undefined ? undefined : JSON.stringify(init.body), + })}); + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { body = { text }; } + console.log(JSON.stringify({ ok: res.ok, status: res.status, body })); + `; + const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot); + if (result.exitCode !== 0) { + return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) }; + } + try { + return JSON.parse(result.stdout.trim()) as unknown; + } catch { + return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) }; + } +} + export async function debugHealth(config: UniDeskConfig): Promise { 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`), + coreInternal: await coreInternalFetch("/health"), + overviewInternal: await coreInternalFetch("/api/overview"), + nodesInternal: await coreInternalFetch("/api/nodes"), + frontendPublic: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`), + providerIngressPublic: await readJson(`http://127.0.0.1:${config.network.providerIngress.port}/health`), + publicExposureBoundary: { + coreHostPort: { port: config.network.core.port, expected: "not-exposed" }, + databaseHostPort: { port: config.network.database.port, expected: "not-exposed" }, + }, }; } export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise { - return readJson(`http://127.0.0.1:${config.network.core.port}/api/dispatch`, { + return coreInternalFetch("/api/dispatch", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ providerId, command, payload: { source: "cli-debug" } }), + body: { providerId, command, payload: { source: "cli-debug" } }, }); } diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index c679c53b..cbe54be9 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -48,14 +48,17 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): const stateDir = rootPath(config.paths.stateDir); mkdirSync(stateDir, { recursive: true }); const envFile = join(stateDir, "docker-compose.env"); + let logDir: string; + let logPrefix: string; 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 }; + logDir = raw.match(/^UNIDESK_LOG_DIR=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? rootPath(config.paths.logsDir); + logPrefix = raw.match(/^UNIDESK_LOG_PREFIX=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? localDateParts(new Date()).stamp; + } else { + const parts = localDateParts(new Date()); + logDir = resolve(rootPath(config.paths.logsDir, parts.day)); + logPrefix = parts.stamp; } - 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); @@ -64,6 +67,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): 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_PROVIDER_INGRESS_PORT: String(config.network.providerIngress.port), UNIDESK_DATABASE_USER: config.database.user, UNIDESK_DATABASE_PASSWORD: config.database.password, UNIDESK_DATABASE_NAME: config.database.name, @@ -71,18 +75,22 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_PROVIDER_ID: config.providerGateway.id, UNIDESK_PROVIDER_NAME: config.providerGateway.name, UNIDESK_PROVIDER_LABELS_JSON: labels, + UNIDESK_AUTH_USERNAME: config.auth.username, + UNIDESK_AUTH_PASSWORD: config.auth.password, + UNIDESK_SESSION_SECRET: config.auth.sessionSecret, + UNIDESK_SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds), 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_LOG_PREFIX: logPrefix, 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 }; + return { envFile, logDir, logPrefix }; } export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType } { @@ -117,9 +125,8 @@ export function stopStack(config: UniDeskConfig): unknown { 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) }, + { name: "provider-ingress", port: config.network.providerIngress.port, listening: isPortListening(config.network.providerIngress.port) }, ]; } @@ -166,21 +173,51 @@ async function probe(url: string): Promise { } } +function dockerExecJson(container: string, code: string): unknown { + const result = runCommand(["docker", "exec", container, "bun", "-e", code], repoRoot); + if (result.exitCode !== 0) { + return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; + } + try { + return JSON.parse(result.stdout.trim()) as unknown; + } catch { + return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; + } +} + +function dockerExec(config: UniDeskConfig, container: string, command: string[]): unknown { + const result = runCommand(["docker", "exec", container, ...command], repoRoot); + return { ok: result.exitCode === 0, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; +} + export async function stackStatus(config: UniDeskConfig): Promise { const runtimeEnv = writeComposeEnv(config, false); + const coreHealth = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/health').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})"); + const overview = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/api/overview').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})"); return { runtimeEnv, - ports: fixedPorts(config), + publicPorts: fixedPorts(config), + blockedPublicPorts: [ + { name: "backend-core-rest", port: config.network.core.port, listening: isPortListening(config.network.core.port), expected: "not-listening" }, + { name: "database", port: config.network.database.port, listening: isPortListening(config.network.database.port), expected: "not-listening" }, + ], + internalPorts: [ + { name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null }, + { name: "database", containerPort: config.network.database.containerPort, hostPort: null }, + ], containers: dockerContainers(config), health: { - core: await probe(`http://127.0.0.1:${config.network.core.port}/health`), + core: coreHealth, 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`), + providerIngress: await probe(`http://127.0.0.1:${config.network.providerIngress.port}/health`), + database: dockerExec(config, "unidesk-database", ["pg_isready", "-U", config.database.user, "-d", config.database.name]), + 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}`, + providerIngress: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`, + internalCore: `http://backend-core:${config.network.core.containerPort}`, + internalDatabase: `postgres://${config.database.user}:***@database:${config.network.database.containerPort}/${config.database.name}`, }, }; } diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 82c52c75..ba350bad 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -14,27 +14,35 @@ interface E2ECheck { interface PublicUrls { frontendUrl: string; - coreUrl: string; - databaseHost: string; - databasePort: number; + providerIngressHealthUrl: string; + providerIngressWsUrl: string; + blockedCoreUrl: string; + blockedDatabaseHost: string; + blockedDatabasePort: 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, + providerIngressHealthUrl: `http://${config.network.publicHost}:${config.network.providerIngress.port}/health`, + providerIngressWsUrl: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`, + blockedCoreUrl: `http://${config.network.publicHost}:${config.network.core.port}`, + blockedDatabaseHost: config.network.publicHost, + blockedDatabasePort: config.network.database.port, }; } -async function fetchJson(url: string): Promise { +async function fetchProbe(url: string, timeoutMs = 8000): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 8000); + const timer = setTimeout(() => controller.abort(), timeoutMs); 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 }; + let body: unknown = text; + try { body = text.length > 0 ? JSON.parse(text) : null; } catch { /* keep text body */ } + return { reachable: true, ok: response.ok, status: response.status, body }; + } catch (error) { + return { reachable: false, ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { clearTimeout(timer); } @@ -63,15 +71,81 @@ function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: str return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode }; } -async function apiChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { - 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 dockerCoreJson(path: string): unknown { + const code = ` + const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}); + const text = await res.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { body = { text }; } + console.log(JSON.stringify({ ok: res.ok, status: res.status, body })); + `; + const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot); + if (result.exitCode !== 0) return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; + try { + return JSON.parse(result.stdout.trim()) as unknown; + } catch { + return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; + } } -function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): string { +function dockerPortSummary(): unknown { + const result = runCommand([ + "docker", + "ps", + "--filter", + "label=com.docker.compose.project=unidesk", + "--format", + "{{.Names}}\t{{.Ports}}", + ], repoRoot); + return { + ok: result.exitCode === 0, + exitCode: result.exitCode, + rows: result.stdout.trim().split("\n").filter(Boolean).map((line) => { + const [name, ports = ""] = line.split("\t"); + return { name, ports }; + }), + stderr: result.stderr.trim(), + }; +} + +async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { + const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> }; + const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n"); + const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500); + const databasePublic = runCommand([ + "docker", + "run", + "--rm", + "postgres:16-alpine", + "pg_isready", + "-h", + urls.blockedDatabaseHost, + "-p", + String(urls.blockedDatabasePort), + "-U", + config.database.user, + ], repoRoot); + addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); + addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); + addCheck(checks, "network:database-public-blocked", databasePublic.exitCode !== 0, { + exitCode: databasePublic.exitCode, + stdout: databasePublic.stdout.trim(), + stderr: databasePublic.stderr.trim(), + }); +} + +async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise { + const coreOverview = dockerCoreJson("/api/overview"); + const coreNodes = dockerCoreJson("/api/nodes"); + const providerIngress = await fetchProbe(urls.providerIngressHealthUrl); + const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body; + const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? []; + addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); + addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); + addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress); +} + +function databaseChecks(config: UniDeskConfig, 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 ( @@ -87,25 +161,6 @@ function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2EChec 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; } @@ -122,14 +177,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 }); 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('[data-testid="login-screen"]', { timeout: 10000 }); + await page.fill('input[name="username"]', config.auth.username); + await page.fill('input[name="password"]', config.auth.password); + await page.click('button[type="submit"]'); + await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 }); + await page.waitForFunction(() => document.querySelector('[data-testid="conn-text"]')?.textContent?.includes("核心在线"), undefined, { timeout: 15000 }); await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 }); - await page.waitForSelector("text=Online Nodes", { timeout: 5000 }); + await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 }); 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 }); + const rawBlocksBefore = await page.locator("pre.raw-json").count(); + const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"'); 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 }); + await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click(); + await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 }); + const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 }); + addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); + addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); + addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { @@ -140,8 +205,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 export async function runE2E(config: UniDeskConfig): Promise { const checks: E2ECheck[] = []; const urls = publicUrls(config); - await apiChecks(config, urls, checks); - const markerId = databaseChecks(config, urls, checks); + await exposureChecks(config, urls, checks); + await serviceChecks(config, urls, checks); + const markerId = databaseChecks(config, checks); const frontend = await frontendCheck(config, urls, checks); const ok = checks.every((check) => check.status === "passed"); return { diff --git a/src/bun.lock b/src/bun.lock index 7e9b84ce..e0832de3 100644 --- a/src/bun.lock +++ b/src/bun.lock @@ -18,6 +18,10 @@ }, "components/frontend": { "name": "@unidesk/frontend", + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1", + }, }, "components/provider-gateway": { "name": "@unidesk/provider-gateway", @@ -41,8 +45,18 @@ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "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=="], diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index da810b6c..eb2ca8d2 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -15,6 +15,7 @@ import { interface RuntimeConfig { port: number; + providerPort: number; databaseUrl: string; providerToken: string; heartbeatTimeoutMs: number; @@ -62,6 +63,7 @@ function readNumberEnv(name: string): number { function readConfig(): RuntimeConfig { return { port: readNumberEnv("PORT"), + providerPort: readNumberEnv("PROVIDER_PORT"), databaseUrl: requiredEnv("DATABASE_URL"), providerToken: requiredEnv("PROVIDER_TOKEN"), heartbeatTimeoutMs: readNumberEnv("HEARTBEAT_TIMEOUT_MS"), @@ -408,20 +410,10 @@ async function dispatchTask(req: Request): Promise { return jsonResponse({ ok: true, taskId, status: "dispatched", providerOnline: true }); } -async function route(req: Request, server: Server): Promise { +async function route(req: Request): Promise { 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() }); @@ -443,6 +435,23 @@ async function route(req: Request, server: Server): Promise): Promise { + const url = new URL(req.url); + if (url.pathname === "/" || url.pathname === "/health") { + return jsonResponse({ ok: true, service: "unidesk-provider-ingress", activeSocketCount: activeProviders.size }); + } + if (url.pathname !== "/ws/provider") { + return jsonResponse({ ok: false, error: "provider ingress only accepts /ws/provider", path: url.pathname }, 404); + } + 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); +} + function readLimit(url: URL, defaultLimit: number): number { const raw = url.searchParams.get("limit"); if (raw === null) return defaultLimit; @@ -453,10 +462,16 @@ function readLimit(url: URL, defaultLimit: number): number { await initDatabaseWithRetry(); -const server = Bun.serve({ +const apiServer = Bun.serve({ port: config.port, hostname: "0.0.0.0", fetch: route, +}); + +const providerServer = Bun.serve({ + port: config.providerPort, + hostname: "0.0.0.0", + fetch: providerRoute, websocket: { open(ws) { logger("info", "provider_socket_open", { remoteAddress: ws.remoteAddress }); @@ -481,4 +496,8 @@ 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 }); +logger("info", "server_listening", { + apiUrl: `http://0.0.0.0:${apiServer.port}`, + providerIngressUrl: `ws://0.0.0.0:${providerServer.port}/ws/provider`, + logFile: config.logFile, +}); diff --git a/src/components/frontend/package.json b/src/components/frontend/package.json index 711d1eab..e34c5ec7 100644 --- a/src/components/frontend/package.json +++ b/src/components/frontend/package.json @@ -5,5 +5,9 @@ "scripts": { "start": "bun run src/index.ts", "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" } } diff --git a/src/components/frontend/public/app.js b/src/components/frontend/public/app.js index f2cf1dc5..40b23282 100644 --- a/src/components/frontend/public/app.js +++ b/src/components/frontend/public/app.js @@ -1,153 +1,626 @@ -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 cfg = window.UNIDESK_CONFIG || { apiBaseUrl: "/api", authUsername: "admin" }; +const h = React.createElement; +const { useEffect, useMemo, useState } = React; -const $ = (id) => document.getElementById(id); -const fmtTime = (value) => value ? new Date(value).toLocaleTimeString() : "--"; -const compactJson = (value) => JSON.stringify(value ?? {}, null, 0); +const MODULES = [ + { id: "ops", label: "运行总览", code: "OPS", tabs: [ + { id: "status", label: "态势总览" }, + { id: "events", label: "事件摘要" }, + { id: "logs", label: "服务日志" }, + ] }, + { id: "nodes", label: "资源节点", code: "NODE", tabs: [ + { id: "list", label: "节点清单" }, + { id: "labels", label: "资源标签" }, + { id: "heartbeats", label: "心跳状态" }, + ] }, + { id: "tasks", label: "任务调度", code: "TASK", tabs: [ + { id: "dispatch", label: "下发任务" }, + { id: "history", label: "任务历史" }, + { id: "results", label: "执行结果" }, + ] }, + { id: "config", label: "系统配置", code: "CFG", tabs: [ + { id: "topology", label: "连接拓扑" }, + { id: "auth", label: "认证策略" }, + { id: "security", label: "安全边界" }, + ] }, +]; -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 fmtDate(value) { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString("zh-CN", { hour12: false }); } -function setConnection(ok, text) { - const dot = $("conn-dot"); - dot.className = `dot ${ok ? "ok" : "fail"}`; - $("conn-text").textContent = text; +function fmtClock(value) { + return value.toLocaleTimeString("zh-CN", { hour12: false }); } -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 fmtDuration(seconds) { + if (!Number.isFinite(seconds)) return "--"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } -function metric(label, value, hint) { - return `
${label}
${value}
${hint}
`; +function summarizeValue(value) { + if (value === null || value === undefined) return "--"; + if (typeof value === "boolean") return value ? "是" : "否"; + if (typeof value === "number") return String(value); + if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}...` : value; + if (Array.isArray(value)) return `${value.length} 项`; + if (typeof value === "object") return `${Object.keys(value).length} 字段`; + return String(value); } -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 objectEntries(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + return Object.entries(value); } -function renderNodes() { - $("node-count").textContent = `${state.nodes.length} nodes`; - $("nodes-body").innerHTML = state.nodes.map((node) => ` - - ${node.status} -
${node.name}
${node.providerId}
- ${compactJson(node.labels)} - ${fmtTime(node.lastHeartbeat)} - - `).join("") || `暂无 Provider 节点`; - if (state.nodes[0] && !$("dispatch-provider").value) $("dispatch-provider").value = state.nodes[0].providerId; +function safeId(value) { + return String(value).replace(/[^a-zA-Z0-9_-]/g, "_"); } -function renderEvents() { - $("events-body").innerHTML = state.events.map((event) => ` - - ${event.id} - ${event.type} - ${event.source} - ${compactJson(event.payload)} - ${fmtTime(event.createdAt)} - - `).join("") || `暂无事件`; -} - -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() { +async function requestJson(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); + const response = await fetch(path, { credentials: "same-origin", ...options, headers }); + const text = await response.text(); + let body = null; try { - 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); + body = text ? JSON.parse(text) : null; + } catch { + body = { text }; } + if (!response.ok || body?.ok === false) { + const message = body?.error?.message || body?.error || `HTTP ${response.status}`; + const error = new Error(message); + error.status = response.status; + throw error; + } + return body; } -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 StatusBadge({ status, children }) { + const normalized = String(status || "unknown").toLowerCase(); + return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown"); } -function setTab(tab) { - state.activeTab = tab; - document.querySelectorAll(".tab").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab)); - render(); +function MetricCard({ label, value, hint, tone }) { + return h("article", { className: `metric-card ${tone || ""}` }, + h("div", { className: "metric-label" }, label), + h("div", { className: "metric-value" }, value), + h("div", { className: "metric-hint" }, hint), + ); } -function bindDispatch() { - $("dispatch-form").addEventListener("submit", async (event) => { +function Panel({ title, eyebrow, actions, children, className }) { + return h("section", { className: `panel ${className || ""}` }, + h("div", { className: "panel-head" }, + h("div", null, + eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null, + h("h2", null, title), + ), + actions ? h("div", { className: "panel-actions" }, actions) : null, + ), + h("div", { className: "panel-body" }, children), + ); +} + +function RawButton({ title, data, onOpen, testId }) { + return h("button", { + type: "button", + className: "ghost-btn", + "data-testid": testId, + onClick: () => onOpen(title, data), + }, "查看原始JSON"); +} + +function RawDialog({ raw, onClose }) { + if (!raw) return null; + return h("div", { className: "modal-backdrop", role: "presentation" }, + h("section", { className: "raw-dialog", role: "dialog", "aria-modal": "true", "aria-label": raw.title }, + h("div", { className: "raw-dialog-head" }, + h("h2", null, raw.title), + h("button", { type: "button", className: "ghost-btn", onClick: onClose }, "关闭"), + ), + h("pre", { className: "raw-json", "data-testid": "raw-json" }, JSON.stringify(raw.data, null, 2)), + ), + ); +} + +function LabelChips({ labels, limit = 8 }) { + const entries = objectEntries(labels).slice(0, limit); + if (entries.length === 0) return h("span", { className: "muted" }, "无标签"); + return h("div", { className: "chip-row" }, + entries.map(([key, value]) => h("span", { key, className: "data-chip" }, + h("b", null, key), + h("span", null, summarizeValue(value)), + )), + ); +} + +function DataSummary({ data, empty = "无数据" }) { + if (data === null || data === undefined) return h("span", { className: "muted" }, empty); + if (typeof data !== "object") return h("span", { className: "summary-value" }, summarizeValue(data)); + if (Array.isArray(data)) return h("span", { className: "summary-value" }, `${data.length} 项列表`); + const entries = Object.entries(data).slice(0, 5); + if (entries.length === 0) return h("span", { className: "muted" }, empty); + return h("div", { className: "summary-grid" }, entries.map(([key, value]) => + h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeValue(value))), + )); +} + +function EmptyState({ title, text }) { + return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text)); +} + +function LoginScreen({ onLogin }) { + const [username, setUsername] = useState(cfg.authUsername || "admin"); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(event) { event.preventDefault(); - const providerId = $("dispatch-provider").value.trim(); - const command = $("dispatch-command").value; - const payloadText = $("dispatch-payload").value.trim(); + setBusy(true); + setError(""); try { - const payload = payloadText ? JSON.parse(payloadText) : {}; - const result = await api("/api/dispatch", { + const session = await requestJson("/login", { method: "POST", body: JSON.stringify({ username, password }) }); + onLogin(session); + } catch (err) { + setError(err.message || "登录失败"); + } finally { + setBusy(false); + } + } + + return h("main", { className: "login-screen", "data-testid": "login-screen" }, + h("section", { className: "login-card" }, + h("div", { className: "login-brand" }, h("span", { className: "brand-mark" }, "UD"), h("div", null, h("h1", null, "UniDesk"), h("p", null, "Control Plane Login"))), + h("form", { className: "login-form", onSubmit: submit }, + h("label", null, "账号", h("input", { name: "username", autoComplete: "username", value: username, onChange: (event) => setUsername(event.target.value) })), + h("label", null, "密码", h("input", { name: "password", type: "password", autoComplete: "current-password", value: password, onChange: (event) => setPassword(event.target.value) })), + error ? h("div", { className: "form-error" }, error) : null, + h("button", { type: "submit", disabled: busy }, busy ? "登录中" : "登录"), + ), + h("div", { className: "login-note" }, "默认账号由 config.json 注入;公网入口只暴露前端登录面。"), + ), + ); +} + +function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }) { + return h("header", { className: "topbar" }, + h("div", null, h("p", { className: "eyebrow" }, "Distributed Work Platform"), h("h1", null, "UniDesk 控制平面")), + h("div", { className: "status-strip" }, + h("span", { className: `dot ${connection.ok ? "ok" : "fail"}` }), + h("span", { "data-testid": "conn-text" }, connection.text), + h("span", null, lastRefresh ? `刷新 ${fmtClock(lastRefresh)}` : "未刷新"), + h("span", null, fmtClock(clock)), + h("span", { className: "user-pill" }, session?.user?.username || "--"), + h("button", { type: "button", className: "ghost-btn", onClick: onRefresh }, "刷新"), + h("button", { type: "button", className: "ghost-btn danger", onClick: onLogout }, "退出"), + ), + ); +} + +function Sidebar({ activeModule, onChange }) { + return h("aside", { className: "rail", "aria-label": "主模块" }, + h("div", { className: "brand" }, h("span", { className: "brand-mark" }, "UD"), h("span", { className: "brand-text" }, "UniDesk")), + MODULES.map((module) => h("button", { + key: module.id, + type: "button", + className: `module ${activeModule === module.id ? "active" : ""}`, + onClick: () => onChange(module.id), + }, h("span", { className: "module-code" }, module.code), h("span", null, module.label))), + ); +} + +function TabBar({ module, activeTab, onChange }) { + return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` }, + module.tabs.map((tab) => h("button", { + key: tab.id, + type: "button", + className: `tab ${activeTab === tab.id ? "active" : ""}`, + onClick: () => onChange(tab.id), + }, tab.label)), + ); +} + +function OverviewPage({ data, onRaw }) { + const overview = data.overview || {}; + const onlineNodes = data.nodes.filter((node) => node.status === "online"); + const recentTasks = data.tasks.slice(0, 5); + return h("div", { className: "page-grid overview-grid" }, + h(Panel, { title: "核心指标", eyebrow: "Control" }, + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "数据库", value: overview.dbReady ? "READY" : "WAIT", hint: "PostgreSQL internal network", tone: overview.dbReady ? "ok" : "warn" }), + h(MetricCard, { label: "在线节点", value: overview.onlineNodeCount ?? 0, hint: `${overview.nodeCount ?? 0} registered`, tone: "ok" }), + h(MetricCard, { label: "WebSocket", value: overview.activeSocketCount ?? 0, hint: "Provider ingress sockets" }), + h(MetricCard, { label: "待处理任务", value: overview.pendingTaskCount ?? 0, hint: `uptime ${fmtDuration(overview.uptimeSeconds ?? 0)}` }), + ), + ), + h(Panel, { title: "本机 Provider", eyebrow: "Self Connected" }, + onlineNodes.length === 0 ? h(EmptyState, { title: "暂无在线节点", text: "provider-gateway 未完成自接入" }) : + h("div", { className: "node-card-list" }, onlineNodes.slice(0, 4).map((node) => h(NodeCard, { key: node.providerId, node, onRaw }))), + ), + h(Panel, { title: "最近任务", eyebrow: "Dispatch" }, + recentTasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "可以在任务调度模块发起 docker.ps 或 echo" }) : + h("div", { className: "compact-list" }, recentTasks.map((task) => h(TaskCompactRow, { key: task.id, task, onRaw }))), + ), + ); +} + +function NodeCard({ node, onRaw }) { + return h("article", { className: "node-card" }, + h("div", { className: "node-card-head" }, + h("div", null, h("strong", null, node.name), h("code", null, node.providerId)), + h(StatusBadge, { status: node.status }), + ), + h(LabelChips, { labels: node.labels, limit: 6 }), + h("div", { className: "node-card-foot" }, + h("span", null, `心跳 ${fmtDate(node.lastHeartbeat)}`), + h(RawButton, { title: `Provider ${node.providerId}`, data: node, onOpen: onRaw, testId: `raw-node-${safeId(node.providerId)}` }), + ), + ); +} + +function EventsPage({ events, onRaw }) { + return h(Panel, { title: "事件摘要", eyebrow: "Latest 100" }, + events.length === 0 ? h(EmptyState, { title: "暂无事件", text: "Provider 注册、心跳超时和任务状态会写入事件流" }) : + h("div", { className: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "ID"), h("th", null, "类型"), h("th", null, "来源"), h("th", null, "摘要"), h("th", null, "时间"), h("th", null, "操作"))), + h("tbody", null, events.map((event) => h("tr", { key: event.id }, + h("td", null, h("code", null, event.id)), + h("td", null, h(StatusBadge, { status: event.type }, event.type)), + h("td", null, h("code", null, event.source)), + h("td", null, h(DataSummary, { data: event.payload })), + h("td", null, fmtDate(event.createdAt)), + h("td", null, h(RawButton, { title: `Event ${event.id}`, data: event, onOpen: onRaw })), + ))), + )), + ); +} + +function LogsPage({ logs, onRaw }) { + return h(Panel, { title: "服务日志", eyebrow: "Core Recent" }, + logs.length === 0 ? h(EmptyState, { title: "暂无日志", text: "backend-core 内存日志会在请求和 provider 事件后出现" }) : + h("div", { className: "log-list" }, logs.slice(-80).reverse().map((log, index) => h("article", { key: index, className: `log-row ${log.level || "info"}` }, + h("span", null, fmtDate(log.ts)), + h("b", null, log.level || "info"), + h("strong", null, log.message || "log"), + h(DataSummary, { data: log.data, empty: "无附加字段" }), + h(RawButton, { title: `Log ${log.message || index}`, data: log, onOpen: onRaw }), + ))), + ); +} + +function NodeListPage({ nodes, onRaw }) { + return h(Panel, { title: "节点清单", eyebrow: `${nodes.length} Providers` }, + nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "确认 provider-gateway 已连接 provider ingress" }) : + h("div", { className: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Provider"), h("th", null, "资源标签"), h("th", null, "连接时间"), h("th", null, "最后心跳"), h("th", null, "操作"))), + h("tbody", null, nodes.map((node) => h("tr", { key: node.providerId }, + h("td", null, h(StatusBadge, { status: node.status })), + h("td", null, h("strong", null, node.name), h("code", null, node.providerId)), + h("td", null, h(LabelChips, { labels: node.labels, limit: 5 })), + h("td", null, fmtDate(node.connectedAt)), + h("td", null, fmtDate(node.lastHeartbeat)), + h("td", null, h(RawButton, { title: `Provider ${node.providerId}`, data: node, onOpen: onRaw, testId: `raw-node-table-${safeId(node.providerId)}` })), + ))), + )), + ); +} + +function LabelsPage({ nodes }) { + const labels = useMemo(() => { + const rows = []; + for (const node of nodes) { + for (const [key, value] of objectEntries(node.labels)) rows.push({ providerId: node.providerId, name: node.name, key, value }); + } + return rows; + }, [nodes]); + return h(Panel, { title: "资源标签", eyebrow: "Structured Labels" }, + labels.length === 0 ? h(EmptyState, { title: "暂无标签", text: "provider-gateway 注册消息会同步资源标签" }) : + h("div", { className: "label-matrix" }, labels.map((row) => h("article", { key: `${row.providerId}-${row.key}`, className: "label-card" }, + h("span", null, row.key), + h("strong", null, summarizeValue(row.value)), + h("code", null, row.providerId), + ))), + ); +} + +function HeartbeatPage({ nodes }) { + return h(Panel, { title: "心跳状态", eyebrow: "Provider Liveness" }, + nodes.length === 0 ? h(EmptyState, { title: "无心跳", text: "等待 provider 注册和 heartbeat" }) : + h("div", { className: "heartbeat-list" }, nodes.map((node) => h("article", { key: node.providerId, className: "heartbeat-row" }, + h("span", { className: `pulse ${node.status}` }), + h("div", null, h("strong", null, node.name), h("code", null, node.providerId)), + h("div", null, h("span", null, "connected"), h("b", null, fmtDate(node.connectedAt))), + h("div", null, h("span", null, "last heartbeat"), h("b", null, fmtDate(node.lastHeartbeat))), + ))), + ); +} + +function DispatchPage({ nodes, onDispatched, onRaw }) { + const onlineNodes = nodes.filter((node) => node.status === "online"); + const [providerId, setProviderId] = useState(onlineNodes[0]?.providerId || nodes[0]?.providerId || ""); + const [command, setCommand] = useState("docker.ps"); + const [source, setSource] = useState("frontend"); + const [note, setNote] = useState("operator-check"); + const [priority, setPriority] = useState("normal"); + const [rawOpen, setRawOpen] = useState(false); + const [rawPayload, setRawPayload] = useState(""); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + if (!providerId && (onlineNodes[0]?.providerId || nodes[0]?.providerId)) setProviderId(onlineNodes[0]?.providerId || nodes[0].providerId); + }, [nodes.length, onlineNodes.length, providerId]); + + function structuredPayload() { + return { source, note, priority }; + } + + function revealRawPayload() { + setRawPayload(JSON.stringify(structuredPayload(), null, 2)); + setRawOpen(true); + } + + async function submit(event) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + const payload = rawOpen ? JSON.parse(rawPayload || "{}") : structuredPayload(); + const response = await requestJson(`${cfg.apiBaseUrl}/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}`; + setResult(response); + await onDispatched(); + } catch (err) { + setError(err.message || "下发失败"); + } finally { + setBusy(false); } - }); + } + + return h("div", { className: "page-grid dispatch-grid" }, + h(Panel, { title: "下发任务", eyebrow: "Real WebSocket Dispatch" }, + h("form", { className: "dispatch-form", onSubmit: submit }, + h("label", null, "Provider", h("select", { value: providerId, onChange: (event) => setProviderId(event.target.value) }, + nodes.map((node) => h("option", { key: node.providerId, value: node.providerId }, `${node.name} / ${node.providerId}`)), + )), + h("label", null, "Command", h("select", { value: command, onChange: (event) => setCommand(event.target.value) }, + h("option", { value: "docker.ps" }, "docker.ps"), + h("option", { value: "echo" }, "echo"), + )), + h("label", null, "来源", h("input", { value: source, onChange: (event) => setSource(event.target.value) })), + h("label", null, "备注", h("input", { value: note, onChange: (event) => setNote(event.target.value) })), + h("label", null, "优先级", h("select", { value: priority, onChange: (event) => setPriority(event.target.value) }, + h("option", { value: "normal" }, "normal"), + h("option", { value: "low" }, "low"), + h("option", { value: "urgent" }, "urgent"), + )), + h("div", { className: "dispatch-actions" }, + h("button", { type: "button", className: "ghost-btn", onClick: revealRawPayload }, "查看原始JSON"), + h("button", { type: "submit", disabled: busy || !providerId }, busy ? "下发中" : "下发任务"), + ), + rawOpen ? h("label", { className: "raw-editor-label" }, "高级 Payload", h("textarea", { className: "raw-editor", value: rawPayload, onChange: (event) => setRawPayload(event.target.value) })) : null, + error ? h("div", { className: "form-error wide" }, error) : null, + ), + ), + h(Panel, { title: "下发结果", eyebrow: "Response" }, + result ? h("div", { className: "result-card" }, + h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"), + h("dl", null, + h("dt", null, "Task ID"), h("dd", null, h("code", null, result.taskId || "--")), + h("dt", null, "Provider 在线"), h("dd", null, summarizeValue(result.providerOnline)), + ), + h(RawButton, { title: "Dispatch Response", data: result, onOpen: onRaw }), + ) : h(EmptyState, { title: "等待操作", text: "任务响应会以结构化结果卡展示" }), + ), + ); } -function tick() { - $("clock").textContent = new Date().toLocaleTimeString(); +function TaskCompactRow({ task, onRaw }) { + return h("article", { className: "compact-row" }, + h(StatusBadge, { status: task.status }), + h("div", null, h("strong", null, task.command), h("code", null, task.id)), + h("span", null, fmtDate(task.updatedAt)), + h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw }), + ); } -bindNav(); -bindDispatch(); -tick(); -setInterval(tick, 1000); -refresh(); -setInterval(refresh, 5000); +function TaskHistoryPage({ tasks, onRaw }) { + return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` }, + tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) : + h("div", { className: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "Provider"), h("th", null, "载荷摘要"), h("th", null, "更新时间"), h("th", null, "操作"))), + h("tbody", null, tasks.map((task) => h("tr", { key: task.id }, + h("td", null, h(StatusBadge, { status: task.status })), + h("td", null, h("strong", null, task.command), h("code", null, task.id)), + h("td", null, h("code", null, task.providerId)), + h("td", null, h(DataSummary, { data: task.payload })), + h("td", null, fmtDate(task.updatedAt)), + h("td", null, h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw })), + ))), + )), + ); +} + +function TaskResultsPage({ tasks, onRaw }) { + const finished = tasks.filter((task) => ["succeeded", "failed"].includes(task.status)); + return h(Panel, { title: "执行结果", eyebrow: "Finished Tasks" }, + finished.length === 0 ? h(EmptyState, { title: "暂无结果", text: "任务完成后展示 provider 返回的结构化摘要" }) : + h("div", { className: "result-grid" }, finished.map((task) => h("article", { key: task.id, className: "result-card" }, + h("div", { className: "node-card-head" }, h("strong", null, task.command), h(StatusBadge, { status: task.status })), + h("code", null, task.id), + h(DataSummary, { data: task.result, empty: "无执行输出" }), + h(RawButton, { title: `Task Result ${task.id}`, data: task, onOpen: onRaw }), + ))), + ); +} + +function TopologyPage({ data }) { + const overview = data.overview || {}; + return h("div", { className: "page-grid topology-grid" }, + h(Panel, { title: "公开入口", eyebrow: "Public" }, + h("div", { className: "endpoint-list" }, + h("article", null, h("b", null, "Frontend"), h("span", null, cfg.frontendPublicUrl || window.location.origin), h(StatusBadge, { status: "online" }, "public")), + h("article", null, h("b", null, "Provider Ingress"), h("span", null, cfg.providerIngressPublicUrl || "ws://public/ws/provider"), h(StatusBadge, { status: "online" }, "public")), + ), + ), + h(Panel, { title: "内部服务", eyebrow: "Docker Network Only" }, + h("div", { className: "endpoint-list" }, + h("article", null, h("b", null, "backend-core API"), h("span", null, "http://backend-core:8080"), h(StatusBadge, { status: "internal" }, "internal")), + h("article", null, h("b", null, "database"), h("span", null, "postgres://database:5432/unidesk"), h(StatusBadge, { status: "internal" }, "internal")), + ), + ), + h(Panel, { title: "运行态", eyebrow: "Runtime" }, + h("div", { className: "metric-grid" }, + h(MetricCard, { label: "DB Ready", value: overview.dbReady ? "YES" : "NO", hint: "internal health" }), + h(MetricCard, { label: "Online Nodes", value: overview.onlineNodeCount ?? 0, hint: "provider-gateway self-link" }), + ), + ), + ); +} + +function AuthPage({ session }) { + return h(Panel, { title: "认证策略", eyebrow: "Frontend Login" }, + h("div", { className: "policy-grid" }, + h("article", null, h("span", null, "默认账号"), h("strong", null, cfg.authUsername || "admin")), + h("article", null, h("span", null, "当前会话"), h("strong", null, session?.user?.username || "--")), + h("article", null, h("span", null, "Session TTL"), h("strong", null, `${cfg.sessionTtlSeconds || 0}s`)), + h("article", null, h("span", null, "API 访问"), h("strong", null, "同源 Cookie 保护")), + ), + h("p", { className: "muted paragraph" }, "浏览器只访问 frontend 同源接口;frontend 容器使用 Docker 内网代理 backend-core API。"), + ); +} + +function SecurityPage() { + return h(Panel, { title: "安全边界", eyebrow: "Exposure Rule" }, + h("div", { className: "security-board" }, + h("article", { className: "allow" }, h("b", null, "允许公网"), h("span", null, "frontend 登录入口"), h("span", null, "provider ingress WebSocket/health")), + h("article", { className: "deny" }, h("b", null, "禁止公网"), h("span", null, "backend-core REST API"), h("span", null, "PostgreSQL database")), + h("article", null, h("b", null, "数据库卷"), h("span", null, "named volume unidesk_pgdata_10gb"), h("span", null, "CLI stop/start 不删除数据卷")), + ), + ); +} + +function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }) { + if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw }); + if (activeModule === "ops" && activeTab === "events") return h(EventsPage, { events: data.events, onRaw }); + if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, onRaw }); + if (activeModule === "nodes" && activeTab === "list") return h(NodeListPage, { nodes: data.nodes, onRaw }); + if (activeModule === "nodes" && activeTab === "labels") return h(LabelsPage, { nodes: data.nodes }); + if (activeModule === "nodes" && activeTab === "heartbeats") return h(HeartbeatPage, { nodes: data.nodes }); + if (activeModule === "tasks" && activeTab === "dispatch") return h(DispatchPage, { nodes: data.nodes, onDispatched: refresh, onRaw }); + if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw }); + if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw }); + if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data }); + if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session }); + if (activeModule === "config" && activeTab === "security") return h(SecurityPage); + return h(EmptyState, { title: "未找到页面", text: "请选择左侧主模块和顶部子功能标签" }); +} + +function Shell({ session, onLogout }) { + const [activeModule, setActiveModule] = useState("ops"); + const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" }); + const [data, setData] = useState({ overview: null, nodes: [], events: [], tasks: [], logs: [] }); + const [connection, setConnection] = useState({ ok: false, text: "连接中" }); + const [lastRefresh, setLastRefresh] = useState(null); + const [clock, setClock] = useState(new Date()); + const [raw, setRaw] = useState(null); + + const module = MODULES.find((item) => item.id === activeModule) || MODULES[0]; + const activeTab = activeTabs[activeModule] || module.tabs[0].id; + + async function refresh() { + try { + const [overview, nodes, events, tasks, logs] = await Promise.all([ + requestJson(`${cfg.apiBaseUrl}/overview`), + requestJson(`${cfg.apiBaseUrl}/nodes`), + requestJson(`${cfg.apiBaseUrl}/events?limit=100`), + requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`), + requestJson("/logs?limit=100"), + ]); + setData({ + overview, + nodes: nodes.nodes || [], + events: events.events || [], + tasks: tasks.tasks || [], + logs: logs.logs || [], + }); + setConnection({ ok: true, text: "核心在线" }); + setLastRefresh(new Date()); + } catch (err) { + setConnection({ ok: false, text: err.message || "连接失败" }); + if (err.status === 401) onLogout(false); + } + } + + useEffect(() => { + refresh(); + const timer = setInterval(refresh, 5000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + const timer = setInterval(() => setClock(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + function setTab(tab) { + setActiveTabs((prev) => ({ ...prev, [activeModule]: tab })); + } + + function openRaw(title, rawData) { + setRaw({ title, data: rawData }); + } + + return h("div", { className: "shell", "data-testid": "app-shell" }, + h(Sidebar, { activeModule, onChange: setActiveModule }), + h("main", { className: "workspace" }, + h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }), + h(TabBar, { module, activeTab, onChange: setTab }), + h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw }), + ), + h(RawDialog, { raw, onClose: () => setRaw(null) }), + ); +} + +function App() { + const [checking, setChecking] = useState(true); + const [session, setSession] = useState(null); + + async function loadSession() { + setChecking(true); + try { + const current = await requestJson("/api/session"); + setSession(current.authenticated ? current : null); + } catch { + setSession(null); + } finally { + setChecking(false); + } + } + + async function logout(callServer) { + if (callServer) { + try { await requestJson("/logout", { method: "POST" }); } catch { /* ignore logout network errors */ } + } + setSession(null); + } + + useEffect(() => { loadSession(); }, []); + + if (checking) return h("main", { className: "loading-screen" }, h("div", { className: "brand-mark" }, "UD"), h("span", null, "加载会话")); + if (!session) return h(LoginScreen, { onLogin: setSession }); + return h(Shell, { session, onLogout: logout }); +} + +ReactDOM.createRoot(document.getElementById("root")).render(h(App)); diff --git a/src/components/frontend/public/index.html b/src/components/frontend/public/index.html index e038a403..d4fe91fc 100644 --- a/src/components/frontend/public/index.html +++ b/src/components/frontend/public/index.html @@ -8,61 +8,9 @@ -
- -
-
-
-

Distributed Work Platform

-

控制平面

-
-
- - 连接中 - --:--:-- -
-
- -
-
-

核心指标

--
-
-
-
-

Provider 节点

0
-
状态Provider标签最后心跳
-
-
-

事件流

最近 100 条
-
ID类型来源载荷时间
-
-
-

调度试运行

真实 WebSocket 下发
-
- - - - -
-
等待操作
-
-
-
-
+
+ + diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 714909f6..0ac6e654 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -1,41 +1,46 @@ :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; + --bg: #0d141a; + --bg-grid: rgba(255,255,255,0.025); + --rail: #080d12; + --panel: #131d26; + --panel-2: #182530; + --panel-3: #0f1820; + --line: #2b3a45; + --line-soft: #1f2d37; + --text: #d7e3e7; + --muted: #81939f; + --faint: #546672; + --accent: #d7a13a; + --accent-2: #4eb7a8; + --danger: #cf6a54; + --ok: #71bf78; + --warn: #d7a13a; + --shadow: 0 18px 46px rgba(0, 0, 0, 0.32); + font-family: "Aptos Narrow", "DIN Condensed", "Liberation Sans Narrow", "Noto Sans", sans-serif; } * { box-sizing: border-box; } -html, body { min-height: 100%; } +html, body, #root { 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), + linear-gradient(135deg, rgba(215, 161, 58, 0.08), transparent 28%), + linear-gradient(315deg, rgba(78, 183, 168, 0.07), transparent 32%), + repeating-linear-gradient(90deg, var(--bg-grid) 0, var(--bg-grid) 1px, transparent 1px, transparent 34px), + repeating-linear-gradient(0deg, rgba(255,255,255,0.014) 0, rgba(255,255,255,0.014) 1px, transparent 1px, transparent 28px), var(--bg); font-size: 13px; letter-spacing: 0.01em; } -button, input, select, textarea { - font: inherit; -} +button, input, select, textarea { font: inherit; } +button { cursor: pointer; } +code, pre, textarea { font-family: "Cascadia Mono", "IBM Plex Mono", "Liberation Mono", monospace; } .shell { display: grid; - grid-template-columns: 176px minmax(0, 1fr); + grid-template-columns: 184px minmax(0, 1fr); min-height: 100vh; } @@ -45,14 +50,17 @@ button, input, select, textarea { height: 100vh; padding: 12px 10px; border-right: 1px solid var(--line); - background: linear-gradient(180deg, #0a1015, var(--rail)); + background: linear-gradient(180deg, #060a0f, var(--rail)); } -.brand { +.brand, .login-brand { display: flex; align-items: center; gap: 9px; - height: 40px; +} + +.brand { + height: 42px; padding: 0 5px 12px; border-bottom: 1px solid var(--line-soft); margin-bottom: 10px; @@ -61,8 +69,8 @@ button, input, select, textarea { .brand-mark { display: grid; place-items: center; - width: 32px; - height: 26px; + width: 34px; + height: 27px; border: 1px solid var(--accent); color: var(--accent); font-weight: 800; @@ -75,20 +83,25 @@ button, input, select, textarea { letter-spacing: 0.14em; } -.module, .tab, .dispatch-form button { +.module { + display: grid; + grid-template-columns: 42px 1fr; + align-items: center; + width: 100%; + min-height: 34px; + margin: 4px 0; + padding: 6px 8px; border: 1px solid transparent; + border-left: 3px solid transparent; color: var(--muted); background: transparent; - cursor: pointer; + text-align: left; } -.module { - display: block; - width: 100%; - padding: 8px 10px; - margin: 4px 0; - text-align: left; - border-left: 3px solid transparent; +.module-code { + color: var(--faint); + font-size: 10px; + letter-spacing: 0.16em; } .module:hover, .module.active { @@ -108,11 +121,11 @@ button, input, select, textarea { align-items: center; gap: 14px; min-height: 54px; - padding: 0 0 10px; + padding-bottom: 10px; border-bottom: 1px solid var(--line); } -.eyebrow { +.eyebrow, .panel-eyebrow { margin: 0 0 2px; color: var(--accent); font-size: 10px; @@ -121,30 +134,36 @@ button, input, select, textarea { } 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; } +h1 { font-size: 21px; letter-spacing: 0.07em; } +h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } .status-strip { display: flex; align-items: center; gap: 8px; - padding: 7px 9px; + padding: 6px 8px; border: 1px solid var(--line); - background: rgba(0,0,0,0.14); + background: rgba(0,0,0,0.16); color: var(--muted); white-space: nowrap; } -.dot { +.dot, .pulse { 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); } +.dot.ok, .pulse.online { background: var(--ok); } +.dot.fail, .pulse.offline { background: var(--danger); } + +.user-pill { + padding: 2px 7px; + border: 1px solid var(--line-soft); + color: var(--text); + background: var(--panel-3); +} .tabs { display: flex; @@ -153,32 +172,44 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.09em; } overflow-x: auto; } -.tab { - padding: 7px 12px; - border-color: var(--line); - background: rgba(12, 18, 24, 0.58); +.tab, .ghost-btn { + border: 1px solid var(--line); + background: rgba(12, 18, 24, 0.62); color: var(--muted); - min-width: 108px; +} + +.tab { + min-width: 112px; + padding: 7px 12px; } .tab.active, .tab:hover { color: var(--text); border-color: var(--accent-2); - background: rgba(75, 183, 170, 0.12); + background: rgba(78, 183, 168, 0.12); } -.content-grid { - display: grid; - grid-template-columns: minmax(320px, 0.9fr) minmax(520px, 1.6fr); - gap: 10px; - align-items: stretch; +.ghost-btn { + min-height: 28px; + padding: 4px 8px; } +.ghost-btn:hover { color: var(--text); border-color: var(--accent); } +.ghost-btn.danger:hover { border-color: var(--danger); color: #ffd7cf; } + +.page-grid { + display: grid; + grid-template-columns: minmax(340px, 0.95fr) minmax(520px, 1.55fr); + gap: 10px; + align-items: start; +} + +.overview-grid .panel:nth-child(3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1 / -1; } .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; + border: 1px solid var(--line); + background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.012)), var(--panel); + box-shadow: var(--shadow); } .panel-head { @@ -186,50 +217,110 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.09em; } justify-content: space-between; align-items: center; gap: 10px; - height: 38px; - padding: 0 10px; + min-height: 40px; + padding: 7px 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; } +.panel-actions { display: flex; gap: 6px; align-items: center; } +.panel-body { padding: 10px; } .metric-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; - padding: 10px; } -.metric { +.metric-card { + min-height: 76px; padding: 10px; - min-height: 74px; border: 1px solid var(--line-soft); background: var(--panel-2); } - -.metric .label { +.metric-card.ok { border-color: rgba(113, 191, 120, 0.42); } +.metric-card.warn { border-color: rgba(215, 161, 58, 0.45); } +.metric-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; } -.metric .value { +.metric-value { margin-top: 8px; color: var(--text); - font-size: 24px; - font-weight: 720; + font-size: 23px; + font-weight: 760; } -.metric .hint { - margin-top: 3px; - color: var(--muted); - font-size: 11px; +.metric-hint { margin-top: 3px; color: var(--muted); font-size: 11px; } + +.node-card-list, .compact-list, .log-list, .heartbeat-list, .endpoint-list, .policy-grid, .security-board, .result-grid { + display: grid; + gap: 8px; } -.table-wrap { overflow: auto; max-height: 46vh; } -table { width: 100%; border-collapse: collapse; min-width: 680px; } +.node-card, .compact-row, .log-row, .heartbeat-row, .endpoint-list article, .policy-grid article, .security-board article, .result-card, .label-card { + border: 1px solid var(--line-soft); + background: var(--panel-3); +} + +.node-card, .result-card { padding: 9px; } +.node-card-head { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.node-card code, .compact-row code, td code { + display: block; + margin-top: 2px; + color: #bcd2d7; + font-size: 12px; +} +.node-card-foot { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-top: 8px; + color: var(--muted); +} + +.status-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 7px; + border: 1px solid var(--line); + color: var(--muted); + background: rgba(0,0,0,0.18); + text-transform: uppercase; + font-size: 11px; +} +.status-badge.online, .status-badge.succeeded, .status-badge.public { color: var(--ok); border-color: rgba(113, 191, 120, 0.45); } +.status-badge.offline, .status-badge.failed { color: var(--danger); border-color: rgba(207, 106, 84, 0.45); } +.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); } +.status-badge.queued, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); } + +.chip-row, .summary-grid { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.data-chip, .summary-item, .summary-value { + display: inline-flex; + gap: 5px; + align-items: center; + min-height: 22px; + padding: 2px 6px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.03); + color: var(--muted); +} +.data-chip b, .summary-item b { color: var(--accent-2); font-weight: 650; } + +.table-wrap { overflow: auto; max-height: calc(100vh - 174px); } +table { width: 100%; border-collapse: collapse; min-width: 760px; } th, td { padding: 7px 9px; border-bottom: 1px solid var(--line-soft); @@ -239,37 +330,42 @@ th, td { th { position: sticky; top: 0; - background: #121b24; + z-index: 1; + background: #111a22; 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; } +.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .policy-grid article { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 8px; +} +.log-row { grid-template-columns: 150px 52px 160px minmax(220px, 1fr) auto; } +.log-row.error { border-color: rgba(207, 106, 84, 0.48); } +.log-row.warn { border-color: rgba(215, 161, 58, 0.48); } +.heartbeat-row { grid-template-columns: auto minmax(220px, 1fr) minmax(180px, auto) minmax(180px, auto); } +.heartbeat-row span, .endpoint-list span, .policy-grid span { color: var(--muted); } +.heartbeat-row b { display: block; margin-top: 2px; } + +.label-matrix { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; +} +.label-card { padding: 8px; } +.label-card span { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; } +.label-card strong { display: block; margin: 5px 0; } + .dispatch-form { display: grid; - grid-template-columns: 1fr 180px auto; + grid-template-columns: 1.2fr 180px 150px 1fr 140px auto; gap: 8px; - padding: 10px; align-items: end; } .dispatch-form label { @@ -280,50 +376,148 @@ td { color: var(--text); } text-transform: uppercase; letter-spacing: 0.12em; } -.dispatch-form .wide { grid-column: 1 / -1; } +.dispatch-actions { display: flex; gap: 6px; align-items: end; } +.dispatch-actions button[type="submit"], .login-form button[type="submit"] { + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--accent); + color: #151107; + background: var(--accent); + font-weight: 760; +} +.raw-editor-label, .wide { grid-column: 1 / -1; } input, select, textarea { width: 100%; border: 1px solid var(--line); color: var(--text); - background: #0d151c; + background: #0b1218; 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; +textarea { min-height: 96px; resize: vertical; } +input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } + +.result-card dl { + display: grid; + grid-template-columns: 90px 1fr; + gap: 5px 8px; + margin: 8px 0; } -.result-block { - margin: 0 10px 10px; - padding: 8px; - max-height: 170px; - overflow: auto; - border: 1px solid var(--line-soft); - background: #0d151c; - color: #bfd7dc; +.result-card dt { color: var(--muted); } +.result-card dd { margin: 0; } +.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } + +.endpoint-list article { grid-template-columns: 150px minmax(220px, 1fr) auto; } +.policy-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.policy-grid article { grid-template-columns: 1fr; align-items: start; } +.policy-grid strong { font-size: 16px; } +.paragraph { margin: 10px 0 0; } +.security-board { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.security-board article { padding: 10px; display: grid; gap: 5px; } +.security-board .allow { border-color: rgba(113,191,120,0.45); } +.security-board .deny { border-color: rgba(207,106,84,0.45); } +.security-board span { color: var(--muted); } + +.empty-state { + display: grid; + gap: 4px; + min-height: 88px; + place-content: center; + border: 1px dashed var(--line); + color: var(--muted); + text-align: center; +} +.empty-state strong { color: var(--text); } +.muted { color: var(--muted); } + +.login-screen, .loading-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 18px; +} +.loading-screen { gap: 10px; align-content: center; color: var(--muted); } +.login-card { + width: min(440px, 100%); + border: 1px solid var(--line); + background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.018)), var(--panel); + box-shadow: var(--shadow); + padding: 18px; +} +.login-brand { padding-bottom: 14px; border-bottom: 1px solid var(--line-soft); margin-bottom: 14px; } +.login-brand h1 { font-size: 24px; } +.login-brand p { margin: 2px 0 0; color: var(--muted); text-transform: uppercase; letter-spacing: 0.14em; font-size: 11px; } +.login-form { display: grid; gap: 10px; } +.login-form label { display: grid; gap: 5px; color: var(--muted); } +.login-note { margin-top: 12px; color: var(--muted); font-size: 12px; } +.form-error { + padding: 7px 8px; + border: 1px solid rgba(207,106,84,0.5); + color: #ffd7cf; + background: rgba(207,106,84,0.1); } -@media (max-width: 980px) { +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 18px; + background: rgba(0,0,0,0.58); +} +.raw-dialog { + width: min(920px, 96vw); + max-height: 86vh; + border: 1px solid var(--line); + background: #0a1015; + box-shadow: var(--shadow); +} +.raw-dialog-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px; + border-bottom: 1px solid var(--line); +} +.raw-json { + max-height: calc(86vh - 58px); + overflow: auto; + margin: 0; + padding: 10px; + color: #c5dadd; + font-size: 12px; + line-height: 1.45; +} + +@media (max-width: 1120px) { + .metric-grid, .policy-grid, .security-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dispatch-form { grid-template-columns: 1fr 1fr; } + .dispatch-actions { align-items: center; } + .page-grid { grid-template-columns: 1fr; } + .overview-grid .panel:nth-child(3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; } +} + +@media (max-width: 760px) { + body { font-size: 12px; } .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 { width: auto; min-width: 120px; grid-template-columns: 1fr; 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; } + .workspace { padding: 10px; } + .topbar { align-items: flex-start; flex-direction: column; } + .status-strip { flex-wrap: wrap; white-space: normal; } + .metric-grid, .policy-grid, .security-board, .dispatch-form { grid-template-columns: 1fr; } + .compact-row, .heartbeat-row, .log-row, .endpoint-list article { grid-template-columns: 1fr; align-items: start; } + .tab { min-width: 104px; } } diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 51609546..80eaada1 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -1,20 +1,41 @@ import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { dirname, join } from "node:path"; interface RuntimeConfig { port: number; - corePublicUrl: string; + coreInternalUrl: string; + frontendPublicUrl: string; + providerIngressPublicUrl: string; + authUsername: string; + authPassword: string; + sessionSecret: string; + sessionTtlSeconds: number; logFile: string; } +interface SessionPayload { + username: string; + expiresAt: number; + nonce: string; +} + type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +const sessionCookieName = "unidesk_session"; const config = readConfig(); const logger = createLogger("frontend", config.logFile); const publicDir = join(import.meta.dir, "..", "public"); +const vendorDir = join(import.meta.dir, "..", "node_modules"); const indexHtml = readFileSync(join(publicDir, "index.html"), "utf8").replace( "__UNIDESK_CONFIG__", - JSON.stringify({ coreApiUrl: config.corePublicUrl, corePort: new URL(config.corePublicUrl).port }), + JSON.stringify({ + frontendPublicUrl: config.frontendPublicUrl, + providerIngressPublicUrl: config.providerIngressPublicUrl, + authUsername: config.authUsername, + sessionTtlSeconds: config.sessionTtlSeconds, + apiBaseUrl: "/api", + }), ); function requiredEnv(name: string): string { @@ -37,7 +58,13 @@ function readNumberEnv(name: string): number { function readConfig(): RuntimeConfig { return { port: readNumberEnv("PORT"), - corePublicUrl: requiredEnv("CORE_PUBLIC_URL"), + coreInternalUrl: requiredEnv("CORE_INTERNAL_URL"), + frontendPublicUrl: requiredEnv("FRONTEND_PUBLIC_URL"), + providerIngressPublicUrl: requiredEnv("PROVIDER_INGRESS_PUBLIC_URL"), + authUsername: requiredEnv("AUTH_USERNAME"), + authPassword: requiredEnv("AUTH_PASSWORD"), + sessionSecret: requiredEnv("SESSION_SECRET"), + sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"), logFile: requiredEnv("LOG_FILE"), }; } @@ -45,7 +72,9 @@ function readConfig(): RuntimeConfig { 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 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"); @@ -58,41 +87,199 @@ function createLogger(service: string, logFile: string) { } function contentType(pathname: string): string { + if (pathname.endsWith(".html")) return "text/html; charset=utf-8"; 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"; + if (pathname.endsWith(".ico")) return "image/x-icon"; return "text/plain; charset=utf-8"; } -function jsonResponse(body: JsonValue, status = 200): Response { +function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit): Response { + const headers = new Headers({ "content-type": "application/json; charset=utf-8" }); + if (extraHeaders !== undefined) { + new Headers(extraHeaders).forEach((value, key) => headers.set(key, value)); + } return new Response(JSON.stringify(body, null, 2), { status, - headers: { "content-type": "application/json; charset=utf-8" }, + headers, }); } +function signPayload(payload: string): string { + return createHmac("sha256", config.sessionSecret).update(payload).digest("base64url"); +} + +function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +function createSession(username: string): { token: string; expiresAt: number } { + const expiresAt = Date.now() + config.sessionTtlSeconds * 1000; + const payload: SessionPayload = { username, expiresAt, nonce: randomBytes(12).toString("base64url") }; + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); + return { token: `${encoded}.${signPayload(encoded)}`, expiresAt }; +} + +function verifySession(token: string | undefined): SessionPayload | null { + if (!token) return null; + const [encoded, signature, extra] = token.split("."); + if (!encoded || !signature || extra !== undefined) return null; + if (!safeEqual(signPayload(encoded), signature)) return null; + try { + const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Partial; + if (payload.username !== config.authUsername || typeof payload.expiresAt !== "number" || payload.expiresAt <= Date.now()) return null; + if (typeof payload.nonce !== "string") return null; + return payload as SessionPayload; + } catch { + return null; + } +} + +function parseCookies(header: string | null): Record { + const cookies: Record = {}; + if (!header) return cookies; + for (const item of header.split(";")) { + const index = item.indexOf("="); + if (index === -1) continue; + const key = item.slice(0, index).trim(); + const value = item.slice(index + 1).trim(); + try { + cookies[key] = decodeURIComponent(value); + } catch { + cookies[key] = value; + } + } + return cookies; +} + +function sessionFromRequest(req: Request): SessionPayload | null { + return verifySession(parseCookies(req.headers.get("cookie"))[sessionCookieName]); +} + +function setSessionCookie(token: string): string { + return `${sessionCookieName}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${config.sessionTtlSeconds}`; +} + +function clearSessionCookie(): string { + return `${sessionCookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`; +} + +async function readLoginBody(req: Request): Promise<{ username: string; password: string }> { + const contentTypeHeader = req.headers.get("content-type") ?? ""; + if (contentTypeHeader.includes("application/x-www-form-urlencoded") || contentTypeHeader.includes("multipart/form-data")) { + const form = await req.formData(); + return { username: String(form.get("username") ?? ""), password: String(form.get("password") ?? "") }; + } + const body = (await req.json()) as { username?: unknown; password?: unknown }; + return { + username: typeof body.username === "string" ? body.username : "", + password: typeof body.password === "string" ? body.password : "", + }; +} + +async function login(req: Request): Promise { + try { + const body = await readLoginBody(req); + if (body.username !== config.authUsername || body.password !== config.authPassword) { + logger("warn", "login_failed", { username: body.username }); + return jsonResponse({ ok: false, error: "invalid credentials" }, 401); + } + const session = createSession(body.username); + logger("info", "login_succeeded", { username: body.username, expiresAt: new Date(session.expiresAt).toISOString() }); + return jsonResponse( + { ok: true, user: { username: body.username }, expiresAt: new Date(session.expiresAt).toISOString() }, + 200, + { "set-cookie": setSessionCookie(session.token) }, + ); + } catch (error) { + logger("error", "login_failed", { error: error instanceof Error ? error.message : String(error) }); + return jsonResponse({ ok: false, error: "invalid login request" }, 400); + } +} + +function logout(): Response { + return jsonResponse({ ok: true }, 200, { "set-cookie": clearSessionCookie() }); +} + +function sessionResponse(req: Request): Response { + const session = sessionFromRequest(req); + if (session === null) return jsonResponse({ ok: true, authenticated: false }); + return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() }); +} + +async function proxyApi(req: Request, url: URL): Promise { + if (sessionFromRequest(req) === null) { + return jsonResponse({ ok: false, error: "authentication required" }, 401); + } + const upstreamUrl = new URL(`${url.pathname}${url.search}`, config.coreInternalUrl); + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("connection"); + headers.delete("content-length"); + headers.delete("cookie"); + const init: RequestInit = { method: req.method, headers, redirect: "manual" }; + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = await req.arrayBuffer(); + } + const upstream = await fetch(upstreamUrl, init); + const responseHeaders = new Headers(); + const upstreamContentType = upstream.headers.get("content-type"); + if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType); + return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: responseHeaders }); +} + +function vendorPath(pathname: string): string | null { + if (pathname === "/vendor/react.production.min.js") return join(vendorDir, "react", "umd", "react.production.min.js"); + if (pathname === "/vendor/react-dom.production.min.js") return join(vendorDir, "react-dom", "umd", "react-dom.production.min.js"); + return null; +} + +async function staticResponse(pathname: string): Promise { + const vendor = vendorPath(pathname); + const filePath = vendor ?? join(publicDir, pathname.replace(/^\/+/, "")); + const file = Bun.file(filePath); + if (!(await file.exists())) { + return jsonResponse({ ok: false, error: "not found", path: pathname }, 404); + } + return new Response(file, { headers: { "content-type": contentType(pathname) } }); +} + 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 }); + try { + if (url.pathname === "/health") { + return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl }); + } + if (url.pathname === "/login" && req.method === "POST") return login(req); + if (url.pathname === "/logout" && req.method === "POST") return logout(); + if (url.pathname === "/api/session") return sessionResponse(req); + if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url); + 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("..") || safePath.includes("\0")) { + return jsonResponse({ ok: false, error: "invalid path" }, 400); + } + return staticResponse(url.pathname); + } catch (error) { + logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) }); + return jsonResponse({ ok: false, error: "request failed" }, 500); } - 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 }); +logger("info", "server_listening", { + url: `http://0.0.0.0:${server.port}`, + coreInternalUrl: config.coreInternalUrl, + frontendPublicUrl: config.frontendPublicUrl, + providerIngressPublicUrl: config.providerIngressPublicUrl, + logFile: config.logFile, +});