From b2818428cd7796c8817e9d97f4908fdce7c9ed77 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 08:58:19 +0000 Subject: [PATCH] feat: add M3 status workspace --- internal/cloud/m3-io-control.mjs | 509 ++++++++++++++ internal/cloud/m3-io-control.test.mjs | 127 +++- internal/cloud/server.mjs | 7 + internal/cloud/server.test.mjs | 67 ++ scripts/dev-artifact-publish.mjs | 6 +- scripts/dev-cloud-workbench-smoke.mjs | 3 + scripts/dev-cloud-workbench-smoke.test.mjs | 2 +- scripts/m3-io-control-e2e.test.mjs | 6 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 652 ++++++++++++++++-- scripts/src/m3-io-control-e2e.mjs | 8 +- web/hwlab-cloud-web/app.mjs | 467 +++++++++---- web/hwlab-cloud-web/index.html | 46 +- web/hwlab-cloud-web/scripts/check.mjs | 63 +- .../scripts/m3-control-panel-guard.mjs | 13 +- .../scripts/m3-readonly-contract.mjs | 4 +- web/hwlab-cloud-web/styles.css | 108 ++- 16 files changed, 1854 insertions(+), 234 deletions(-) diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index f2ad063f..248be311 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -13,6 +13,8 @@ import { export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1"; export const M3_IO_CONTROL_ROUTE = "/v1/m3/io"; +export const M3_STATUS_CONTRACT_VERSION = "m3-status-v1"; +export const M3_STATUS_ROUTE = "/v1/m3/status"; export const M3_IO_RPC_METHODS = Object.freeze({ doWrite: "m3.io.do.write", diRead: "m3.io.di.read" @@ -121,6 +123,225 @@ export async function describeM3IoControlLive(options = {}) { }; } +export async function describeM3StatusLive(options = {}) { + const env = options.env ?? process.env; + const config = buildM3IoConfig(env); + const requestJson = options.m3IoRequestJson ?? options.requestJson; + const observedAt = options.now?.() ?? new Date().toISOString(); + const runtime = await safeRuntimeSummary(options.runtimeStore); + const trust = await buildM3StatusTrust({ + runtimeStore: options.runtimeStore, + runtime + }); + + const base = { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: M3_STATUS_CONTRACT_VERSION, + route: M3_STATUS_ROUTE, + observedAt, + chain: M3_IO_CHAIN, + boundaries: { + frontendCallsOnly: M3_STATUS_ROUTE, + cloudApiAggregatesRuntimeStatus: true, + patchPanelOwnsPropagation: true, + directFrontendGatewayOrBoxAccess: false, + sourceTopologyFallbackMustStayUnverified: true + } + }; + + if (!config.enabled) { + 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。" + } + }; + } + + const [sourceGateway, targetGateway, patchStatus, patchWiring] = await Promise.all([ + readGatewaySession(config.gateway1Url, { + gatewayRole: "source", + expectedGatewayId: M3_IO_CHAIN.sourceGatewayId, + expectedGatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId, + requestJson, + timeoutMs: config.timeoutMs + }), + readGatewaySession(config.gateway2Url, { + gatewayRole: "target", + expectedGatewayId: M3_IO_CHAIN.targetGatewayId, + expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId, + requestJson, + timeoutMs: config.timeoutMs + }), + requestRuntimeJson(new URL("/status", ensureTrailingSlash(config.patchPanelUrl)).toString(), { + method: "GET", + requestJson, + timeoutMs: config.timeoutMs + }), + requestRuntimeJson(new URL("/wiring", ensureTrailingSlash(config.patchPanelUrl)).toString(), { + method: "GET", + requestJson, + timeoutMs: config.timeoutMs + }) + ]); + + const sourceCommand = normalizeCommand("do.write", { value: false }); + const targetCommand = normalizeCommand("di.read", {}); + const sourceBox = sourceGateway.available ? findGatewayBox(sourceGateway.status, sourceCommand) : null; + const targetBox = targetGateway.available ? findGatewayBox(targetGateway.status, targetCommand) : null; + const [sourceDoRead, targetDiRead] = await Promise.all([ + sourceGateway.available && sourceBox + ? readM3StatusPort({ + gateway: sourceGateway, + command: sourceCommand, + requestJson, + timeoutMs: config.timeoutMs, + observedAt, + port: M3_IO_CHAIN.sourcePort, + direction: "output" + }) + : Promise.resolve(unverifiedPortRead({ + port: M3_IO_CHAIN.sourcePort, + direction: "output", + source: "gateway-simu", + reason: sourceGateway.reason ?? `${M3_IO_CHAIN.sourceResourceId} 未在 source gateway registry 中可见`, + observedAt + })), + targetGateway.available && targetBox + ? readM3StatusPort({ + gateway: targetGateway, + command: targetCommand, + requestJson, + timeoutMs: config.timeoutMs, + observedAt, + port: M3_IO_CHAIN.targetPort, + direction: "input" + }) + : Promise.resolve(unverifiedPortRead({ + port: M3_IO_CHAIN.targetPort, + direction: "input", + source: "gateway-simu", + reason: targetGateway.reason ?? `${M3_IO_CHAIN.targetResourceId} 未在 target gateway registry 中可见`, + observedAt + })) + ]); + + const gateways = [ + gatewayStatusEntry(sourceGateway, { + id: M3_IO_CHAIN.sourceGatewayId, + sessionId: M3_IO_CHAIN.sourceGatewaySessionId, + role: "source", + observedAt + }), + gatewayStatusEntry(targetGateway, { + id: M3_IO_CHAIN.targetGatewayId, + sessionId: M3_IO_CHAIN.targetGatewaySessionId, + role: "target", + observedAt + }) + ]; + const boxes = [ + boxStatusEntry({ + gateway: sourceGateway, + box: sourceBox, + boxId: M3_IO_CHAIN.sourceBoxId, + resourceId: M3_IO_CHAIN.sourceResourceId, + gatewayId: M3_IO_CHAIN.sourceGatewayId, + gatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId, + observedAt, + ports: { + [M3_IO_CHAIN.sourcePort]: sourceDoRead.port + } + }), + boxStatusEntry({ + gateway: targetGateway, + box: targetBox, + boxId: M3_IO_CHAIN.targetBoxId, + resourceId: M3_IO_CHAIN.targetResourceId, + gatewayId: M3_IO_CHAIN.targetGatewayId, + gatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId, + observedAt, + ports: { + [M3_IO_CHAIN.targetPort]: targetDiRead.port + } + }) + ]; + const patchPanel = patchPanelStatusEntry({ + statusResponse: patchStatus, + wiringResponse: patchWiring, + observedAt + }); + const blockers = [ + ...gateways.filter((gateway) => !gateway.online).map((gateway) => ({ + code: gateway.blocker, + layer: gateway.id, + zh: gateway.lastError + })), + ...boxes.filter((box) => !box.online).map((box) => ({ + code: M3_IO_BLOCKER_CODES.boxUnavailable, + layer: box.resourceId, + zh: `${box.resourceId} 未在 gateway-simu registry 中可见` + })), + patchPanel.connectionActive ? null : { + code: patchPanel.observable === false ? M3_IO_BLOCKER_CODES.patchPanelUnavailable : M3_IO_BLOCKER_CODES.wiringMissing, + layer: M3_IO_CHAIN.patchPanelServiceId, + zh: patchPanel.lastError ?? `hwlab-patch-panel 未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} 接线` + }, + sourceDoRead.error ? { + code: M3_IO_BLOCKER_CODES.dispatchFailed, + layer: M3_IO_CHAIN.sourceResourceId, + zh: sourceDoRead.error + } : null, + targetDiRead.error ? { + code: M3_IO_BLOCKER_CODES.dispatchFailed, + layer: M3_IO_CHAIN.targetResourceId, + zh: targetDiRead.error + } : null + ].filter(Boolean); + + const hardwareLive = + blockers.length === 0 && + sourceDoRead.port.sourceKind === "DEV-LIVE" && + targetDiRead.port.sourceKind === "DEV-LIVE"; + const trustGreen = trust.durableStatus === "green" && trust.evidenceId && trust.auditId && trust.operationId && trust.traceId; + const status = blockers.length > 0 + ? 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"; + + return { + ...base, + status, + sourceKind, + summary: status === "live" + ? "M3 硬件状态与可信持久化均为 DEV-LIVE。" + : hardwareLive + ? "M3 DO1/DI1 实况读数来自 cloud-api 聚合入口;可信持久化仍保持 blocked,不得标为 trusted green。" + : "M3 硬件状态尚未完整验证;SOURCE 拓扑只能作为未验证 fallback。", + gateways, + boxes, + patchPanel, + trust, + blocker: blockers[0] ?? (trust.blocker ? { + code: trust.blocker, + layer: "runtime-durable", + zh: `runtime durable 仍 blocked:${trust.blocker}` + } : null) + }; +} + export async function handleM3IoControl(params = {}, context = {}) { const env = context.env ?? process.env; const now = context.now?.() ?? new Date().toISOString(); @@ -441,6 +662,294 @@ function blockedReadiness({ checks, code, layer, reason, observedAt }) { }; } +async function buildM3StatusTrust({ runtimeStore, runtime }) { + const durable = durableStatus(runtime); + const base = { + operationId: null, + traceId: null, + auditId: null, + evidenceId: null, + durableStatus: isDurableRuntimeReady(runtime) ? "green" : "blocked", + runtime: durable, + blocker: isDurableRuntimeReady(runtime) ? null : durable.blocker + }; + + const [audit, evidence] = await Promise.all([ + safeRuntimeRead(() => runtimeStore?.queryAuditEvents?.({ projectId: M3_IO_CHAIN.projectId, limit: 8 })), + safeRuntimeRead(() => runtimeStore?.queryEvidenceRecords?.({ projectId: M3_IO_CHAIN.projectId, limit: 8 })) + ]); + const latestAudit = latestM3AuditEvent(audit.value?.events ?? []); + const latestEvidence = latestM3EvidenceRecord(evidence.value?.records ?? []); + const operationId = latestEvidence?.operationId ?? latestAudit?.operationId ?? null; + const traceId = latestEvidence?.traceId ?? latestEvidence?.metadata?.traceId ?? latestAudit?.traceId ?? null; + + return { + ...base, + operationId, + traceId, + auditId: latestAudit?.auditId ?? null, + evidenceId: latestEvidence?.evidenceId ?? null, + durableStatus: base.durableStatus, + blocker: base.blocker ?? audit.error?.code ?? evidence.error?.code ?? null, + readStatus: { + audit: audit.ok ? "read" : "blocked", + evidence: evidence.ok ? "read" : "blocked", + auditError: audit.error?.message ?? null, + evidenceError: evidence.error?.message ?? null + } + }; +} + +async function safeRuntimeRead(read) { + if (typeof read !== "function") { + return { + ok: false, + value: null, + error: { + code: "runtime_read_unavailable", + message: "runtime read method is unavailable" + } + }; + } + try { + const value = await read(); + if (value === undefined || value === null) { + return { + ok: false, + value: null, + error: { + code: "runtime_read_unavailable", + message: "runtime read returned no value" + } + }; + } + return { + ok: true, + value, + error: null + }; + } catch (error) { + return { + ok: false, + value: null, + error: { + code: error?.code ?? "runtime_read_blocked", + message: error instanceof Error ? redactBlockerMessage(error.message) : String(error) + } + }; + } +} + +function latestM3AuditEvent(events) { + return [...events].reverse().find((event) => + event?.operationId && + event?.traceId && + (event.action === "m3.io.do.write" || event.action === "m3.io.di.read" || String(event.metadata?.route ?? "").includes("res_boxsimu_1:DO1")) + ) ?? null; +} + +function latestM3EvidenceRecord(records) { + return [...records].reverse().find((record) => + record?.operationId && + (record.serviceId === CLOUD_API_SERVICE_ID || String(record.metadata?.route ?? "").includes("res_boxsimu_1:DO1")) + ) ?? null; +} + +function defaultM3Gateways({ sourceKind, observedAt }) { + return [ + { + id: M3_IO_CHAIN.sourceGatewayId, + role: "source", + online: false, + observable: false, + sessionId: M3_IO_CHAIN.sourceGatewaySessionId, + lastSeenAt: observedAt, + sourceKind, + lastError: "未验证" + }, + { + id: M3_IO_CHAIN.targetGatewayId, + role: "target", + online: false, + observable: false, + sessionId: M3_IO_CHAIN.targetGatewaySessionId, + lastSeenAt: observedAt, + sourceKind, + lastError: "未验证" + } + ]; +} + +function defaultM3Boxes({ sourceKind, observedAt }) { + return [ + { + id: M3_IO_CHAIN.sourceBoxId, + resourceId: M3_IO_CHAIN.sourceResourceId, + gatewayId: M3_IO_CHAIN.sourceGatewayId, + gatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId, + online: false, + observable: false, + sourceKind, + observedAt, + ports: { + [M3_IO_CHAIN.sourcePort]: { + value: null, + direction: "output", + source: "未验证", + sourceKind, + observedAt + } + } + }, + { + id: M3_IO_CHAIN.targetBoxId, + resourceId: M3_IO_CHAIN.targetResourceId, + gatewayId: M3_IO_CHAIN.targetGatewayId, + gatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId, + online: false, + observable: false, + sourceKind, + observedAt, + ports: { + [M3_IO_CHAIN.targetPort]: { + value: null, + direction: "input", + source: "未验证", + sourceKind, + observedAt + } + } + } + ]; +} + +function defaultM3PatchPanel({ observedAt }) { + return { + serviceId: M3_IO_CHAIN.patchPanelServiceId, + observable: false, + connectionActive: false, + connectionVersion: null, + wiringConfigId: null, + lastSyncAt: observedAt, + lastError: "未验证", + sourceKind: "UNVERIFIED" + }; +} + +async function readM3StatusPort({ gateway, command, requestJson, timeoutMs, observedAt, port, direction }) { + const capability = capabilityFor({ ...command, action: "di.read", port }, { + boxId: command.boxId, + resourceId: command.resourceId + }); + const dispatch = await invokeGateway(gateway.url, { + operation: "hardware.port.read", + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId: gateway.gatewaySessionId, + resourceId: command.resourceId, + boxId: command.boxId, + capabilityId: capability.capabilityId, + port, + operationId: `op_m3_status_read_${port.toLowerCase()}_${randomUUID()}`, + traceId: `trc_m3_status_read_${port.toLowerCase()}_${randomUUID()}`, + actorType: "service", + actorId: `svc_${CLOUD_API_SERVICE_ID}`, + source: "cloud-api-m3-status" + }, { requestJson, timeoutMs }); + + if (!dispatch.ok || dispatch.body?.accepted !== true) { + return unverifiedPortRead({ + port, + direction, + source: "gateway-simu", + reason: `gateway-simu ${port} 读取失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`, + observedAt + }); + } + + return { + port: { + value: readValueFromGatewayResult(dispatch.body), + direction, + source: port === M3_IO_CHAIN.targetPort ? "gateway-simu/patch-panel" : "gateway-simu", + sourceKind: "DEV-LIVE", + observedAt: dispatch.body?.result?.state?.updatedAt ?? dispatch.body?.state?.updatedAt ?? observedAt, + auditId: dispatch.body?.auditId ?? dispatch.body?.result?.auditId ?? null, + evidenceId: dispatch.body?.evidenceId ?? dispatch.body?.result?.evidenceId ?? null, + state: dispatch.body?.result?.state ?? dispatch.body?.state ?? null + }, + error: null + }; +} + +function unverifiedPortRead({ port, direction, source, reason, observedAt }) { + return { + port: { + value: null, + direction, + source, + sourceKind: "UNVERIFIED", + observedAt, + lastError: reason + }, + error: reason + }; +} + +function gatewayStatusEntry(gateway, { id, sessionId, role, observedAt }) { + return { + id: gateway.gatewayId ?? id, + role, + online: gateway.available === true, + observable: gateway.available === true, + sessionId: gateway.gatewaySessionId ?? sessionId, + lastSeenAt: gateway.status?.lastHeartbeatAt ?? gateway.status?.updatedAt ?? gateway.status?.session?.lastSeenAt ?? observedAt, + sourceKind: gateway.available === true ? "DEV-LIVE" : "UNVERIFIED", + lastError: gateway.available === true ? null : gateway.reason, + blocker: gateway.available === true ? null : gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable + }; +} + +function boxStatusEntry({ gateway, box, boxId, resourceId, gatewayId, gatewaySessionId, observedAt, ports }) { + return { + id: box?.boxId ?? boxId, + resourceId: box?.resourceId ?? resourceId, + gatewayId: gateway.gatewayId ?? gatewayId, + gatewaySessionId: gateway.gatewaySessionId ?? gatewaySessionId, + online: gateway.available === true && Boolean(box), + observable: gateway.available === true && Boolean(box), + sourceKind: gateway.available === true && box ? "DEV-LIVE" : "UNVERIFIED", + observedAt, + ports + }; +} + +function patchPanelStatusEntry({ statusResponse, wiringResponse, observedAt }) { + const status = statusResponse.body ?? {}; + const wiring = wiringResponse.body ?? {}; + const connectionActive = statusResponse.ok && wiringResponse.ok && hasExpectedPatchPanelWiring(status) && hasExpectedPatchPanelWiring(wiring); + const lastError = !statusResponse.ok + ? `hwlab-patch-panel status 不可读:${statusResponse.error ?? `HTTP ${statusResponse.status}`}` + : !wiringResponse.ok + ? `hwlab-patch-panel wiring 不可读:${wiringResponse.error ?? `HTTP ${wiringResponse.status}`}` + : connectionActive + ? status.metadata?.evidenceSummary?.diagnostics?.errorCount > 0 + ? "hwlab-patch-panel diagnostics reports errors" + : null + : `未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`; + return { + serviceId: status.serviceId ?? M3_IO_CHAIN.patchPanelServiceId, + observable: statusResponse.ok, + connectionActive, + connectionVersion: status.metadata?.evidenceSummary?.tickCount ?? status.metadata?.evidenceSummary?.routeCount ?? null, + wiringConfigId: status.wiringConfigId ?? wiring.wiringConfigId ?? null, + lastSyncAt: status.metadata?.evidenceSummary?.lastSyncAt ?? status.observedAt ?? wiring.updatedAt ?? observedAt, + lastError, + sourceKind: connectionActive ? "DEV-LIVE" : statusResponse.ok ? "UNVERIFIED" : "BLOCKED", + activeConnections: status.activeConnections ?? [], + diagnostics: status.metadata?.diagnostics ?? status.metadata?.evidenceSummary?.diagnostics ?? null + }; +} + function readinessCheckFromGateway(id, gateway) { return { id, diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index ad6d9906..bd74c07d 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -10,8 +10,10 @@ import { M3_IO_BLOCKER_CODES, M3_IO_CHAIN, M3_IO_RPC_METHODS, + M3_STATUS_ROUTE, describeM3IoControl, describeM3IoControlLive, + describeM3StatusLive, handleM3IoControl } from "./m3-io-control.mjs"; @@ -90,6 +92,107 @@ test("M3 IO live descriptor blocks controls with Chinese reason when readiness i assert.equal(contract.readiness.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing); }); +test("M3 status live descriptor aggregates gateways, boxes, patch-panel, IO values, and blocked trust", async () => { + const fixture = createM3ControlFixture(); + writeBoxPort({ + state: fixture.box1, + port: "DO1", + value: true, + source: "gateway-simu" + }); + writeBoxPort({ + state: fixture.box2, + port: "DI1", + value: true, + source: "patch-panel", + sourceResourceId: "res_boxsimu_1", + sourcePort: "DO1", + propagatedBy: "hwlab-patch-panel" + }); + + const status = await describeM3StatusLive({ + runtimeStore: createBlockedDurableRuntimeStore({ blocker: "runtime_durable_adapter_auth_blocked" }), + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + now: () => fixedNow, + m3IoRequestJson: fixture.requestJson + }); + + assert.equal(status.route, M3_STATUS_ROUTE); + assert.equal(status.status, "blocked"); + assert.equal(status.sourceKind, "DEV-LIVE"); + assert.equal(status.chain.sourceResourceId, "res_boxsimu_1"); + assert.equal(status.boundaries.frontendCallsOnly, "/v1/m3/status"); + assert.equal(status.boundaries.directFrontendGatewayOrBoxAccess, false); + assert.deepEqual(status.gateways.map((gateway) => [gateway.id, gateway.online, gateway.sessionId, gateway.sourceKind]), [ + ["gwsimu_1", true, "gws_gwsimu_1", "DEV-LIVE"], + ["gwsimu_2", true, "gws_gwsimu_2", "DEV-LIVE"] + ]); + const box1 = status.boxes.find((box) => box.resourceId === "res_boxsimu_1"); + const box2 = status.boxes.find((box) => box.resourceId === "res_boxsimu_2"); + assert.equal(box1.online, true); + assert.equal(box1.ports.DO1.value, true); + assert.equal(box1.ports.DO1.sourceKind, "DEV-LIVE"); + assert.equal(box2.online, true); + assert.equal(box2.ports.DI1.value, true); + assert.equal(box2.ports.DI1.sourceKind, "DEV-LIVE"); + assert.equal(box2.ports.DI1.source, "gateway-simu/patch-panel"); + assert.equal(status.patchPanel.serviceId, "hwlab-patch-panel"); + assert.equal(status.patchPanel.connectionActive, true); + assert.equal(status.patchPanel.wiringConfigId, "wir_m3_do1_di1"); + assert.equal(status.patchPanel.lastError, null); + assert.equal(status.trust.durableStatus, "blocked"); + assert.equal(status.trust.blocker, "runtime_durable_adapter_auth_blocked"); + assert.equal(status.trust.evidenceId, null); + assert.equal(status.blocker.code, "runtime_durable_adapter_auth_blocked"); + assert.deepEqual(fixture.calls.map((call) => call.path), [ + "/status", + "/status", + "/status", + "/wiring", + "/invoke", + "/invoke" + ]); +}); + +test("M3 status live descriptor keeps SOURCE fallback unverified when patch-panel route is missing", async () => { + const fixture = createM3ControlFixture({ + patchPanelStatusBody: { + serviceId: "hwlab-patch-panel", + state: "active", + activeConnections: [] + }, + patchPanelWiringBody: { + wiringConfigId: "wir_wrong", + status: "active", + connections: [] + } + }); + + const status = await describeM3StatusLive({ + runtimeStore: createBlockedDurableRuntimeStore(), + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + now: () => fixedNow, + m3IoRequestJson: fixture.requestJson + }); + + assert.equal(status.status, "blocked"); + assert.equal(status.patchPanel.connectionActive, false); + assert.equal(status.patchPanel.sourceKind, "UNVERIFIED"); + assert.match(status.patchPanel.lastError, /未确认 active/u); + assert.equal(status.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing); + assert.equal(status.boxes[0].ports.DO1.sourceKind, "DEV-LIVE"); + assert.equal(status.boxes[1].ports.DI1.sourceKind, "DEV-LIVE"); + assert.notEqual(status.sourceKind, "SOURCE"); +}); + test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, and records audit/evidence fields", async () => { const fixture = createM3ControlFixture(); const runtimeStore = createCloudRuntimeStore({ @@ -627,7 +730,7 @@ test("M3 IO fixture success cannot be promoted to DEV-LIVE without durable evide assert.notEqual(result.auditState.sourceKind, "DEV-LIVE"); }); -function createBlockedDurableRuntimeStore() { +function createBlockedDurableRuntimeStore({ blocker = "runtime_durable_adapter_query_blocked" } = {}) { return { async readiness() { return { @@ -637,7 +740,7 @@ function createBlockedDurableRuntimeStore() { durableCapable: false, ready: false, status: "blocked", - blocker: "runtime_durable_adapter_query_blocked", + blocker, reason: "Postgres runtime adapter could not complete a live runtime schema query", liveRuntimeEvidence: false, fixtureEvidence: false @@ -697,6 +800,26 @@ function createM3ControlFixture({ patchPanelBody, patchPanelStatusBody, patchPan return { ok: true, status: 200, body: gateway2 }; } if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") { + if (body.operation === "hardware.port.read") { + const state = box1.ports[body.port]; + return { + ok: true, + status: 200, + body: { + accepted: true, + operationId: body.operationId, + traceId: body.traceId, + boxId: box1.boxId, + resourceId: box1.resource.resourceId, + capabilityId: body.capabilityId, + port: body.port, + value: state.value, + state, + auditId: "aud_box1_read", + evidenceId: "evd_box1_read" + } + }; + } return { ok: true, status: 200, diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 1ec93af2..faf3c922 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -20,8 +20,10 @@ import { import { buildCloudApiReadiness } from "./health-contract.mjs"; import { M3_IO_CONTROL_ROUTE, + M3_STATUS_ROUTE, describeM3IoControl, describeM3IoControlLive, + describeM3StatusLive, handleM3IoControl } from "./m3-io-control.mjs"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs"; @@ -195,6 +197,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname === M3_STATUS_ROUTE) { + sendJson(response, 200, await describeM3StatusLive(options)); + return; + } + if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) { await handleM3IoControlHttp(request, response, options); return; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 40de6bc1..58d49d08 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -693,6 +693,30 @@ async function m3ReadinessRequestJson(url) { }) }; } + if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") { + return { + ok: true, + status: 200, + body: { + accepted: true, + operationId: "op_m3_status_do1", + traceId: "trc_m3_status_do1", + boxId: "boxsimu_1", + resourceId: "res_boxsimu_1", + port: "DO1", + value: false, + state: { + port: "DO1", + direction: "output", + value: false, + source: "gateway-simu", + updatedAt: "2026-05-23T00:00:00.000Z" + }, + auditId: "aud_m3_status_do1", + evidenceId: "evd_m3_status_do1" + } + }; + } if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") { return { ok: true, @@ -705,6 +729,33 @@ async function m3ReadinessRequestJson(url) { }) }; } + if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") { + return { + ok: true, + status: 200, + body: { + accepted: true, + operationId: "op_m3_status_di1", + traceId: "trc_m3_status_di1", + boxId: "boxsimu_2", + resourceId: "res_boxsimu_2", + port: "DI1", + value: false, + state: { + port: "DI1", + direction: "input", + value: false, + source: "patch-panel", + sourceResourceId: "res_boxsimu_1", + sourcePort: "DO1", + propagatedBy: "hwlab-patch-panel", + updatedAt: "2026-05-23T00:00:00.000Z" + }, + auditId: "aud_m3_status_di1", + evidenceId: "evd_m3_status_di1" + } + }; + } if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") { return { ok: true, @@ -1475,6 +1526,22 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io"); assert.equal(payload.boundaries.patchPanelOwnsPropagation, true); assert.equal(payload.boundaries.genericHardwareRpcExposedToFrontend, false); + + const statusResponse = await fetch(`http://127.0.0.1:${port}/v1/m3/status`); + assert.equal(statusResponse.status, 200); + const statusPayload = await statusResponse.json(); + assert.equal(statusPayload.route, "/v1/m3/status"); + assert.equal(statusPayload.chain.sourceResourceId, "res_boxsimu_1"); + assert.equal(statusPayload.chain.targetResourceId, "res_boxsimu_2"); + assert.equal(statusPayload.gateways.length, 2); + assert.equal(statusPayload.gateways.every((gateway) => gateway.online === true), true); + 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.trust.durableStatus, "blocked"); + assert.notEqual(statusPayload.trust.durableStatus, "green"); + assert.equal(statusPayload.boundaries.frontendCallsOnly, "/v1/m3/status"); + assert.equal(statusPayload.boundaries.directFrontendGatewayOrBoxAccess, false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 6305a3d8..e69360bc 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -478,10 +478,10 @@ async function serveCloudWeb() { return; } - if ( - (request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) || + if ( + (request.method === "GET" && (url.pathname === "/v1" || url.pathname === "/v1/m3/status" || url.pathname.startsWith("/v1/"))) || (request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io")) - ) { + ) { await proxyCloudApi(request, response, url); return; } diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index 87de5237..7aee094d 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -7,6 +7,7 @@ import { parseSmokeArgs, printSmokeHelp, runDevCloudWorkbenchMobileSmoke, + runM3StatusFixtureSmoke, runDevCloudWorkbenchSmoke } from "./src/dev-cloud-workbench-smoke-lib.mjs"; @@ -52,6 +53,8 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { ? printSmokeHelp() : args.mobile ? await runDevCloudWorkbenchMobileSmoke() + : args.mode === "m3-status-fixture" + ? await runM3StatusFixtureSmoke() : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); const reportWrite = decideSmokeReportWrite(args, report); if (reportWrite.write) { diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 04e57a30..61c09a74 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -505,7 +505,7 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(Array.isArray(report.failures), true); assert.equal(Array.isArray(report.skipped), true); assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true); - assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "skip"); + assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass"); assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip"); const desktopCollapsed = report.checks.find((check) => check.id === "layout-desktop-collapsed")?.observations; diff --git a/scripts/m3-io-control-e2e.test.mjs b/scripts/m3-io-control-e2e.test.mjs index 3dda815f..6101b071 100644 --- a/scripts/m3-io-control-e2e.test.mjs +++ b/scripts/m3-io-control-e2e.test.mjs @@ -225,7 +225,7 @@ test("M3 IO E2E frontend guardrail requires readiness before controls unlock", ( `; const pass = checkFrontendNoDirectRuntimeCalls({ appSource, - artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();' + artifactPublisherSource: 'if (url.pathname === "/v1/m3/io" || url.pathname === "/v1/m3/status") proxyCloudApi();' }); assert.equal(pass.status, "pass"); @@ -238,7 +238,7 @@ test("M3 IO E2E frontend guardrail requires readiness before controls unlock", ( return fetchJson("/v1/m3/io", { method: "POST" }); } `, - artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();' + artifactPublisherSource: 'if (url.pathname === "/v1/m3/io" || url.pathname === "/v1/m3/status") proxyCloudApi();' }); assert.equal(fail.status, "fail"); assert.ok(fail.issues.some((issue) => /readiness/u.test(issue))); @@ -263,7 +263,7 @@ test("M3 IO E2E frontend guardrail catches direct gateway or patch-panel calls", `; const pass = checkFrontendNoDirectRuntimeCalls({ appSource, - artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();' + artifactPublisherSource: 'if (url.pathname === "/v1/m3/io" || url.pathname === "/v1/m3/status") proxyCloudApi();' }); assert.equal(pass.status, "pass"); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 78d34a5f..6756cf9f 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -133,17 +133,21 @@ const requiredTrustedRecordTerms = Object.freeze([ const requiredHardwareStatusTerms = Object.freeze([ "hardware-source-status", - "hardwareGroup", - "hardwareRow", - "Gateway/Box 在线", - "BOX-SIMU 端口", - "Patch Panel 连线", - "DO1 -> patch-panel -> DI1", - "AI/AO/FREQ", - "后续能力", + "hardware-status-tabs", + "hardwareKvGroup", + "/v1/m3/status", + "Gateway-SIMU", + "BOX-SIMU-1", + "BOX-SIMU-2", + "Patch Panel", + "Gateway 在线", + "BOX 在线", + "patch-panel", + "runtime durable", + "DISABLED 后续能力", "SOURCE", "DEV-LIVE", - "BLOCKED" + "unverified_cloud_api_m3_status" ]); const workbenchMarkers = Object.freeze([ @@ -231,6 +235,7 @@ export async function runDevCloudWorkbenchSmoke(argv = []) { if (args.mode === "live") return runLiveSmoke(args); if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke(); if (args.mode === "dom-only") return runLiveDomOnlySmoke(args); + if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke(); if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); @@ -283,6 +288,8 @@ export function parseSmokeArgs(argv) { args.confirmDevLive = true; } else if (arg === "--dom-only") { args.mode = "dom-only"; + } else if (arg === "--m3-status-fixture") { + args.mode = "m3-status-fixture"; } else if (arg === "--layout") { args.mode = "layout"; } else if (arg === "--build") { @@ -416,8 +423,8 @@ function runStaticSmoke() { "收起左侧资源树 / 展开左侧资源树 aria-label", "收起资源树 / 展开资源树 visible button text", "explorer-collapsed sets explorer column to 0", - "collapsed desktop center column minmax(560px, 1fr)", - "right side keeps a future width boundary through --right-width" + "collapsed desktop center column minmax(420px, 1fr)", + "right side keeps a 560-740px M3 status workspace boundary through --right-width" ] }); @@ -426,9 +433,9 @@ function runStaticSmoke() { evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] }); - addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable degraded API from fully OK without exposing backend blocker details.", { + addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable blocked API from fully OK with concrete blocker copy.", { blocker: "observability_blocker", - evidence: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "API 降级 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics/blocker copy in default status classifier"] + evidence: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "runtime_durable_adapter_auth_blocked", "API 错误 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics copy in default status classifier"] }); addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { @@ -494,7 +501,7 @@ function runStaticSmoke() { addCheck(checks, blockers, "m3-rendered-workbench-not-m5-fixture", m3RenderedWorkbenchNotM5Fixture(files.app), "Right workbench hardware status and wiring table render the M3 chain, not M5 fixture rows or undefined DOM-node rows.", { blocker: "runtime_blocker", evidence: [ - "renderHardwareStatus passes data objects into hardwareGroup", + "renderHardwareStatus renders tabbed Key/Value groups", "renderWiringList uses M3_TRUSTED_ROUTE", "M5 activeConnections are not mapped into the workbench wiring table" ] @@ -1734,16 +1741,27 @@ function hardwareStatusStructureContract({ html, app, styles }) { return ( requiredHardwareStatusTerms.every((term) => source.includes(term)) && /id=["']hardware-source-status["']/u.test(html) && + /data-hardware-tab=["']overview["'][\s\S]*?>总览<\/button>/u.test(html) && + /data-hardware-tab=["']gateways["'][\s\S]*?>Gateway-SIMU<\/button>/u.test(html) && + /data-hardware-tab=["']box1["'][\s\S]*?>BOX-SIMU-1<\/button>/u.test(html) && + /data-hardware-tab=["']box2["'][\s\S]*?>BOX-SIMU-2<\/button>/u.test(html) && + /data-hardware-tab=["']patch["'][\s\S]*?>Patch Panel<\/button>/u.test(html) && /function\s+renderHardwareStatus\s*\(/u.test(app) && - /function\s+hardwareGroup\s*\(/u.test(app) && - /function\s+hardwareRow\s*\(/u.test(app) && + /function\s+initHardwareTabs\s*\(/u.test(app) && + /function\s+hardwareKvGroup\s*\(/u.test(app) && + /function\s+sourceFallbackM3Status\s*\(/u.test(app) && /function\s+trustedM3Link\s*\(/u.test(app) && /function\s+hasTrustedM3LiveEvidence\s*\(/u.test(app) && /function\s+linkStatusLabel\s*\(/u.test(app) && /function\s+normalizePort\s*\(/u.test(app) && /页面不直连模拟器,也不伪造硬件状态。/u.test(app) && + /fetchJson\("\/v1\/m3\/status"\)/u.test(app) && + /sourceFallbackM3Status\(\)/u.test(functionBody(app, "renderHardwareStatus")) && /\.hardware-section\s*\{/u.test(styles) && - /\.hardware-row\s*\{/u.test(styles) + /\.hardware-status-tabs\s*\{/u.test(styles) && + /\.hardware-tab\s*\{/u.test(styles) && + /\.kv-list\s*\{/u.test(styles) && + /\.kv-row\s*\{/u.test(styles) ); } @@ -1850,9 +1868,9 @@ function hasSidebarCollapseContract({ html, app, styles }) { /textContent = collapsed \? "展开资源树" : "收起资源树"/u.test(setCollapsedBody) && /title = collapsed \? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"/u.test(setCollapsedBody) && /--explorer-width:\s*292px/u.test(styles) && - /--right-width:\s*clamp\(366px,\s*28vw,\s*440px\)/u.test(styles) && - /--right-width-expanded:\s*clamp\(366px,\s*26vw,\s*410px\)/u.test(styles) && - /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(560px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) && + /--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/u.test(styles) && + /--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/u.test(styles) && + /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) && /\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles) ); } @@ -1876,6 +1894,7 @@ function hasSameOriginReadOnlyBoundary(app) { return ( /fetchJson\("\/health\/live"\)/u.test(app) && /fetchJson\("\/v1"\)/u.test(app) && + /fetchJson\("\/v1\/m3\/status"\)/u.test(app) && /fetchJson\("\/v1\/agent\/chat"/u.test(app) && /fetchJson\("\/json-rpc"/u.test(app) && JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) && @@ -1898,13 +1917,16 @@ function hasApiDegradedDefaultStatus({ app }) { /payload\.readiness\?\.ready/u.test(functionBody(app, "readinessBooleanValues")) && /readyValues\.includes\(false\)/u.test(readinessBody) && /runtime_durable_adapter_query_blocked/u.test(durableBlockBody) && + /runtime_durable_adapter_/u.test(durableBlockBody) && /payload\.runtime\?\.blocker/u.test(durableBlockBody) && /payload\.readiness\?\.durability\?\.blocker/u.test(durableBlockBody) && /blockedLayer === "durability_query"/u.test(durableBlockBody) && /可信记录受阻 \/ 只读模式/u.test(statusBody) && - /API 降级 \/ 只读模式/u.test(statusBody) && - /同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。/u.test(statusBody) && - /同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。/u.test(statusBody) && + /API 错误 \/ 只读模式/u.test(statusBody) && + /apiDurableBlockerDetail\(coreProbes\)/u.test(statusBody) && + /apiProbeErrorDetail\(coreProbes\)/u.test(statusBody) && + /\/health\/live 可响应,但可信记录持久化尚未就绪:/u.test(app) && + !/API 降级 \/ 只读模式/u.test(app) && !/runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u.test(statusBody) ); } @@ -2059,32 +2081,33 @@ function sourceEvidenceNotDevLive(source) { function m3FixtureCountsNotDevLive(app) { const hardwareBody = functionBody(app, "renderHardwareStatus"); - const linkDetailBody = functionBody(app, "m3LinkDetail"); + const fallbackBody = functionBody(app, "sourceFallbackM3Status"); const liveEvidenceBody = functionBody(app, "hasTrustedM3LiveEvidence"); const patchPanelReportBody = functionBody(app, "hasTrustedPatchPanelLiveReport"); - const sourceOnlyRuntimeCounts = - /runtimeCountsSourceKind\s*=\s*["']SOURCE["']/u.test(hardwareBody) && - !/sourceKind:\s*counts\s*\?\s*["']DEV-LIVE["']/u.test(hardwareBody) && - !/counts\s*&&\s*(?:liveLink|sourceLink|m3Link)[\s\S]{0,80}\?\s*["']DEV-LIVE["']/u.test(hardwareBody); + const sourceFallbackStaysUnverified = + /sourceKind:\s*"SOURCE"/u.test(fallbackBody) && + /unverified_cloud_api_m3_status/u.test(fallbackBody) && + /SOURCE 拓扑不能冒充实况硬件状态/u.test(fallbackBody) && + !/sourceKind:\s*"DEV-LIVE"/u.test(fallbackBody); return ( - sourceOnlyRuntimeCounts && - /hasTrustedM3LiveEvidence\(liveM3Evidence\)/u.test(hardwareBody) && + sourceFallbackStaysUnverified && + /sourceFallbackM3Status\(\)/u.test(hardwareBody) && /patchPanelLiveReport/u.test(liveEvidenceBody) && /LIVE_M3_ID_FIELDS\.every/u.test(liveEvidenceBody) && /hasTrustedPatchPanelLiveReport/u.test(liveEvidenceBody) && /serviceId\s*!==\s*M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(patchPanelReportBody) && - /trustedM3Link\(\[link\]\)/u.test(patchPanelReportBody) && - /只读运行计数已返回,但还不能证明接线盘闭环/u.test(linkDetailBody) && - /来源拓扑包含目标接线/u.test(linkDetailBody) + /trustedM3Link\(\[link\]\)/u.test(patchPanelReportBody) ); } function m3RenderedWorkbenchNotM5Fixture(app) { const hardwareBody = functionBody(app, "renderHardwareStatus"); - const hardwareGroupBody = functionBody(app, "hardwareGroup"); + const overviewBody = functionBody(app, "hardwareOverviewGroups"); const wiringBody = functionBody(app, "renderWiringList"); return ( - /group\.rows\.map\(hardwareRow\)/u.test(hardwareGroupBody) && + /Key\/Value groups/u.test(hardwareBody) && + /M3_TRUSTED_ROUTE\.fromResourceId/u.test(overviewBody) && + /M3_TRUSTED_ROUTE\.toResourceId/u.test(overviewBody) && !/hardwareRow\(\s*\{/u.test(hardwareBody) && /M3_TRUSTED_ROUTE\.fromResourceId/u.test(wiringBody) && /M3_TRUSTED_ROUTE\.patchPanelServiceId/u.test(wiringBody) && @@ -3124,8 +3147,11 @@ async function inspectDefaultApiStatus(page) { methodText, firstScreenText, degradedStatusVisible: - liveStatus === "可信记录受阻 / 只读模式" && - liveDetail === "同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。" && + (liveStatus === "可信记录受阻 / 只读模式" || liveStatus === "API 错误 / 只读模式") && + ( + liveDetail.startsWith("/health/live 可响应,但可信记录持久化尚未就绪:") || + liveDetail === "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。" + ) && methodText.includes("system.health"), defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`) }; @@ -3302,7 +3328,7 @@ async function runLocalAgentFixtureSmoke({ { id: "local-api-degraded-default-status", status: apiStatus.degradedStatusVisible && apiStatus.defaultCopyIsWorkbenchSafe ? "pass" : "blocked", - summary: "Default workbench status renders reachable degraded /health/live as API degraded read-only without first-screen backend blocker details.", + summary: "Default workbench status renders reachable blocked /health/live as concrete read-only status without first-screen backend route details.", observations: apiStatus }, { @@ -3588,10 +3614,142 @@ export async function runDevCloudWorkbenchMobileSmoke() { } } +export async function runM3StatusFixtureSmoke() { + let chromium; + try { + ({ chromium } = await importPlaywright()); + } catch (error) { + const summary = `M3 status fixture smoke skipped because Playwright is unavailable: ${error.message}`; + return { + status: "skip", + task: "DC-DCSN-P0-2026-003", + mode: "m3-status-fixture", + evidenceLevel: "SOURCE", + devLive: false, + summary, + checks: [ + { + id: "m3-status-browser-dependency", + status: "skip", + summary: playwrightDependencySummary, + evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"] + } + ], + blockers: [ + { + type: "environment_blocker", + scope: "m3-status-browser-dependency", + status: "open", + summary + } + ], + safety: staticSafety() + }; + } + + const m3State = { + doValue: false, + diValue: false, + observedAt: "2026-05-23T00:00:00.000Z", + sequence: 1 + }; + const server = await startStaticWebServer({ m3StatusFixture: true, m3State }); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const desktop = await inspectM3StatusFixtureViewport(browser, server.url, { + width: 1366, + height: 768, + expectedRightWidthMin: 560 + }); + const mobile = await inspectM3StatusFixtureViewport(browser, server.url, { + width: 390, + height: 844, + expectedRightWidthMin: 300 + }); + const refresh = await inspectM3StatusFixtureRefresh(browser, server.url); + const checks = [ + { + id: "m3-status-fixture-desktop", + status: desktop.pass ? "pass" : "blocked", + summary: "Desktop hardware workspace reads /v1/m3/status, exposes tabs/Key-Value state, and has no outer or horizontal scroll.", + observations: desktop + }, + { + id: "m3-status-fixture-mobile", + status: mobile.pass ? "pass" : "blocked", + summary: "390x844 hardware workspace wraps tabs, preserves Key-Value content, and avoids horizontal overflow.", + observations: mobile + }, + { + id: "m3-status-fixture-refresh", + status: refresh.pass ? "pass" : "blocked", + summary: "DO1 false/true writes refresh through same-origin cloud-api status and DI1 follows through patch-panel.", + observations: refresh + } + ]; + const blockers = checks + .filter((check) => check.status !== "pass") + .map((check) => ({ + type: "runtime_blocker", + scope: check.id, + status: "open", + summary: check.summary + })); + return { + status: blockers.length === 0 ? "pass" : "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "m3-status-fixture", + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: "Local SOURCE fixture browser smoke validates the M3 status UI contract; it is not DEV-LIVE hardware evidence.", + refs: ["pikasTech/HWLAB#287", "pikasTech/HWLAB#273", "pikasTech/HWLAB#278"], + checks, + blockers, + safety: { + ...staticSafety(), + devLive: false, + sourceIsDevLive: false, + hardwareWriteApis: false, + statement: "Fixture smoke intercepts only same-origin cloud-api routes on a local static server; it never contacts gateway-simu, box-simu, patch-panel, DEV, or PROD." + } + }; + } catch (error) { + return { + status: "blocked", + task: "DC-DCSN-P0-2026-003", + mode: "m3-status-fixture", + url: server.url, + generatedAt: new Date().toISOString(), + evidenceLevel: "SOURCE", + devLive: false, + summary: `M3 status fixture smoke failed: ${error.message}`, + checks: [], + blockers: [ + { + type: "runtime_blocker", + scope: "m3-status-fixture", + status: "open", + summary: error.message + } + ], + safety: staticSafety() + }; + } finally { + if (browser) await browser.close(); + await server.close(); + } +} + async function startStaticWebServer(options = {}) { const rootDir = path.resolve(options.rootDir ?? webRoot); const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) { + return; + } if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { return; } @@ -3625,6 +3783,219 @@ async function startStaticWebServer(options = {}) { }; } +async function handleM3StatusFixtureApi({ request, response, url, options = {} }) { + if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { + jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State)); + return true; + } + if (request.method === "GET" && url.pathname === "/v1/m3/io") { + jsonResponse(response, 200, { + status: "available", + route: "/v1/m3/io", + readiness: { + status: "ready", + controlReady: true, + sourceKind: "DEV-LIVE", + evidenceLevel: "DEV-LIVE" + } + }); + return true; + } + if (request.method === "GET" && url.pathname === "/v1/m3/status") { + jsonResponse(response, 200, m3StatusFixturePayload(options.m3State)); + return true; + } + if (request.method === "POST" && url.pathname === "/v1/m3/io") { + const body = await readJsonBody(request); + if (body?.action === "do.write") { + options.m3State.doValue = body.value === true; + options.m3State.diValue = body.value === true; + options.m3State.sequence += 1; + options.m3State.observedAt = `2026-05-23T00:00:0${Math.min(options.m3State.sequence, 9)}.000Z`; + } + jsonResponse(response, 200, { + status: "succeeded", + accepted: true, + 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 + }, + operationId: `op_fixture_${options.m3State.sequence}`, + traceId: `trc_fixture_${options.m3State.sequence}`, + auditId: null, + evidenceId: null, + evidenceState: { + status: "blocked", + reason: "runtime_durable_adapter_auth_blocked" + }, + controlPath: { + frontendBypass: false + } + }); + return true; + } + if (request.method === "POST" && url.pathname === "/json-rpc") { + const body = await readJsonBody(request); + jsonResponse(response, 200, { + jsonrpc: "2.0", + id: body?.id ?? "req_m3_status_fixture", + result: localRpcFixtureResult(body?.method) + }); + return true; + } + return false; +} + +function m3StatusFixtureHealth(m3State = { sequence: 1 }) { + return { + serviceId: "hwlab-cloud-api", + status: "degraded", + ready: false, + methods: [...readOnlyRpcMethods], + runtime: { + durable: true, + ready: false, + liveRuntimeEvidence: false, + status: "blocked", + blocker: "runtime_durable_adapter_auth_blocked", + connection: { + queryAttempted: true, + queryResult: "auth_blocked" + } + }, + readiness: { + status: "degraded", + ready: false, + durability: { + status: "blocked", + ready: false, + blocker: "runtime_durable_adapter_auth_blocked", + blockedLayer: "durability_auth", + queryResult: "auth_blocked" + } + }, + blockerCodes: ["runtime_durable_adapter_auth_blocked"], + m3FixtureSequence: m3State.sequence + }; +} + +function m3StatusFixturePayload(m3State = { doValue: false, diValue: false, observedAt: "2026-05-23T00:00:00.000Z", sequence: 1 }) { + const observedAt = m3State.observedAt; + return { + serviceId: "hwlab-cloud-api", + contractVersion: "m3-status-v1", + route: "/v1/m3/status", + status: "blocked", + sourceKind: "DEV-LIVE", + observedAt, + chain: { + projectId: gateSummary.topology.projectId, + sourceGatewayId: "gwsimu_1", + sourceGatewaySessionId: "gws_gwsimu_1", + sourceResourceId: "res_boxsimu_1", + sourceBoxId: "boxsimu_1", + sourcePort: "DO1", + targetGatewayId: "gwsimu_2", + targetGatewaySessionId: "gws_gwsimu_2", + targetResourceId: "res_boxsimu_2", + targetBoxId: "boxsimu_2", + targetPort: "DI1", + patchPanelServiceId: "hwlab-patch-panel" + }, + boundaries: { + frontendCallsOnly: "/v1/m3/status", + directFrontendGatewayOrBoxAccess: false, + sourceTopologyFallbackMustStayUnverified: true + }, + gateways: [ + { + id: "gwsimu_1", + role: "source", + online: true, + observable: true, + sessionId: "gws_gwsimu_1", + lastSeenAt: observedAt, + sourceKind: "DEV-LIVE", + lastError: null + }, + { + id: "gwsimu_2", + role: "target", + online: true, + observable: true, + sessionId: "gws_gwsimu_2", + lastSeenAt: observedAt, + sourceKind: "DEV-LIVE", + lastError: null + } + ], + boxes: [ + { + id: "boxsimu_1", + resourceId: "res_boxsimu_1", + gatewayId: "gwsimu_1", + gatewaySessionId: "gws_gwsimu_1", + online: true, + observable: true, + sourceKind: "DEV-LIVE", + observedAt, + ports: { + DO1: { + value: m3State.doValue, + direction: "output", + source: "gateway-simu", + sourceKind: "DEV-LIVE", + observedAt + } + } + }, + { + id: "boxsimu_2", + resourceId: "res_boxsimu_2", + gatewayId: "gwsimu_2", + gatewaySessionId: "gws_gwsimu_2", + online: true, + observable: true, + sourceKind: "DEV-LIVE", + observedAt, + ports: { + DI1: { + value: m3State.diValue, + direction: "input", + source: "gateway-simu/patch-panel", + sourceKind: "DEV-LIVE", + observedAt + } + } + } + ], + patchPanel: { + serviceId: "hwlab-patch-panel", + observable: true, + connectionActive: true, + connectionVersion: m3State.sequence, + wiringConfigId: "wir_m3_do1_di1", + lastSyncAt: observedAt, + lastError: null, + sourceKind: "DEV-LIVE" + }, + trust: { + operationId: `op_fixture_${m3State.sequence}`, + traceId: `trc_fixture_${m3State.sequence}`, + auditId: null, + evidenceId: null, + durableStatus: "blocked", + blocker: "runtime_durable_adapter_auth_blocked" + }, + blocker: { + code: "runtime_durable_adapter_auth_blocked", + layer: "runtime-durable", + zh: "runtime durable 仍 blocked:runtime_durable_adapter_auth_blocked" + } + }; +} + async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) { if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) { jsonResponse(response, 200, { @@ -3715,6 +4086,160 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} return false; } +async function inspectM3StatusFixtureViewport(browser, url, viewport) { + const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } }); + const requests = []; + try { + page.on("request", (request) => { + const requestUrl = new URL(request.url()); + requests.push({ + method: request.method(), + pathname: requestUrl.pathname, + href: request.url() + }); + }); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); + await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 }); + for (const tab of ["gateways", "box1", "box2", "patch", "overview"]) { + await page.locator(`[data-hardware-tab="${tab}"]`).click(); + } + const dom = await page.evaluate(() => { + const text = document.body.textContent ?? ""; + const hardwareText = document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; + const right = document.querySelector(".right-sidebar"); + const hardwareList = document.querySelector("#hardware-list"); + const tabs = [...document.querySelectorAll("[data-hardware-tab]")]; + const tabRows = new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size; + const tabLabels = tabs.map((tab) => tab.textContent?.trim() ?? ""); + const visible = (selector) => { + const element = document.querySelector(selector); + if (!element) return false; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + return { + hardwareStatus: document.querySelector("#hardware-source-status")?.textContent?.trim() ?? "", + liveDetail: document.querySelector("#live-detail")?.textContent?.trim() ?? "", + hardwareText, + tabLabels, + tabRows, + rightWidth: right?.getBoundingClientRect().width ?? 0, + rightScrollWidth: right?.scrollWidth ?? 0, + rightClientWidth: right?.clientWidth ?? 0, + hardwareScrollWidth: hardwareList?.scrollWidth ?? 0, + hardwareClientWidth: hardwareList?.clientWidth ?? 0, + htmlScrollWidth: document.documentElement.scrollWidth, + htmlClientWidth: document.documentElement.clientWidth, + bodyScrollWidth: document.body.scrollWidth, + bodyClientWidth: document.body.clientWidth, + outerScrollLocked: + getComputedStyle(document.documentElement).overflow === "hidden" && + getComputedStyle(document.body).overflow === "hidden" && + document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && + document.body.scrollHeight <= document.body.clientHeight + 2, + keysVisible: ["状态", "Gateway 在线", "BOX 在线", "DO1", "DI1", "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: + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && + 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"]') + }; + }); + const directRuntimeRequests = requests.filter((request) => /gateway-simu|box-simu|patch-panel|:7101|:7201|:7301/iu.test(request.href)); + const pass = + dom.hardwareStatus === "实况受阻" && + dom.tabsVisible && + dom.keysVisible && + dom.blockedVisible && + dom.outerScrollLocked && + dom.noHorizontalOverflow && + dom.boxTabClickable && + dom.rightWidth >= viewport.expectedRightWidthMin && + requests.some((request) => request.method === "GET" && request.pathname === "/v1/m3/status") && + directRuntimeRequests.length === 0; + return { + viewport: { width: viewport.width, height: viewport.height }, + pass, + ...dom, + statusRequests: requests.filter((request) => request.pathname === "/v1/m3/status").length, + directRuntimeRequests + }; + } finally { + await page.close(); + } +} + +async function inspectM3StatusFixtureRefresh(browser, url) { + const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const requests = []; + try { + page.on("request", (request) => { + const requestUrl = new URL(request.url()); + requests.push({ + method: request.method(), + pathname: requestUrl.pathname, + href: request.url() + }); + }); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); + await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 }); + await page.locator('[data-hardware-tab="box1"]').click(); + const initialBox1 = await hardwarePanelText(page); + await page.locator("#m3-value-select").selectOption("true"); + 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); + 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); + await page.locator("#m3-value-select").selectOption("false"); + await page.locator("#m3-write-do").click(); + await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DI1false"), null, { timeout: 8000 }); + await page.locator('[data-hardware-tab="box1"]').click(); + await page.waitForFunction(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, "").includes("DO1false"), null, { timeout: 8000 }); + const falseBox1 = await hardwarePanelText(page); + 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 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)); + const blockedStillVisible = await page.evaluate(() => document.body.textContent?.includes("runtime_durable_adapter_auth_blocked") === true && !document.body.textContent?.includes("实况可信")); + const pass = + initialBox1.includes("DO1false") && + trueBox1.includes("DO1true") && + trueBox2.includes("DI1true") && + falseBox1.includes("DO1false") && + falseBox2.includes("DI1false") && + statusRequests >= 3 && + ioPosts === 2 && + directRuntimeRequests.length === 0 && + blockedStillVisible; + return { + pass, + initialBox1, + trueBox1, + trueBox2, + falseBox1, + falseBox2, + statusRequests, + ioPosts, + directRuntimeRequests, + blockedStillVisible + }; + } finally { + await page.close(); + } +} + +async function hardwarePanelText(page) { + return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); +} + function delay(ms) { const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0; return new Promise((resolve) => setTimeout(resolve, normalized)); @@ -4029,7 +4554,12 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { ["#tab-wiring", "M3 接线标签"], ["#tab-records", "可信记录标签"], ["#m3-write-do", "写入 DO1"], - ["#m3-read-di", "读取 DI1"] + ["#m3-read-di", "读取 DI1"], + ["[data-hardware-tab='overview']", "硬件总览标签"], + ["[data-hardware-tab='gateways']", "Gateway-SIMU 标签"], + ["[data-hardware-tab='box1']", "BOX-SIMU-1 标签"], + ["[data-hardware-tab='box2']", "BOX-SIMU-2 标签"], + ["[data-hardware-tab='patch']", "Patch Panel 标签"] ]; const overflowSelectors = [ "body", @@ -4074,10 +4604,11 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { visibility: style.visibility, overflow: style.overflow, overflowY: style.overflowY, + overflowX: style.overflowX, + scrollHeight: element.scrollHeight, clientWidth: element.clientWidth, clientHeight: element.clientHeight, scrollWidth: element.scrollWidth, - scrollHeight: element.scrollHeight, text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? "" }; }; @@ -4211,6 +4742,8 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { const shell = document.querySelector("[data-app-shell]"); const toggleElement = document.querySelector("#explorer-toggle"); + const isDesktop = viewport.width >= 861; + const isMobile = !isDesktop; const boxes = { shell: boxFor("[data-app-shell]"), rail: boxFor(".activity-rail"), @@ -4228,6 +4761,26 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { controlList: boxFor("#control-list"), topbar: boxFor(".topbar") }; + const noHorizontalOverflow = { + html: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2, + body: document.body.scrollWidth <= document.body.clientWidth + 2, + shell: boxes.shell ? boxes.shell.scrollWidth <= boxes.shell.clientWidth + 2 : false, + right: boxes.right ? boxes.right.scrollWidth <= boxes.right.clientWidth + 2 : false, + hardwareList: (() => { + const element = document.querySelector("#hardware-list"); + return element ? element.scrollWidth <= element.clientWidth + 2 : false; + })() + }; + const hardwareTabRows = (() => { + const tabs = [...document.querySelectorAll("[data-hardware-tab]")]; + return new Set(tabs.map((tab) => Math.round(tab.getBoundingClientRect().top))).size; + })(); + const rightPanelStacked = Boolean(boxes.right && boxes.center && boxes.right.top >= boxes.center.bottom - 2); + const rightWidthTarget = isDesktop + ? rightPanelStacked + ? boxes.right?.width >= 560 && boxes.right?.width <= viewport.width + : boxes.right?.width >= (viewport.width >= 1500 ? 700 : 560) && boxes.right?.width <= 760 + : boxes.right?.width <= viewport.width && boxes.right?.width >= Math.max(0, viewport.width - 80); const rootAfterScrollAttempt = { windowScrollY: window.scrollY, htmlScrollTop: document.documentElement.scrollTop, @@ -4259,8 +4812,6 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { boxes.explorer.width > 1 && boxes.explorer.height > 1 ); - const isDesktop = viewport.width >= 861; - const isMobile = !isDesktop; const requireKeyTargets = !(isMobile && mode === "mobile-drawer"); if (requireKeyTargets) { for (const target of blockedKeyTargets) { @@ -4564,6 +5115,16 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { sidePanelScroll }); } + if (!rightWidthTarget) { + failures.push({ + failureType: "overflow", + selector: ".right-sidebar", + viewport, + summary: "M3 hardware status workspace width is outside the expected desktop, stacked desktop, or mobile boundary.", + rightPanelStacked, + rightBox: boxes.right + }); + } const issue287HardwareTabsReady = ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((label) => Boolean([...document.querySelectorAll(".hardware-status button, .hardware-status [role='tab'], .hardware-status .side-tab")].find((item) => item.textContent?.includes(label))) ); @@ -4580,6 +5141,8 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { noIncoherentOverlap && semanticOverlapChecks.every((check) => !check.overlaps) && overflowOk && + Object.values(noHorizontalOverflow).every(Boolean) && + rightWidthTarget && keyTargetsRequirement && collapsedStateMatches && explorerStateMatches && @@ -4606,7 +5169,12 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { rightWidthDelta, collapsedWidthGain, restoredWidth, + rightPanelStacked, overlapChecks, + noHorizontalOverflow, + horizontalOverflowFree: Object.values(noHorizontalOverflow).every(Boolean), + hardwareTabRows, + rightWidthTarget, noIncoherentOverlap, keyTargetsReachable, keyTargetsRequirement, diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 9eb95a4c..8e126469 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -333,6 +333,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", "/v1", "/v1/agent/chat", "/v1/m3/io", + "/v1/m3/status", "/json-rpc" ]); @@ -361,8 +362,8 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", if (!/fetchJson\("\/v1\/m3\/io"/u.test(actionBody)) { issues.push("runM3IoAction must POST through same-origin /v1/m3/io"); } - if (/fetchJson\(["'`](?!\/v1\/m3\/io)/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) { - issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\")"); + if (/fetchJson\(["'`](?!\/v1\/m3\/(?:io|status))/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) { + issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\") or fetchJson(\"/v1/m3/status\")"); } if (!/if \(!m3ControlCanOperate\(\)\)/u.test(actionBody)) { issues.push("runM3IoAction must fail closed before POST when M3 readiness is blocked"); @@ -381,6 +382,9 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) { issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api"); } + if (!/\/v1\/m3\/status/u.test(artifactPublisherSource)) { + issues.push("cloud-web artifact server must proxy same-origin /v1/m3/status to cloud-api"); + } return { status: issues.length === 0 ? "pass" : "fail", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 3fefdd5b..4634e177 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -37,6 +37,7 @@ const STATUS_LABELS = Object.freeze({ sent: "已发送", requires: "依赖", source: "来源", + unverified: "未验证", "dry-run": "演练", "dev-live": "实况已验证" }); @@ -111,6 +112,8 @@ const state = { liveSurface: null, m3Control: { contract: null, + status: null, + hardwareTab: "overview", operation: null, pending: false } @@ -120,6 +123,7 @@ initRoutes(); initExplorerToggle(); syncMobileExplorer(); initSideTabs(); +initHardwareTabs(); initQuickActions(); initCommandBar(); initM3Control(); @@ -277,6 +281,25 @@ function selectSideTab(tabId) { } } +function initHardwareTabs() { + for (const tab of document.querySelectorAll("[data-hardware-tab]")) { + tab.addEventListener("click", () => { + state.m3Control.hardwareTab = tab.dataset.hardwareTab; + syncHardwareTabs(); + renderHardwareStatus(state.m3Control.status); + }); + } + syncHardwareTabs(); +} + +function syncHardwareTabs() { + for (const tab of document.querySelectorAll("[data-hardware-tab]")) { + const active = tab.dataset.hardwareTab === state.m3Control.hardwareTab; + tab.classList.toggle("active", active); + tab.setAttribute("aria-selected", active ? "true" : "false"); + } +} + function initCommandBar() { el.commandForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -466,10 +489,11 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI async function loadLiveSurface() { const projectId = gateSummary.topology.projectId; - const [healthLive, restIndex, m3Control, health, adapter, audit, evidence] = await Promise.all([ + const [healthLive, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ fetchJson("/health/live"), fetchJson("/v1"), fetchJson("/v1/m3/io"), + fetchJson("/v1/m3/status"), callRpc("system.health"), callRpc("cloud.adapter.describe"), callRpc("audit.event.query", { projectId, limit: 6 }), @@ -481,6 +505,7 @@ async function loadLiveSurface() { healthLive, restIndex, m3Control, + m3Status, health, adapter, audit, @@ -666,10 +691,14 @@ async function runM3IoAction(action) { sourceKind: response.data?.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED" }; } + const refreshedStatus = await fetchJson("/v1/m3/status"); + if (refreshedStatus.ok) { + state.m3Control.status = refreshedStatus.data; + } state.m3Control.pending = false; renderM3ControlStatus(); - renderHardwareStatus(runtimeSummaryFrom(state.liveSurface)); - renderWiringList(runtimeSummaryFrom(state.liveSurface)); + renderHardwareStatus(state.m3Control.status); + renderWiringList(state.m3Control.status); renderRecords(state.liveSurface); renderDrafts(); } @@ -682,6 +711,7 @@ function nextProtocolId(prefix) { function renderLiveSurface(live) { state.liveSurface = live; state.m3Control.contract = live.m3Control?.ok ? live.m3Control.data : null; + state.m3Control.status = live.m3Status?.ok ? live.m3Status.data : null; const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); const runtimeSummary = runtimeSummaryFrom(live); @@ -693,8 +723,8 @@ function renderLiveSurface(live) { el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary(); renderAgentChatStatus(deriveAgentChatStatus()); - renderHardwareStatus(runtimeSummary); - renderWiringList(runtimeSummary); + renderHardwareStatus(state.m3Control.status); + renderWiringList(state.m3Control.status ?? runtimeSummary); renderRecords(live); renderDiagnostics(live); renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, surfaceStatus.methodTone); @@ -719,16 +749,16 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res label: "可信记录受阻 / 只读模式", tone: "blocked", methodTone: "degraded", - detail: "同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。" + detail: apiDurableBlockerDetail(coreProbes) }; } if (surface.degraded) { return { - label: "API 降级 / 只读模式", - tone: "degraded", + label: "API 错误 / 只读模式", + tone: "blocked", methodTone: "degraded", - detail: "同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。" + detail: apiProbeErrorDetail(coreProbes) }; } @@ -740,6 +770,33 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res }; } +function apiDurableBlockerDetail(coreProbes = []) { + const payloads = coreProbes + .filter((probe) => probe?.ok) + .map((probe) => probe.data) + .filter((payload) => payload && typeof payload === "object"); + const blocker = safeDurableBlockerLabel(payloads.map(durableRuntimeBlockerCode).find(Boolean)); + return `/health/live 可响应,但可信记录持久化尚未就绪:${blocker};当前只保留只读查看和本地任务整理。`; +} + +function safeDurableBlockerLabel(blocker) { + const normalized = normalizedStatusValue(blocker); + if (normalized.includes("auth")) return "runtime durable adapter auth blocked"; + if (normalized.includes("schema")) return "runtime durable adapter schema blocked"; + if (normalized.includes("migration")) return "runtime durable adapter migration blocked"; + if (normalized.includes("ssl")) return "runtime durable adapter ssl blocked"; + if (normalized.includes("query")) return "runtime durable adapter query blocked"; + return "runtime durable adapter blocked"; +} + +function apiProbeErrorDetail(coreProbes = []) { + const failedProbe = coreProbes.find((probe) => probe && probe.ok !== true); + if (failedProbe?.error?.message) { + return `同源 API 探测失败:${failedProbe.error.message};当前保持只读查看和本地任务整理。`; + } + return "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。"; +} + function workbenchSurfaceReadiness(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) { const payloads = coreProbes .filter((probe) => probe?.ok) @@ -775,6 +832,7 @@ function readinessBooleanValues(payload) { } function hasDurableRuntimeQueryBlock(payload) { + const legacyQueryBlockerCode = "runtime_durable_adapter_query_blocked"; const codes = [ ...(Array.isArray(payload.blockerCodes) ? payload.blockerCodes : []), ...(Array.isArray(payload.readiness?.blockerCodes) ? payload.readiness.blockerCodes : []), @@ -786,12 +844,34 @@ function hasDurableRuntimeQueryBlock(payload) { const queryResult = normalizedStatusValue(payload.runtime?.connection?.queryResult ?? payload.readiness?.durability?.queryResult); const blockedLayer = normalizedStatusValue(payload.runtime?.durabilityContract?.blockedLayer ?? payload.readiness?.durability?.blockedLayer); return ( - codes.includes("runtime_durable_adapter_query_blocked") || - queryResult === "query_blocked" || - blockedLayer === "durability_query" + codes.includes(legacyQueryBlockerCode) || + codes.some((code) => code.startsWith("runtime_durable_adapter_") && code.endsWith("_blocked")) || + queryResult.endsWith("_blocked") || + blockedLayer === "durability_query" || + blockedLayer === "durability_auth" || + blockedLayer === "durability_schema" || + blockedLayer === "durability_migration" || + blockedLayer === "durability_ssl" ); } +function durableRuntimeBlockerCode(payload) { + if (!payload || typeof payload !== "object") return null; + const candidates = [ + ...(Array.isArray(payload.blockerCodes) ? payload.blockerCodes : []), + ...(Array.isArray(payload.readiness?.blockerCodes) ? payload.readiness.blockerCodes : []), + ...(Array.isArray(payload.blockers) ? payload.blockers.map((item) => item?.code) : []), + ...(Array.isArray(payload.readiness?.blockers) ? payload.readiness.blockers.map((item) => item?.code) : []), + payload.runtime?.blocker, + payload.readiness?.durability?.blocker, + payload.runtime?.connection?.queryResult, + payload.readiness?.durability?.queryResult, + payload.runtime?.durabilityContract?.blockedLayer, + payload.readiness?.durability?.blockedLayer + ]; + return candidates.find((value) => normalizedStatusValue(value).includes("durable") || normalizedStatusValue(value).endsWith("_blocked")) ?? null; +} + function normalizedStatusValue(value) { return String(value ?? "").trim().toLowerCase(); } @@ -948,108 +1028,31 @@ function renderUnblockList() { replaceChildren(el.unblockList, ...rows.map(infoCard)); } -function renderHardwareStatus(summary) { - const counts = summary?.counts ?? null; - const patchPanel = gateSummary.topology.patchPanel; - const sourceLink = trustedM3Link(patchPanel.activeConnections); - const liveM3Evidence = trustedM3LiveEvidenceFrom(summary) ?? m3ControlLiveEvidence(); - const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence); - const controlReady = m3ControlCanOperate(); - const runtimeCountsSourceKind = "SOURCE"; - const runtimeCountsTone = "source"; - const patchTone = m3Live ? "live" : patchPanel.state === "active" ? "source" : patchPanel.state; - const linkTone = m3Live ? "live" : sourceLink || patchPanel.activeConnectionCount > 0 ? "degraded" : "blocked"; +function renderHardwareStatus(m3Status) { + syncHardwareTabs(); + const status = m3Status ?? sourceFallbackM3Status(); + const sourceKind = status.sourceKind ?? "UNVERIFIED"; + const trustedGreen = status.trust?.durableStatus === "green"; + const statusTone = status.status === "live" && trustedGreen + ? "dev-live" + : status.status === "error" || status.status === "blocked" + ? "blocked" + : sourceKind === "DEV-LIVE" + ? "dev-live" + : "source"; - el.hardwareSourceStatus.textContent = m3Live ? "实况已验证" : "只读核对"; - el.hardwareSourceStatus.className = `state-tag tone-${toneClass(m3Live ? "dev-live" : "source")}`; + el.hardwareSourceStatus.textContent = hardwareStatusLabel(status); + el.hardwareSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`; - replaceChildren( - el.hardwareList, - hardwareGroup({ - title: "Gateway/Box 在线", - rows: [ - { - label: "Gateway-SIMU", - value: counts ? `${counts.gatewaySessions ?? 0} 会话` : "离线查看", - detail: counts - ? `${counts.gatewaySessions ?? 0} 个 Gateway 会话来自只读运行计数;仅用于资源核对。` - : `${gateSummary.gatewaySessionCount} 个 Gateway 会话来自内置拓扑。`, - sourceKind: runtimeCountsSourceKind, - tone: runtimeCountsTone - }, - { - label: "BOX-SIMU", - value: counts ? `${counts.boxResources ?? 0} 资源` : "离线查看", - detail: counts - ? `${counts.boxResources ?? 0} 个 BOX 资源来自只读运行计数;仅用于资源核对。` - : `${gateSummary.boxResourceCount} 个 BOX 资源来自内置拓扑。`, - sourceKind: runtimeCountsSourceKind, - tone: runtimeCountsTone - } - ] - }), - hardwareGroup({ - title: "BOX-SIMU 端口", - rows: [ - { - label: "DO1", - value: m3Live ? "已验证" : "待接线", - detail: m3Live ? m3LiveDetail(liveM3Evidence) : "尚未看到 DO1 电平的完整可信记录。", - sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED", - tone: m3Live ? "live" : "blocked" - }, - { - label: "DI1", - value: m3Live ? "已验证" : "待回读", - detail: m3Live ? m3LiveDetail(liveM3Evidence) : "尚未看到 DI1 回读的完整可信记录。", - sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED", - tone: m3Live ? "live" : "blocked" - }, - { - label: "AI/AO/FREQ", - value: "后续能力", - detail: "当前未声明模拟量或频率闭环,按后续能力处理。", - sourceKind: "SOURCE", - tone: "disabled" - } - ] - }), - hardwareGroup({ - title: "Patch Panel 连线", - rows: [ - { - label: "hwlab-patch-panel", - value: statusLabel(patchPanel.state), - detail: m3Live - ? `接线盘实况记录 ${liveM3Evidence.patchPanelLiveReport.reportId ?? liveM3Evidence.patchPanelLiveReport.patchPanelStatusId} 已绑定目标链路。` - : `${patchPanel.activeConnectionCount} 条内置接线记录;工作台只展示目标链路,未升级为可信闭环证据。`, - sourceKind: m3Live ? "DEV-LIVE" : "SOURCE", - tone: patchTone - }, - { - label: "DO1 -> patch-panel -> DI1", - value: linkStatusLabel(linkTone), - detail: m3LinkDetail({ liveM3Evidence, sourceLink, patchPanel, counts }), - sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED", - tone: linkTone - } - ] - }), - hardwareGroup({ - title: "写入边界", - rows: [ - { - label: "硬件控制", - value: controlReady ? "受控路径" : "受控路径受阻", - detail: controlReady - ? "控制只通过受控后端执行,并经由 gateway-simu、box-simu 与 patch-panel;页面不直连模拟器,也不伪造硬件状态。" - : `${m3ControlBlockedReason()} 页面不直连模拟器,也不伪造硬件状态。`, - sourceKind: controlReady ? "SOURCE" : "BLOCKED", - tone: controlReady ? "source" : "blocked" - } - ] - }) - ); + const groups = { + overview: hardwareOverviewGroups(status), + gateways: hardwareGatewayGroups(status), + box1: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.fromResourceId), + box2: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.toResourceId), + patch: hardwarePatchPanelGroups(status) + }; + // Tab bodies are compact Key/Value groups; SOURCE fallback stays unverified. + replaceChildren(el.hardwareList, ...(groups[state.m3Control.hardwareTab] ?? groups.overview)); } function hardwareGroup(group) { @@ -1076,6 +1079,230 @@ function hardwareRow(item) { return row; } +function hardwareStatusLabel(status) { + if (!status) return "未验证"; + if (status.status === "live" && status.trust?.durableStatus === "green") return "实况可信"; + if (status.status === "error") return "错误"; + if (status.status === "blocked") return "实况受阻"; + if (status.sourceKind === "DEV-LIVE") return "DEV-LIVE 状态"; + return "未验证"; +} + +function sourceFallbackM3Status() { + const observedAt = new Date().toISOString(); + return { + status: "unverified", + sourceKind: "SOURCE", + observedAt, + chain: { + sourceGatewayId: "gwsimu_1", + sourceGatewaySessionId: "gws_gwsimu_1", + sourceResourceId: M3_TRUSTED_ROUTE.fromResourceId, + sourceBoxId: "boxsimu_1", + sourcePort: M3_TRUSTED_ROUTE.fromPort, + targetGatewayId: "gwsimu_2", + targetGatewaySessionId: "gws_gwsimu_2", + targetResourceId: M3_TRUSTED_ROUTE.toResourceId, + targetBoxId: "boxsimu_2", + targetPort: M3_TRUSTED_ROUTE.toPort, + patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId + }, + gateways: [ + { + id: "gwsimu_1", + role: "source", + online: false, + sessionId: "gws_gwsimu_1", + sourceKind: "SOURCE", + lastSeenAt: observedAt, + lastError: "等待 cloud-api /v1/m3/status 聚合读取" + }, + { + id: "gwsimu_2", + role: "target", + online: false, + sessionId: "gws_gwsimu_2", + sourceKind: "SOURCE", + lastSeenAt: observedAt, + lastError: "等待 cloud-api /v1/m3/status 聚合读取" + } + ], + boxes: [ + sourceFallbackBox("boxsimu_1", M3_TRUSTED_ROUTE.fromResourceId, "gwsimu_1", "gws_gwsimu_1", "DO1", "output", observedAt), + sourceFallbackBox("boxsimu_2", M3_TRUSTED_ROUTE.toResourceId, "gwsimu_2", "gws_gwsimu_2", "DI1", "input", observedAt) + ], + patchPanel: { + serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId, + connectionActive: false, + connectionVersion: null, + wiringConfigId: gateSummary.topology.patchPanel.wiringConfigId ?? null, + lastSyncAt: observedAt, + lastError: "SOURCE 拓扑 fallback,未验证 DEV-LIVE 接线盘状态", + sourceKind: "SOURCE" + }, + trust: { + operationId: null, + traceId: null, + auditId: null, + evidenceId: null, + durableStatus: "blocked", + blocker: "unverified_cloud_api_m3_status" + }, + blocker: { + code: "unverified_cloud_api_m3_status", + layer: "cloud-api", + zh: "等待 cloud-api /v1/m3/status 聚合读取;SOURCE 拓扑不能冒充实况硬件状态。" + } + }; +} + +function sourceFallbackBox(id, resourceId, gatewayId, gatewaySessionId, port, direction, observedAt) { + return { + id, + resourceId, + gatewayId, + gatewaySessionId, + online: false, + sourceKind: "SOURCE", + observedAt, + ports: { + [port]: { + value: null, + direction, + source: "SOURCE fallback", + sourceKind: "SOURCE", + observedAt, + lastError: "未验证" + } + } + }; +} + +function hardwareOverviewGroups(status) { + const box1 = findM3Box(status, M3_TRUSTED_ROUTE.fromResourceId); + const box2 = findM3Box(status, M3_TRUSTED_ROUTE.toResourceId); + const do1 = box1?.ports?.DO1; + const di1 = box2?.ports?.DI1; + const patch = status.patchPanel ?? {}; + const trust = status.trust ?? {}; + 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"], + ["链路", `${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)], + ["patchPanel connectionActive", 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"] + ]), + hardwareKvGroup("可信证据", [ + ["operationId", trust.operationId ?? "未持久化", trust.operationId ? "source" : "blocked"], + ["traceId", trust.traceId ?? "未持久化", trust.traceId ? "source" : "blocked"], + ["auditId", trust.auditId ?? "未持久化", trust.auditId ? "source" : "blocked"], + ["evidenceId", trust.evidenceId ?? "未持久化", trust.evidenceId ? "source" : "blocked"] + ]) + ]; +} + +function hardwareGatewayGroups(status) { + return (status.gateways ?? []).map((gateway) => + hardwareKvGroup(`${gateway.id} / ${gateway.role}`, [ + ["online", String(Boolean(gateway.online)), gateway.online ? "live" : "blocked"], + ["sessionId", gateway.sessionId ?? "未验证", gateway.sessionId ? "source" : "blocked"], + ["lastSeenAt", gateway.lastSeenAt ?? status.observedAt ?? "未验证", gateway.online ? "live" : "source"], + ["source", gateway.sourceKind ?? "UNVERIFIED", sourceKindTone(gateway.sourceKind)], + ["lastError", gateway.lastError ?? "none", gateway.lastError ? "blocked" : "source"] + ]) + ); +} + +function hardwareBoxGroups(status, resourceId) { + const box = findM3Box(status, resourceId); + const portEntries = Object.entries(box?.ports ?? {}); + return [ + hardwareKvGroup(box ? `${box.id} / ${box.resourceId}` : resourceId, [ + ["online", String(Boolean(box?.online)), box?.online ? "live" : "blocked"], + ["gateway", `${box?.gatewayId ?? "未验证"} / ${box?.gatewaySessionId ?? "未验证"}`, box?.gatewayId ? "source" : "blocked"], + ["更新时间", box?.observedAt ?? status.observedAt ?? "未验证", sourceKindTone(box?.sourceKind)], + ["来源", box?.sourceKind ?? "UNVERIFIED", sourceKindTone(box?.sourceKind)], + ...portEntries.map(([port, portState]) => [port, valueLabel(portState), portTone(portState)]), + ["AI1", "DISABLED 后续能力", "disabled"], + ["AO1", "DISABLED 后续能力", "disabled"], + ["FREQ", "DISABLED 后续能力", "disabled"] + ]) + ]; +} + +function hardwarePatchPanelGroups(status) { + const patch = status.patchPanel ?? {}; + return [ + hardwareKvGroup("hwlab-patch-panel", [ + ["serviceId", patch.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId, "source"], + ["connectionActive", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"], + ["connectionVersion", patch.connectionVersion ?? "未验证", patch.connectionVersion !== null && patch.connectionVersion !== undefined ? "source" : "blocked"], + ["wiringConfigId", patch.wiringConfigId ?? "未验证", patch.wiringConfigId ? "source" : "blocked"], + ["lastSyncAt", patch.lastSyncAt ?? status.observedAt ?? "未验证", patch.connectionActive ? "live" : "source"], + ["lastError", patch.lastError ?? "none", patch.lastError ? "blocked" : "source"], + ["来源", patch.sourceKind ?? "UNVERIFIED", sourceKindTone(patch.sourceKind)] + ]) + ]; +} + +function hardwareKvGroup(title, rows) { + const article = document.createElement("article"); + article.className = "hardware-section hardware-kv-section"; + article.append(textSpan(title, "hardware-section-title")); + const list = document.createElement("dl"); + list.className = "kv-list"; + for (const [key, value, tone = "source"] of rows) { + const row = document.createElement("div"); + row.className = `kv-row tone-border-${toneClass(tone)}`; + row.append(textSpan(key, "kv-key"), textSpan(value, `kv-value tone-${toneClass(tone)}`)); + list.append(row); + } + article.append(list); + return article; +} + +function findM3Box(status, resourceId) { + return (status?.boxes ?? []).find((box) => box.resourceId === resourceId) ?? null; +} + +function onlineCount(items = []) { + return items.filter((item) => item?.online === true).length; +} + +function valueLabel(portState) { + if (!portState) return "未验证"; + if (portState.value === null || portState.value === undefined) return portState.lastError ? `未验证:${portState.lastError}` : "未验证"; + return String(portState.value); +} + +function portTone(portState) { + if (!portState) return "blocked"; + if (portState.sourceKind === "DEV-LIVE") return "live"; + if (portState.sourceKind === "SOURCE") return "source"; + return "blocked"; +} + +function statusTone(status) { + if (status.status === "live") return "dev-live"; + if (status.status === "error" || status.status === "blocked") return "blocked"; + if (status.sourceKind === "DEV-LIVE") return "live"; + return "source"; +} + +function sourceKindTone(sourceKind) { + const normalized = String(sourceKind ?? "").toUpperCase(); + if (normalized === "DEV-LIVE") return "live"; + if (normalized === "SOURCE") return "source"; + if (normalized === "BLOCKED") return "blocked"; + return "blocked"; +} + function trustedM3Link(connections) { return (connections ?? []).find( (link) => @@ -1316,7 +1543,7 @@ function controlRows() { title: "控制路径", detail: controlReady ? "按钮只走受控后端,再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。" - : `${m3ControlBlockedReason()} 不可用时不会直连 gateway/box-simu,也不会伪造硬件状态。`, + : `${m3ControlBlockedReason()} 不可用时页面不直连模拟器,也不伪造硬件状态。`, tone: controlReady ? "source" : "blocked" }, ...operationCards, @@ -1384,14 +1611,18 @@ function renderDrafts() { } function renderWiringList(summary = null) { - const patchPanel = gateSummary.topology.patchPanel; - const liveM3Evidence = trustedM3LiveEvidenceFrom(summary); + const m3Status = summary?.contractVersion === "m3-status-v1" ? summary : null; + const liveM3Evidence = m3Status ? null : trustedM3LiveEvidenceFrom(summary); const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence); + const connectionActive = m3Status?.patchPanel?.connectionActive === true; + const blockedTrust = m3Status?.trust?.blocker; const traceEvidence = m3Live ? [liveM3Evidence.operationId, liveM3Evidence.traceId, liveM3Evidence.auditId, liveM3Evidence.evidenceId].join(" / ") - : "等待可信记录"; - const stateLabel = m3Live ? "实况已验证" : "待可信记录"; - const sourceLabel = m3Live ? "实况可信记录" : "来源拓扑"; + : m3Status + ? [m3Status.trust?.operationId, m3Status.trust?.traceId, m3Status.trust?.auditId, m3Status.trust?.evidenceId].filter(Boolean).join(" / ") || `durable blocked: ${blockedTrust ?? "unverified"}` + : "等待可信记录"; + const stateLabel = m3Live ? "实况已验证" : connectionActive ? "接线实况 / 持久化受阻" : "待可信记录"; + const sourceLabel = m3Live ? "实况可信记录" : m3Status ? "/v1/m3/status 聚合" : "来源拓扑"; const sourceHeader = byId("wiring-source-device"); const targetHeader = byId("wiring-target-device"); diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 574c6187..e4f87639 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -208,40 +208,26 @@
-

运行界面

-

硬件状态:BOX-SIMU / Gateway-SIMU / hwlab-patch-panel

+

M3 实况状态

+

硬件状态工作区

- 只读核对 + 未验证 +
+
+ + + + +
- 只读资源概览 -
-
-
- Gateway/Box 资源 - 来源拓扑 -
- 离线查看 - 等待同源只读数据覆盖;当前展示内置拓扑摘要。 -
-
-
- hwlab-patch-panel - 来源拓扑 -
- 目标接线 - res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 仅为源码兜底显示。 -
-
-
- DO1 -> patch-panel -> DI1 - 待可信记录 -
- 等待可信记录 - 尚未看到完整可信记录,不能作为实况闭环证据。 -
-
+ Key / Value +
+
数据入口
等待 cloud-api /v1/m3/status
+
链路
res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1
+
来源
未验证;拓扑资料只可作为 fallback
+
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 8644a2df..b8d2d3ff 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -141,11 +141,13 @@ for (const legacyEnglishTitle of [ ]) { assert.doesNotMatch(`${html}\n${app}`, legacyEnglishTitle); } -assert.match(html, /硬件状态:BOX-SIMU \/ Gateway-SIMU \/ hwlab-patch-panel/); +assert.match(html, /M3 实况状态/); +assert.match(html, /硬件状态工作区/); assert.match(html, /id="hardware-source-status"/); assert.match(app, /function renderHardwareStatus/); -assert.match(app, /function hardwareGroup/); -assert.match(app, /function hardwareRow/); +assert.match(app, /function initHardwareTabs/); +assert.match(app, /function hardwareKvGroup/); +assert.match(app, /function sourceFallbackM3Status/); assert.match(app, /function trustedM3Link/); assert.match(app, /function hasTrustedM3LiveEvidence/); assert.match(app, /function hasTrustedPatchPanelLiveReport/); @@ -153,9 +155,10 @@ assert.match(app, /function linkStatusLabel/); assert.match(app, /function normalizePort/); assert.match(app, /LIVE_M3_ID_FIELDS/); assert.match(app, /patchPanelLiveReport/); -assert.match(app, /runtimeCountsSourceKind = "SOURCE"/); +assert.match(functionBody(app, "sourceFallbackM3Status"), /unverified_cloud_api_m3_status/); +assert.match(functionBody(app, "sourceFallbackM3Status"), /SOURCE 拓扑不能冒充实况硬件状态/); assert.doesNotMatch(functionBody(app, "renderHardwareStatus"), /hardwareRow\(\s*\{/u); -assert.match(functionBody(app, "hardwareGroup"), /group\.rows\.map\(hardwareRow\)/u); +assert.match(functionBody(app, "renderHardwareStatus"), /Key\/Value groups/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/); @@ -186,28 +189,29 @@ assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*150000/); assert.match(app, /async function sendAgentMessage[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/); assert.match(functionBody(app, "fetchJson"), /timeoutMs\s*=\s*API_TIMEOUT_MS/); for (const hardwareTerm of [ - "Gateway/Box 在线", - "BOX-SIMU 端口", - "Patch Panel 连线", - "DO1 -> patch-panel -> DI1", - "AI/AO/FREQ", - "后续能力", + "Gateway 在线", + "BOX 在线", + "patchPanel connectionActive", + "runtime durable", + "DISABLED 后续能力", "SOURCE", "DEV-LIVE", - "BLOCKED", - "只读运行计数;仅用于资源核对", - "只读运行计数已返回,但还不能证明接线盘闭环", + "unverified_cloud_api_m3_status", + "/v1/m3/status", "仍在等待完整可信记录", "页面不直连模拟器,也不伪造硬件状态。" ]) { assert.match(app, new RegExp(escapeRegExp(hardwareTerm)), `missing hardware term: ${hardwareTerm}`); } +for (const hardwareHtmlTerm of ["Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"]) { + assert.match(html, new RegExp(escapeRegExp(hardwareHtmlTerm)), `missing hardware HTML term: ${hardwareHtmlTerm}`); +} assert.match(app, /function hardwareEvidenceLabel/); -assert.match(app, /dataset\.sourceKind/); assert.match(styles, /\.record-group\s*{/); assert.match(styles, /\.record-group-title\s*{/); assert.match(styles, /\.hardware-section\s*{/); -assert.match(styles, /\.hardware-row\s*{/); +assert.match(styles, /\.hardware-status-tabs\s*{/); +assert.match(styles, /\.kv-row\s*{/); assert.match(html, /Gate \/ 诊断 \/ 验收/); assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/); assert.match(html, /href="\/styles\.css"/); @@ -240,9 +244,9 @@ assert.match(html, />收起资源树<\/button>/); assert.match(app, /setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/); assert.match(app, /textContent = collapsed \? "展开资源树" : "收起资源树"/); assert.match(styles, /--explorer-width:\s*292px/); -assert.match(styles, /--right-width:\s*clamp\(366px,\s*28vw,\s*440px\)/); -assert.match(styles, /--right-width-expanded:\s*clamp\(366px,\s*26vw,\s*410px\)/); -assert.match(styles, /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(560px,\s*1fr\)\s+var\(--right-width\);/s); +assert.match(styles, /--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/); +assert.match(styles, /--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/); +assert.match(styles, /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/s); for (const viewId of ["workspace", "gate"]) { assert.match(html, new RegExp(`data-view="${viewId}"`)); } @@ -345,13 +349,15 @@ assert.match(functionBody(app, "readinessBooleanValues"), /payload\.ready/); assert.match(functionBody(app, "readinessBooleanValues"), /payload\.readiness\?\.ready/); assert.match(functionBody(app, "workbenchSurfaceReadiness"), /readyValues\.includes\(false\)/); assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /runtime_durable_adapter_query_blocked/); +assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /runtime_durable_adapter_/); assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /payload\.runtime\?\.blocker/); assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /payload\.readiness\?\.durability\?\.blocker/); assert.match(functionBody(app, "hasDurableRuntimeQueryBlock"), /blockedLayer === "durability_query"/); assert.match(app, /可信记录受阻 \/ 只读模式/); -assert.match(app, /API 降级 \/ 只读模式/); -assert.match(app, /同源 API 可响应但处于降级状态;当前保持只读查看和本地任务整理。/); -assert.match(app, /同源 API 可响应,但可信记录持久化尚未就绪;当前只保留只读查看和本地任务整理。/); +assert.match(app, /API 错误 \/ 只读模式/); +assert.match(app, /同源 API 返回 blocked\/degraded\/failed 或 ready=false;当前保持只读查看和本地任务整理。/); +assert.match(app, /\/health\/live 可响应,但可信记录持久化尚未就绪:/); +assert.doesNotMatch(app, /API 降级 \/ 只读模式/); assert.doesNotMatch(functionBody(app, "workbenchApiSurfaceStatus"), /runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u); assert.match(html, /method-list/); assert.match(html, /quick-actions/); @@ -365,6 +371,7 @@ assert.doesNotMatch(html, /主流程[\s\S]*添加草稿/u); assert.match(app, /sendAgentMessage/); assert.match(app, /fetchJson\("\/v1\/agent\/chat"/); assert.match(app, /fetchJson\("\/v1\/m3\/io"/); +assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/); assert.match(html, /id="m3-control-form"/); assert.match(html, /id="m3-control-status"/); assert.match(html, />写入 DO1<\/button>/); @@ -375,6 +382,17 @@ assert.match(app, /frontendBypass=false/); assert.match(app, /按钮只走受控后端/); assert.match(app, /页面不直连模拟器,也不伪造硬件状态。/); assert.match(styles, /\.m3-control-form\s*{/); +assert.match(html, /data-hardware-tab="overview"[\s\S]*>总览<\/button>/); +assert.match(html, /data-hardware-tab="gateways"[\s\S]*>Gateway-SIMU<\/button>/); +assert.match(html, /data-hardware-tab="box1"[\s\S]*>BOX-SIMU-1<\/button>/); +assert.match(html, /data-hardware-tab="box2"[\s\S]*>BOX-SIMU-2<\/button>/); +assert.match(html, /data-hardware-tab="patch"[\s\S]*>Patch Panel<\/button>/); +assert.match(app, /function initHardwareTabs/); +assert.match(app, /function hardwareKvGroup/); +assert.match(app, /sourceFallbackM3Status/); +assert.match(styles, /\.hardware-status-tabs\s*\{/); +assert.match(styles, /\.kv-list\s*\{/); +assert.match(styles, /\.right-sidebar \.table-wrap\s*\{[^}]*overflow-x:\s*hidden;/s); assert.match(app, /Code Agent 调用失败/); assert.match(app, /normalizeBlockedAgentResult/); assert.match(app, /isBlockedAgentResponse/); @@ -491,6 +509,7 @@ assert.match(artifactPublisher, /from "node:http"/); assert.doesNotMatch(artifactPublisher, /await fetch\(target/); assert.match(artifactPublisher, /url\.pathname === "\/v1\/agent\/chat"/); assert.match(artifactPublisher, /url\.pathname === "\/v1\/m3\/io"/); +assert.match(artifactPublisher, /url\.pathname === "\/v1\/m3\/status"/); assert.match(artifactPublisher, /"system\.health"/); assert.match(artifactPublisher, /"cloud\.adapter\.describe"/); assert.match(artifactPublisher, /"audit\.event\.query"/); diff --git a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs index a17b2133..e525b9b8 100644 --- a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs +++ b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs @@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url"; import { M3_IO_CHAIN, - M3_IO_CONTROL_ROUTE + M3_IO_CONTROL_ROUTE, + M3_STATUS_ROUTE } from "../../../internal/cloud/m3-io-control.mjs"; import { checkFrontendNoDirectRuntimeCalls } from "../../../scripts/src/m3-io-control-e2e.mjs"; @@ -31,6 +32,7 @@ export function runM3ControlPanelGuard() { const browserSource = `${html}\n${app}\n${styles}`; assert.equal(M3_IO_CONTROL_ROUTE, "/v1/m3/io", "M3 IO control route must remain exact"); + assert.equal(M3_STATUS_ROUTE, "/v1/m3/status", "M3 status aggregate route must remain exact"); assert.equal( exactTrustedRoute, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", @@ -57,7 +59,7 @@ export function runM3ControlPanelGuard() { "M3 IO 只读 readiness 已确认", "受控路径受阻", "等待 cloud-api 返回 M3 IO 只读 readiness", - "不可用时不会直连 gateway/box-simu", + "不可用时页面不直连模拟器", "浏览器不直连 box-simu", "阻塞原因=", "仍在等待完整可信记录", @@ -118,8 +120,9 @@ function assertControlPanelSource(app) { const actionBody = functionBody(app, "runM3IoAction"); assert.match(actionBody, /if \(!m3ControlCanOperate\(\)\)/u, "M3 action must fail closed before POST when readiness is blocked"); assert.match(actionBody, /fetchJson\("\/v1\/m3\/io",\s*\{[\s\S]*method:\s*"POST"/u, "M3 action must POST same-origin /v1/m3/io"); + assert.match(actionBody, /fetchJson\("\/v1\/m3\/status"\)/u, "M3 action may refresh only through same-origin aggregate status after control"); assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch"); - assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/io)/u, "M3 action must not call any route except /v1/m3/io"); + assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/(?:io|status))/u, "M3 action must not call any route except /v1/m3/io and /v1/m3/status"); assert.match(actionBody, /gatewayId:\s*el\.m3GatewaySelect\.value/u, "DO write request must use the selected source gateway"); assert.match(actionBody, /resourceId:\s*el\.m3BoxSelect\.value/u, "DO write request must use the selected source resource"); assert.match(actionBody, /boxId:\s*"boxsimu_1"/u, "DO write request must target source boxsimu_1"); @@ -169,6 +172,7 @@ function assertControlPanelSource(app) { function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) { const browserSource = `${html}\n${app}`; + const sanitizedBrowserSource = browserSource.replace(/data-static-source-fallback="SOURCE"/gu, ""); const frontendGuard = checkFrontendNoDirectRuntimeCalls({ appSource: app, htmlSource: html, @@ -191,6 +195,7 @@ function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) { const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat"); assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route"); + assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "browser hardware status reads must use same-origin /v1/m3/status"); for (const [pattern, label] of [ [/\bcallRpc\(\s*["']hardware\./u, "generic hardware JSON-RPC"], @@ -203,7 +208,7 @@ function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) { [/https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator URL"], [/\/wiring\/apply|\/wiring\/reload|\/signals\/route/u, "patch-panel write endpoint"] ]) { - assert.doesNotMatch(browserSource, pattern, `browser source must not expose ${label}`); + assert.doesNotMatch(sanitizedBrowserSource, pattern, `browser source must not expose ${label}`); } } diff --git a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs index c4b3a42f..c68c330a 100644 --- a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs +++ b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs @@ -14,6 +14,7 @@ const READ_ONLY_RPC_METHODS = Object.freeze([ "evidence.record.query" ]); const CONTROL_ROUTE = "/v1/m3/io"; +const STATUS_ROUTE = "/v1/m3/status"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = path.resolve(rootDir, "../.."); @@ -58,6 +59,7 @@ export function runCloudWebM3ReadonlyContract() { ); assert.match(app, /fetchJson\("\/v1"\)/u, "Cloud Web must read the public REST index"); assert.match(app, /fetchJson\("\/v1\/m3\/io"/u, "Cloud Web M3 control must use only the same-origin cloud-api M3 IO route"); + assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "Cloud Web M3 status must use only the same-origin cloud-api aggregate status route"); assert.match(app, /fetchJson\("\/json-rpc"/u, "Cloud Web must use same-origin JSON-RPC for diagnostics"); assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/u, "Cloud Web must render the observed route contract"); assert.ok(app.includes("frontendBypass=false"), "M3 control evidence must explicitly record no frontend bypass"); @@ -82,7 +84,7 @@ export function runCloudWebM3ReadonlyContract() { const mutationFetches = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,220}?method:\s*["']POST["']/gu)] .map((match) => match[1]) - .filter((path) => path !== "/v1/agent/chat" && path !== CONTROL_ROUTE && path !== "/json-rpc"); + .filter((path) => path !== "/v1/agent/chat" && path !== CONTROL_ROUTE && path !== STATUS_ROUTE && path !== "/json-rpc"); assert.deepEqual(mutationFetches, [], "Cloud Web must not add POST mutation fetches outside Code Agent chat and M3 cloud-api control route"); const m3Milestone = gateSummary.milestones.find((item) => item.id === "M3"); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 12383d8f..191edc3e 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -19,8 +19,8 @@ --info: #7ea6c9; --rail-width: 92px; --explorer-width: 292px; - --right-width: clamp(366px, 28vw, 440px); - --right-width-expanded: clamp(366px, 26vw, 410px); + --right-width: clamp(560px, 38vw, 740px); + --right-width-expanded: clamp(560px, 45vw, 740px); --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; --body: "Aptos", "Segoe UI", system-ui, sans-serif; } @@ -76,7 +76,7 @@ ul { max-height: 100dvh; min-height: 0; display: grid; - grid-template-columns: var(--rail-width) var(--explorer-width) minmax(460px, 1fr) var(--right-width-expanded); + grid-template-columns: var(--rail-width) var(--explorer-width) minmax(360px, 1fr) var(--right-width-expanded); grid-template-rows: 1fr; overflow: hidden; overscroll-behavior: contain; @@ -151,7 +151,7 @@ ul { .explorer-collapsed { --explorer-width: 0px; - grid-template-columns: var(--rail-width) 0 minmax(560px, 1fr) var(--right-width); + grid-template-columns: var(--rail-width) 0 minmax(420px, 1fr) var(--right-width); } .explorer-collapsed .explorer { @@ -779,7 +779,7 @@ h3 { .right-sidebar { display: grid; - grid-template-rows: minmax(0, 0.45fr) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(0, 0.9fr); gap: 10px; padding: 10px; background: rgba(18, 20, 18, 0.98); @@ -790,16 +790,53 @@ h3 { .hardware-status { min-height: 0; display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: 10px; padding: 12px; overflow: hidden; } +.hardware-status-tabs { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.hardware-tab { + flex: 1 1 92px; + min-width: 0; + min-height: 30px; + padding: 5px 7px; + border: 1px solid var(--line); + background: var(--surface); + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + font-weight: 760; + cursor: pointer; + overflow-wrap: anywhere; + word-break: keep-all; +} + +.hardware-tab:hover, +.hardware-tab:focus-visible, +.hardware-tab.active { + border-color: var(--line-strong); + background: var(--surface-2); + color: var(--text); + outline: 0; +} + +.hardware-tab.active { + box-shadow: inset 0 -3px 0 var(--accent); +} + .hardware-list { display: grid; gap: 8px; align-content: start; + overflow-x: hidden; } .hardware-section { @@ -824,6 +861,49 @@ h3 { gap: 6px; } +.hardware-kv-section { + min-width: 0; +} + +.kv-list { + min-width: 0; + display: grid; + gap: 6px; + margin: 0; +} + +.kv-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(96px, 0.42fr) minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 7px 8px; + background: var(--surface); + border: 1px solid var(--line); + border-left-width: 3px; +} + +.kv-key, +.kv-value { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.kv-key { + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + font-weight: 760; +} + +.kv-value { + color: var(--text); + font-family: var(--mono); + font-size: 11px; +} + .hardware-row { min-width: 0; display: grid; @@ -931,6 +1011,7 @@ h3 { gap: 10px; padding: 12px; overflow: auto; + overflow-x: hidden; overscroll-behavior: contain; } @@ -1071,6 +1152,10 @@ h3 { overflow-y: auto; } +.right-sidebar .table-wrap { + overflow-x: hidden; +} + table { width: 100%; border-collapse: collapse; @@ -1136,6 +1221,12 @@ table { overflow-wrap: anywhere; } +.right-sidebar table, +.right-sidebar .wiring-table { + min-width: 0; + table-layout: fixed; +} + th, td { text-align: left; @@ -1428,6 +1519,11 @@ tbody tr:last-child td { grid-template-columns: 1fr; } + .kv-row { + grid-template-columns: 1fr; + gap: 3px; + } + .message-card, .trace-item { grid-template-columns: 1fr;