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,