feat: show provider operation availability

This commit is contained in:
Codex
2026-05-05 04:57:23 +00:00
parent 6ffc06ed1f
commit e824634f32
8 changed files with 121 additions and 10 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun`TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md` - `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` - `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` - `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 展示断言、数据库命名卷持久化要求。 - `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。
+2 -2
View File
@@ -34,7 +34,7 @@
## T8 Playwright 公网前端 E2E ## 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 命名卷持久化 ## T9 Database 命名卷持久化
@@ -74,7 +74,7 @@
## T18 Provider Gateway 版本与自动更新记录 ## 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 前端单服务重建 ## T19 前端单服务重建
+3 -1
View File
@@ -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 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. - 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. - 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 ## 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`. 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 ## 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. 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.
+5 -1
View File
@@ -28,7 +28,11 @@ frontend 应用源码必须使用 TypeScript + React,禁止在 `src/components
## Provider Gateway Version View ## 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 ## Provider Gateway Upgrade Control
+1 -1
View File
@@ -58,7 +58,7 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前
## Gateway Version Metadata ## 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 ## Docker Status Telemetry
+4 -1
View File
@@ -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.waitForSelector(`[data-testid="gateway-version-${safeTestId(config.providerGateway.id)}"]`, { timeout: 10000 });
await page.waitForFunction(() => { await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase(); 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 }); }, undefined, { timeout: 10000 });
const gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 }); const gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 });
const gatewayTextLower = gatewayText.toLowerCase(); 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: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: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 }); 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: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: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: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 }); addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors }; return { screenshotPath, bodyText, consoleErrors };
} finally { } finally {
+55 -1
View File
@@ -333,6 +333,59 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
background: rgba(207, 106, 84, 0.1); 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 { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -693,7 +746,7 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.gateway-version-table { min-width: 1120px; } .gateway-version-table { min-width: 1280px; }
.gateway-version-table-wrap { max-height: 340px; } .gateway-version-table-wrap { max-height: 340px; }
.capability-row { .capability-row {
display: flex; 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-wrap { overflow: auto; max-height: calc(100vh - 174px); }
table { width: 100%; border-collapse: collapse; min-width: 760px; } table { width: 100%; border-collapse: collapse; min-width: 760px; }
.node-list-table { min-width: 1180px; }
.task-history-table { min-width: 1080px; } .task-history-table { min-width: 1080px; }
th, td { th, td {
padding: 7px 9px; padding: 7px 9px;
+50 -2
View File
@@ -179,9 +179,33 @@ function nodeGatewayStartedAt(node: any): string {
function nodeCapabilities(node: any): string[] { function nodeCapabilities(node: any): string[] {
const value = recordValue(node?.labels, "unideskCapabilities"); 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") : []; 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 { function fmtGatewayVersion(value: any): string {
const text = typeof value === "string" && value.length > 0 ? value : "未知"; const text = typeof value === "string" && value.length > 0 ? value : "未知";
if (text === "未知") return "版本未知"; if (text === "未知") return "版本未知";
@@ -336,6 +360,26 @@ function GatewayVersionBadge({ node }: AnyRecord) {
}, fmtGatewayVersion(version)); }, 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) { function DataSummary({ data, empty = "无数据" }: AnyRecord) {
if (data === null || data === undefined) return h("span", { className: "muted" }, 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 (typeof data !== "object") return h("span", { className: "summary-value" }, summarizeValue(data));
@@ -467,6 +511,7 @@ function NodeCard({ node, onRaw }: AnyRecord) {
h(GatewayVersionBadge, { node }), h(GatewayVersionBadge, { node }),
h("span", null, `升级策略 ${nodeGatewayPolicy(node)}`), h("span", null, `升级策略 ${nodeGatewayPolicy(node)}`),
), ),
h(NodeAvailabilityStrip, { node }),
h(LabelChips, { labels: node.labels, limit: 6 }), h(LabelChips, { labels: node.labels, limit: 6 }),
h("div", { className: "node-card-foot" }, h("div", { className: "node-card-foot" },
h("span", null, `心跳 ${fmtDate(node.lastHeartbeat)}`), h("span", null, `心跳 ${fmtDate(node.lastHeartbeat)}`),
@@ -508,12 +553,13 @@ function LogsPage({ logs, onRaw }: AnyRecord) {
function NodeListPage({ nodes, onRaw }: AnyRecord) { function NodeListPage({ nodes, onRaw }: AnyRecord) {
return h(Panel, { title: "节点清单", eyebrow: `${nodes.length} Providers` }, return h(Panel, { title: "节点清单", eyebrow: `${nodes.length} Providers` },
nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "确认 provider-gateway 已连接 provider ingress" }) : nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "确认 provider-gateway 已连接 provider ingress" }) :
h("div", { className: "table-wrap" }, h("table", 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("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("tbody", null, nodes.map((node: any) => h("tr", { key: node.providerId },
h("td", null, h(StatusBadge, { status: node.status })), 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("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("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, h(LabelChips, { labels: node.labels, limit: 5 })),
h("td", null, fmtDate(node.connectedAt)), h("td", null, fmtDate(node.connectedAt)),
h("td", null, fmtDate(node.lastHeartbeat)), h("td", null, fmtDate(node.lastHeartbeat)),
@@ -766,6 +812,7 @@ function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
h("th", null, "Provider"), h("th", null, "Provider"),
h("th", null, "Gateway 版本"), h("th", null, "Gateway 版本"),
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, "最近自动更新"),
@@ -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("strong", null, row.node.name), h("code", null, row.node.providerId)),
h("td", null, h(GatewayVersionBadge, { node: row.node })), h("td", null, h(GatewayVersionBadge, { node: row.node })),
h("td", null, nodeGatewayPolicy(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, nodeGatewayStartedAt(row.node) ? fmtDate(nodeGatewayStartedAt(row.node)) : "待新版上报"),
h("td", null, h("div", { className: "capability-row" }, h("td", null, h("div", { className: "capability-row" },
row.capabilities.length === 0 ? h("span", { className: "muted" }, "未声明") : row.capabilities.length === 0 ? h("span", { className: "muted" }, "未声明") :