From bb47cb0c7e5ab09a1db443f300c1bf098f5e249a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 02:15:34 +0000 Subject: [PATCH] feat: show provider gateway versions in webui --- AGENTS.md | 2 +- TEST.md | 6 +- docs/reference/e2e.md | 6 +- docs/reference/frontend.md | 6 +- docs/reference/provider-gateway.md | 6 + scripts/src/e2e.ts | 31 ++- src/components/frontend/public/style.css | 114 +++++++++- src/components/frontend/src/app.tsx | 206 ++++++++++++++++++- src/components/provider-gateway/package.json | 1 + src/components/provider-gateway/src/index.ts | 18 ++ 10 files changed, 384 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1032c21e..4917cbb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun`:TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`。 - `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`。 -- `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含任务管理器风格资源监控与 Docker Desktop 风格状态页,界面规则见 `docs/reference/frontend.md`。 +- `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含资源监控、Docker 状态、网关版本和自动更新记录,界面规则见 `docs/reference/frontend.md`。 - `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,必须支持 always-enabled 远程升级,并可配置维护专用 Host SSH / WSL SSH 桥,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。 diff --git a/TEST.md b/TEST.md index 8f334bed..b558de42 100644 --- a/TEST.md +++ b/TEST.md @@ -34,7 +34,7 @@ ## T8 Playwright 公网前端 E2E -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`core:internal-overview`、`provider:self-node-online`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认 `config.json` 的 `network.publicHost` 是主 server 公网地址,运行 `bun scripts/cli.ts e2e run`,要求 JSON 中 `network:only-frontend-provider-ports`、`network:core-public-blocked`、`network:database-public-blocked`、`core:internal-overview`、`provider:self-node-online`、`provider:gateway-version-label`、`provider:system-status`、`provider:docker-status`、`provider:upgrade-plan`、`provider-ingress:public-health`、`database:named-volume-write`、`frontend:login-provider-visible`、`frontend:public-provider-info-visible`、`frontend:no-naked-json-before-click`、`frontend:system-monitor-visible`、`frontend:upgrade-plan-dispatch`、`frontend:docker-status-visible`、`frontend:gateway-version-records-visible` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -71,3 +71,7 @@ ## T17 Provider Gateway Host SSH / WSL SSH 维护桥 阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:确认目标 provider-gateway 已只读挂载维护私钥目录到 `/run/host-ssh`,目标宿主或 WSL 的 sshd 已启动且 `authorized_keys` 包含对应公钥;运行 `bun scripts/cli.ts debug dispatch main-server host.ssh --wait-ms 15000`,再运行 `bun scripts/cli.ts debug task latest`,确认任务通过真实 WebSocket 下发、状态为 `succeeded`、result 中 `probeLine` 包含 `UNIDESK_SSH_TEST`、`exitCode` 为 0、`hostSshKeyPresent` 为 true。随后运行 `bun scripts/cli.ts ssh main-server hostname`,确认输出是远端 hostname 且进程 exit code 为 0;再用 `printf 'pwd\nexit\n' | bun scripts/cli.ts ssh main-server` 验证无命令参数时能进入并退出远端登录 shell。对 D518 这类无公网 SSH 的 WSL 节点,使用同一命令替换 Provider ID 为 `D518`,必要时先用 debug dispatch 加 `--cwd /home/ubuntu` 覆盖远端工作目录,只能通过 provider-gateway 自连维护桥验证,不得把主 server 直连节点公网 22 端口作为通过标准。 + +## T18 Provider Gateway 版本与自动更新记录 + +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:gateway-version-label` 和 `frontend:gateway-version-records-visible` passed;再登录公网 frontend,进入 `资源节点 / 网关版本`,确认每个 Provider 行都显示 provider-gateway 版本号、升级策略、能力摘要、最近自动更新记录,并在下方以表格记录 `provider.upgrade` 的状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间。自动更新记录默认必须是结构化控件,不得展示裸 JSON;完整 task/result 只能通过 `查看原始JSON` 按钮查看。 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 6a2d0ddf..ea9a3e4f 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -14,15 +14,17 @@ UniDesk delivery is not complete until the public frontend, public provider ingr - Public exposure: Docker port summary must show only frontend and provider ingress host mappings; public core and public database probes must fail. - Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true` and at least one online node. -- Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; public provider ingress `/health` must return ok. +- Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`, `labels.providerGatewayVersion` equal to `src/components/provider-gateway/package.json` and `labels.providerGatewayUpgradePolicy: "always-enabled"`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; public provider ingress `/health` must return ok. - Provider remote control: internal `/api/dispatch` must successfully complete a real `provider.upgrade` task in `mode: "plan"` so the upgrade path is validated without recreating the running gateway during E2E. - Database: the command writes an `unidesk_e2e_markers` row through `docker exec unidesk-database psql` and confirms provider state is stored in PostgreSQL. -- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, then opens `Docker 状态` and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`. +- Frontend: Playwright must open the public frontend URL derived from `network.publicHost`, not localhost or a Docker-internal URL; it logs in with the configured account, waits for `核心在线`, asserts that `main-server` and `Main Server Provider` are visible, clicks `查看原始JSON` to verify Provider data from the frontend, confirms no raw JSON is visible before that click, opens task history to verify duration and failure diagnostics, opens resource nodes `资源监控` to verify CPU/Memory/Disk curves and provider upgrade precheck dispatch, opens `Docker 状态` and verifies the Docker Desktop-style container view including the database named volume `unidesk_pgdata_10gb`, then opens `网关版本` and verifies the provider-gateway version plus structured automatic update records for `provider.upgrade`. ## Frontend JSON Rule 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. +Automatic update records in the frontend are covered by the same rule: `provider.upgrade` task history must be rendered as rows/cards with status, mode, task id, source, duration, policy, outcome summary, and updated time. The page must not expose upgrade plan/result JSON as a log block unless the operator clicks `查看原始JSON`. + ## 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. diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index a0e79779..43da360d 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -26,9 +26,13 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components 资源节点模块必须提供 `Docker 状态` 子标签,用类似 Docker Desktop 的结构展示每个 provider 节点的 Docker daemon 状态。该页面应包含节点切换、daemon 摘要、Containers/Images/Volumes/Networks 指标、容器表格、镜像/卷/网络侧栏,并通过状态徽标区分 running、paused、exited 等状态。数据库命名卷 `unidesk_pgdata_10gb` 必须在 Volumes 区域和数据库命名卷卡片中显式可见,不得因为列表截断或匿名卷排序被隐藏。 +## Provider Gateway Version View + +资源节点模块必须提供 `网关版本` 子标签,按每个 Provider 展示 provider-gateway 版本号、升级策略、启动时间、能力摘要、最近自动更新状态和自动更新记录。自动更新记录的数据源是 `provider.upgrade` 任务历史,默认必须渲染为结构化表格字段:状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间;不得把升级 plan、task result 或服务日志作为裸 JSON 直接铺在页面上。完整升级任务 JSON 只能通过对应行的 `查看原始JSON` 按钮显式打开。 + ## Provider Gateway Upgrade Control -`资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id 和摘要,完整升级计划必须通过 `查看原始JSON` 显式查看。 +`资源监控` 子标签中的升级控制区通过 backend-core `/api/dispatch` 下发 `provider.upgrade` 任务。默认 `预检升级` 只生成升级计划并回传任务结果;`执行升级` 才允许调度节点本地 updater 容器执行 Compose 重建。前端只展示结构化任务状态、task id、摘要和当前节点的自动更新记录,完整升级计划必须通过 `查看原始JSON` 显式查看。 ## Component Data Rendering diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index ec783319..dd078a58 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -46,6 +46,10 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 自动任务执行只允许走本地 Docker socket。Compose 将 `/var/run/docker.sock` 挂入 provider-gateway,provider 标签会报告 `dockerSocketPresent`,`docker.ps` 调试任务会通过该 socket 查询宿主 Docker 容器。 +## Gateway Version Metadata + +provider-gateway 必须从自身 `package.json` 读取版本号,并在 register 与 heartbeat labels 中上报 `providerGatewayName`、`providerGatewayVersion`、`providerGatewayStartedAt` 和 `providerGatewayUpgradePolicy`。backend-core 将这些 labels 合并到 `unidesk_nodes.labels`,frontend 在节点清单、资源监控和 `资源节点 / 网关版本` 中展示;旧节点缺少这些字段时只能显示版本未知,不能用猜测值替代。 + ## Docker Status Telemetry provider-gateway 连接成功后必须周期性上报 Docker daemon 状态,数据来源是本地 Docker socket 上的 `docker info`、`docker ps -a`、`docker images`、`docker volume ls` 和 `docker network ls`。backend-core 将最新快照保存到 `unidesk_node_docker_status`,frontend 的资源节点 `Docker 状态` 子标签用该快照渲染 Docker Desktop 风格视图;该能力仍然只通过 provider 主动上报,不要求主 server 反向连接计算节点。 @@ -60,6 +64,8 @@ backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provi 远程升级策略固定为 always-enabled:只要 provider-gateway 在线并声明 `provider.upgrade`,`mode: "schedule"` 就必须真正调度升级容器,不允许被 `PROVIDER_UPGRADE_ENABLED=false`、前端隐藏按钮或服务端特殊名单禁用。升级能力的安全边界不是开关,而是显式 `PROVIDER_UPGRADE_*` 配置、Docker socket 权限、只读仓库挂载、固定 Compose service 和 `--no-deps` 约束。升级计划中必须展示 `policy: "always-enabled"`、updater 容器名、runner image、workspace、Compose project/service、env file、compose file 和实际 `docker run` 命令,方便前端任务历史与 CLI debug 直接诊断。 +自动更新记录的权威来源是 backend-core 保存的 `provider.upgrade` 任务历史,而不是 provider-gateway 容器日志文件。frontend 必须按 Provider 聚合这些任务,并把状态、模式、task id、来源、耗时、策略、updater 容器摘要、失败原因和更新时间渲染为表格或卡片;完整 task/result JSON 只能由操作员点击 `查看原始JSON` 后查看。 + 旧版 provider-gateway 如果只能返回 plan 或因为旧环境中的 `PROVIDER_UPGRADE_ENABLED=false` 拒绝 schedule,需要先通过任意现有维护通道手动 bootstrap 一次。bootstrap 的目标不是长期流程,而是把节点更新到支持 always-enabled 远程升级和 Host SSH / WSL SSH 维护桥的版本;完成后必须立刻用 `bun scripts/cli.ts debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 做一次真实一键升级验证,再用 `bun scripts/cli.ts debug health` 或公网 frontend 确认该节点仍在线、`unideskCapabilities` 包含 `provider.upgrade`,需要 SSH 维护的 WSL 节点还必须包含 `host.ssh`。 ## Manual Upgrade Maintenance diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 164607c0..9f891f68 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, readFileSync } from "node:fs"; import { connect } from "node:net"; import { join } from "node:path"; import { chromium } from "playwright"; @@ -70,6 +70,20 @@ function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unk checks.push({ name, status: passed ? "passed" : "failed", detail }); } +function safeTestId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function providerGatewayPackageVersion(): string { + try { + const raw = readFileSync(rootPath("src", "components", "provider-gateway", "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" ? parsed.version : ""; + } catch { + return ""; + } +} + function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: string; stderr: string; exitCode: number | null } { const result = runCommand([ "docker", @@ -209,13 +223,16 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const dockerStatus = dockerCoreJson("/api/nodes/docker-status"); const providerIngress = await fetchProbe(urls.providerIngressHealthUrl); const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body; - const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? []; + const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record }> } }).body?.nodes ?? []; + const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id); + const expectedGatewayVersion = providerGatewayPackageVersion(); const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number; mode?: string; cacheBytes?: number }; disk?: { percent?: number } }; history?: unknown[] }> } }).body?.systemStatuses ?? []; const mainSystem = systemStatuses.find((item) => item.providerId === config.providerGateway.id); const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? []; const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id); addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); + addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id)); addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id)); const upgradeDispatch = dockerCoreJson("/api/dispatch", { @@ -353,6 +370,15 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 return text.includes("docker desktop 视图") && text.includes("containers") && text.includes("unidesk_pgdata_10gb"); }, undefined, { timeout: 10000 }); const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); + await page.getByRole("button", { name: /网关版本/ }).click(); + await page.waitForSelector('[data-testid="gateway-version-page"]', { timeout: 10000 }); + await page.waitForSelector(`[data-testid="gateway-version-${safeTestId(config.providerGateway.id)}"]`, { timeout: 10000 }); + await page.waitForFunction(() => { + const text = document.body.innerText.toLowerCase(); + return text.includes("provider gateway 版本") && text.includes("自动更新记录") && text.includes("provider.upgrade"); + }, undefined, { timeout: 10000 }); + const gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 }); + const gatewayTextLower = gatewayText.toLowerCase(); addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) }); addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); @@ -364,6 +390,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存"), { monitorTextPreview: monitorText.slice(0, 800) }); addCheck(checks, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发"), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) }); addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) }); + addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayText.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) }); addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 0f17b629..b7f7516f 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -296,6 +296,43 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } color: var(--muted); } +.node-version-line, .gateway-cell { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 7px; + color: var(--muted); +} +.gateway-cell { + display: grid; + gap: 4px; + margin-bottom: 0; +} + +.version-chip, .mode-chip { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 7px; + border: 1px solid rgba(78, 183, 168, 0.48); + color: #c9f3ec; + background: rgba(78, 183, 168, 0.1); + font-family: "Cascadia Mono", "IBM Plex Mono", "Liberation Mono", monospace; + font-size: 11px; + letter-spacing: 0.04em; +} +.version-chip.unknown { + color: var(--warn); + border-color: rgba(215, 161, 58, 0.45); + background: rgba(215, 161, 58, 0.08); +} +.mode-chip.schedule { + color: #ffd7cf; + border-color: rgba(207, 106, 84, 0.5); + background: rgba(207, 106, 84, 0.1); +} + .status-badge { display: inline-flex; align-items: center; @@ -652,6 +689,80 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } color: #bcd2d7; } +.gateway-page { + display: grid; + gap: 10px; +} +.gateway-version-table { min-width: 1120px; } +.gateway-version-table-wrap { max-height: 340px; } +.capability-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 180px; +} +.latest-upgrade-cell { + display: grid; + gap: 4px; + min-width: 220px; +} +.latest-upgrade-cell small, .gateway-record-meta, .upgrade-outcome { + color: var(--muted); +} +.upgrade-outcome { + display: block; + min-width: 210px; + max-width: 420px; + overflow-wrap: anywhere; +} +.upgrade-outcome.failed { color: #ffd7cf; } +.upgrade-outcome.succeeded { color: #d8f6dd; } +.gateway-record-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); + gap: 8px; +} +.gateway-record-card { + min-width: 0; + padding: 8px; + border: 1px solid var(--line-soft); + background: var(--panel-3); +} +.gateway-record-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: start; + margin-bottom: 7px; +} +.gateway-record-head code { + display: block; + margin-top: 2px; + color: #bcd2d7; +} +.gateway-record-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 7px; +} +.gateway-record-meta span { + padding: 2px 6px; + border: 1px solid var(--line-soft); + background: rgba(255,255,255,0.025); +} +.upgrade-record-table { min-width: 1080px; } +.upgrade-record-table-wrap { + max-height: 300px; + border: 1px solid var(--line-soft); +} +.upgrade-record-table-wrap.compact { + max-height: 230px; +} +.provider-upgrade-records-panel .panel-body { + padding: 8px; +} + .chip-row, .summary-grid { display: flex; flex-wrap: wrap; @@ -887,6 +998,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .dispatch-form { grid-template-columns: 1fr 1fr; } .dispatch-actions { align-items: center; } .page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; } + .gateway-record-grid { grid-template-columns: 1fr; } .overview-grid .panel:nth-child(n+3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; } } @@ -982,7 +1094,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } padding: 4px 9px; white-space: nowrap; } - .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: 1fr; } + .metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid { grid-template-columns: 1fr; } .compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route { grid-template-columns: 1fr; align-items: start; } .docker-hero, .monitor-hero { flex-direction: column; } } diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 26a6c891..6081e608 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -40,6 +40,7 @@ const MODULES = [ { id: "list", label: "节点清单" }, { id: "monitor", label: "资源监控" }, { id: "docker", label: "Docker 状态" }, + { id: "gateway", label: "网关版本" }, { id: "labels", label: "资源标签" }, { id: "heartbeats", label: "心跳状态" }, ] }, @@ -155,6 +156,82 @@ function safeId(value: any): string { return String(value).replace(/[^a-zA-Z0-9_-]/g, "_"); } +function recordValue(data: any, key: string): any { + return data && typeof data === "object" && !Array.isArray(data) ? data[key] : undefined; +} + +function labelString(node: any, key: string, fallback = "未知"): string { + const value = recordValue(node?.labels, key); + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function nodeGatewayVersion(node: any): string { + return labelString(node, "providerGatewayVersion"); +} + +function nodeGatewayPolicy(node: any): string { + return labelString(node, "providerGatewayUpgradePolicy"); +} + +function nodeGatewayStartedAt(node: any): string { + return labelString(node, "providerGatewayStartedAt", ""); +} + +function nodeCapabilities(node: any): string[] { + const value = recordValue(node?.labels, "unideskCapabilities"); + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function fmtGatewayVersion(value: any): string { + const text = typeof value === "string" && value.length > 0 ? value : "未知"; + if (text === "未知") return "版本未知"; + return text.startsWith("v") ? text : `v${text}`; +} + +function taskPayload(task: any): AnyRecord { + return task?.payload && typeof task.payload === "object" && !Array.isArray(task.payload) ? task.payload as AnyRecord : {}; +} + +function taskResult(task: any): AnyRecord { + return task?.result && typeof task.result === "object" && !Array.isArray(task.result) ? task.result as AnyRecord : {}; +} + +function taskUpgradeMode(task: any): string { + const payload = taskPayload(task); + const result = taskResult(task); + const mode = payload.mode ?? result.mode; + return mode === "schedule" ? "schedule" : "plan"; +} + +function taskUpgradeSource(task: any): string { + const source = taskPayload(task).source; + return typeof source === "string" && source.length > 0 ? source : "unknown"; +} + +function taskUpgradePolicy(task: any): string { + const result = taskResult(task); + const plan = result.plan && typeof result.plan === "object" && !Array.isArray(result.plan) ? result.plan as AnyRecord : {}; + const policy = plan.policy; + return typeof policy === "string" && policy.length > 0 ? policy : "--"; +} + +function taskUpgradeOutcome(task: any): string { + const status = String(task?.status || "").toLowerCase(); + if (status === "failed") return taskFailureReason(task); + if (isPendingTask(task)) return "等待 provider 回传升级终态"; + const result = taskResult(task); + if (typeof result.updaterContainerId === "string" && result.updaterContainerId.length > 0) return `updater ${result.updaterContainerId.slice(0, 18)}`; + if (typeof result.message === "string" && result.message.length > 0) return result.message; + if (result.plan) return "升级计划已生成"; + return "无升级结果摘要"; +} + +function providerUpgradeTasks(tasks: any[], providerId: string): any[] { + return tasks + .filter((task) => task?.providerId === providerId && task?.command === "provider.upgrade") + .sort((left, right) => (timeMs(right.updatedAt) ?? 0) - (timeMs(left.updatedAt) ?? 0)); +} + async function requestJson(path: string, options: AnyRecord = {}): Promise { const headers = new Headers(options.headers || {}); if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json"); @@ -247,6 +324,14 @@ function LabelChips({ labels, limit = 8 }: AnyRecord) { ); } +function GatewayVersionBadge({ node }: AnyRecord) { + const version = nodeGatewayVersion(node); + return h("span", { + className: `version-chip ${version === "未知" ? "unknown" : ""}`, + "data-testid": `gateway-version-${safeId(node?.providerId || "unknown")}`, + }, fmtGatewayVersion(version)); +} + function DataSummary({ data, empty = "无数据" }: AnyRecord) { if (data === null || data === undefined) return h("span", { className: "muted" }, empty); if (typeof data !== "object") return h("span", { className: "summary-value" }, summarizeValue(data)); @@ -374,6 +459,10 @@ function NodeCard({ node, onRaw }: AnyRecord) { h("div", null, h("strong", null, node.name), h("code", null, node.providerId)), h(StatusBadge, { status: node.status }), ), + h("div", { className: "node-version-line" }, + h(GatewayVersionBadge, { node }), + h("span", null, `升级策略 ${nodeGatewayPolicy(node)}`), + ), h(LabelChips, { labels: node.labels, limit: 6 }), h("div", { className: "node-card-foot" }, h("span", null, `心跳 ${fmtDate(node.lastHeartbeat)}`), @@ -416,10 +505,11 @@ function NodeListPage({ nodes, onRaw }: AnyRecord) { return h(Panel, { title: "节点清单", eyebrow: `${nodes.length} Providers` }, nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "确认 provider-gateway 已连接 provider ingress" }) : h("div", { className: "table-wrap" }, h("table", null, - h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Provider"), h("th", null, "资源标签"), h("th", null, "连接时间"), h("th", null, "最后心跳"), h("th", null, "操作"))), + h("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("th", null, "操作"))), h("tbody", null, nodes.map((node: any) => h("tr", { key: node.providerId }, h("td", null, h(StatusBadge, { status: node.status })), h("td", null, h("strong", null, node.name), h("code", null, node.providerId)), + h("td", null, h("div", { className: "gateway-cell" }, h(GatewayVersionBadge, { node }), h("span", null, nodeGatewayPolicy(node)))), h("td", null, h(LabelChips, { labels: node.labels, limit: 5 })), h("td", null, fmtDate(node.connectedAt)), h("td", null, fmtDate(node.lastHeartbeat)), @@ -459,7 +549,7 @@ function HeartbeatPage({ nodes }: AnyRecord) { ); } -function NodeMonitorPage({ nodes, systemStatuses, onRaw, refresh }: AnyRecord) { +function NodeMonitorPage({ nodes, systemStatuses, tasks, onRaw, refresh }: AnyRecord) { const [selectedProvider, setSelectedProvider] = useState(""); const merged = useMemo(() => nodes.map((node: any) => { const status = systemStatuses.find((item: any) => item.providerId === node.providerId); @@ -535,6 +625,7 @@ function NodeMonitorPage({ nodes, systemStatuses, onRaw, refresh }: AnyRecord) { ), h("div", { className: "monitor-side-stack" }, h(UpgradeControl, { provider: active, refresh, onRaw }), + h(ProviderUpgradeRecordsPanel, { provider: active, tasks, onRaw, limit: 5 }), h(Panel, { title: "采样说明", eyebrow: "Retention" }, h("div", { className: "monitor-note-list" }, h("article", null, h("b", null, "CPU"), h("span", null, "从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")), @@ -611,6 +702,112 @@ function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) { ); } +function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) { + if (records.length === 0) { + return h(EmptyState, { title: "暂无自动更新记录", text: "该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录" }); + } + return h("div", { className: `upgrade-record-table-wrap table-wrap ${compact ? "compact" : ""}` }, h("table", { className: "upgrade-record-table" }, + h("thead", null, h("tr", null, + h("th", null, "状态"), + h("th", null, "模式"), + h("th", null, "任务"), + h("th", null, "来源"), + h("th", null, "耗时"), + h("th", null, "策略"), + h("th", null, "结果记录"), + h("th", null, "更新时间"), + h("th", null, "操作"), + )), + h("tbody", null, records.map((task: any) => h("tr", { key: task.id, "data-testid": `gateway-upgrade-record-${safeId(task.id)}` }, + h("td", null, h(StatusBadge, { status: task.status })), + h("td", null, h("span", { className: `mode-chip ${taskUpgradeMode(task)}` }, taskUpgradeMode(task) === "schedule" ? "执行升级" : "预检")), + h("td", null, h("strong", null, "provider.upgrade"), h("code", null, task.id)), + h("td", null, taskUpgradeSource(task)), + h("td", null, h(TaskDurationCell, { task })), + h("td", null, taskUpgradePolicy(task)), + h("td", null, h("span", { className: `upgrade-outcome ${String(task.status || "").toLowerCase()}` }, taskUpgradeOutcome(task))), + h("td", null, fmtDate(task.updatedAt)), + h("td", null, h(RawButton, { title: `Provider Upgrade Task ${task.id}`, data: task, onOpen: onRaw })), + ))), + )); +} + +function ProviderUpgradeRecordsPanel({ provider, tasks, onRaw, limit = 5 }: AnyRecord) { + const records = providerUpgradeTasks(tasks, provider.providerId).slice(0, limit); + return h(Panel, { + title: "自动更新记录", + eyebrow: provider.providerId, + actions: h(GatewayVersionBadge, { node: provider }), + className: "provider-upgrade-records-panel", + }, + h("div", { "data-testid": `provider-upgrade-records-${safeId(provider.providerId)}` }, + h(UpgradeRecordsTable, { records, onRaw, compact: true }), + ), + ); +} + +function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) { + const rows = useMemo(() => nodes.map((node: any) => { + const records = providerUpgradeTasks(tasks, node.providerId); + return { node, records, latest: records[0] || null, capabilities: nodeCapabilities(node) }; + }), [nodes, tasks]); + const totalRecords = rows.reduce((sum: number, row: any) => sum + row.records.length, 0); + + return h("div", { className: "gateway-page", "data-testid": "gateway-version-page" }, + h(Panel, { title: "Provider Gateway 版本", eyebrow: `${nodes.length} Providers / ${totalRecords} 更新记录` }, + nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "等待 provider-gateway 注册后显示版本号和升级记录" }) : + h("div", { className: "table-wrap gateway-version-table-wrap" }, h("table", { className: "gateway-version-table" }, + h("thead", null, h("tr", null, + h("th", null, "状态"), + h("th", null, "Provider"), + h("th", null, "Gateway 版本"), + h("th", null, "升级策略"), + h("th", null, "运行时间"), + h("th", null, "能力"), + h("th", null, "最近自动更新"), + h("th", null, "操作"), + )), + h("tbody", null, rows.map((row: any) => h("tr", { key: row.node.providerId }, + h("td", null, h(StatusBadge, { status: row.node.status })), + h("td", null, h("strong", null, row.node.name), h("code", null, row.node.providerId)), + h("td", null, h(GatewayVersionBadge, { node: row.node })), + h("td", null, nodeGatewayPolicy(row.node)), + h("td", null, nodeGatewayStartedAt(row.node) ? fmtDate(nodeGatewayStartedAt(row.node)) : "待新版上报"), + h("td", null, h("div", { className: "capability-row" }, + row.capabilities.length === 0 ? h("span", { className: "muted" }, "未声明") : + row.capabilities.slice(0, 5).map((item: string) => h("span", { key: item, className: "data-chip" }, item)), + )), + h("td", null, row.latest ? h("div", { className: "latest-upgrade-cell" }, + h(StatusBadge, { status: row.latest.status }), + h("span", null, `${taskUpgradeMode(row.latest) === "schedule" ? "执行升级" : "预检"} / ${fmtDate(row.latest.updatedAt)}`), + h("small", null, taskUpgradeOutcome(row.latest)), + ) : h("span", { className: "muted" }, "暂无记录")), + h("td", null, h(RawButton, { title: `Provider ${row.node.providerId}`, data: row.node, onOpen: onRaw })), + ))), + )), + ), + h(Panel, { title: "自动更新记录", eyebrow: "Structured provider.upgrade records" }, + nodes.length === 0 ? h(EmptyState, { title: "暂无记录", text: "没有 provider 节点时不会生成自动更新记录" }) : + h("div", { className: "gateway-record-grid" }, rows.map((row: any) => h("article", { + key: row.node.providerId, + className: "gateway-record-card", + "data-testid": `gateway-records-${safeId(row.node.providerId)}`, + }, + h("div", { className: "gateway-record-head" }, + h("div", null, h("strong", null, row.node.name), h("code", null, row.node.providerId)), + h(GatewayVersionBadge, { node: row.node }), + ), + h("div", { className: "gateway-record-meta" }, + h("span", null, `心跳 ${fmtDate(row.node.lastHeartbeat)}`), + h("span", null, `策略 ${nodeGatewayPolicy(row.node)}`), + h("span", null, `${row.records.length} 条记录`), + ), + h(UpgradeRecordsTable, { records: row.records.slice(0, 8), onRaw, compact: true }), + ))), + ), + ); +} + function dockerStateTone(state: string): string { if (state === "running") return "online"; if (state === "paused" || state === "restarting") return "warn"; @@ -1017,8 +1214,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa if (activeModule === "ops" && activeTab === "events") return h(EventsPage, { events: data.events, onRaw }); if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, onRaw }); if (activeModule === "nodes" && activeTab === "list") return h(NodeListPage, { nodes: data.nodes, onRaw }); - if (activeModule === "nodes" && activeTab === "monitor") return h(NodeMonitorPage, { nodes: data.nodes, systemStatuses: data.systemStatuses, onRaw, refresh }); + if (activeModule === "nodes" && activeTab === "monitor") return h(NodeMonitorPage, { nodes: data.nodes, systemStatuses: data.systemStatuses, tasks: data.tasks, onRaw, refresh }); if (activeModule === "nodes" && activeTab === "docker") return h(DockerStatusPage, { nodes: data.nodes, dockerStatuses: data.dockerStatuses, onRaw }); + if (activeModule === "nodes" && activeTab === "gateway") return h(GatewayVersionPage, { nodes: data.nodes, tasks: data.tasks, 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 }); @@ -1051,7 +1249,7 @@ function Shell({ session, onLogout }: AnyRecord) { requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`), requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`), requestJson(`${cfg.apiBaseUrl}/events?limit=100`), - requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`), + requestJson(`${cfg.apiBaseUrl}/tasks?limit=300`), requestJson(`${cfg.apiBaseUrl}/tasks?status=pending&limit=100`), requestJson("/logs?limit=100"), ]); diff --git a/src/components/provider-gateway/package.json b/src/components/provider-gateway/package.json index d6e7ded0..3a999835 100644 --- a/src/components/provider-gateway/package.json +++ b/src/components/provider-gateway/package.json @@ -1,5 +1,6 @@ { "name": "@unidesk/provider-gateway", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index ae39312e..f3142673 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -70,6 +70,20 @@ interface HostSshStdin { } const hostSshSessions = new Map(); +const gatewayMetadata = readGatewayMetadata(); + +function readGatewayMetadata(): { name: string; version: string } { + try { + const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8"); + const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown }; + return { + name: typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : "@unidesk/provider-gateway", + version: typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "0.0.0-unknown", + }; + } catch { + return { name: "@unidesk/provider-gateway", version: "0.0.0-unknown" }; + } +} function requiredEnv(name: string): string { const value = process.env[name]; @@ -165,6 +179,10 @@ function currentLabels(): ProviderLabels { hostSshKeyPresent: config.hostSshKey !== null && existsSync(config.hostSshKey), hostSshTarget: hostSshConfigured ? `${config.hostSshUser}@${config.hostSshHost}:${config.hostSshPort}` : "not-configured", runtime: "bun", + providerGatewayName: gatewayMetadata.name, + providerGatewayVersion: gatewayMetadata.version, + providerGatewayStartedAt: startedAt.toISOString(), + providerGatewayUpgradePolicy: "always-enabled", gatewayUptimeSeconds: Math.floor((Date.now() - startedAt.getTime()) / 1000), }; }