fix: 收口 M3 控制面板操作体验

This commit is contained in:
unidesk-code-queue-runner
2026-05-24 08:20:52 +00:00
parent bb4d58b07d
commit c0516aa7bc
5 changed files with 440 additions and 42 deletions
+106 -4
View File
@@ -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(/<section\b[^>]*\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: "通过",
+127 -35
View File
@@ -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),
+38
View File
@@ -278,6 +278,44 @@
<h2>基础 IO 控制</h2>
<span class="state-tag tone-blocked" id="m3-control-status">探测中</span>
</div>
<div class="m3-flow-summary" id="m3-flow-summary" role="group" aria-label="M3 DO1 到 DI1 操作摘要">
<div class="m3-flow-head">
<div>
<span class="metric-label">操作链路</span>
<strong>DO1 -> patch-panel -> DI1</strong>
</div>
<span class="state-tag tone-pending" id="m3-flow-state">等待 readiness</span>
</div>
<div class="m3-flow-line" aria-label="res_boxsimu_1 DO1 经 hwlab-patch-panel 到 res_boxsimu_2 DI1">
<span class="m3-flow-node mono">res_boxsimu_1</span>
<span class="m3-flow-port">DO1</span>
<span class="m3-flow-hop">hwlab-patch-panel</span>
<span class="m3-flow-node mono">res_boxsimu_2</span>
<span class="m3-flow-port">DI1</span>
</div>
<dl class="m3-flow-kv">
<div>
<dt>当前 action</dt>
<dd id="m3-action-summary">等待用户写入 DO1 或读取 DI1</dd>
</div>
<div>
<dt>DO1 目标</dt>
<dd id="m3-do1-target">选择 true/false 后写入</dd>
</div>
<div>
<dt>DI1 观测</dt>
<dd id="m3-di1-observed">待读取</dd>
</div>
<div>
<dt>可信状态</dt>
<dd id="m3-trust-summary">等待 cloud-api readiness</dd>
</div>
</dl>
<div class="m3-flow-footer">
<button class="m3-jump-button" id="m3-records-jump" type="button">查看最近操作/trace</button>
<span class="m3-trace-summary mono" id="m3-trace-summary">最近 trace:无</span>
</div>
</div>
<form class="m3-control-form" id="m3-control-form" aria-label="DO1 写入">
<div class="m3-control-grid">
<label>
@@ -45,7 +45,10 @@ export function runM3ControlPanelGuard() {
assertNoStaticDevLiveClaim({ html, app });
for (const pattern of [
/#panel-control\s*{\s*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/s,
/#panel-control\s*{\s*grid-template-rows:\s*auto auto auto minmax\(0,\s*1fr\);/s,
/\.m3-flow-summary\s*{/s,
/\.m3-flow-line\s*{/s,
/\.m3-flow-kv\s*{/s,
/\.m3-control-form\s*{/s,
/\.m3-control-grid\s*{/s,
/\.m3-control-actions\s*{/s
@@ -65,7 +68,13 @@ export function runM3ControlPanelGuard() {
"仍在等待完整可信记录",
"页面不直连模拟器,也不伪造硬件状态。",
"控制可达 / 等待记录",
"待可信记录"
"待可信记录",
"选择 DO1 true/false 后点击“写入 DO1”",
"最近 trace 与 operation 可在“可信记录”页签追踪",
"DO1目标=",
"DI1观测=",
"trusted=",
"durable="
]) {
assert.match(browserSource, new RegExp(escapeRegExp(copy), "u"), `missing Chinese blocked/error copy: ${copy}`);
}
@@ -76,6 +85,12 @@ function assertControlPanelDom(html) {
assert.match(controlPanel, /data-side-panel="control"/u, "right-side control tab panel must be present");
assert.match(controlPanel, /<h2>基础 IO 控制<\/h2>/u, "M3 control panel title must be user-visible without milestone shorthand");
assert.match(controlPanel, /id="m3-control-status"[^>]*>探测中</u, "M3 control status starts as probing, not live");
assert.match(controlPanel, /id="m3-flow-summary"[^>]*aria-label="M3 DO1 到 DI1 操作摘要"/u, "M3 operation summary must be visible above the form");
for (const id of ["m3-flow-state", "m3-action-summary", "m3-do1-target", "m3-di1-observed", "m3-trust-summary", "m3-trace-summary"]) {
assert.match(controlPanel, new RegExp(`id="${id}"`, "u"), `M3 flow summary missing #${id}`);
}
assert.match(controlPanel, /DO1 -> patch-panel -> DI1/u, "M3 summary must state the operator-facing DO1 to DI1 route");
assert.match(controlPanel, /id="m3-records-jump"[\s\S]*查看最近操作\/trace/u, "M3 summary must link recent trace to records tab");
assert.match(controlPanel, /<form[^>]+id="m3-control-form"[^>]+aria-label="DO1 写入"/u, "DO write form must be labeled");
assert.match(controlPanel, /id="m3-gateway-select"[\s\S]*value="gwsimu_1"[\s\S]*gateway-simu-1/u, "DO write uses source gateway");
assert.match(controlPanel, /id="m3-box-select"[\s\S]*value="res_boxsimu_1"[\s\S]*box-simu-1 \/ res_boxsimu_1/u, "DO write uses source box resource");
@@ -144,6 +159,16 @@ function assertControlPanelSource(app) {
const statusBody = functionBody(app, "renderM3ControlStatus");
assert.match(statusBody, /m3ControlStatusTitle\(\{ operation, contract, controlReady \}\)/u, "ready status title must use the control-ready-aware helper");
assert.match(statusBody, /renderM3FlowSummary\(\{ label, tone, operation, contract, controlReady, pending \}\)/u, "control status must keep the operator summary in sync");
const flowSummaryBody = functionBody(app, "renderM3FlowSummary");
for (const term of ["m3ActionSummary", "m3Do1Target", "m3Di1Observed", "m3TrustSummary", "m3TraceSummary"]) {
assert.match(flowSummaryBody, new RegExp(`el\\.${term}\\.textContent`, "u"), `M3 flow summary must update ${term}`);
}
assert.match(flowSummaryBody, /m3OperationDo1Target\(operation/u, "M3 flow summary must show the DO1 target");
assert.match(flowSummaryBody, /m3OperationDi1Observed\(operation/u, "M3 flow summary must show the DI1 observation");
assert.match(flowSummaryBody, /m3TrustSummaryText\(operation, contract\)/u, "M3 flow summary must show trusted/durable status");
assert.match(flowSummaryBody, /m3TraceSummaryText\(operation, status\)/u, "M3 flow summary must show recent operation/trace ids");
const statusTitleBody = functionBody(app, "m3ControlStatusTitle");
assert.match(statusTitleBody, /if \(controlReady\)/u, "ready status title must not fall through to blocked copy");
@@ -151,6 +176,8 @@ function assertControlPanelSource(app) {
assert.match(statusTitleBody, /return m3ControlBlockedReason\(contract\)/u, "blocked status title must still surface Chinese blocked reason");
const controlRowsBody = functionBody(app, "controlRows");
assert.match(controlRowsBody, /选择 DO1 true\/false 后点击/u, "control copy must start with an operator action script");
assert.match(controlRowsBody, /最近 trace 与 operation/u, "control copy must direct users to recent trace records");
assert.match(controlRowsBody, /按钮只走受控后端/u, "control copy must describe the controlled backend route");
assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path");
assert.match(controlRowsBody, /m3ControlBlockedReason\(\)/u, "blocked control copy must surface the cloud-api Chinese readiness reason");
@@ -161,11 +188,17 @@ function assertControlPanelSource(app) {
assert.match(operationDetailBody, new RegExp(`recordField\\("${field}"`, "u"), `operation detail missing ${field}`);
}
assert.match(operationDetailBody, /operation\.blocker\?\.zh/u, "operation detail must prefer Chinese blocker copy");
assert.match(operationDetailBody, /DO1目标=/u, "operation detail must expose DO1 target");
assert.match(operationDetailBody, /DI1观测=/u, "operation detail must expose DI1 observation");
const operationRecordBody = functionBody(app, "operationRecordCards");
for (const field of ["action", "trace", "audit", "evidence"]) {
assert.match(operationRecordBody, new RegExp(`recordField\\("${field}"`, "u"), `operation record missing ${field}`);
}
assert.match(operationRecordBody, /DO1目标=/u, "operation records must expose DO1 target");
assert.match(operationRecordBody, /DI1观测=/u, "operation records must expose DI1 observation");
assert.match(operationRecordBody, /trusted=/u, "operation records must expose trusted state");
assert.match(operationRecordBody, /durable=/u, "operation records must expose durable state");
assert.match(operationRecordBody, /via=\/v1\/m3\/io/u, "operation record must show the control route");
assert.match(operationRecordBody, /frontendBypass=false/u, "operation record must show no frontend bypass");
}
+134 -1
View File
@@ -1616,7 +1616,7 @@ h3 {
}
#panel-control {
grid-template-rows: auto auto minmax(0, 1fr);
grid-template-rows: auto auto auto minmax(0, 1fr);
}
#panel-wiring {
@@ -1633,6 +1633,137 @@ h3 {
border: 1px solid var(--line);
}
.m3-flow-summary {
min-width: 0;
display: grid;
gap: 9px;
padding: 10px;
background: rgba(20, 24, 23, 0.78);
border: 1px solid var(--line-strong);
}
.m3-flow-head,
.m3-flow-footer {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.m3-flow-head > div {
min-width: 0;
display: grid;
gap: 2px;
}
.m3-flow-head strong {
min-width: 0;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.m3-flow-line {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1.1fr) auto minmax(0, 1.2fr) minmax(0, 1.1fr) auto;
gap: 6px;
align-items: center;
}
.m3-flow-node,
.m3-flow-port,
.m3-flow-hop {
min-width: 0;
padding: 6px 7px;
border: 1px solid var(--line);
background: var(--surface);
line-height: 1.25;
overflow-wrap: anywhere;
}
.m3-flow-port {
color: #151812;
background: var(--accent);
border-color: var(--accent);
font-family: var(--mono);
font-size: 10px;
font-weight: 900;
text-align: center;
}
.m3-flow-hop {
color: var(--accent-2);
font-family: var(--mono);
font-size: 10px;
font-weight: 800;
text-align: center;
}
.m3-flow-kv {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin: 0;
}
.m3-flow-kv div {
min-width: 0;
display: grid;
gap: 3px;
padding: 7px;
background: var(--surface);
border: 1px solid var(--line);
}
.m3-flow-kv dt {
color: var(--dim);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
}
.m3-flow-kv dd {
min-width: 0;
margin: 0;
color: var(--text);
font-family: var(--mono);
font-size: 10px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.m3-jump-button {
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--line-strong);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
}
.m3-jump-button:hover,
.m3-jump-button:focus-visible {
border-color: var(--accent);
outline: 0;
}
.m3-trace-summary {
min-width: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.m3-control-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2135,6 +2266,8 @@ tbody tr:last-child td {
}
.hardware-list,
.m3-flow-line,
.m3-flow-kv,
.m3-control-grid,
.m3-control-actions {
grid-template-columns: 1fr;