From 93373ac104f68c6c50310e2dd3fb51c29850b250 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 09:07:39 +0000 Subject: [PATCH] fix: collapse internal gate review to live table --- internal/cloud/gate-diagnostics.mjs | 320 ++++++++++++ internal/cloud/server.mjs | 6 + internal/cloud/server.test.mjs | 60 +++ scripts/dev-cloud-workbench-smoke.test.mjs | 7 + scripts/src/dev-cloud-workbench-smoke-lib.mjs | 253 +++++++-- scripts/src/m3-io-control-e2e.mjs | 1 + scripts/validate-dev-gate-report.mjs | 27 +- web/hwlab-cloud-web/app.mjs | 483 ++++++++---------- web/hwlab-cloud-web/help.md | 11 +- web/hwlab-cloud-web/index.html | 127 ++--- web/hwlab-cloud-web/scripts/check.mjs | 68 ++- .../scripts/m3-readonly-contract.mjs | 11 +- web/hwlab-cloud-web/styles.css | 159 ++---- 13 files changed, 1007 insertions(+), 526 deletions(-) create mode 100644 internal/cloud/gate-diagnostics.mjs diff --git a/internal/cloud/gate-diagnostics.mjs b/internal/cloud/gate-diagnostics.mjs new file mode 100644 index 00000000..fa90274c --- /dev/null +++ b/internal/cloud/gate-diagnostics.mjs @@ -0,0 +1,320 @@ +import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; +import { describeCodeAgentAvailability } from "./code-agent-chat.mjs"; +import { + applyRuntimeDbReadinessLayers, + buildDbRuntimeReadiness +} from "./db-contract.mjs"; +import { buildCloudApiReadiness } from "./health-contract.mjs"; +import { + M3_IO_CONTROL_ROUTE, + describeM3IoControlLive +} from "./m3-io-control.mjs"; + +const STATUS = Object.freeze({ + pass: "通过", + blocked: "阻塞", + failed: "失败", + pending: "待验证", + info: "信息" +}); + +export async function buildGateDiagnosticsRows(options = {}) { + const observedAt = new Date().toISOString(); + const rows = []; + const health = await observeHealth(options, observedAt); + const m3 = await observeM3(options, observedAt); + const audit = await observeAudit(options, observedAt); + const evidence = await observeEvidence(options, observedAt); + + rows.push(...health.rows, m3.row, audit.row, evidence.row); + + const status = rows.some((row) => row.statusKey === "failed") + ? "failed" + : rows.some((row) => row.statusKey === "blocked") + ? "blocked" + : rows.some((row) => row.statusKey === "pending") + ? "pending" + : "pass"; + + return { + serviceId: CLOUD_API_SERVICE_ID, + route: "/v1/diagnostics/gate", + contractVersion: "gate-diagnostics-table-v1", + status, + sourceKind: "LIVE-BACKEND", + liveBackend: true, + observedAt, + rowCount: rows.length, + columns: ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"], + rows, + safety: { + frontendHardcodedRows: false, + sourceFixturePromoted: false, + localStorageUsed: false, + blockedIsGreen: false, + hardwareControlSurface: false, + codeAgentConversationSurface: false + } + }; +} + +async function observeHealth(options, observedAt) { + try { + const env = options.env ?? process.env; + const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe); + const runtime = await runtimeReadiness(options.runtimeStore); + const db = applyRuntimeDbReadinessLayers(dbProbe, runtime); + const codeAgent = describeCodeAgentAvailability(env, options); + const readiness = buildCloudApiReadiness({ db, codeAgent, runtime }); + const commitId = env.HWLAB_COMMIT_ID || env.HWLAB_GIT_SHA || "unknown"; + const imageTag = env.HWLAB_IMAGE_TAG || shortHash(commitId); + return { + rows: [ + row({ + category: "部署", + check: "Cloud API 实况身份", + statusKey: commitId === "unknown" ? "info" : "pass", + owner: CLOUD_API_SERVICE_ID, + detail: `service=${CLOUD_API_SERVICE_ID}; env=dev; status=${readiness.status}; imageTag=${imageTag}`, + evidence: [`commit=${commitId}`, "GET /health/live"], + updatedAt: observedAt, + next: readiness.ready ? "继续保持与 DEV desired state 对齐。" : "先处理下方阻塞行,再重新执行 DEV live 复核。" + }), + row({ + category: "健康检查", + check: "/health/live readiness", + statusKey: readiness.ready ? "pass" : "blocked", + owner: CLOUD_API_SERVICE_ID, + detail: `ready=${readiness.ready}; status=${readiness.status}; blockers=${readiness.blockerCodes.join(",") || "无"}`, + evidence: readiness.blockerCodes.length > 0 ? readiness.blockerCodes.map((code) => `blocker=${code}`) : ["GET /health/live"], + updatedAt: observedAt, + next: readiness.ready ? "无额外动作。" : "按 blocker code 分别处理 DB、runtime、Code Agent 或 M3 依赖。" + }), + row({ + category: "DB", + check: "DB live readiness", + statusKey: db?.ready && db?.connected && db?.liveDbEvidence ? "pass" : "blocked", + owner: "hwlab-cloud-api / DEV Postgres", + detail: `ready=${Boolean(db?.ready)}; connected=${Boolean(db?.connected)}; liveDbEvidence=${Boolean(db?.liveDbEvidence)}; endpointSource=${db?.endpointSource ?? "unknown"}`, + evidence: db?.blocker ? [`blocker=${db.blocker}`] : ["db.liveDbEvidence=true"], + updatedAt: observedAt, + next: db?.ready && db?.connected && db?.liveDbEvidence + ? "继续保持 Secret URL host 与 live DB 证据分层。" + : "修复 DEV DB env/连接/auth,且不打印 Secret 值。" + }), + row({ + category: "运行态", + check: "runtime durable adapter", + statusKey: runtime?.durable === true && runtime?.ready === true && runtime?.liveRuntimeEvidence === true ? "pass" : "blocked", + owner: "hwlab-cloud-api runtime-store", + detail: `adapter=${runtime?.adapter ?? "unknown"}; durable=${Boolean(runtime?.durable)}; ready=${Boolean(runtime?.ready)}; liveRuntimeEvidence=${Boolean(runtime?.liveRuntimeEvidence)}`, + evidence: [ + runtime?.blocker ? `blocker=${runtime.blocker}` : null, + runtime?.durabilityContract?.blockedLayer ? `blockedLayer=${runtime.durabilityContract.blockedLayer}` : null, + runtime?.connection?.queryResult ? `queryResult=${runtime.connection.queryResult}` : null + ].filter(Boolean), + updatedAt: observedAt, + next: runtime?.durable === true && runtime?.ready === true && runtime?.liveRuntimeEvidence === true + ? "可作为可信记录持久化前置。" + : "修复 runtime durable auth/schema/migration/readiness 后再升级可信闭环。" + }), + row({ + category: "Code Agent", + check: "provider readiness", + statusKey: codeAgent?.ready && codeAgent?.status !== "blocked" ? "pass" : "blocked", + owner: "hwlab-cloud-api / Code Agent provider", + detail: `status=${codeAgent?.status ?? "unknown"}; provider=${codeAgent?.provider ?? "unknown"}; model=${codeAgent?.model ?? "unknown"}; backend=${codeAgent?.backend ?? "unknown"}`, + evidence: [ + codeAgent?.reason ? `reason=${codeAgent.reason}` : null, + ...(Array.isArray(codeAgent?.missingEnv) ? codeAgent.missingEnv.map((name) => `missingEnv=${name}`) : []), + "Secret 值未读取/未展示" + ].filter(Boolean), + updatedAt: observedAt, + next: codeAgent?.ready && codeAgent?.status !== "blocked" + ? "继续用受控 /v1/agent/chat 验证真实 runner 能力。" + : "补齐 provider env/Secret/egress 后复测,不用 mock 回复冒充。" + }) + ] + }; + } catch (error) { + return { + rows: [ + row({ + category: "健康检查", + check: "后端聚合读取", + statusKey: "failed", + owner: CLOUD_API_SERVICE_ID, + detail: `live 聚合失败:${oneLine(error.message)}`, + evidence: "GET /v1/diagnostics/gate", + updatedAt: observedAt, + next: "先修复 cloud-api 聚合接口,再重新刷新内部复核表。" + }) + ] + }; + } +} + +async function observeM3(options, observedAt) { + try { + const payload = await describeM3IoControlLive(options); + const ready = payload?.readiness?.status === "ready" && payload?.readiness?.controlReady === true; + return { + row: row({ + category: "M3", + check: "DO1 经 hwlab-patch-panel 同步到 DI1", + statusKey: ready ? "pass" : payload?.readiness?.status === "pending" ? "pending" : "blocked", + owner: "hwlab-cloud-api / hwlab-patch-panel", + detail: payload?.readiness?.summary ?? payload?.blockedReason ?? "M3 IO readiness 未返回摘要。", + evidence: [ + `route=${M3_IO_CONTROL_ROUTE}`, + payload?.readiness?.sourceKind ? `sourceKind=${payload.readiness.sourceKind}` : null, + payload?.readiness?.blocker?.code ? `blocker=${payload.readiness.blocker.code}` : null, + payload?.readiness?.blocker?.layer ? `layer=${payload.readiness.blocker.layer}` : null + ].filter(Boolean), + updatedAt: payload?.readiness?.observedAt ?? observedAt, + next: ready + ? "只表示控制路径可执行;M3 验收仍需 operation/trace/audit/evidence 闭环。" + : payload?.readiness?.blocker?.zh ?? "修复 gateway-simu、box-simu 或 patch-panel 只读 readiness 后再复核。" + }) + }; + } catch (error) { + return { + row: row({ + category: "M3", + check: "DO1 经 hwlab-patch-panel 同步到 DI1", + statusKey: "failed", + owner: "hwlab-cloud-api / hwlab-patch-panel", + detail: `M3 live readiness 读取失败:${oneLine(error.message)}`, + evidence: `GET ${M3_IO_CONTROL_ROUTE}`, + updatedAt: observedAt, + next: "先恢复 M3 IO readiness 接口,再验证 patch-panel 目标链路。" + }) + }; + } +} + +async function observeAudit(options, observedAt) { + try { + const result = await options.runtimeStore?.queryAuditEvents?.({ limit: 6 }); + const events = Array.isArray(result?.events) ? result.events : []; + return { + row: row({ + category: "证据", + check: "audit.event.query", + statusKey: events.length > 0 ? "info" : "pending", + owner: "runtime-store / audit", + detail: `只读查询返回 ${events.length} 条 audit;该行不单独证明 M3 通过。`, + evidence: events.slice(0, 3).map((event) => [ + event.auditId ? `audit=${event.auditId}` : null, + event.traceId ? `trace=${event.traceId}` : null + ].filter(Boolean).join(" / ")).filter(Boolean), + updatedAt: observedAt, + next: events.length > 0 ? "将 audit 与 operation/evidence 绑定后再判断可信闭环。" : "等待 live audit 记录,或先修复 runtime durable blocker。" + }) + }; + } catch (error) { + return { + row: row({ + category: "证据", + check: "audit.event.query", + statusKey: "blocked", + owner: "runtime-store / audit", + detail: `只读 audit 查询受阻:${oneLine(error.message)}`, + evidence: "POST /json-rpc audit.event.query", + updatedAt: observedAt, + next: "修复 runtime durable/readiness 后再查询 audit,不用 SOURCE 回退冒充。" + }) + }; + } +} + +async function observeEvidence(options, observedAt) { + try { + const result = await options.runtimeStore?.queryEvidenceRecords?.({ limit: 6 }); + const records = Array.isArray(result?.records) ? result.records : []; + return { + row: row({ + category: "证据", + check: "evidence.record.query", + statusKey: records.length > 0 ? "info" : "pending", + owner: "runtime-store / evidence", + detail: `只读查询返回 ${records.length} 条 evidence;该行不单独证明 M3 通过。`, + evidence: records.slice(0, 3).map((record) => [ + record.evidenceId ? `evidence=${record.evidenceId}` : null, + record.traceId ?? record.metadata?.traceId ? `trace=${record.traceId ?? record.metadata?.traceId}` : null + ].filter(Boolean).join(" / ")).filter(Boolean), + updatedAt: observedAt, + next: records.length > 0 ? "将 evidence 与 operation/audit/patch-panel live report 绑定后再判断。" : "等待 live evidence 记录,或先修复 runtime durable blocker。" + }) + }; + } catch (error) { + return { + row: row({ + category: "证据", + check: "evidence.record.query", + statusKey: "blocked", + owner: "runtime-store / evidence", + detail: `只读 evidence 查询受阻:${oneLine(error.message)}`, + evidence: "POST /json-rpc evidence.record.query", + updatedAt: observedAt, + next: "修复 runtime durable/readiness 后再查询 evidence,不用 SOURCE 回退冒充。" + }) + }; + } +} + +async function runtimeReadiness(runtimeStore) { + if (typeof runtimeStore?.readiness === "function") { + return runtimeStore.readiness(); + } + if (typeof runtimeStore?.summary === "function") { + return runtimeStore.summary(); + } + return { + adapter: "unknown", + durable: false, + ready: false, + liveRuntimeEvidence: false, + blocker: "runtime_store_unavailable" + }; +} + +function row({ category, check, statusKey, owner, detail, evidence, updatedAt, next }) { + const normalizedStatusKey = normalizeStatusKey(statusKey); + return { + category, + check, + status: STATUS[normalizedStatusKey], + statusKey: normalizedStatusKey, + owner, + detail: oneLine(detail), + evidence: normalizeEvidence(evidence), + updatedAt, + next: oneLine(next), + sourceKind: "LIVE-BACKEND" + }; +} + +function normalizeStatusKey(statusKey) { + const normalized = String(statusKey ?? "").trim().toLowerCase(); + if (normalized === "pass" || normalized === "passed" || normalized === "ok" || normalized === "ready") return "pass"; + if (normalized === "failed" || normalized === "failure" || normalized === "error") return "failed"; + if (normalized === "pending" || normalized === "unknown" || normalized === "loading") return "pending"; + if (normalized === "info" || normalized === "information") return "info"; + return "blocked"; +} + +function normalizeEvidence(value) { + const items = Array.isArray(value) ? value : [value]; + const normalized = items.map(oneLine).filter(Boolean); + return normalized.length > 0 ? normalized : ["live 后端未返回可用 evidence 字段"]; +} + +function oneLine(value) { + return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 360); +} + +function shortHash(value) { + const text = String(value ?? "unknown"); + return text === "unknown" ? "unknown" : text.slice(0, 7); +} diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index faf3c922..8900cbe8 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -13,6 +13,7 @@ import { handleJsonRpcRequest } from "./json-rpc.mjs"; import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; +import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs"; import { applyRuntimeDbReadinessLayers, buildDbRuntimeReadiness @@ -202,6 +203,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") { + sendJson(response, 200, await buildGateDiagnosticsRows(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 30f8651c..b0c34e09 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1646,6 +1646,66 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard } }); +test("cloud api /v1/diagnostics/gate exposes Chinese single-table rows from live backend aggregation", async () => { + const runtimeStore = createCloudRuntimeStore({ + now: () => "2026-05-23T00:00:00.000Z" + }); + const server = createCloudApiServer({ + runtimeStore, + 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", + HWLAB_COMMIT_ID: "abc123456789", + HWLAB_IMAGE_TAG: "abc1234" + }, + m3IoRequestJson: async () => ({ + ok: false, + status: 503, + body: { + error: { + message: "gateway offline" + } + } + }) + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/diagnostics/gate`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.route, "/v1/diagnostics/gate"); + assert.equal(payload.contractVersion, "gate-diagnostics-table-v1"); + assert.equal(payload.sourceKind, "LIVE-BACKEND"); + assert.equal(payload.liveBackend, true); + assert.deepEqual(payload.columns, ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]); + assert.equal(payload.safety.frontendHardcodedRows, false); + assert.equal(payload.safety.sourceFixturePromoted, false); + assert.equal(payload.safety.blockedIsGreen, false); + assert.equal(payload.safety.hardwareControlSurface, false); + assert.equal(payload.safety.codeAgentConversationSurface, false); + assert.ok(payload.rows.length >= 6); + assert.ok(payload.rows.every((row) => row.sourceKind === "LIVE-BACKEND")); + assert.ok(payload.rows.every((row) => ["通过", "阻塞", "失败", "待验证", "信息"].includes(row.status))); + assert.ok(payload.rows.some((row) => row.category === "M3" && row.status === "阻塞")); + assert.ok(payload.rows.some((row) => row.check === "/health/live readiness")); + assert.ok(payload.rows.some((row) => row.check === "audit.event.query")); + assert.ok(payload.rows.some((row) => row.check === "evidence.record.query")); + assert.ok(payload.rows.some((row) => row.check === "Cloud API 实况身份" && row.status === "通过")); + assert.equal( + payload.rows.some((row) => row.status === "阻塞" && row.statusKey === "pass"), + false, + "blocked rows must not be mapped to pass" + ); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/m3/io returns structured blocked payload when gateway is unavailable", async () => { const runtimeStore = createCloudRuntimeStore({ now: () => "2026-05-23T00:00:00.000Z" diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 779642a2..4cc1890c 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -216,6 +216,13 @@ test("source/default smoke covers #276 two-column wiring long-table contract", ( assert.equal(check.evidence.includes("legacy 8-column table forbidden"), true); }); +test("source/default smoke covers #288 gate single-table contract", () => { + const report = runDevCloudWorkbenchStaticSmoke(); + const check = report.checks.find((item) => item.id === "feedback-288-gate-single-table"); + assert.equal(check?.status, "pass"); + assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false); +}); + test("live workbench identity passes only when runtime commit or image tag matches current source", () => { assert.equal( classifyLiveDeploymentIdentity(sourceIdentity, { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 034d4dc5..5a6e83ab 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -89,12 +89,35 @@ const defaultHomepageForbiddenTerms = Object.freeze([ "waiting-for-dev-live" ]); -const diagnosticsRequiredTerms = Object.freeze([ - "Gate / 诊断 / 验收", - "M0-M5", - "BLOCKED", - "DB live readiness", - "patch-panel DO1 -> DI1" +const gateReviewTableColumns = Object.freeze([ + "类别", + "检查项", + "状态", + "归因对象", + "关键细节", + "证据/trace", + "更新时间", + "下一步" +]); + +const gateReviewStatusLabels = Object.freeze([ + "通过", + "阻塞", + "失败", + "待验证", + "信息" +]); + +const legacyGateDashboardMarkers = Object.freeze([ + "workspace-panel", + "gate-grid", + "gate-split", + "milestone-grid", + "trace-list", + "health-body", + "diagnostics-list", + "method-list", + "unblock-list" ]); const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]); @@ -370,9 +393,9 @@ function runStaticSmoke() { evidence: ["工作台", "内部复核", "使用说明", "资源树"] }); - addCheck(checks, blockers, "feedback-119-gate-retains-diagnostics", gateRetainsDiagnostics(files.html, files.app), "Internal Gate page retains BLOCKED/M0-M5 diagnostics and the DB live plus patch-panel DO1 -> DI1 unblock path.", { + addCheck(checks, blockers, "feedback-288-gate-single-table", gateSingleTableContract(files), "Internal review /gate is a single read-only table backed by the live diagnostics aggregation route.", { blocker: "contract_blocker", - evidence: diagnosticsRequiredTerms + evidence: [...gateReviewTableColumns, "/v1/diagnostics/gate", "LIVE-BACKEND", ...gateReviewStatusLabels] }); addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", { @@ -439,7 +462,7 @@ function runStaticSmoke() { addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", { blocker: "safety_blocker", - evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] + evidence: ["/health/live", "/v1", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] }); addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable blocked API from fully OK with concrete blocker copy.", { @@ -839,8 +862,8 @@ async function runLiveDomOnlySmoke(args) { liveMode: "dom-only-readonly-no-code-agent-post", codeAgentBrowserJourneySkipped: true, codeAgentPostSent: false, - retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status", "DOM route/control/wiring observations"], - statement: "DOM-only live smoke preserves runtime and web-asset identity preflight semantics, continues with read-only browser DOM/help inspection even when identity is blocked, and never posts /v1/agent/chat, reads Secret values, mutates live DEV, or claims M3/M4/M5 acceptance." + retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status", "DOM route/control/wiring observations", "gate single-table observations"], + statement: "DOM-only live smoke preserves runtime and web-asset identity preflight semantics, continues with read-only browser DOM/help/gate table inspection even when identity is blocked, and never posts /v1/agent/chat, reads Secret values, mutates live DEV, or claims M3/M4/M5 acceptance." } }); } @@ -1376,7 +1399,8 @@ function baseReport({ "pikasTech/HWLAB#143", "pikasTech/HWLAB#164", "pikasTech/HWLAB#224", - "pikasTech/HWLAB#276" + "pikasTech/HWLAB#276", + "pikasTech/HWLAB#288" ], mode, url, @@ -1504,7 +1528,8 @@ function readStaticFiles() { styles: readText("web/hwlab-cloud-web/styles.css"), app: readText("web/hwlab-cloud-web/app.mjs"), runtime: readText("web/hwlab-cloud-web/runtime.mjs"), - panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs") + panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs"), + help: readText("web/hwlab-cloud-web/help.md") }; } @@ -1771,8 +1796,18 @@ function firstTagForDataView(html, view) { function sectionSource(source, viewId) { const start = source.search(new RegExp(`]*\\bdata-view=["']${escapeRegExp(viewId)}["'][^>]*>`, "u")); if (start === -1) return ""; - const next = source.slice(start + 1).search(/]*\bdata-view=["'][^"']+["'][^>]*>/u); - return next === -1 ? source.slice(start) : source.slice(start, start + 1 + next); + const tagPattern = /<\/?section\b[^>]*>/giu; + tagPattern.lastIndex = start; + let depth = 0; + for (let match = tagPattern.exec(source); match; match = tagPattern.exec(source)) { + if (match[0].startsWith(" gateSource.includes(term)); + const gateSource = `${gateHtml}\n${app}\n${styles}`; + const tableMatches = gateHtml.match(/]*\bclass=["'][^"']*\bgate-table\b[^"']*["'][^>]*>/gu) ?? []; + const controlIds = ["gate-source-status", "gate-status-filter", "gate-search", "gate-refresh", "gate-review-body"]; + const oldRenderFunctions = /function\s+(?:renderTrace|renderUnblockList|renderDiagnostics|renderGateDiagnostics|renderMethodList)\s*\(/u; + return ( + /]*\bid=["']gate-title["'][^>]*>内部复核<\/h2>/u.test(gateHtml) && + tableMatches.length === 1 && + gateReviewTableColumns.every((column) => gateHtml.includes(`${column}`)) && + controlIds.every((id) => gateHtml.includes(`id="${id}"`) || gateHtml.includes(`id='${id}'`)) && + gateReviewStatusLabels.every((label) => gateSource.includes(label)) && + /fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) && + /function\s+renderGateTable\s*\(/u.test(app) && + /function\s+normalizeGateRow\s*\(/u.test(app) && + /function\s+gateLiveBlockerRow\s*\(/u.test(app) && + /sourceKind:\s*"LIVE-BACKEND"/u.test(app) && + /live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。/u.test(app) && + /\.gate-table\b/u.test(styles) && + /\.gate-controls\b/u.test(styles) && + legacyGateDashboardMarkers.every((marker) => !gateHtml.includes(marker)) && + !oldRenderFunctions.test(app) + ); } function gateRouteAliasesAreServed(app, artifactPublisher, buildScript, distContractScript) { @@ -1998,7 +2052,7 @@ function hasScrollLockContract(styles) { /body\s*>\s*\[data-app-shell\]\s*\{[^\}]*min-height:\s*0;/su.test(styles) && /\.workbench-shell\s*\{[^\}]*height:\s*100(?:d)?vh;[^\}]*overflow:\s*hidden;/su.test(styles) && /\.view\s*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) && - /\.(?:resource-tree|compact-list|conversation-list|trace-list|task-list|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) + /\.(?:resource-tree|compact-list|conversation-list|task-list|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) ); } @@ -2063,6 +2117,7 @@ function hasSameOriginReadOnlyBoundary(app) { /fetchJson\("\/health\/live"\)/u.test(app) && /fetchJson\("\/v1"\)/u.test(app) && /fetchJson\("\/v1\/m3\/status"\)/u.test(app) && + /fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) && /fetchJson\("\/v1\/agent\/chat"/u.test(app) && /fetchJson\("\/json-rpc"/u.test(app) && JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) && @@ -2254,7 +2309,7 @@ function sourceEvidenceNotDevLive(source) { gateSummary.auditEvents.every((event) => event.dryRun === true) && gateSummary.evidenceRecords.every((record) => record.dryRun === true) && gateSummary.safety.allowNetwork === false && - ["来源 SOURCE", "演练 DRY-RUN", "开发实况 DEV-LIVE", "阻塞 BLOCKED"].every((label) => source.includes(label)) + ["`SOURCE`", "`DRY-RUN`", "`DEV-LIVE`", "`BLOCKED`"].every((label) => source.includes(label)) ); } @@ -2875,13 +2930,34 @@ async function inspectLiveGateRoute(url, options = {}) { try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const gateBackendRequests = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/v1/diagnostics/gate") { + gateBackendRequests.push({ method: request.method(), urlPath: "/v1/diagnostics/gate" }); + } + }); const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null; await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 12000 }); - const gate = await page.evaluate(() => { + await page.waitForFunction(() => { + const rows = document.querySelectorAll("#gate-review-body tr"); + const source = document.querySelector("#gate-source-status")?.textContent ?? ""; + return rows.length > 0 && !/等待 live 后端|读取 live 后端中/u.test(source); + }, null, { timeout: 10000 }); + const gate = await page.evaluate(({ columns, statuses, legacyMarkers }) => { const workspace = document.querySelector('[data-view="workspace"]'); const gateView = document.querySelector('[data-view="gate"]'); const help = document.querySelector('[data-view="help"]'); - const text = document.body.textContent ?? ""; + const gateText = gateView?.textContent ?? ""; + const tableHeaders = [...(gateView?.querySelectorAll(".gate-table thead th") ?? [])] + .map((cell) => cell.textContent?.trim() ?? ""); + const tableRows = [...(gateView?.querySelectorAll("#gate-review-body tr") ?? [])].map((row) => + [...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "") + ); + const tableStatuses = [...(gateView?.querySelectorAll("#gate-review-body tr[data-status]") ?? [])] + .map((row) => row.getAttribute("data-status")) + .filter(Boolean); + const blockedRows = [...(gateView?.querySelectorAll('#gate-review-body tr[data-status="阻塞"], #gate-review-body tr[data-status-key="blocked"]') ?? [])]; + const blockedRowsGreen = blockedRows.some((row) => row.querySelector(".tone-pass, .tone-ok, .tone-healthy")); return { pathname: window.location.pathname, hash: window.location.hash, @@ -2889,26 +2965,67 @@ async function inspectLiveGateRoute(url, options = {}) { workspaceHidden: workspace ? workspace.hidden : null, gateHidden: gateView ? gateView.hidden : null, helpHidden: help ? help.hidden : null, - diagnosticsTermsPresent: ["Gate / 诊断 / 验收", "M0-M5", "BLOCKED", "DB live readiness", "patch-panel DO1 -> DI1"].every((term) => text.includes(term)) + heading: document.querySelector("#gate-title")?.textContent?.trim() ?? "", + sourceStatus: document.querySelector("#gate-source-status")?.textContent?.trim() ?? "", + tableCount: gateView?.querySelectorAll("table").length ?? 0, + mainTableCount: gateView?.querySelectorAll(".gate-table").length ?? 0, + tableHeaders, + tableRows, + tableStatuses, + controls: { + statusFilter: Boolean(gateView?.querySelector("#gate-status-filter")), + search: Boolean(gateView?.querySelector("#gate-search")), + refresh: Boolean(gateView?.querySelector("#gate-refresh")), + controlCount: gateView?.querySelectorAll("select, input, button").length ?? 0 + }, + requiredHeadersPresent: columns.every((column) => tableHeaders.includes(column)), + statusLabelsChinese: tableStatuses.length > 0 && tableStatuses.every((status) => statuses.includes(status)), + rowsPresent: tableRows.length > 0, + liveBackendOrBlockerVisible: /live 后端|\/v1\/diagnostics\/gate|LIVE-BACKEND/u.test(gateText), + legacyLayoutAbsent: legacyMarkers.every((marker) => !gateView?.querySelector(`.${marker}, #${marker}`)), + userOperationsAbsent: + !gateView?.querySelector("#command-form, #m3-control-form, [data-side-tab], [data-side-tab-jump], #command-input, #command-send"), + blockedRowsGreen, + oldDashboardTermsAbsent: !/(M0-M5|Gate \/ 诊断 \/ 验收|执行轨迹)/u.test(gateText) }; + }, { + columns: gateReviewTableColumns, + statuses: gateReviewStatusLabels, + legacyMarkers: legacyGateDashboardMarkers }); + gate.backendRouteRequested = gateBackendRequests.some((request) => request.method === "GET"); const pass = gate.pathname === "/gate" && gate.title === "HWLAB 云工作台" && gate.workspaceHidden === true && gate.gateHidden === false && (gate.helpHidden === true || gate.helpHidden === null) && - gate.diagnosticsTermsPresent && + gate.heading === "内部复核" && + gate.tableCount === 1 && + gate.mainTableCount === 1 && + gate.requiredHeadersPresent && + gate.rowsPresent && + gate.statusLabelsChinese && + gate.liveBackendOrBlockerVisible && + gate.legacyLayoutAbsent && + gate.userOperationsAbsent && + !gate.blockedRowsGreen && + gate.oldDashboardTermsAbsent && + gate.backendRouteRequested && (postGuard ? postGuard.attempts.length === 0 : true); return { status: pass ? "pass" : "blocked", summary: pass - ? "Direct /gate route opens the internal diagnostics view without becoming the default workbench." - : "Direct /gate route did not satisfy the internal diagnostics route contract.", + ? "Direct /gate route opens the internal single-table review view backed by the live diagnostics route." + : "Direct /gate route did not satisfy the internal single-table review contract.", evidence: [ `pathname=${gate.pathname}`, `gateHidden=${gate.gateHidden}`, - `diagnosticsTermsPresent=${gate.diagnosticsTermsPresent}`, + `heading=${gate.heading}`, + `tableCount=${gate.tableCount}`, + `rows=${gate.tableRows.length}`, + `statuses=${gate.tableStatuses.join(",") || "none"}`, + `backendRouteRequested=${gate.backendRouteRequested}`, ...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : []) ], observations: { @@ -3317,21 +3434,18 @@ async function inspectDefaultApiStatus(page) { return page.evaluate(() => { const liveStatus = document.querySelector("#live-status")?.textContent?.trim() ?? ""; const liveDetail = document.querySelector("#live-detail")?.textContent?.trim() ?? ""; - const methodText = [...document.querySelectorAll("#method-list .badge")].map((element) => element.textContent?.trim() ?? ""); const firstScreenText = document.querySelector(".topbar")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""; const forbidden = /Gate|诊断|验收|BLOCKED|M0-M5|执行轨迹|runtime_durable_adapter_query_blocked|DB live readiness/u; return { liveStatus, liveDetail, - methodText, firstScreenText, degradedStatusVisible: (liveStatus === "可信记录受阻 / 只读模式" || liveStatus === "API 错误 / 只读模式") && ( liveDetail.startsWith("/health/live 可响应,但可信记录持久化尚未就绪:") || liveDetail === "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。" - ) && - methodText.includes("system.health"), + ), defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`) }; }); @@ -4213,6 +4327,61 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} }); return true; } + if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") { + const timestamp = "2026-05-22T00:00:00.000Z"; + jsonResponse(response, 200, { + serviceId: "hwlab-cloud-api", + route: "/v1/diagnostics/gate", + contractVersion: "gate-diagnostics-table-v1", + status: "blocked", + sourceKind: "LIVE-BACKEND", + liveBackend: true, + observedAt: timestamp, + rowCount: 3, + columns: [...gateReviewTableColumns], + rows: [ + localGateReviewRow({ + category: "健康检查", + check: "/health/live readiness", + statusKey: "blocked", + owner: "hwlab-cloud-api", + detail: "Local fixture mirrors degraded live backend readiness for browser contract verification.", + evidence: ["GET /health/live", "blocker=runtime_durable_adapter_query_blocked"], + updatedAt: timestamp, + next: "DEV live 验证必须读取真实 /v1/diagnostics/gate。" + }), + localGateReviewRow({ + category: "证据", + check: "audit.event.query", + statusKey: "pending", + owner: "runtime-store / audit", + detail: "Local fixture has no audit records; this is not DEV-LIVE evidence.", + evidence: ["POST /json-rpc audit.event.query"], + updatedAt: timestamp, + next: "等待真实 live audit。" + }), + localGateReviewRow({ + category: "路由", + check: "single-table contract", + statusKey: "info", + owner: "cloud-web", + detail: "Browser smoke only validates the read-only table surface.", + evidence: ["GET /v1/diagnostics/gate", "LIVE-BACKEND fixture shape"], + updatedAt: timestamp, + next: "上线后以 DEV live route 结果为准。" + }) + ], + safety: { + frontendHardcodedRows: false, + sourceFixturePromoted: false, + localStorageUsed: false, + blockedIsGreen: false, + hardwareControlSurface: false, + codeAgentConversationSurface: false + } + }); + return true; + } if (request.method === "POST" && url.pathname === "/json-rpc") { const body = await readJsonBody(request); jsonResponse(response, 200, { @@ -4419,6 +4588,28 @@ async function hardwarePanelText(page) { return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? ""); } +function localGateReviewRow({ category, check, statusKey, owner, detail, evidence, updatedAt, next }) { + const label = { + pass: "通过", + blocked: "阻塞", + failed: "失败", + pending: "待验证", + info: "信息" + }[statusKey] ?? "阻塞"; + return { + category, + check, + status: label, + statusKey, + owner, + detail, + evidence, + updatedAt, + next, + sourceKind: "LIVE-BACKEND" + }; +} + function delay(ms) { const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0; return new Promise((resolve) => setTimeout(resolve, normalized)); diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 8e126469..977fd42e 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -331,6 +331,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", "/help.md", "/health/live", "/v1", + "/v1/diagnostics/gate", "/v1/agent/chat", "/v1/m3/io", "/v1/m3/status", diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 9e988a99..3b3702df 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -2451,7 +2451,32 @@ function assertDevCloudWorkbenchGateRoute(gate, label) { assert.equal(gate.workspaceHidden, true, `${label}.workspaceHidden`); assert.equal(gate.gateHidden, false, `${label}.gateHidden`); assert.equal(gate.helpHidden, true, `${label}.helpHidden`); - assert.equal(gate.diagnosticsTermsPresent, true, `${label}.diagnosticsTermsPresent`); + assert.equal(gate.heading, "内部复核", `${label}.heading`); + assert.equal(gate.tableCount, 1, `${label}.tableCount`); + assert.equal(gate.mainTableCount, 1, `${label}.mainTableCount`); + assert.equal(gate.requiredHeadersPresent, true, `${label}.requiredHeadersPresent`); + assert.equal(gate.rowsPresent, true, `${label}.rowsPresent`); + assert.equal(gate.statusLabelsChinese, true, `${label}.statusLabelsChinese`); + assert.equal(gate.liveBackendOrBlockerVisible, true, `${label}.liveBackendOrBlockerVisible`); + assert.equal(gate.legacyLayoutAbsent, true, `${label}.legacyLayoutAbsent`); + assert.equal(gate.userOperationsAbsent, true, `${label}.userOperationsAbsent`); + assert.equal(gate.blockedRowsGreen, false, `${label}.blockedRowsGreen`); + assert.equal(gate.oldDashboardTermsAbsent, true, `${label}.oldDashboardTermsAbsent`); + assert.equal(gate.backendRouteRequested, true, `${label}.backendRouteRequested`); + assertStringArray(gate.tableHeaders, `${label}.tableHeaders`, { minLength: 8 }); + for (const column of ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]) { + assert.ok(gate.tableHeaders.includes(column), `${label}.tableHeaders missing ${column}`); + } + assertStringArray(gate.tableStatuses, `${label}.tableStatuses`, { minLength: 1 }); + for (const status of gate.tableStatuses) { + assert.ok(["通过", "阻塞", "失败", "待验证", "信息"].includes(status), `${label}.tableStatuses unexpected ${status}`); + } + assertArray(gate.tableRows, `${label}.tableRows`); + assertObject(gate.controls, `${label}.controls`); + assert.equal(gate.controls.statusFilter, true, `${label}.controls.statusFilter`); + assert.equal(gate.controls.search, true, `${label}.controls.search`); + assert.equal(gate.controls.refresh, true, `${label}.controls.refresh`); + assert.ok(gate.controls.controlCount <= 4, `${label}.controls.controlCount`); if (Object.hasOwn(gate, "codeAgentPostGuard")) { assertObject(gate.codeAgentPostGuard, `${label}.codeAgentPostGuard`); assert.equal(gate.codeAgentPostGuard.installed, true, `${label}.codeAgentPostGuard.installed`); diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 41f594e6..15f42813 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1,5 +1,4 @@ import { gateSummary } from "./gate-summary.mjs"; -import { runtime } from "./runtime.mjs"; import { marked } from "./third_party/marked/marked.esm.js"; const DEFAULT_API_TIMEOUT_MS = 4500; @@ -74,13 +73,11 @@ const el = { agentChatStatus: byId("agent-chat-status"), conversationList: byId("conversation-list"), taskList: byId("task-list"), - traceList: byId("trace-list"), - unblockList: byId("unblock-list"), - gateMetadata: byId("gate-metadata"), - milestoneGrid: byId("milestone-grid"), - healthBody: byId("health-body"), - gateBlockers: byId("gate-blockers"), - validationList: byId("validation-list"), + gateSourceStatus: byId("gate-source-status"), + gateStatusFilter: byId("gate-status-filter"), + gateSearch: byId("gate-search"), + gateRefresh: byId("gate-refresh"), + gateReviewBody: byId("gate-review-body"), commandForm: byId("command-form"), commandInput: byId("command-input"), commandSend: byId("command-send"), @@ -90,8 +87,6 @@ const el = { controlList: byId("control-list"), wiringBody: byId("wiring-body"), recordsList: byId("records-list"), - diagnosticsList: byId("diagnostics-list"), - methodList: byId("method-list"), m3ControlForm: byId("m3-control-form"), m3ControlStatus: byId("m3-control-status"), m3GatewaySelect: byId("m3-gateway-select"), @@ -111,6 +106,13 @@ const state = { chatMessages: [], chatPending: false, liveSurface: null, + gateDiagnostics: { + loading: false, + payload: null, + rows: [], + error: null, + loadedAt: null + }, m3Control: { contract: null, status: null, @@ -128,6 +130,7 @@ initHardwareTabs(); initQuickActions(); initCommandBar(); initM3Control(); +initGateControls(); renderStaticWorkbench(); renderProbePending(); loadHelpSurface(); @@ -184,6 +187,9 @@ function showView(route) { button.classList.toggle("active", active); button.setAttribute("aria-current", active ? "page" : "false"); } + if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) { + loadGateDiagnostics(); + } } function initExplorerToggle() { @@ -433,6 +439,14 @@ function initM3Control() { }); } +function initGateControls() { + el.gateStatusFilter.addEventListener("change", renderGateTable); + el.gateSearch.addEventListener("input", renderGateTable); + el.gateRefresh.addEventListener("click", () => { + loadGateDiagnostics(); + }); +} + function renderStaticWorkbench() { el.explorerStatus.textContent = "只读工作区"; el.explorerStatus.className = "status-dot tone-source"; @@ -442,22 +456,18 @@ function renderStaticWorkbench() { renderCapabilityList(); renderConversation(); renderTaskList(); - renderTrace(); - renderUnblockList(); renderHardwareStatus(null); renderDrafts(); renderM3ControlStatus(); renderWiringList(null); renderRecords(null); - renderDiagnostics(null); - renderGateDiagnostics(); + renderGateTable(); } function renderProbePending() { el.liveStatus.textContent = "只读模式"; el.liveStatus.className = "tone-source"; el.liveDetail.textContent = "可整理任务、查看资源、核对接线和可信记录;不会触发硬件变更。"; - renderMethodList(rpcReadMethods, "pending"); } async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) { @@ -523,6 +533,29 @@ async function loadLiveSurface() { }; } +async function loadGateDiagnostics() { + if (state.gateDiagnostics.loading) return; + state.gateDiagnostics.loading = true; + state.gateDiagnostics.error = null; + renderGateTable(); + const response = await fetchJson("/v1/diagnostics/gate", { + timeoutMs: API_TIMEOUT_MS, + timeoutName: "内部复核 live 聚合" + }); + state.gateDiagnostics.loading = false; + if (response.ok && Array.isArray(response.data?.rows)) { + state.gateDiagnostics.payload = response.data; + state.gateDiagnostics.rows = response.data.rows.map(normalizeGateRow); + state.gateDiagnostics.loadedAt = new Date().toISOString(); + } else { + state.gateDiagnostics.payload = null; + state.gateDiagnostics.rows = [gateLiveBlockerRow(response)]; + state.gateDiagnostics.error = response.error ?? "内部复核 live 后端聚合不可用"; + state.gateDiagnostics.loadedAt = new Date().toISOString(); + } + renderGateTable(); +} + async function fetchJson(path, options = {}) { const { timeoutMs = API_TIMEOUT_MS, @@ -725,7 +758,6 @@ function renderLiveSurface(live) { const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); const runtimeSummary = runtimeSummaryFrom(live); - const methods = methodsFrom(live); el.liveStatus.textContent = surfaceStatus.label; el.liveStatus.className = `tone-${toneClass(surfaceStatus.tone)}`; @@ -736,8 +768,6 @@ function renderLiveSurface(live) { 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); renderM3ControlStatus(); renderConversation(); renderDrafts(); @@ -964,7 +994,6 @@ function renderCapabilityList() { ]; el.capabilityCount.textContent = String(capabilities.length); replaceChildren(el.explorerCapabilities, ...capabilities.map(infoCard)); - replaceChildren(el.gateBlockers, ...gateSummary.blockers.map(blockerCard)); } function renderConversation() { @@ -1001,43 +1030,6 @@ function renderTaskList() { replaceChildren(el.taskList, ...rows.map(infoCard)); } -function renderTrace() { - replaceChildren( - el.traceList, - ...gateSummary.steps.map((step) => { - const article = document.createElement("article"); - article.className = "trace-item"; - article.append(textSpan(String(step.order).padStart(2, "0"), "trace-order")); - const body = document.createElement("div"); - body.append(textSpan(stepTitle(step.title), "trace-title")); - body.append(textSpan(`${stepKindLabel(step.kind)} / ${step.outputs.length} 个输出`, "trace-meta")); - article.append(body, badge(step.requires.length === 0 ? "入口" : "依赖前序", step.requires.length === 0 ? "source" : "dry-run")); - return article; - }) - ); -} - -function renderUnblockList() { - const rows = [ - { - title: "DB live readiness", - detail: "先让 cloud-api /health/live 返回 db.ready=true、db.connected=true,再进入 Agent runtime 调度。", - tone: "blocked" - }, - { - title: "patch-panel DO1 -> DI1", - detail: "再由 hwlab-patch-panel 建立 res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 的活动链路,并附带可信记录。", - tone: "blocked" - }, - { - title: "证据升级规则", - detail: "源码、本地与演练记录只能作为解释材料,不能升级为实况验收证据。", - tone: "dry-run" - } - ]; - replaceChildren(el.unblockList, ...rows.map(infoCard)); -} - function renderHardwareStatus(m3Status) { syncHardwareTabs(); const status = m3Status ?? sourceFallbackM3Status(); @@ -1923,124 +1915,120 @@ function sourceKindLabel(tone) { return statusLabel(tone); } -function renderDiagnostics(live) { - const diagnostics = [ - { - title: "前端", - detail: runtime.endpoints.frontend, - tone: "source" - }, - { - title: "API 边界", - detail: runtime.endpoints.api, - tone: "source" - }, - { - title: "运行路由", - detail: runtime.serviceRoute.join(" -> "), - tone: "source" - } - ]; +function renderGateTable() { + const rows = filteredGateRows(); + const loading = state.gateDiagnostics.loading; + const payload = state.gateDiagnostics.payload; + const statusTone = gatePayloadTone(); + const loadedAt = state.gateDiagnostics.loadedAt; + el.gateSourceStatus.textContent = loading + ? "读取 live 后端中" + : payload + ? `live 后端 ${rows.length}/${state.gateDiagnostics.rows.length} 行` + : state.gateDiagnostics.error + ? "live 后端阻塞" + : "等待 live 后端"; + el.gateSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`; + el.gateSourceStatus.title = payload + ? `route=${payload.route}; observedAt=${payload.observedAt ?? loadedAt ?? "unknown"}` + : state.gateDiagnostics.error ?? "等待 /v1/diagnostics/gate"; - if (live) { - diagnostics.push( - { - title: "/health/live", - detail: live.healthLive.ok ? `HTTP ${live.healthLive.status} ${live.healthLive.data?.status ?? "ok"}` : live.healthLive.error, - tone: live.healthLive.ok ? live.healthLive.data?.status ?? "dev-live" : "blocked" - }, - { - title: "/v1", - detail: live.restIndex.ok ? `HTTP ${live.restIndex.status} ${live.restIndex.data?.status ?? "ok"}` : live.restIndex.error, - tone: live.restIndex.ok ? live.restIndex.data?.status ?? "dev-live" : "blocked" - }, - { - title: "system.health", - detail: live.health.ok ? `RPC ${live.health.data?.status ?? "ok"}` : live.health.error, - tone: live.health.ok ? live.health.data?.status ?? "dev-live" : "blocked" - }, - { - title: "audit.event.query", - detail: live.audit.ok ? `${live.audit.data?.count ?? live.audit.data?.events?.length ?? 0} 条记录` : live.audit.error, - tone: live.audit.ok ? "dev-live" : "blocked" - } - ); - } else { - diagnostics.push( - { - title: "/health/live", - detail: "正在探测同源 Web/API 健康状态", - tone: "dev-live" - }, - { - title: "/v1 + /json-rpc", - detail: "只读诊断等待中", - tone: "source" - } - ); - } - - replaceChildren(el.diagnosticsList, ...diagnostics.map(infoCard)); -} - -function renderGateDiagnostics() { - appendMetricGrid(el.gateMetadata, [ - ["环境", gateSummary.environment, "source"], - ["前端", runtime.endpoints.frontend, "source"], - ["API", runtime.endpoints.api, "source"], - ["Gate", statusLabel(gateSummary.gateStatus), gateSummary.gateStatus], - ["演练 DRY-RUN", statusLabel(gateSummary.dryRun.status), "dry-run"], - ["报告", shortHash(gateSummary.reportCommitId), "source"] - ]); - - replaceChildren( - el.milestoneGrid, - ...gateSummary.milestones.map((milestone) => { - const item = document.createElement("article"); - item.className = "milestone-card"; - const header = document.createElement("div"); - header.className = "panel-title-row"; - header.append(textSpan(milestone.id, "milestone-id"), badge(statusLabel(milestone.status), milestone.status)); - item.append(header); - item.append(textSpan(userFacingSummary(milestone.summary), "milestone-summary")); - item.append(textSpan(`${milestone.commandCount} 条命令 / ${milestone.evidenceCount} 条证据`, "milestone-meta")); - return item; - }) - ); - - el.healthBody.replaceChildren(); - for (const row of gateSummary.health) { + if (loading && state.gateDiagnostics.rows.length === 0) { const tr = document.createElement("tr"); - tr.append(cell(componentLabel(row.component))); - tr.append(cell(row.serviceId, "mono")); - const status = document.createElement("td"); - status.append(badge(statusLabel(row.status), row.status)); - tr.append(status); - tr.append(cell(observedByLabel(row.observedBy))); - tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap")); - el.healthBody.append(tr); + tr.append(tableCell("正在读取 live 后端聚合。", "wrap", 8)); + replaceChildren(el.gateReviewBody, tr); + return; } - replaceChildren( - el.validationList, - ...[ - "node --check web/hwlab-cloud-web/app.mjs", - "node --check web/hwlab-cloud-web/scripts/check.mjs", - "node web/hwlab-cloud-web/scripts/check.mjs", - "node scripts/dev-cloud-workbench-smoke.mjs --mobile", - "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", - "node scripts/l6-cli-web-smoke.mjs", - "git diff --check" - ].map((command) => { - const li = document.createElement("li"); - li.textContent = command; - return li; - }) - ); + if (rows.length === 0) { + const tr = document.createElement("tr"); + tr.append(tableCell("没有匹配当前过滤条件的复核行。", "wrap", 8)); + replaceChildren(el.gateReviewBody, tr); + return; + } + + replaceChildren(el.gateReviewBody, ...rows.map(gateTableRow)); } -function renderMethodList(methods, tone) { - replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone))); +function filteredGateRows() { + const filter = el.gateStatusFilter.value; + const query = el.gateSearch.value.trim().toLowerCase(); + return state.gateDiagnostics.rows.filter((row) => { + if (filter !== "all" && row.status !== filter) return false; + if (!query) return true; + return [ + row.category, + row.check, + row.status, + row.owner, + row.detail, + ...(row.evidence ?? []), + row.updatedAt, + row.next + ].join(" ").toLowerCase().includes(query); + }); +} + +function gateTableRow(row) { + const tr = document.createElement("tr"); + tr.dataset.status = row.status; + tr.dataset.statusKey = row.statusKey; + tr.append(tableCell(row.category, "wrap")); + tr.append(tableCell(row.check, "wrap")); + const status = document.createElement("td"); + status.append(badge(row.status, row.statusKey)); + tr.append(status); + tr.append(tableCell(row.owner, "mono wrap")); + tr.append(tableCell(row.detail, "wrap")); + tr.append(tableCell((row.evidence ?? []).join(" / "), "mono wrap")); + tr.append(tableCell(formatTimestamp(row.updatedAt), "mono wrap")); + tr.append(tableCell(row.next, "wrap")); + return tr; +} + +function normalizeGateRow(row) { + const statusKey = normalizeGateStatusKey(row.statusKey ?? row.status); + return { + category: oneLine(row.category || "内部复核"), + check: oneLine(row.check || "未命名检查项"), + status: gateStatusLabel(row.status, statusKey), + statusKey, + owner: oneLine(row.owner || "未标明"), + detail: oneLine(row.detail || "live 后端未返回关键细节"), + evidence: normalizeEvidenceList(row.evidence), + updatedAt: row.updatedAt || new Date().toISOString(), + next: oneLine(row.next || "等待维护者补充下一步。"), + sourceKind: "LIVE-BACKEND" + }; +} + +function gateLiveBlockerRow(response) { + const now = new Date().toISOString(); + return normalizeGateRow({ + category: "健康检查", + check: "内部复核 live 后端聚合", + status: "阻塞", + statusKey: response.timeout ? "failed" : "blocked", + owner: "hwlab-cloud-api /v1/diagnostics/gate", + detail: response.error || "live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。", + evidence: [ + "GET /v1/diagnostics/gate", + response.status ? `HTTP ${response.status}` : null, + response.timeout ? `timeoutMs=${response.timeoutMs}` : null + ].filter(Boolean), + updatedAt: now, + next: "先恢复后端聚合接口,再刷新本表。" + }); +} + +function gatePayloadTone() { + if (state.gateDiagnostics.loading) return "pending"; + if (!state.gateDiagnostics.payload) return state.gateDiagnostics.error ? "blocked" : "source"; + const rows = state.gateDiagnostics.rows; + if (rows.some((row) => row.statusKey === "failed")) return "failed"; + if (rows.some((row) => row.statusKey === "blocked")) return "blocked"; + if (rows.some((row) => row.statusKey === "pending")) return "pending"; + return "pass"; } function renderAgentChatStatus(status, result = null) { @@ -2112,14 +2100,6 @@ function failureMessage(result) { return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}。${suffix}请稍后重试或联系维护者补齐后端 provider。`; } -function blockerCard(blocker) { - return infoCard({ - title: blockerTitle(blocker), - detail: userFacingSummary(blocker.summary), - tone: blocker.status === "open" ? "blocked" : blocker.status - }); -} - function messageCard(message) { const article = document.createElement("article"); article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`; @@ -2264,18 +2244,6 @@ function badge(text, tone = text) { return span; } -function appendMetricGrid(parent, entries) { - replaceChildren( - parent, - ...entries.map(([label, value, tone]) => { - const item = document.createElement("div"); - item.className = "metric"; - item.append(textSpan(label, "metric-label"), textSpan(String(value ?? "无"), `metric-value tone-${toneClass(tone ?? "source")}`)); - return item; - }) - ); -} - function textSpan(text, className) { const span = document.createElement("span"); span.textContent = text ?? "无"; @@ -2290,16 +2258,20 @@ function cell(text, className) { return td; } -function runtimeSummaryFrom(live) { - return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null; +function formatTimestamp(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "未知"; + return shortTime(date.toISOString()); } -function methodsFrom(live) { - const restMethods = live.restIndex.data?.methods; - const adapterMethods = live.adapter.data?.methods; - if (Array.isArray(restMethods)) return restMethods; - if (Array.isArray(adapterMethods)) return adapterMethods; - return []; +function tableCell(text, className, colspan = 1) { + const td = cell(text, className); + if (colspan > 1) td.colSpan = colspan; + return td; +} + +function runtimeSummaryFrom(live) { + return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null; } function messageFromPayload(payload) { @@ -2575,6 +2547,37 @@ function statusLabel(value) { return STATUS_LABELS[normalized] ?? key; } +function gateStatusLabel(value, statusKey) { + const explicit = String(value ?? "").trim(); + const labels = { + pass: "通过", + blocked: "阻塞", + failed: "失败", + pending: "待验证", + info: "信息" + }; + return labels[statusKey] ?? (["通过", "阻塞", "失败", "待验证", "信息"].includes(explicit) ? explicit : "阻塞"); +} + +function normalizeGateStatusKey(value) { + const text = String(value ?? "").trim().toLowerCase(); + if (["通过", "pass", "passed", "ok", "ready"].includes(text)) return "pass"; + if (["失败", "failed", "failure", "error"].includes(text)) return "failed"; + if (["待验证", "pending", "loading", "unknown"].includes(text)) return "pending"; + if (["信息", "info", "information"].includes(text)) return "info"; + return "blocked"; +} + +function normalizeEvidenceList(value) { + const items = Array.isArray(value) ? value : [value]; + const normalized = items.map(oneLine).filter(Boolean); + return normalized.length > 0 ? normalized : ["live 后端未返回 evidence 字段"]; +} + +function oneLine(value) { + return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 240); +} + function roleLabel(role) { return ( { @@ -2595,37 +2598,6 @@ function resourceTypeLabel(type) { ); } -function stepKindLabel(kind) { - return ( - { - agent: "Agent 会话", - audit: "审计", - cleanup: "清理", - evidence: "证据", - hardware_operation: "硬件操作", - health: "健康检查", - status: "状态检查", - wiring: "接线" - }[String(kind ?? "").toLowerCase()] ?? statusLabel(kind) - ); -} - -function stepTitle(title) { - return ( - { - audit: "审计", - cleanup: "清理", - "agent hardware call": "Agent 硬件调用", - "agent session": "Agent 会话", - "DEV health": "DEV 健康", - "direct hardware call": "直接硬件调用", - evidence: "证据", - "gateway/box status": "Gateway/Box 状态", - wiring: "接线" - }[String(title ?? "")] ?? statusLabel(title) - ); -} - function recordKindLabel(kind) { return ( { @@ -2639,40 +2611,6 @@ function recordKindLabel(kind) { ); } -function componentLabel(component) { - return ( - { - "Agent manager": "Agent 管理器", - "Agent skills": "Agent 技能", - "Agent worker": "Agent Worker", - "Box simulator": "Box 模拟器", - CLI: "CLI", - "Cloud API": "Cloud API", - "Cloud Web": "Cloud Web", - "D601 router": "D601 路由器", - "DEV ingress": "DEV 入口", - Gateway: "Gateway", - "Gateway simulator": "Gateway 模拟器", - "Patch panel": "Patch Panel", - frp: "frp", - "master edge proxy": "master 边界代理" - }[String(component ?? "")] ?? statusLabel(component) - ); -} - -function observedByLabel(value) { - return ( - { - fixture: "fixture 来源 SOURCE" - }[String(value ?? "").toLowerCase()] ?? statusLabel(value) - ); -} - -function blockerTitle(blocker) { - const scope = blocker?.scope ?? "unknown"; - return `阻塞:${scope}`; -} - function internalGatePathnames() { return new Set(["/gate", "/diagnostics/gate"]); } @@ -2681,25 +2619,6 @@ function helpPathnames() { return new Set(["/help"]); } -function userFacingSummary(text) { - return String(text ?? "") - .replaceAll("Public DEV route/frp", "公共 DEV 路由/frp") - .replaceAll("live MVP remains blocked", "实况 MVP 仍被阻塞") - .replaceAll("DB live readiness", "DB 实况就绪") - .replaceAll("M3 hardware loop topology", "M3 硬件闭环拓扑") - .replaceAll("no historical 6667 failure is counted as active evidence", "历史 6667 失败不计为当前有效证据") - .replaceAll("cloud-api /health/live reports DB degraded", "cloud-api /health/live 报告 DB 降级") - .replaceAll("live DB evidence is not established", "尚未建立实况 DB 证据") - .replaceAll("Live M3 smoke reached DEV simulators", "实况 M3 smoke 已触达 DEV 模拟器") - .replaceAll("patch-panel active DO1 -> DI1 connection is missing", "缺少 patch-panel 上的活动 DO1 -> DI1 连接") - .replaceAll("Frozen contract, artifact, boundary, drift, and evidence fixtures are parseable and aligned to the DEV boundary.", "冻结的契约、产物、边界、漂移与证据 fixture 可解析,并已对齐 DEV 边界。") - .replaceAll("The local M1 contract smoke passes.", "本地 M1 契约 smoke 已通过。") - .replaceAll("The deploy smoke remains fixture-only and validates the frozen route phases and artifact metadata.", "部署 smoke 仍仅使用 fixture,用于验证冻结路由阶段与产物元数据。") - .replaceAll("Local M3 remains green, but active DEV-LIVE M3 is blocked by hardware loop topology.", "本地 M3 仍为通过,但活动 DEV-LIVE M3 受硬件闭环拓扑阻塞。") - .replaceAll("Local M4 remains green, but active DEV-LIVE M4 is blocked at DB live readiness before agent runtime scheduling.", "本地 M4 仍为通过,但活动 DEV-LIVE M4 在 Agent 运行时调度前受 DB 实况就绪阻塞。") - .replaceAll("The M5 orchestration is green in dry-run mode, but active DEV-LIVE acceptance is blocked by DB live and hardware loop evidence.", "M5 编排在 DRY-RUN 模式下通过,但活动 DEV-LIVE 验收受 DB 实况与硬件闭环证据阻塞。"); -} - function valueOrUnavailable(liveValue, sourceValue) { if (liveValue !== undefined && liveValue !== null) return liveValue; if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`; @@ -2710,10 +2629,6 @@ function shortTime(value) { return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z"); } -function shortHash(value) { - return String(value ?? "unknown").slice(0, 7); -} - function toneClass(tone) { return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase(); } diff --git a/web/hwlab-cloud-web/help.md b/web/hwlab-cloud-web/help.md index af76b099..c0d8d8b8 100644 --- a/web/hwlab-cloud-web/help.md +++ b/web/hwlab-cloud-web/help.md @@ -4,7 +4,7 @@ ## 左侧资源与功能导航 -- 活动栏使用完整中文标签:`工作台` 回到默认工作台,`内部复核` 打开 Gate / 诊断 / 验收二级页面,`使用说明` 打开本说明,`资源树` 展开或收起资源树。 +- 活动栏使用完整中文标签:`工作台` 回到默认工作台,`内部复核` 打开只读复核表,`使用说明` 打开本说明,`资源树` 展开或收起资源树。 - 左侧不再放独立的可信记录跳转;可信记录保留在右侧子面板。 - 资源树只展示当前项目、Gateway-SIMU、BOX-SIMU、`hwlab-patch-panel`、Agent manager / worker 等用户可理解资源,不把 M0-M5 Gate 状态放在默认首屏。 - 常用能力区提供任务草稿、资源查看与接线核对入口。`LOCAL` 只表示浏览器本地草稿,不等同于 DEV-LIVE。 @@ -27,13 +27,14 @@ - 控制:显示本地草稿和只读能力边界。这里的控制项保持禁用或本地记录状态。 - 接线:解释 `hwlab-patch-panel` 当前 source wiring,帮助确认 DO1 到 DI1 的可信闭环路径是否仍由 patch-panel 拥有。 - 可信记录:合并 source fixture 与只读 `evidence.record.query` 结果。source、fixture、LOCAL、DRY-RUN 记录不能升级成 DEV-LIVE。 -- 技术复核:Gate、诊断、验收、BLOCKED 和 M0-M5 执行轨迹只在活动栏 `内部复核` 打开的二级页面展示,不替代默认工作台。 +- 技术复核:Gate、诊断和验收状态收敛到活动栏 `内部复核` 打开的只读表,不替代默认工作台,也不展示 M0-M5 流程墙。 ## 内部复核页 -- Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,作为验收复核二级页面保留。 -- 解阻路径:先完成 DB live readiness,让 same-origin `/health/live` 能证明 db.ready=true、db.connected=true;再通过 `hwlab-patch-panel` 建立 `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`,并附带可信轨迹/证据。 -- 诊断探测检查 same-origin `/health/live`、same-origin `/v1`、`/v1/m3/io` 合同与只读 `/json-rpc` 方法。允许的 JSON-RPC 方法包括 `system.health`、`cloud.adapter.describe`、`audit.event.query`、`evidence.record.query`。 +- Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,主体只有一个只读主表和少量过滤、搜索、刷新控件。 +- 表格数据来自 same-origin `/v1/diagnostics/gate` live 后端聚合;如果 live 聚合不可用,页面必须显示明确 blocker,不能用 SOURCE 静态拓扑冒充。 +- 表格状态使用中文:通过、阻塞、失败、待验证、信息。阻塞或降级行不得显示为 green。 +- 该页不提供硬件控制、Agent 对话或资源选择入口;这些操作仍在默认工作台内按受控后端边界执行。 ## 来源标签含义 diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index e4f87639..25bb03f7 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -100,92 +100,51 @@ diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 317d958f..dbea7efa 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -10,6 +10,7 @@ import { runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs"; import { gateSummary } from "../gate-summary.mjs"; +import { runtime } from "../runtime.mjs"; import { runM3ControlPanelGuard } from "./m3-control-panel-guard.mjs"; import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs"; import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs"; @@ -172,8 +173,7 @@ for (const chineseWorkbenchText of [ "工作清单", "可信记录", "内部复核", - "使用说明", - "允许的只读 RPC" + "使用说明" ]) { assert.match(`${html}\n${app}`, new RegExp(chineseWorkbenchText)); } @@ -264,7 +264,7 @@ assert.match(styles, /\.record-group-title\s*{/); assert.match(styles, /\.hardware-section\s*{/); assert.match(styles, /\.hardware-status-tabs\s*{/); assert.match(styles, /\.kv-row\s*{/); -assert.match(html, /Gate \/ 诊断 \/ 验收/); +assert.match(styles, /\.hardware-row\s*{/); assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/); assert.match(html, /href="\/styles\.css"/); assert.match(html, /src="\/app\.mjs"/); @@ -283,7 +283,7 @@ for (const workbenchElement of [ "control-list", "wiring-body", "records-list", - "diagnostics-list" + "gate-review-body" ]) { assert.match(html, new RegExp(workbenchElement)); } @@ -342,6 +342,7 @@ for (const helpTerm of [ "BLOCKED", "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", "same-origin `/v1`", + "`/v1/diagnostics/gate`", "只读 `/json-rpc`", "`/health/live`", "`:16666`", @@ -370,10 +371,35 @@ for (const wiringTerm of ["res_boxsimu_1", "res_boxsimu_2", "DO1", "DI1", "hwlab for (const legacyWideHeader of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"]) { assert.doesNotMatch(wiringPanel, new RegExp(`${escapeRegExp(legacyWideHeader)}<\\/th>`), `legacy wide wiring column must not return: ${legacyWideHeader}`); } -assert.match(html, /id="unblock-list"/); -assert.match(app, /DB live readiness/); -assert.match(app, /res_boxsimu_1:DO1 -> res_boxsimu_2:DI1/); -assert.match(app, /patch-panel DO1 -> DI1/); +const gateHtml = sectionSource(html, "gate"); +assert.match(gateHtml, /

内部复核<\/h2>/); +assert.match(gateHtml, /id="gate-source-status"/); +assert.match(gateHtml, /id="gate-status-filter"/); +assert.match(gateHtml, /id="gate-search"/); +assert.match(gateHtml, /id="gate-refresh"/); +assert.match(gateHtml, /class="gate-table"/); +assert.equal((gateHtml.match(/class="gate-table"/gu) ?? []).length, 1, "/gate must contain one main table"); +for (const gateHeader of ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]) { + assert.match(gateHtml, new RegExp(`${escapeRegExp(gateHeader)}<\\/th>`), `missing gate table column ${gateHeader}`); +} +for (const gateStatus of ["通过", "阻塞", "失败", "待验证", "信息"]) { + assert.match(`${gateHtml}\n${app}`, new RegExp(escapeRegExp(gateStatus)), `missing gate status ${gateStatus}`); +} +for (const forbiddenGateMarker of ["workspace-panel", "gate-grid", "gate-split", "milestone-grid", "trace-list", "health-body", "diagnostics-list", "method-list", "unblock-list"]) { + assert.doesNotMatch(gateHtml, new RegExp(escapeRegExp(forbiddenGateMarker)), `/gate must not contain legacy dashboard marker ${forbiddenGateMarker}`); +} +assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/); +assert.match(app, /function renderGateTable/); +assert.match(app, /function normalizeGateRow/); +assert.match(app, /function gateLiveBlockerRow/); +assert.match(app, /sourceKind:\s*"LIVE-BACKEND"/); +assert.match(app, /live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。/); +assert.doesNotMatch(app, /function\s+(?:renderTrace|renderUnblockList|renderDiagnostics|renderGateDiagnostics|renderMethodList)\s*\(/); +assert.match(styles, /\.gate-table\s*{/); +assert.match(styles, /\.gate-controls\s*{/); +assert.doesNotMatch(styles, /\.gate-grid\s*{/); +assert.doesNotMatch(styles, /\.milestone-grid\s*{/); +assert.doesNotMatch(styles, /\.trace-list\s*{/); for (const trustedRecordTerm of requiredTrustedRecordTerms) { assert.match(app, new RegExp(escapeRegExp(trustedRecordTerm)), `missing trusted-record term: ${trustedRecordTerm}`); } @@ -411,7 +437,6 @@ 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/); assert.match(html, /编写任务/); assert.match(html, /查看接线/); @@ -552,7 +577,7 @@ assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hid assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s); assert.match(styles, /\.workbench-shell\s*{[^}]*height:\s*100(?:d)?vh;[^}]*overflow:\s*hidden;/s); assert.match(styles, /\.view\s*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s); -assert.match(styles, /\.(?:resource-tree|compact-list|conversation-list|trace-list|task-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s); +assert.match(styles, /\.(?:resource-tree|compact-list|conversation-list|task-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s); assert.match(app, /function syncMobileExplorer/); assert.match(app, /window\.matchMedia\("\(max-width: 860px\)"\)/); assert.match(app, /setExplorerCollapsed\(true\)/); @@ -565,6 +590,7 @@ assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.center-workspace\s*{[ assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.right-sidebar\s*{[\s\S]*?grid-row:\s*2;/); assert.match(styles, /@media \(max-width: 520px\)[\s\S]*?\.command-bar\s*{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;/); assert.match(app, /fetchJson\("\/v1"\)/); +assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/); assert.match(app, /fetchJson\("\/health\/live"\)/); assert.match(app, /callRpc\("system\.health"\)/); assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/); @@ -593,7 +619,13 @@ assert.match(artifactPublisher, /routePath === "\/gate"/); assert.match(artifactPublisher, /routePath === "\/diagnostics\/gate"/); assert.match(artifactPublisher, /routePath === "\/help"/); assert.match(app, /degraded/); -assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/); +assert.deepEqual(runtime.serviceRoute, [ + "browser/CLI/gateway", + "master hwlab-edge-proxy", + "frp", + "D601 hwlab-dev/hwlab-router", + "cloud-web/cloud-api" +]); assert.doesNotMatch(app, /callRpc\("hardware\./); assert.doesNotMatch(app, /hardware\.operation\.request/); assert.doesNotMatch(app, /hardware\.invoke\.shell/); @@ -657,8 +689,18 @@ function escapeRegExp(value) { function sectionSource(source, viewId) { const start = source.search(new RegExp(`]*\\bdata-view=["']${escapeRegExp(viewId)}["'][^>]*>`, "u")); if (start === -1) return ""; - const next = source.slice(start + 1).search(/]*\bdata-view=["'][^"']+["'][^>]*>/u); - return next === -1 ? source.slice(start) : source.slice(start, start + 1 + next); + const tagPattern = /<\/?section\b[^>]*>/giu; + tagPattern.lastIndex = start; + let depth = 0; + for (let match = tagPattern.exec(source); match; match = tagPattern.exec(source)) { + if (match[0].startsWith(" "\)/u, "Cloud Web must render the observed route contract"); + assert.deepEqual(runtime.serviceRoute, [ + "browser/CLI/gateway", + "master hwlab-edge-proxy", + "frp", + "D601 hwlab-dev/hwlab-router", + "cloud-web/cloud-api" + ], "Cloud Web runtime must retain the observed route contract"); assert.ok(app.includes("frontendBypass=false"), "M3 control evidence must explicitly record no frontend bypass"); for (const [pattern, label] of [ @@ -110,7 +117,7 @@ export function runCloudWebM3ReadonlyContract() { /res_m5-control-relay|res_m5-target-board|out1|reset/u, "M5/reset dry-run wiring must not appear in the default M3 workbench topology" ); - assert.ok(gateSummary.health.length >= 1, "health diagnostics must remain visible"); + assert.ok(gateSummary.health.length >= 1, "checked-in gate summary must retain source health context for fallback explanation"); assert.ok(gateSummary.evidenceRecords.every((record) => record.dryRun === true), "checked-in evidence must stay dry-run labeled"); assert.ok( diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 191edc3e..39f6a5d9 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -292,7 +292,6 @@ h3 { .resource-tree, .compact-list, .conversation-list, -.trace-list, .task-list, .hardware-list { min-height: 0; @@ -456,7 +455,6 @@ h3 { } .conversation-panel, -.trace-panel, .task-panel { grid-template-rows: auto minmax(0, 1fr); } @@ -469,7 +467,6 @@ h3 { .conversation-list, .compact-list, -.trace-list, .task-list { display: grid; align-content: start; @@ -512,10 +509,7 @@ h3 { .message-card, .info-card, -.trace-item, -.metric, -.milestone-card, -.status-card { +.metric { min-width: 0; background: var(--surface-2); border: 1px solid var(--line); @@ -684,39 +678,14 @@ h3 { color: var(--bad); } -.trace-item { - display: grid; - grid-template-columns: 38px minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - padding: 8px 10px; -} - -.trace-order { - color: var(--accent); - font-family: var(--mono); - font-weight: 760; -} - -.trace-title, -.info-title, -.milestone-id { +.info-title { display: block; color: var(--text); font-weight: 760; overflow-wrap: anywhere; } -.trace-meta, .info-detail, -.milestone-meta, -.milestone-summary, -.status-meta { - display: block; - color: var(--muted); - overflow-wrap: anywhere; -} - .command-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; @@ -948,19 +917,6 @@ h3 { font-size: 11px; } -.status-card { - display: grid; - gap: 5px; - padding: 9px; -} - -.status-value { - font-size: 18px; - font-weight: 800; - line-height: 1; - overflow-wrap: anywhere; -} - .side-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; @@ -1091,28 +1047,53 @@ h3 { background: var(--surface); } -.rpc-chip-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - .gate-view { display: grid; - gap: 10px; - padding: 10px; -} - -.gate-grid { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-rows: auto auto minmax(0, 1fr); gap: 8px; + padding: 10px; + overflow: hidden; } -.gate-split { +.gate-header, +.gate-controls { display: grid; - grid-template-columns: 1fr 1fr; + align-items: center; gap: 10px; + padding: 12px; + background: rgba(24, 27, 24, 0.97); + border: 1px solid var(--line); +} + +.gate-header { + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.gate-controls { + grid-template-columns: minmax(92px, 140px) minmax(0, 1fr) auto; +} + +.gate-controls label { + min-width: 0; + display: grid; + gap: 4px; +} + +.gate-controls label span { + color: var(--muted); + font-size: 10px; + font-weight: 760; +} + +.gate-controls select, +.gate-controls input { + width: 100%; + min-height: 34px; + padding: 0 9px; + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--text); } .metric { @@ -1128,25 +1109,10 @@ h3 { text-transform: uppercase; } -.metric-value { - color: var(--text); - font-family: var(--mono); - overflow-wrap: anywhere; -} - -.milestone-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} - -.milestone-card { - display: grid; - gap: 8px; - padding: 10px; -} - -.table-wrap { +.table-wrap, +.gate-table-wrap { + min-height: 0; + overflow: auto; border: 1px solid var(--line); overflow-x: auto; overflow-y: auto; @@ -1227,6 +1193,10 @@ table { table-layout: fixed; } +.gate-table { + min-width: 1120px; +} + th, td { text-align: left; @@ -1251,23 +1221,6 @@ tbody tr:last-child td { border-bottom: 0; } -.command-list { - display: grid; - gap: 8px; - padding: 0; - list-style: none; -} - -.command-list li { - min-width: 0; - padding: 9px; - background: var(--surface-2); - border: 1px solid var(--line); - color: var(--muted); - font-family: var(--mono); - overflow-wrap: anywhere; -} - .mono { font-family: var(--mono); font-size: 11px; @@ -1307,6 +1260,7 @@ tbody tr:last-child td { .tone-dry-run, .tone-not_run, +.tone-pending, .tone-warn, .tone-warning, .tone-probing, @@ -1408,10 +1362,6 @@ tbody tr:last-child td { border-top: 1px solid var(--line); border-left: 0; } - - .gate-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } } @media (max-width: 860px) { @@ -1506,13 +1456,11 @@ tbody tr:last-child td { } .topbar, - .gate-split { + .gate-controls { grid-template-columns: 1fr; } .hardware-list, - .milestone-grid, - .gate-grid, .m3-control-grid, .m3-control-actions, .quick-actions { @@ -1524,8 +1472,7 @@ tbody tr:last-child td { gap: 3px; } - .message-card, - .trace-item { + .message-card { grid-template-columns: 1fr; }