diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index f9babb4c..660e6b26 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -271,6 +271,23 @@ const requiredHardwareStatusTerms = Object.freeze([ "unverified_cloud_api_m3_status" ]); +const requiredM3ControlUserFlowTerms = Object.freeze([ + "m3-flow-summary", + "m3-action-summary", + "m3-do1-target", + "m3-di1-observed", + "m3-trust-summary", + "m3-trace-summary", + "DO1 -> patch-panel -> DI1", + "查看最近操作/trace", + "选择 DO1 true/false 后点击“写入 DO1”", + "最近 trace 与 operation 可在“可信记录”页签追踪", + "DO1目标=", + "DI1观测=", + "trusted=", + "durable=" +]); + const workbenchMarkers = Object.freeze([ "data-app-shell", "workbench-shell", @@ -541,6 +558,11 @@ function runStaticSmoke() { evidence: requiredHardwareStatusTerms }); + addCheck(checks, blockers, "m3-control-user-flow", m3ControlUserFlowContract(files), "Right control panel exposes an operator-readable DO1 -> patch-panel -> DI1 action summary, current action, DO1 target, DI1 observation, trusted/durable state, and recent trace navigation.", { + blocker: "contract_blocker", + evidence: requiredM3ControlUserFlowTerms + }); + addCheck(checks, blockers, "m3-default-wiring-source", defaultTopologyIsM3Only(), "Default 16666 workbench topology exposes only the M3 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 wiring.", { blocker: "runtime_blocker", evidence: ["res_boxsimu_1:DO1", "hwlab-patch-panel", "res_boxsimu_2:DI1"] @@ -2180,6 +2202,43 @@ function hardwareStatusStructureContract({ html, app, styles }) { ); } +function m3ControlUserFlowContract({ html, app, styles }) { + const source = `${html}\n${app}\n${styles}`; + const controlPanel = html.match(/]*\bdata-side-panel=["']control["'][\s\S]*?<\/section>/u)?.[0] ?? ""; + const flowSummaryBody = functionBody(app, "renderM3FlowSummary"); + const operationDetailBody = functionBody(app, "m3OperationDetail"); + const operationRecordsBody = functionBody(app, "operationRecordCards"); + return ( + requiredM3ControlUserFlowTerms.every((term) => source.includes(term)) && + /id=["']m3-flow-summary["'][^>]*aria-label=["']M3 DO1 到 DI1 操作摘要["']/u.test(controlPanel) && + /id=["']m3-flow-state["']/u.test(controlPanel) && + /id=["']m3-action-summary["']/u.test(controlPanel) && + /id=["']m3-do1-target["']/u.test(controlPanel) && + /id=["']m3-di1-observed["']/u.test(controlPanel) && + /id=["']m3-trust-summary["']/u.test(controlPanel) && + /id=["']m3-trace-summary["']/u.test(controlPanel) && + /id=["']m3-records-jump["']/u.test(controlPanel) && + /function\s+renderM3FlowSummary\s*\(/u.test(app) && + /function\s+m3OperationDo1Target\s*\(/u.test(app) && + /function\s+m3OperationDi1Observed\s*\(/u.test(app) && + /function\s+m3TrustSummaryText\s*\(/u.test(app) && + /function\s+m3TraceSummaryText\s*\(/u.test(app) && + /m3OperationDo1Target\(operation/u.test(flowSummaryBody) && + /m3OperationDi1Observed\(operation/u.test(flowSummaryBody) && + /m3TrustSummaryText\(operation, contract\)/u.test(flowSummaryBody) && + /m3TraceSummaryText\(operation, status\)/u.test(flowSummaryBody) && + /DO1目标=/u.test(operationDetailBody) && + /DI1观测=/u.test(operationDetailBody) && + /DO1目标=/u.test(operationRecordsBody) && + /DI1观测=/u.test(operationRecordsBody) && + /trusted=/u.test(operationRecordsBody) && + /durable=/u.test(operationRecordsBody) && + /\.m3-flow-summary\s*\{/u.test(styles) && + /\.m3-flow-line\s*\{/u.test(styles) && + /\.m3-flow-kv\s*\{/u.test(styles) + ); +} + function defaultTopologyIsM3Only() { const activeConnections = gateSummary.topology.patchPanel.activeConnections ?? []; return ( @@ -5623,14 +5682,24 @@ async function handleM3StatusFixtureApi({ request, response, url, options = {} } action: body?.action ?? "di.read", value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue, result: { - value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue + value: body?.action === "di.read" ? options.m3State.diValue : options.m3State.doValue, + targetReadback: body?.action === "do.write" + ? { + status: "succeeded", + value: options.m3State.diValue, + resourceId: "res_boxsimu_2", + port: "DI1" + } + : null }, operationId: `op_fixture_${options.m3State.sequence}`, traceId: `trc_fixture_${options.m3State.sequence}`, - auditId: null, - evidenceId: null, + auditId: `aud_fixture_${options.m3State.sequence}`, + evidenceId: `evd_fixture_${options.m3State.sequence}`, evidenceState: { status: "blocked", + durable: false, + blocker: "runtime_durable_adapter_auth_blocked", reason: "runtime_durable_adapter_auth_blocked" }, controlPath: { @@ -6525,7 +6594,15 @@ async function inspectM3StatusFixtureViewport(browser, url, viewport) { document.body.scrollWidth <= document.body.clientWidth + 2 && (!right || right.scrollWidth <= right.clientWidth + 2) && (!hardwareList || hardwareList.scrollWidth <= hardwareList.clientWidth + 2), - boxTabClickable: visible('[data-hardware-tab="box1"]') && visible('[data-hardware-tab="box2"]') + boxTabClickable: visible('[data-hardware-tab="box1"]') && visible('[data-hardware-tab="box2"]'), + controlSummaryVisible: + visible("#m3-flow-summary") && + text.includes("DO1 -> patch-panel -> DI1") && + text.includes("当前 action") && + text.includes("DO1 目标") && + text.includes("DI1 观测") && + text.includes("可信状态") && + text.includes("查看最近操作/trace") }; }); const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href)); @@ -6537,6 +6614,7 @@ async function inspectM3StatusFixtureViewport(browser, url, viewport) { dom.outerScrollLocked && dom.noHorizontalOverflow && dom.boxTabClickable && + dom.controlSummaryVisible && dom.rightWidth >= viewport.expectedRightWidthMin && requests.some((request) => request.method === "GET" && request.pathname === "/v1/m3/status") && directRuntimeRequests.length === 0; @@ -6573,6 +6651,7 @@ async function inspectM3StatusFixtureRefresh(browser, url) { await page.locator("#m3-write-do").click(); await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1true"), null, { timeout: 8000 }); const trueBox1 = await hardwarePanelText(page); + const trueControlSummary = await m3ControlSummaryText(page); await page.locator('[data-hardware-tab="box2"]').click(); await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1true"), null, { timeout: 8000 }); const trueBox2 = await hardwarePanelText(page); @@ -6585,6 +6664,9 @@ async function inspectM3StatusFixtureRefresh(browser, url) { await page.locator('[data-hardware-tab="box2"]').click(); await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 }); const falseBox2 = await hardwarePanelText(page); + const falseControlSummary = await m3ControlSummaryText(page); + await page.locator("#m3-records-jump").click(); + const recordsText = await page.evaluate(() => document.querySelector("#records-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); const statusRequests = requests.filter((request) => request.method === "GET" && request.pathname === "/v1/m3/status").length; const ioPosts = requests.filter((request) => request.method === "POST" && request.pathname === "/v1/m3/io").length; const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href)); @@ -6593,8 +6675,21 @@ async function inspectM3StatusFixtureRefresh(browser, url) { initialBox1.includes("DO1false") && trueBox1.includes("DO1true") && trueBox2.includes("DI1true") && + trueControlSummary.includes("DO1 目标 res_boxsimu_1:DO1=true") && + trueControlSummary.includes("DI1 观测 res_boxsimu_2:DI1=true") && + trueControlSummary.includes("trusted=false") && + trueControlSummary.includes("durable=false") && + trueControlSummary.includes("operation=op_fixture_") && + trueControlSummary.includes("trace=trc_fixture_") && falseBox1.includes("DO1false") && falseBox2.includes("DI1false") && + falseControlSummary.includes("DO1 目标 res_boxsimu_1:DO1=false") && + falseControlSummary.includes("DI1 观测 res_boxsimu_2:DI1=false") && + falseControlSummary.includes("trusted=false") && + falseControlSummary.includes("durable=false") && + recordsText.includes("DO1目标=res_boxsimu_1:DO1=false") && + recordsText.includes("DI1观测=res_boxsimu_2:DI1=false") && + recordsText.includes("trace=trc_fixture_") && statusRequests >= 3 && ioPosts === 2 && directRuntimeRequests.length === 0 && @@ -6604,8 +6699,11 @@ async function inspectM3StatusFixtureRefresh(browser, url) { initialBox1, trueBox1, trueBox2, + trueControlSummary, falseBox1, falseBox2, + falseControlSummary, + recordsText, statusRequests, ioPosts, directRuntimeRequests, @@ -6620,6 +6718,10 @@ async function hardwarePanelText(page) { return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); } +async function m3ControlSummaryText(page) { + return page.evaluate(() => document.querySelector("#m3-flow-summary")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); +} + function localGateReviewRow({ category, check, statusKey, owner, detail, evidence, updatedAt, next }) { const label = { pass: "通过", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 538c3c4b..b817ada9 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -131,6 +131,13 @@ const el = { recordsList: byId("records-list"), m3ControlForm: byId("m3-control-form"), m3ControlStatus: byId("m3-control-status"), + m3FlowState: byId("m3-flow-state"), + m3ActionSummary: byId("m3-action-summary"), + m3Do1Target: byId("m3-do1-target"), + m3Di1Observed: byId("m3-di1-observed"), + m3TrustSummary: byId("m3-trust-summary"), + m3TraceSummary: byId("m3-trace-summary"), + m3RecordsJump: byId("m3-records-jump"), m3GatewaySelect: byId("m3-gateway-select"), m3BoxSelect: byId("m3-box-select"), m3PortSelect: byId("m3-port-select"), @@ -552,11 +559,9 @@ function initSideTabs() { for (const tab of document.querySelectorAll("[data-side-tab]")) { tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab)); } - for (const jump of document.querySelectorAll("[data-side-tab-jump]")) { - jump.addEventListener("click", () => { - selectSideTab(jump.dataset.sideTabJump); - }); - } + el.m3RecordsJump.addEventListener("click", () => { + selectSideTab("records"); + }); } function initQuickActions() { @@ -1150,34 +1155,6 @@ async function callRpc(method, params = {}) { async function runM3IoAction(action) { if (state.m3Control.pending) return; const traceId = nextProtocolId("trc"); - if (!m3ControlCanOperate()) { - state.m3Control.operation = { - status: "blocked", - action, - traceId, - blocker: { - code: state.m3Control.contract?.readiness?.blocker?.code ?? "m3_control_readiness_blocked", - zh: m3ControlBlockedReason() - }, - observedAt: new Date().toISOString(), - sourceKind: "BLOCKED" - }; - renderM3ControlStatus(); - renderRecords(state.liveSurface); - renderDrafts(); - return; - } - state.m3Control.pending = true; - state.m3Control.operation = { - status: "running", - action, - traceId, - observedAt: new Date().toISOString() - }; - renderM3ControlStatus(); - renderDrafts(); - renderRecords(state.liveSurface); - const body = action === "do.write" ? { action, @@ -1198,6 +1175,35 @@ async function runM3IoAction(action) { projectId: gateSummary.topology.projectId, traceId }; + if (!m3ControlCanOperate()) { + state.m3Control.operation = { + status: "blocked", + action, + traceId, + command: body, + blocker: { + code: state.m3Control.contract?.readiness?.blocker?.code ?? "m3_control_readiness_blocked", + zh: m3ControlBlockedReason() + }, + observedAt: new Date().toISOString(), + sourceKind: "BLOCKED" + }; + renderM3ControlStatus(); + renderRecords(state.liveSurface); + renderDrafts(); + return; + } + state.m3Control.pending = true; + state.m3Control.operation = { + status: "running", + action, + traceId, + command: body, + observedAt: new Date().toISOString() + }; + renderM3ControlStatus(); + renderDrafts(); + renderRecords(state.liveSurface); const response = await fetchJson("/v1/m3/io", { method: "POST", @@ -1952,6 +1958,7 @@ function renderM3ControlStatus() { input.disabled = disabled; } el.m3WriteDo.textContent = pending ? "执行中" : "写入 DO1"; + renderM3FlowSummary({ label, tone, operation, contract, controlReady, pending }); } function m3ControlCanOperate(contract = state.m3Control.contract) { @@ -1995,6 +2002,11 @@ function controlRows() { ] : []; return [ + { + title: "用户操作", + detail: "选择 DO1 true/false 后点击“写入 DO1”;再点击“读取 DI1”核对 res_boxsimu_2:DI1。最近 trace 与 operation 可在“可信记录”页签追踪。", + tone: controlReady ? "source" : "blocked" + }, { title: "控制路径", detail: controlReady @@ -2027,7 +2039,7 @@ function operationActionLabel(action) { function m3OperationDetail(operation) { if (operation.status === "running") { - return `trace=${operation.traceId} / 正在通过 cloud-api 提交。`; + return `trace=${operation.traceId} / action=${operationActionLabel(operation.action)} / DO1目标=${m3OperationDo1Target(operation)} / DI1观测=等待返回 / 正在通过 cloud-api 提交。`; } const ids = [ recordField("operation", operation.operationId), @@ -2038,11 +2050,85 @@ function m3OperationDetail(operation) { const value = operation.result?.value !== undefined && operation.result?.value !== null ? ` / value=${String(operation.result.value)}` : ""; + const ioValues = ` / DO1目标=${m3OperationDo1Target(operation)} / DI1观测=${m3OperationDi1Observed(operation, { + fallback: m3StatusPortValue(state.m3Control.status, M3_TRUSTED_ROUTE.toResourceId, M3_TRUSTED_ROUTE.toPort) + })}`; const blocker = operation.blocker?.zh || operation.blocker?.message ? ` / 阻塞原因=${operation.blocker.zh || operation.blocker.message}` : ""; const evidence = operation.evidenceState?.reason ? ` / evidence=${operation.evidenceState.reason}` : ""; - return `${ids}${value}${blocker}${evidence}`; + return `${ids}${value}${ioValues}${blocker}${evidence}`; +} + +function renderM3FlowSummary({ label, tone, operation, contract, controlReady, pending }) { + const status = state.m3Control.status; + const do1Live = m3StatusPortValue(status, M3_TRUSTED_ROUTE.fromResourceId, M3_TRUSTED_ROUTE.fromPort); + const di1Live = m3StatusPortValue(status, M3_TRUSTED_ROUTE.toResourceId, M3_TRUSTED_ROUTE.toPort); + const do1Target = m3OperationDo1Target(operation, { fallback: el.m3ValueSelect.value }); + const di1Observed = m3OperationDi1Observed(operation, { fallback: di1Live }); + const actionLabel = operation + ? `${operationActionLabel(operation.action)} / ${statusLabel(operation.status)}` + : controlReady + ? "可执行:写入 DO1 后读取 DI1" + : "等待 cloud-api readiness"; + + el.m3FlowState.textContent = label; + el.m3FlowState.className = `state-tag tone-${toneClass(tone)}`; + el.m3ActionSummary.textContent = pending + ? `正在执行 ${operationActionLabel(operation?.action)};trace 已生成。` + : actionLabel; + el.m3Do1Target.textContent = operation?.action === "di.read" && do1Live === null + ? `本次只读取;当前选择 ${el.m3ValueSelect.value}` + : do1Target; + el.m3Di1Observed.textContent = di1Observed; + el.m3TrustSummary.textContent = m3TrustSummaryText(operation, contract); + el.m3TraceSummary.textContent = m3TraceSummaryText(operation, status); +} + +function m3OperationDo1Target(operation, { fallback = null } = {}) { + if (operation?.action === "do.write") { + const value = operation.command?.value ?? operation.value ?? operation.target?.value ?? operation.result?.value; + return value === undefined || value === null ? "DO1 目标值等待返回" : `res_boxsimu_1:DO1=${String(value)}`; + } + if (fallback !== null && fallback !== undefined && fallback !== "") return `待写入选择=${String(fallback)}`; + return "本次未写入 DO1"; +} + +function m3OperationDi1Observed(operation, { fallback = null } = {}) { + const value = operation?.result?.targetReadback?.value ?? operation?.readback?.value ?? (operation?.action === "di.read" ? operation?.result?.value : undefined); + if (value !== undefined && value !== null) return `res_boxsimu_2:DI1=${String(value)}`; + if (fallback !== null && fallback !== undefined && fallback !== "") return `res_boxsimu_2:DI1=${String(fallback)}`; + if (operation?.status === "running") return "等待 cloud-api 返回"; + return "待读取"; +} + +function m3StatusPortValue(status, resourceId, port) { + const portState = findM3Box(status, resourceId)?.ports?.[port]; + return portState?.value === undefined || portState?.value === null ? null : portState.value; +} + +function m3TrustSummaryText(operation, contract) { + if (operation?.evidenceState) { + const trusted = operation.evidenceState.status === "green"; + const durable = operation.evidenceState.durable === true; + const blocker = operation.evidenceState.blocker ?? operation.trustBlocker?.code ?? operation.blocker?.code ?? "无"; + return `trusted=${String(trusted)};durable=${String(durable)};${trusted ? "可信记录完整" : `阻塞原因=${blocker}`}`; + } + const trust = contract?.readiness?.trust ?? contract?.trustReadiness; + if (trust?.status) { + return `trusted=${String(trust.trustedEvidenceReady === true)};durable=${String(trust.durableStatus?.durable === true)};状态=${trust.status}`; + } + return m3ControlBlockedReason(contract); +} + +function m3TraceSummaryText(operation, status) { + const ids = [ + recordField("operation", operation?.operationId ?? status?.trust?.operationId), + recordField("trace", operation?.traceId ?? status?.trust?.traceId), + recordField("audit", operation?.auditId ?? status?.trust?.auditId), + recordField("evidence", operation?.evidenceId ?? status?.trust?.evidenceId) + ].filter(Boolean); + return ids.length ? `最近 ${ids.join(" / ")}` : "最近 trace:无"; } function renderDrafts() { @@ -2169,6 +2255,12 @@ function operationRecordCards() { sourceKindLabel(controlOperation.evidenceState?.status === "green" ? "dev-live" : "blocked_after_cloud_api"), "via=/v1/m3/io", recordField("action", controlOperation.action), + `DO1目标=${m3OperationDo1Target(controlOperation)}`, + `DI1观测=${m3OperationDi1Observed(controlOperation, { + fallback: m3StatusPortValue(state.m3Control.status, M3_TRUSTED_ROUTE.toResourceId, M3_TRUSTED_ROUTE.toPort) + })}`, + `trusted=${String(controlOperation.evidenceState?.status === "green")}`, + `durable=${String(controlOperation.evidenceState?.durable === true)}`, recordField("trace", controlOperation.traceId), recordField("audit", controlOperation.auditId), recordField("evidence", controlOperation.evidenceId), diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 8acfd9d6..6ee2c40c 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -278,6 +278,44 @@

基础 IO 控制

探测中 +
+
+
+ 操作链路 + DO1 -> patch-panel -> DI1 +
+ 等待 readiness +
+
+ res_boxsimu_1 + DO1 + hwlab-patch-panel + res_boxsimu_2 + DI1 +
+
+
+
当前 action
+
等待用户写入 DO1 或读取 DI1
+
+
+
DO1 目标
+
选择 true/false 后写入
+
+
+
DI1 观测
+
待读取
+
+
+
可信状态
+
等待 cloud-api readiness
+
+
+ +