diff --git a/AGENTS.md b/AGENTS.md index e02a0d71..d67af89c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,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 状态、网关版本和自动更新记录,界面规则见 `docs/reference/frontend.md`。 +- `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含资源监控、Docker 状态、网关版本、SSH/远程更新可用性和自动更新记录,界面规则见 `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 fb33f1e8..fcdcbc31 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: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` 和结构化控件。 +阅读 `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`、`frontend:provider-operation-availability-visible` 全部 passed;打开输出的 screenshotPath,确认 Playwright 访问的是公网 frontend,页面上能看到 `main-server`、`Main Server Provider`、`SSH 透传`、`远程更新` 和结构化控件。 ## T9 Database 命名卷持久化 @@ -74,7 +74,7 @@ ## 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` 按钮查看。 +阅读 `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` 和 `frontend:provider-operation-availability-visible` passed;再登录公网 frontend,进入 `资源节点 / 网关版本`,确认每个 Provider 行都显示 provider-gateway 版本号、升级策略、SSH 透传可用性、远程更新可用性、能力摘要、最近自动更新记录,并在下方以表格记录 `provider.upgrade` 的状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间。自动更新记录默认必须是结构化控件,不得展示裸 JSON;完整 task/result 只能通过 `查看原始JSON` 按钮查看。 ## T19 前端单服务重建 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 55cc90ab..977b0b31 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -17,7 +17,7 @@ UniDesk delivery is not complete until the public frontend, public provider ingr - 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, 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: 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, SSH 透传可用性、远程更新可用性 plus structured automatic update records for `provider.upgrade`. ## Frontend JSON Rule @@ -25,6 +25,8 @@ The frontend must render JSON data into React controls by default. Raw JSON is a 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`. +Provider operation availability is also covered by the structured rendering rule. `host.ssh` availability must be displayed as badges or equivalent controls derived from capabilities and `hostSsh*` labels, and remote update availability must be displayed from `provider.upgrade` capability plus the `always-enabled` policy; these fields must not require opening raw Provider 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 c5c7f53f..21b63b95 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -28,7 +28,11 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components ## Provider Gateway Version View -资源节点模块必须提供 `网关版本` 子标签,按每个 Provider 展示 provider-gateway 版本号、升级策略、启动时间、能力摘要、最近自动更新状态和自动更新记录。自动更新记录的数据源是 `provider.upgrade` 任务历史,默认必须渲染为结构化表格字段:状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间;不得把升级 plan、task result 或服务日志作为裸 JSON 直接铺在页面上。`最近自动更新` 应优先选择最新 `mode: "schedule"` 的真实升级记录,避免后续预检 plan 覆盖真正的升级结果;完整升级任务 JSON 只能通过对应行的 `查看原始JSON` 按钮显式打开。 +资源节点模块必须提供 `网关版本` 子标签,按每个 Provider 展示 provider-gateway 版本号、升级策略、启动时间、能力摘要、SSH 透传可用性、远程更新可用性、最近自动更新状态和自动更新记录。SSH 透传可用性必须由 `unideskCapabilities` 是否包含 `host.ssh`、`hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget` 渲染为结构化徽标;远程更新可用性必须由 `unideskCapabilities` 是否包含 `provider.upgrade` 与 `providerGatewayUpgradePolicy: "always-enabled"` 渲染为结构化徽标。自动更新记录的数据源是 `provider.upgrade` 任务历史,默认必须渲染为结构化表格字段:状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间;不得把升级 plan、task result 或服务日志作为裸 JSON 直接铺在页面上。`最近自动更新` 应优先选择最新 `mode: "schedule"` 的真实升级记录,避免后续预检 plan 覆盖真正的升级结果;完整升级任务 JSON 只能通过对应行的 `查看原始JSON` 按钮显式打开。 + +## Provider Operation Availability + +`资源节点 / 节点清单`、`资源节点 / 网关版本` 和总览中的 Provider 卡片必须显示每个计算节点的运维可用性,不允许只在原始 labels JSON 中隐藏。`SSH 透传` 徽标至少区分可用、未配置、缺 key、未声明能力;`远程更新` 徽标至少区分可用、策略待确认、未声明能力。可用性判断只来自 provider-gateway 注册/心跳 labels 和能力声明,不用前端猜测,也不得把缺失能力默认显示为成功。 ## Provider Gateway Upgrade Control diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index a41f6f4d..26a99aac 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -58,7 +58,7 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 ## Gateway Version Metadata -provider-gateway 必须从自身 `package.json` 读取版本号,并在 register 与 heartbeat labels 中上报 `providerGatewayName`、`providerGatewayVersion`、`providerGatewayStartedAt` 和 `providerGatewayUpgradePolicy`。backend-core 将这些 labels 合并到 `unidesk_nodes.labels`,frontend 在节点清单、资源监控和 `资源节点 / 网关版本` 中展示;旧节点缺少这些字段时只能显示版本未知,不能用猜测值替代。 +provider-gateway 必须从自身 `package.json` 读取版本号,并在 register 与 heartbeat labels 中上报 `providerGatewayName`、`providerGatewayVersion`、`providerGatewayStartedAt` 和 `providerGatewayUpgradePolicy`。backend-core 将这些 labels 合并到 `unidesk_nodes.labels`,frontend 在节点清单、资源监控和 `资源节点 / 网关版本` 中展示;旧节点缺少这些字段时只能显示版本未知,不能用猜测值替代。`unideskCapabilities`、`hostSshConfigured`、`hostSshKeyPresent` 和 `hostSshTarget` 也是 WebUI 运维可用性徽标的数据源,用于直接显示每个计算节点的 SSH 透传可用性与远程更新可用性。 ## Docker Status Telemetry diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 9f891f68..23a9b471 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -375,10 +375,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 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"); + return text.includes("provider gateway 版本") && text.includes("自动更新记录") && text.includes("provider.upgrade") && text.includes("ssh 透传") && text.includes("远程更新"); }, undefined, { timeout: 10000 }); const gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 }); const gatewayTextLower = gatewayText.toLowerCase(); + const sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); + const upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); 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 }); @@ -391,6 +393,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2 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:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts }); 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 b7f7516f..92951bcf 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -333,6 +333,59 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } background: rgba(207, 106, 84, 0.1); } +.node-availability-strip { + display: flex; + flex-wrap: wrap; + gap: 5px; + min-width: 220px; + margin: 5px 0 7px; +} +.capability-badge { + display: grid; + grid-template-columns: auto auto; + gap: 1px 6px; + min-width: 104px; + max-width: 190px; + padding: 4px 6px; + border: 1px solid var(--line); + background: rgba(0,0,0,0.16); + line-height: 1.22; +} +.capability-badge b { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.capability-badge strong { + justify-self: end; + font-size: 11px; +} +.capability-badge small { + grid-column: 1 / -1; + min-width: 0; + overflow: hidden; + color: var(--faint); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.capability-badge.ok { + border-color: rgba(113, 191, 120, 0.42); + background: rgba(113, 191, 120, 0.07); +} +.capability-badge.ok strong { color: var(--ok); } +.capability-badge.warn { + border-color: rgba(215, 161, 58, 0.45); + background: rgba(215, 161, 58, 0.08); +} +.capability-badge.warn strong { color: var(--warn); } +.capability-badge.fail { + border-color: rgba(207, 106, 84, 0.45); + background: rgba(207, 106, 84, 0.08); +} +.capability-badge.fail strong { color: var(--danger); } + .status-badge { display: inline-flex; align-items: center; @@ -693,7 +746,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } display: grid; gap: 10px; } -.gateway-version-table { min-width: 1120px; } +.gateway-version-table { min-width: 1280px; } .gateway-version-table-wrap { max-height: 340px; } .capability-row { display: flex; @@ -782,6 +835,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; } .table-wrap { overflow: auto; max-height: calc(100vh - 174px); } table { width: 100%; border-collapse: collapse; min-width: 760px; } +.node-list-table { min-width: 1180px; } .task-history-table { min-width: 1080px; } th, td { padding: 7px 9px; diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index e622b63a..5d30db95 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -179,9 +179,33 @@ function nodeGatewayStartedAt(node: any): string { function nodeCapabilities(node: any): string[] { const value = recordValue(node?.labels, "unideskCapabilities"); + if (typeof value === "string") return value.split(",").map((item) => item.trim()).filter(Boolean); return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; } +function nodeHasCapability(node: any, capability: string): boolean { + return nodeCapabilities(node).includes(capability); +} + +function labelBoolean(node: any, key: string): boolean { + const value = recordValue(node?.labels, key); + return value === true || value === "true" || value === "1"; +} + +function nodeSshAvailability(node: any): { tone: string; label: string; detail: string } { + if (!nodeHasCapability(node, "host.ssh")) return { tone: "fail", label: "不可用", detail: "未声明 host.ssh" }; + if (!labelBoolean(node, "hostSshConfigured")) return { tone: "warn", label: "未配置", detail: "缺少 SSH 环境变量" }; + if (!labelBoolean(node, "hostSshKeyPresent")) return { tone: "warn", label: "缺 key", detail: "私钥未挂载" }; + return { tone: "ok", label: "可用", detail: labelString(node, "hostSshTarget", "host.ssh ready") }; +} + +function nodeUpgradeAvailability(node: any): { tone: string; label: string; detail: string } { + if (!nodeHasCapability(node, "provider.upgrade")) return { tone: "fail", label: "不可用", detail: "未声明 provider.upgrade" }; + const policy = nodeGatewayPolicy(node); + if (policy !== "always-enabled") return { tone: "warn", label: "待确认", detail: `策略 ${policy}` }; + return { tone: "ok", label: "可用", detail: "always-enabled" }; +} + function fmtGatewayVersion(value: any): string { const text = typeof value === "string" && value.length > 0 ? value : "未知"; if (text === "未知") return "版本未知"; @@ -336,6 +360,26 @@ function GatewayVersionBadge({ node }: AnyRecord) { }, fmtGatewayVersion(version)); } +function CapabilityBadge({ title, state, testId }: AnyRecord) { + return h("span", { + className: `capability-badge ${state.tone}`, + title: state.detail, + "data-testid": testId, + }, + h("b", null, title), + h("strong", null, state.label), + h("small", null, state.detail), + ); +} + +function NodeAvailabilityStrip({ node }: AnyRecord) { + const providerId = safeId(node?.providerId || "unknown"); + return h("div", { className: "node-availability-strip" }, + h(CapabilityBadge, { title: "SSH 透传", state: nodeSshAvailability(node), testId: `ssh-availability-${providerId}` }), + h(CapabilityBadge, { title: "远程更新", state: nodeUpgradeAvailability(node), testId: `upgrade-availability-${providerId}` }), + ); +} + 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)); @@ -467,6 +511,7 @@ function NodeCard({ node, onRaw }: AnyRecord) { h(GatewayVersionBadge, { node }), h("span", null, `升级策略 ${nodeGatewayPolicy(node)}`), ), + h(NodeAvailabilityStrip, { node }), h(LabelChips, { labels: node.labels, limit: 6 }), h("div", { className: "node-card-foot" }, h("span", null, `心跳 ${fmtDate(node.lastHeartbeat)}`), @@ -508,12 +553,13 @@ function LogsPage({ logs, onRaw }: AnyRecord) { 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("th", null, "操作"))), + h("div", { className: "table-wrap" }, h("table", { className: "node-list-table" }, + 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("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(NodeAvailabilityStrip, { node })), h("td", null, h(LabelChips, { labels: node.labels, limit: 5 })), h("td", null, fmtDate(node.connectedAt)), h("td", null, fmtDate(node.lastHeartbeat)), @@ -766,6 +812,7 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) { h("th", null, "Provider"), h("th", null, "Gateway 版本"), h("th", null, "升级策略"), + h("th", null, "运维可用性"), h("th", null, "运行时间"), h("th", null, "能力"), h("th", null, "最近自动更新"), @@ -776,6 +823,7 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) { 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, h(NodeAvailabilityStrip, { node: 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" }, "未声明") :