fix: 收口 M3 控制面板操作体验
This commit is contained in:
@@ -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
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user