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`
- `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 展示断言、数据库命名卷持久化要求。
+2 -2
View File
@@ -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 前端单服务重建
+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 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.
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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
+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.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 {
+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);
}
.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;
+50 -2
View File
@@ -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" }, "未声明") :