From 92799a006adff943f55ca348bcfcef7540620bb0 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Fri, 22 May 2026 14:08:56 +0000 Subject: [PATCH] feat: enrich cloud workbench hardware status --- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 37 ++++ web/hwlab-cloud-web/app.mjs | 188 +++++++++++++++--- web/hwlab-cloud-web/index.html | 2 +- web/hwlab-cloud-web/scripts/check.mjs | 24 +++ web/hwlab-cloud-web/styles.css | 94 ++++++++- 5 files changed, 314 insertions(+), 31 deletions(-) diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index f3460439..beab3c41 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -73,6 +73,21 @@ const requiredWiringColumns = Object.freeze([ "trace/evidence" ]); +const requiredHardwareStatusTerms = Object.freeze([ + "hardware-source-status", + "hardwareGroup", + "hardwareRow", + "Gateway/Box 在线", + "BOX-SIMU 端口", + "Patch Panel 连线", + "DO1 -> patch-panel -> DI1", + "AI/AO/FREQ", + "future capability", + "SOURCE", + "DEV-LIVE", + "BLOCKED" +]); + const workbenchMarkers = Object.freeze([ "data-app-shell", "workbench-shell", @@ -192,6 +207,11 @@ function runStaticSmoke() { evidence: requiredWiringColumns }); + addCheck(checks, blockers, "hardware-status-rich-structure", hardwareStatusStructureContract(files), "Right hardware status renders Gateway/Box, DI/DO/AI/AO/FREQ, patch-panel, and DO1 -> DI1 source-labeled link state.", { + blocker: "contract_blocker", + evidence: requiredHardwareStatusTerms + }); + addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { blocker: "contract_blocker", evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"] @@ -427,6 +447,23 @@ function wiringTableContract(html) { return requiredWiringColumns.every((column) => wiringPanel.includes(`${column}`)); } +function hardwareStatusStructureContract({ html, app, styles }) { + const source = `${html}\n${app}\n${styles}`; + return ( + requiredHardwareStatusTerms.every((term) => source.includes(term)) && + /id=["']hardware-source-status["']/u.test(html) && + /function\s+renderHardwareStatus\s*\(/u.test(app) && + /function\s+hardwareGroup\s*\(/u.test(app) && + /function\s+hardwareRow\s*\(/u.test(app) && + /function\s+trustedM3Link\s*\(/u.test(app) && + /function\s+linkStatusLabel\s*\(/u.test(app) && + /function\s+normalizePort\s*\(/u.test(app) && + /Web 不提供硬件写 RPC;不会新增伪硬件写操作。/u.test(app) && + /\.hardware-section\s*\{/u.test(styles) && + /\.hardware-row\s*\{/u.test(styles) + ); +} + function finalRouteFallback(app) { const body = app.match(/function\s+routeFromLocation\s*\(\)\s*\{([\s\S]*?)\n\}/u)?.[1] ?? ""; const returns = [...body.matchAll(/return\s+["']([^"']+)["']\s*;/gu)].map((match) => match[1]); diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index b7c98e57..c873f850 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -65,6 +65,7 @@ const el = { commandInput: byId("command-input"), commandSend: byId("command-send"), commandClear: byId("command-clear"), + hardwareSourceStatus: byId("hardware-source-status"), hardwareList: byId("hardware-list"), controlList: byId("control-list"), wiringBody: byId("wiring-body"), @@ -609,34 +610,167 @@ function renderUnblockList() { } function renderHardwareStatus(summary) { - const counts = summary?.counts ?? {}; - const cards = [ + const counts = summary?.counts ?? null; + const patchPanel = gateSummary.topology.patchPanel; + const liveLink = trustedM3Link(patchPanel.activeConnections); + const sourceKind = counts ? "DEV-LIVE" : "SOURCE"; + const sourceTone = counts ? "dev-live" : "source"; + const gatewayTone = counts ? "dev-live" : "source"; + const boxTone = counts ? "dev-live" : "source"; + const patchTone = patchPanel.state === "active" ? "live" : patchPanel.state; + const linkTone = counts && liveLink ? "live" : patchPanel.activeConnectionCount > 0 ? "degraded" : "blocked"; + + el.hardwareSourceStatus.textContent = counts ? "DEV-LIVE" : "SOURCE"; + el.hardwareSourceStatus.className = `state-tag tone-${toneClass(sourceTone)}`; + + replaceChildren( + el.hardwareList, + hardwareGroup({ + title: "Gateway/Box 在线", + rows: [ + hardwareRow({ + label: "Gateway-SIMU", + value: counts ? `${counts.gatewaySessions ?? 0} 会话` : "离线查看", + detail: counts + ? `${counts.gatewaySessions ?? 0} 个 Gateway 会话来自只读 runtime。` + : `${gateSummary.gatewaySessionCount} 个 Gateway 会话来自 SOURCE fixture。`, + sourceKind, + tone: gatewayTone + }), + hardwareRow({ + label: "BOX-SIMU", + value: counts ? `${counts.boxResources ?? 0} 资源` : "离线查看", + detail: counts + ? `${counts.boxResources ?? 0} 个 BOX 资源来自只读 runtime。` + : `${gateSummary.boxResourceCount} 个 BOX 资源来自 SOURCE fixture。`, + sourceKind, + tone: boxTone + }) + ] + }), + hardwareGroup({ + title: "BOX-SIMU 端口", + rows: [ + hardwareRow({ + label: "DO1", + value: "not wired", + detail: counts ? "只读 runtime 未返回 DO1 电平;保持 SOURCE/DEV-LIVE 边界。" : "SOURCE 仅说明端口存在,不冒充实况。", + sourceKind: counts ? "DEV-LIVE" : "SOURCE", + tone: "blocked" + }), + hardwareRow({ + label: "DI1", + value: "not wired", + detail: counts ? "只读 runtime 未返回 DI1 回读;保持 SOURCE/DEV-LIVE 边界。" : "SOURCE 仅说明端口存在,不冒充实况。", + sourceKind: counts ? "DEV-LIVE" : "SOURCE", + tone: "blocked" + }), + hardwareRow({ + label: "AI/AO/FREQ", + value: "future capability", + detail: "当前未声明模拟量/频率闭环,按 future capability 处理。", + sourceKind: "SOURCE", + tone: "disabled" + }) + ] + }), + hardwareGroup({ + title: "Patch Panel 连线", + rows: [ + hardwareRow({ + label: "hwlab-patch-panel", + value: statusLabel(patchPanel.state), + detail: `${patchPanel.activeConnectionCount} 条 SOURCE 接线;当前 fixture 为 ${describePatchPanelConnections(patchPanel)}。`, + sourceKind: "SOURCE", + tone: patchTone + }), + hardwareRow({ + label: "DO1 -> patch-panel -> DI1", + value: linkStatusLabel(linkTone), + detail: m3LinkDetail(liveLink, patchPanel, counts), + sourceKind: counts && liveLink ? "DEV-LIVE" : "BLOCKED", + tone: linkTone + }) + ] + }), + hardwareGroup({ + title: "写入边界", + rows: [ + hardwareRow({ + label: "硬件控制", + value: "已禁用", + detail: "Web 不提供硬件写 RPC;不会新增伪硬件写操作。", + sourceKind: "SOURCE", + tone: "source" + }) + ] + }) + ); +} + +function hardwareGroup(group) { + const article = document.createElement("article"); + article.className = "hardware-section"; + article.append(textSpan(group.title, "hardware-section-title")); + const rows = document.createElement("div"); + rows.className = "hardware-rows"; + rows.append(...group.rows.map(hardwareRow)); + article.append(rows); + return article; +} + +function hardwareRow(item) { + const row = document.createElement("div"); + row.className = `hardware-row tone-border-${toneClass(item.tone)}`; + row.dataset.sourceKind = item.sourceKind; + const head = document.createElement("div"); + head.className = "hardware-row-head"; + head.append(textSpan(item.label, "hardware-row-label"), badge(item.sourceKind, item.sourceKind)); + row.append(head); + row.append(textSpan(item.value, `hardware-row-value tone-${toneClass(item.tone)}`)); + row.append(textSpan(item.detail, "hardware-row-detail")); + return row; +} + +function trustedM3Link(connections) { + return (connections ?? []).find( + (link) => + normalizePort(link.fromPort) === "DO1" && + normalizePort(link.toPort) === "DI1" && + String(link.fromResourceId ?? "").includes("boxsimu") && + String(link.toResourceId ?? "").includes("boxsimu") + ); +} + +function describePatchPanelConnections(patchPanel) { + if (!patchPanel.activeConnections.length) return "无活动连线"; + return patchPanel.activeConnections + .map((link) => `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`) + .join(";"); +} + +function m3LinkDetail(liveLink, patchPanel, counts) { + if (liveLink && counts) { + return `DEV-LIVE runtime 仅证明只读观测,链路为 ${liveLink.fromResourceId}:${liveLink.fromPort} -> ${patchPanel.serviceId} -> ${liveLink.toResourceId}:${liveLink.toPort}。`; + } + if (patchPanel.activeConnectionCount > 0) { + return `SOURCE 仅看到 patch-panel 接线:${describePatchPanelConnections(patchPanel)};未冒充 live。`; + } + return "BLOCKED:当前没有 DO1 -> patch-panel -> DI1 的可信链路证据。"; +} + +function linkStatusLabel(status) { + return ( { - title: "Gateway-SIMU", - value: valueOrUnavailable(counts.gatewaySessions, gateSummary.gatewaySessionCount), - meta: "只读数量;来源 SOURCE 回退", - tone: summary ? "dev-live" : "source" - }, - { - title: "BOX-SIMU", - value: valueOrUnavailable(counts.boxResources, gateSummary.boxResourceCount), - meta: "只读数量;来源 SOURCE 回退", - tone: summary ? "dev-live" : "source" - }, - { - title: "接线面板 hwlab-patch-panel", - value: statusLabel(gateSummary.topology.patchPanel.state), - meta: `${gateSummary.topology.patchPanel.activeConnectionCount} 条来源 SOURCE 接线`, - tone: gateSummary.blocked ? "blocked" : gateSummary.topology.patchPanel.state - }, - { - title: "硬件写入界面", - value: "已禁用", - meta: "Web 不提供硬件写 RPC", - tone: "source" - } - ]; - replaceChildren(el.hardwareList, ...cards.map(statusCard)); + live: "live", + degraded: "degraded", + blocked: "blocked" + }[status] ?? statusLabel(status) + ); +} + +function normalizePort(port) { + return String(port ?? "").trim().toUpperCase(); } function controlRows() { diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 45d56554..bbe74bed 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -211,7 +211,7 @@

运行界面

硬件状态:BOX-SIMU / Gateway-SIMU / hwlab-patch-panel

- 开发实况 DEV-LIVE 只读探测 + SOURCE
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 6739512f..669f733c 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -77,6 +77,30 @@ for (const legacyEnglishTitle of [ assert.doesNotMatch(`${html}\n${app}`, legacyEnglishTitle); } assert.match(html, /硬件状态:BOX-SIMU \/ Gateway-SIMU \/ hwlab-patch-panel/); +assert.match(html, /id="hardware-source-status"/); +assert.match(app, /function renderHardwareStatus/); +assert.match(app, /function hardwareGroup/); +assert.match(app, /function hardwareRow/); +assert.match(app, /function trustedM3Link/); +assert.match(app, /function linkStatusLabel/); +assert.match(app, /function normalizePort/); +for (const hardwareTerm of [ + "Gateway/Box 在线", + "BOX-SIMU 端口", + "Patch Panel 连线", + "DO1 -> patch-panel -> DI1", + "AI/AO/FREQ", + "future capability", + "SOURCE", + "DEV-LIVE", + "BLOCKED", + "Web 不提供硬件写 RPC;不会新增伪硬件写操作。" +]) { + assert.match(app, new RegExp(escapeRegExp(hardwareTerm)), `missing hardware term: ${hardwareTerm}`); +} +assert.match(app, /dataset\.sourceKind/); +assert.match(styles, /\.hardware-section\s*{/); +assert.match(styles, /\.hardware-row\s*{/); assert.match(html, /Gate \/ 诊断 \/ 验收/); assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/); assert.match(html, /href="\/styles\.css"/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 1f54e9d3..babaaf37 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -681,8 +681,74 @@ h3 { .hardware-list { display: grid; - grid-template-columns: 1fr 1fr; gap: 8px; + align-content: start; +} + +.hardware-section { + display: grid; + gap: 8px; + padding: 9px; + background: var(--surface-2); + border: 1px solid var(--line); +} + +.hardware-section-title { + color: var(--accent-2); + font-family: var(--mono); + font-size: 10px; + font-weight: 760; + letter-spacing: 0; + text-transform: uppercase; +} + +.hardware-rows { + display: grid; + gap: 6px; +} + +.hardware-row { + min-width: 0; + display: grid; + gap: 5px 8px; + padding: 8px; + background: var(--surface); + border: 1px solid var(--line); + border-left-width: 3px; +} + +.hardware-row-head { + grid-column: 1 / -1; + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + min-width: 0; +} + +.hardware-row-label, +.hardware-row-value, +.hardware-row-detail { + min-width: 0; + overflow-wrap: anywhere; +} + +.hardware-row-label { + color: var(--text); + font-weight: 760; +} + +.hardware-row-value { + color: var(--text); + font-family: var(--mono); + font-size: 14px; + font-weight: 800; + line-height: 1.1; +} + +.hardware-row-detail { + color: var(--muted); + font-size: 11px; } .status-card { @@ -915,7 +981,8 @@ tbody tr:last-child td { .tone-runtime_blocker, .tone-failed, .tone-rejected, -.tone-unavailable { +.tone-unavailable, +.tone-disabled { color: var(--bad); } @@ -957,10 +1024,31 @@ tbody tr:last-child td { .tone-bg-runtime_blocker, .tone-bg-failed, .tone-bg-rejected, -.tone-bg-unavailable { +.tone-bg-unavailable, +.tone-bg-disabled { background: var(--bad); } +.tone-border-live, +.tone-border-dev-live, +.tone-border-connected, +.tone-border-available, +.tone-border-active, +.tone-border-ok { + border-left-color: var(--ok); +} + +.tone-border-source, +.tone-border-disabled { + border-left-color: var(--dim); +} + +.tone-border-degraded, +.tone-border-blocked, +.tone-border-failed { + border-left-color: var(--bad); +} + .tone-bg-dry-run, .tone-bg-not_run, .tone-bg-warn,