From f104f3e687249e871fd333dc1b4c2b0b32fe9d1e Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 24 May 2026 09:32:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=81=9A=E5=90=88=20M3=20=E5=AE=9E?= =?UTF-8?q?=E5=86=B5=E7=8A=B6=E6=80=81=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聚合 /v1/m3/status 的 M3 实况读入口,右侧硬件工作区显示 IO、operation、trace、audit 与 runtime durable blocker;覆盖后端契约与前端 fixture 验证。 --- internal/cloud/m3-io-control.mjs | 149 ++++++++++++++++-- internal/cloud/m3-io-control.test.mjs | 38 +++++ internal/cloud/server.test.mjs | 41 +++++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 2 +- web/hwlab-cloud-web/app.mjs | 13 +- web/hwlab-cloud-web/scripts/check.mjs | 8 + 6 files changed, 230 insertions(+), 21 deletions(-) diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index 58335a4b..8bb8b78b 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -172,23 +172,33 @@ export async function describeM3StatusLive(options = {}) { }; if (!config.enabled) { + const blocker = { + code: "m3_control_disabled", + layer: "cloud-api", + zh: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。" + }; + const gateways = defaultM3Gateways({ sourceKind: "UNVERIFIED", observedAt }); + const boxes = defaultM3Boxes({ sourceKind: "UNVERIFIED", observedAt }); + const patchPanel = defaultM3PatchPanel({ observedAt }); + const blockedTrust = { + ...trust, + blocker: "m3_control_disabled" + }; return { ...base, status: "blocked", sourceKind: "BLOCKED", summary: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。", - gateways: defaultM3Gateways({ sourceKind: "UNVERIFIED", observedAt }), - boxes: defaultM3Boxes({ sourceKind: "UNVERIFIED", observedAt }), - patchPanel: defaultM3PatchPanel({ observedAt }), - trust: { - ...trust, - blocker: "m3_control_disabled" - }, - blocker: { - code: "m3_control_disabled", - layer: "cloud-api", - zh: "M3 状态聚合未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。" - } + gateways, + boxes, + patchPanel, + io: m3StatusIoSummary({ boxes, patchPanel, hardwareLive: false, observedAt }), + operation: m3StatusOperationSummary(blockedTrust), + trace: m3StatusTraceSummary(blockedTrust), + audit: m3StatusAuditSummary(blockedTrust), + runtimeDurable: m3StatusRuntimeDurableSummary(blockedTrust), + trust: blockedTrust, + blocker }; } @@ -342,6 +352,11 @@ export async function describeM3StatusLive(options = {}) { ? blockers.some((blocker) => blocker.code === M3_IO_BLOCKER_CODES.dispatchFailed || blocker.code === M3_IO_BLOCKER_CODES.patchPanelUnavailable) ? "error" : "blocked" : trustGreen ? "live" : hardwareLive ? "blocked" : "unverified"; const sourceKind = hardwareLive ? "DEV-LIVE" : "UNVERIFIED"; + const blocker = blockers[0] ?? (trust.blocker ? { + code: trust.blocker, + layer: "runtime-durable", + zh: `runtime durable 仍 blocked:${trust.blocker}` + } : null); return { ...base, @@ -355,12 +370,13 @@ export async function describeM3StatusLive(options = {}) { gateways, boxes, patchPanel, + io: m3StatusIoSummary({ boxes, patchPanel, hardwareLive, observedAt }), + operation: m3StatusOperationSummary(trust), + trace: m3StatusTraceSummary(trust), + audit: m3StatusAuditSummary(trust), + runtimeDurable: m3StatusRuntimeDurableSummary(trust), trust, - blocker: blockers[0] ?? (trust.blocker ? { - code: trust.blocker, - layer: "runtime-durable", - zh: `runtime durable 仍 blocked:${trust.blocker}` - } : null) + blocker }; } @@ -759,6 +775,105 @@ async function buildM3StatusTrust({ runtimeStore, runtime }) { }; } +function m3StatusIoSummary({ boxes, patchPanel, hardwareLive, observedAt }) { + const sourceBox = boxes.find((box) => box.resourceId === M3_IO_CHAIN.sourceResourceId) ?? null; + const targetBox = boxes.find((box) => box.resourceId === M3_IO_CHAIN.targetResourceId) ?? null; + const do1 = sourceBox?.ports?.[M3_IO_CHAIN.sourcePort] ?? null; + const di1 = targetBox?.ports?.[M3_IO_CHAIN.targetPort] ?? null; + const blocker = !sourceBox?.online + ? M3_IO_BLOCKER_CODES.boxUnavailable + : !targetBox?.online + ? M3_IO_BLOCKER_CODES.boxUnavailable + : patchPanel?.connectionActive !== true + ? M3_IO_BLOCKER_CODES.wiringMissing + : do1?.sourceKind !== "DEV-LIVE" || di1?.sourceKind !== "DEV-LIVE" + ? "m3_io_unverified" + : null; + return { + status: hardwareLive ? "live" : blocker ? "blocked" : "unverified", + sourceKind: hardwareLive ? "DEV-LIVE" : "UNVERIFIED", + observedAt, + chain: { + from: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}`, + via: M3_IO_CHAIN.patchPanelServiceId, + to: `${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}` + }, + do1: m3StatusPortSummary(sourceBox, M3_IO_CHAIN.sourcePort, do1), + di1: m3StatusPortSummary(targetBox, M3_IO_CHAIN.targetPort, di1), + patchPanel: { + serviceId: patchPanel?.serviceId ?? M3_IO_CHAIN.patchPanelServiceId, + connectionActive: patchPanel?.connectionActive === true, + sourceKind: patchPanel?.sourceKind ?? "UNVERIFIED", + observedAt: patchPanel?.lastSyncAt ?? observedAt, + blocker: patchPanel?.connectionActive === true ? null : patchPanel?.lastError ?? M3_IO_BLOCKER_CODES.wiringMissing + }, + blocker + }; +} + +function m3StatusPortSummary(box, port, state) { + return { + resourceId: box?.resourceId ?? null, + boxId: box?.id ?? null, + gatewayId: box?.gatewayId ?? null, + gatewaySessionId: box?.gatewaySessionId ?? null, + port, + direction: state?.direction ?? null, + value: state?.value ?? null, + source: state?.source ?? "unverified", + sourceKind: state?.sourceKind ?? "UNVERIFIED", + observedAt: state?.observedAt ?? box?.observedAt ?? null, + blocker: state?.lastError ?? (box?.online ? null : M3_IO_BLOCKER_CODES.boxUnavailable) + }; +} + +function m3StatusOperationSummary(trust) { + return { + status: trust.operationId ? "read" : "blocked", + operationId: trust.operationId ?? null, + source: trust.operationId ? "runtime-store audit/evidence" : "runtime-store", + blocker: trust.operationId ? null : trust.blocker ?? "operation_not_persisted", + readStatus: { + audit: trust.readStatus?.audit ?? "blocked", + evidence: trust.readStatus?.evidence ?? "blocked" + } + }; +} + +function m3StatusTraceSummary(trust) { + return { + status: trust.traceId ? "read" : "blocked", + traceId: trust.traceId ?? null, + source: trust.traceId ? "runtime-store audit/evidence" : "runtime-store", + blocker: trust.traceId ? null : trust.blocker ?? "trace_not_persisted" + }; +} + +function m3StatusAuditSummary(trust) { + return { + status: trust.auditId ? "read" : "blocked", + auditId: trust.auditId ?? null, + readStatus: trust.readStatus?.audit ?? "blocked", + source: trust.auditId ? "runtime-store audit.event.query" : "runtime-store", + blocker: trust.auditId ? null : trust.readStatus?.auditError ?? trust.blocker ?? "audit_not_persisted" + }; +} + +function m3StatusRuntimeDurableSummary(trust) { + return { + status: trust.durableStatus === "green" ? "green" : "blocked", + green: trust.durableStatus === "green", + blocker: trust.blocker ?? null, + runtime: trust.runtime, + readStatus: trust.readStatus ?? { + audit: "blocked", + evidence: "blocked" + }, + auditReady: trust.readStatus?.audit === "read", + evidenceReady: trust.readStatus?.evidence === "read" + }; +} + async function safeRuntimeRead(read) { if (typeof read !== "function") { return { diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index f7953560..60a078e6 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -206,6 +206,30 @@ test("M3 status live descriptor aggregates gateways, boxes, patch-panel, IO valu assert.equal(status.patchPanel.connectionActive, true); assert.equal(status.patchPanel.wiringConfigId, "wir_m3_do1_di1"); assert.equal(status.patchPanel.lastError, null); + assert.equal(status.io.status, "live"); + assert.equal(status.io.sourceKind, "DEV-LIVE"); + assert.equal(status.io.do1.resourceId, "res_boxsimu_1"); + assert.equal(status.io.do1.port, "DO1"); + assert.equal(status.io.do1.value, true); + assert.equal(status.io.do1.sourceKind, "DEV-LIVE"); + assert.equal(status.io.di1.resourceId, "res_boxsimu_2"); + assert.equal(status.io.di1.port, "DI1"); + assert.equal(status.io.di1.value, true); + assert.equal(status.io.di1.sourceKind, "DEV-LIVE"); + assert.equal(status.io.patchPanel.serviceId, "hwlab-patch-panel"); + assert.equal(status.io.patchPanel.connectionActive, true); + assert.equal(status.operation.status, "blocked"); + assert.equal(status.operation.operationId, null); + assert.equal(status.operation.blocker, "runtime_durable_adapter_auth_blocked"); + assert.equal(status.trace.status, "blocked"); + assert.equal(status.trace.traceId, null); + assert.equal(status.trace.blocker, "runtime_durable_adapter_auth_blocked"); + assert.equal(status.audit.status, "blocked"); + assert.equal(status.audit.auditId, null); + assert.equal(status.audit.readStatus, "blocked"); + assert.equal(status.runtimeDurable.status, "blocked"); + assert.equal(status.runtimeDurable.green, false); + assert.equal(status.runtimeDurable.blocker, "runtime_durable_adapter_auth_blocked"); assert.equal(status.trust.durableStatus, "blocked"); assert.equal(status.trust.blocker, "runtime_durable_adapter_auth_blocked"); assert.equal(status.trust.runtime.blocker, "runtime_durable_adapter_auth_blocked"); @@ -249,6 +273,20 @@ test("M3 status clears nested runtime blocker when trusted durable evidence is g assert.equal(status.trust.traceId, "trc_hotfix_307_verify_1779527747043_read_false"); assert.equal(status.trust.auditId, "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded"); assert.equal(status.trust.evidenceId, "evd_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f_succeeded"); + assert.equal(status.operation.status, "read"); + assert.equal(status.operation.operationId, "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f"); + assert.equal(status.operation.blocker, null); + assert.equal(status.trace.status, "read"); + assert.equal(status.trace.traceId, "trc_hotfix_307_verify_1779527747043_read_false"); + assert.equal(status.trace.blocker, null); + assert.equal(status.audit.status, "read"); + assert.equal(status.audit.auditId, "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded"); + assert.equal(status.audit.blocker, null); + assert.equal(status.runtimeDurable.status, "green"); + assert.equal(status.runtimeDurable.green, true); + assert.equal(status.runtimeDurable.blocker, null); + assert.equal(status.runtimeDurable.auditReady, true); + assert.equal(status.runtimeDurable.evidenceReady, true); }); test("M3 status live descriptor keeps SOURCE fallback unverified when patch-panel route is missing", async () => { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 3788e013..0c7c81fa 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -3613,6 +3613,33 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard assert.equal(statusPayload.boxes.find((box) => box.resourceId === "res_boxsimu_1").ports.DO1.value, false); assert.equal(statusPayload.boxes.find((box) => box.resourceId === "res_boxsimu_2").ports.DI1.value, false); assert.equal(statusPayload.patchPanel.connectionActive, true); + assert.equal(statusPayload.io.status, "live"); + assert.equal(statusPayload.io.do1.resourceId, "res_boxsimu_1"); + assert.equal(statusPayload.io.do1.port, "DO1"); + assert.equal(statusPayload.io.do1.value, false); + assert.equal(statusPayload.io.do1.sourceKind, "DEV-LIVE"); + assert.equal(statusPayload.io.di1.resourceId, "res_boxsimu_2"); + assert.equal(statusPayload.io.di1.port, "DI1"); + assert.equal(statusPayload.io.di1.value, false); + assert.equal(statusPayload.io.di1.sourceKind, "DEV-LIVE"); + assert.equal(statusPayload.io.patchPanel.serviceId, "hwlab-patch-panel"); + assert.equal(statusPayload.io.patchPanel.connectionActive, true); + assert.equal(statusPayload.operation.status, "blocked"); + assert.equal(statusPayload.operation.operationId, null); + assert.equal(statusPayload.operation.blocker, "runtime_durable_adapter_missing"); + assert.equal(statusPayload.trace.status, "blocked"); + assert.equal(statusPayload.trace.traceId, null); + assert.equal(statusPayload.trace.blocker, "runtime_durable_adapter_missing"); + assert.equal(statusPayload.audit.status, "blocked"); + assert.equal(statusPayload.audit.auditId, null); + assert.equal(statusPayload.audit.blocker, "runtime_durable_adapter_missing"); + assert.equal(statusPayload.runtimeDurable.status, "blocked"); + assert.equal(statusPayload.runtimeDurable.green, false); + assert.equal(statusPayload.runtimeDurable.blocker, "runtime_durable_adapter_missing"); + assert.equal(statusPayload.runtimeDurable.readStatus.audit, "read"); + assert.equal(statusPayload.runtimeDurable.readStatus.evidence, "read"); + assert.equal(statusPayload.runtimeDurable.auditReady, true); + assert.equal(statusPayload.runtimeDurable.evidenceReady, true); assert.equal(statusPayload.trust.durableStatus, "blocked"); assert.notEqual(statusPayload.trust.durableStatus, "green"); assert.equal(statusPayload.trust.runtime.blocker, "runtime_durable_adapter_missing"); @@ -3655,6 +3682,20 @@ test("cloud api /v1/m3/status clears nested runtime blocker when trusted durable assert.equal(statusPayload.trust.traceId, "trc_hotfix_307_verify_1779527747043_read_false"); assert.equal(statusPayload.trust.auditId, "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded"); assert.equal(statusPayload.trust.evidenceId, "evd_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f_succeeded"); + assert.equal(statusPayload.operation.status, "read"); + assert.equal(statusPayload.operation.operationId, "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f"); + assert.equal(statusPayload.operation.blocker, null); + assert.equal(statusPayload.trace.status, "read"); + assert.equal(statusPayload.trace.traceId, "trc_hotfix_307_verify_1779527747043_read_false"); + assert.equal(statusPayload.trace.blocker, null); + assert.equal(statusPayload.audit.status, "read"); + assert.equal(statusPayload.audit.auditId, "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded"); + assert.equal(statusPayload.audit.blocker, null); + assert.equal(statusPayload.runtimeDurable.status, "green"); + assert.equal(statusPayload.runtimeDurable.green, true); + assert.equal(statusPayload.runtimeDurable.blocker, null); + assert.equal(statusPayload.runtimeDurable.auditReady, true); + assert.equal(statusPayload.runtimeDurable.evidenceReady, true); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 3d3297dd..e5e17c0d 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -6374,7 +6374,7 @@ async function inspectM3StatusFixtureViewport(browser, url, viewport) { getComputedStyle(document.body).overflow === "hidden" && document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && document.body.scrollHeight <= document.body.clientHeight + 2, - keysVisible: ["状态", "Gateway 在线", "BOX 在线", "链路状态", "blocked", "DO1", "DI1", "runtime durable", "blocker"].every((term) => hardwareText.includes(term)), + keysVisible: ["状态", "Gateway 在线", "BOX 在线", "链路状态", "blocked", "DO1", "DI1", "operation", "audit", "runtime durable", "blocker"].every((term) => hardwareText.includes(term)), tabsVisible: ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((term) => tabLabels.includes(term)), blockedVisible: text.includes("runtime_durable_adapter_auth_blocked") && !text.includes("实况可信"), noHorizontalOverflow: diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 86ed0779..a0bd67db 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1457,19 +1457,26 @@ function hardwareOverviewGroups(status) { const di1 = box2?.ports?.DI1; const patch = status.patchPanel ?? {}; const trust = status.trust ?? {}; + const io = status.io ?? {}; + const operation = status.operation ?? {}; + const audit = status.audit ?? {}; + const runtimeDurable = status.runtimeDurable ?? {}; + const runtimeDurableStatus = runtimeDurable.status ?? trust.durableStatus ?? "blocked"; return [ hardwareKvGroup("总览", [ ["状态", hardwareStatusLabel(status), statusTone(status)], ["观测时间", status.observedAt ?? "未验证", "source"], ["Gateway 在线", `${onlineCount(status.gateways)} / ${status.gateways?.length ?? 0}`, onlineCount(status.gateways) === 2 ? "live" : "blocked"], ["BOX 在线", `${onlineCount(status.boxes)} / ${status.boxes?.length ?? 0}`, onlineCount(status.boxes) === 2 ? "live" : "blocked"], - ["链路状态", m3LinkStatus(status, patch), m3LinkTone(status, patch)], + ["链路状态", io.status ?? m3LinkStatus(status, patch), m3LinkTone(status, patch)], ["链路", `${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort}`, patch.connectionActive ? "live" : "blocked"], ["DO1", valueLabel(do1), portTone(do1)], ["DI1", valueLabel(di1), portTone(di1)], ["接线盘连接", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"], - ["runtime durable", trust.durableStatus ?? "blocked", trust.durableStatus === "green" ? "dev-live" : "blocked"], - ["blocker", trust.blocker ?? status.blocker?.code ?? "none", trust.blocker || status.blocker ? "blocked" : "source"] + ["operation", operation.operationId ?? operation.blocker ?? "未持久化", operation.operationId ? "source" : "blocked"], + ["audit", audit.auditId ?? audit.blocker ?? "未持久化", audit.auditId ? "source" : "blocked"], + ["runtime durable", runtimeDurableStatus, runtimeDurableStatus === "green" ? "dev-live" : "blocked"], + ["blocker", runtimeDurable.blocker ?? trust.blocker ?? status.blocker?.code ?? "none", runtimeDurable.blocker || trust.blocker || status.blocker ? "blocked" : "source"] ]), hardwareKvGroup("可信证据", [ ["operationId", trust.operationId ?? "未持久化", trust.operationId ? "source" : "blocked"], diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index ac7f039e..a783cfd8 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -366,6 +366,14 @@ assert.match(functionBody(app, "sourceFallbackM3Status"), /unverified_cloud_api_ assert.match(functionBody(app, "sourceFallbackM3Status"), /SOURCE 拓扑不能冒充实况硬件状态/); assert.doesNotMatch(functionBody(app, "renderHardwareStatus"), /hardwareRow\(\s*\{/u); assert.match(functionBody(app, "renderHardwareStatus"), /Key\/Value groups/u); +assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.io \?\? \{\}/); +assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.operation \?\? \{\}/); +assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.audit \?\? \{\}/); +assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.runtimeDurable \?\? \{\}/); +assert.match(functionBody(app, "hardwareOverviewGroups"), /\["operation", operation\.operationId \?\? operation\.blocker/u); +assert.match(functionBody(app, "hardwareOverviewGroups"), /\["audit", audit\.auditId \?\? audit\.blocker/u); +assert.match(functionBody(app, "hardwareOverviewGroups"), /\["runtime durable", runtimeDurableStatus/u); +assert.match(functionBody(app, "hardwareOverviewGroups"), /runtimeDurable\.blocker \?\? trust\.blocker/u); assert.doesNotMatch(app, /sourceKind:\s*counts\s*\?\s*"DEV-LIVE"/); assert.doesNotMatch(app, /counts\s*&&\s*(?:liveLink|sourceLink|m3Link)[\s\S]{0,80}\?\s*"DEV-LIVE"/); assert.match(app, /function renderRecords/);