From f3ce52bb84acbcfd3f38b3444c7d0b6016f2d546 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 22 May 2026 17:46:12 +0800 Subject: [PATCH] feat: add workbench hardware evidence panel contract Refs #99 #78 #102 --- web/hwlab-cloud-web/scripts/build.mjs | 2 +- web/hwlab-cloud-web/scripts/check.mjs | 13 +- .../workbench-hardware-panel-contract.mjs | 229 ++++++++ .../workbench-hardware-panel.mjs | 556 ++++++++++++++++++ 4 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/workbench-hardware-panel-contract.mjs create mode 100644 web/hwlab-cloud-web/workbench-hardware-panel.mjs diff --git a/web/hwlab-cloud-web/scripts/build.mjs b/web/hwlab-cloud-web/scripts/build.mjs index 424691cb..37504e4b 100644 --- a/web/hwlab-cloud-web/scripts/build.mjs +++ b/web/hwlab-cloud-web/scripts/build.mjs @@ -5,7 +5,7 @@ const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), ". const distDir = path.resolve(rootDir, "dist"); fs.mkdirSync(distDir, { recursive: true }); -for (const file of ["index.html", "styles.css", "app.mjs", "gate-summary.mjs", "runtime.mjs"]) { +for (const file of ["index.html", "styles.css", "app.mjs", "gate-summary.mjs", "runtime.mjs", "workbench-hardware-panel.mjs"]) { fs.copyFileSync(path.resolve(rootDir, file), path.resolve(distDir, file)); } diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 0dff242c..aa9f0034 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -5,10 +5,18 @@ import { fileURLToPath } from "node:url"; import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; import { gateSummary } from "../gate-summary.mjs"; import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs"; +import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = path.resolve(rootDir, "../.."); -const requiredFiles = ["index.html", "styles.css", "app.mjs", "gate-summary.mjs", "runtime.mjs"]; +const requiredFiles = [ + "index.html", + "styles.css", + "app.mjs", + "gate-summary.mjs", + "runtime.mjs", + "workbench-hardware-panel.mjs" +]; for (const file of requiredFiles) { const filePath = path.resolve(rootDir, file); @@ -99,5 +107,6 @@ assert.equal(gateSummary.evidenceRecords.length, 2); assert.equal(gateSummary.safety.allowNetwork, false); runCloudWebM3ReadonlyContract(); +runWorkbenchHardwarePanelContract(); -console.log("hwlab-cloud-web check ok: workbench shell is present and read-only diagnostics contract is preserved"); +console.log("hwlab-cloud-web check ok: workbench shell, hardware evidence panel, and read-only diagnostics contract are present"); diff --git a/web/hwlab-cloud-web/scripts/workbench-hardware-panel-contract.mjs b/web/hwlab-cloud-web/scripts/workbench-hardware-panel-contract.mjs new file mode 100644 index 00000000..537967dd --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-hardware-panel-contract.mjs @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + M3_TRUSTED_CHAIN, + createHardwareEvidencePanelModel, + defaultHardwareEvidencePanelData, + hardwareEvidencePanelSourceLegend, + renderHardwareEvidencePanel +} from "../workbench-hardware-panel.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const panelPath = path.resolve(rootDir, "workbench-hardware-panel.mjs"); +const panelSource = fs.readFileSync(panelPath, "utf8"); + +const REQUIRED_SOURCE_KINDS = Object.freeze(["SOURCE", "DRY-RUN", "DEV-LIVE", "BLOCKED"]); +const FORBIDDEN_MUTATION_PATTERNS = Object.freeze([ + /hardware\.operation\.request/u, + /callRpc\(\s*["']hardware\./u, + /hardware\.invoke\.shell/u, + /audit\.event\.write/u, + /evidence\.record\.write/u, + /\/wiring\/apply/u, + /\/wiring\/reload/u, + /fetch\(/u, + /XMLHttpRequest/u, + /sendBeacon/u +]); + +export function runWorkbenchHardwarePanelContract() { + assert.equal(typeof renderHardwareEvidencePanel, "function", "renderer export must exist"); + assert.equal(typeof createHardwareEvidencePanelModel, "function", "model export must exist"); + assert.deepEqual(M3_TRUSTED_CHAIN, { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + patchPanelServiceId: "hwlab-patch-panel", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + }); + + const sourceKinds = hardwareEvidencePanelSourceLegend.map((item) => item.sourceKind); + assert.deepEqual(sourceKinds, REQUIRED_SOURCE_KINDS, "panel must expose SOURCE/DRY-RUN/DEV-LIVE/BLOCKED tags"); + for (const sourceKind of REQUIRED_SOURCE_KINDS) { + assert.match(panelSource, new RegExp(`["']${sourceKind}["']`, "u"), `${sourceKind} label must be rendered by source`); + } + + const blockedModel = createHardwareEvidencePanelModel(defaultHardwareEvidencePanelData); + assert.equal(blockedModel.trustedLoop.status, "blocked", "default model must stay blocked without DEV-LIVE evidence"); + assert.equal(blockedModel.trustedLoop.tone, "degraded", "default model must render degraded, not green"); + assert.equal(blockedModel.sourceKind, "BLOCKED", "missing DEV-LIVE evidence must force BLOCKED source kind"); + assert.equal(blockedModel.chain.fromResourceId, "res_boxsimu_1"); + assert.equal(blockedModel.chain.fromPort, "DO1"); + assert.equal(blockedModel.chain.patchPanelServiceId, "hwlab-patch-panel"); + assert.equal(blockedModel.chain.toResourceId, "res_boxsimu_2"); + assert.equal(blockedModel.chain.toPort, "DI1"); + assert.match(blockedModel.chain.detail, /BLOCKED/u); + assert.match(blockedModel.chain.detail, /waiting for DEV-LIVE M3 loop/u); + + assert.ok( + blockedModel.statuses.some((item) => item.kind === "BOX-SIMU" && item.id === "res_boxsimu_1"), + "BOX-SIMU source status must be present" + ); + assert.ok( + blockedModel.statuses.some((item) => item.kind === "Gateway-SIMU"), + "Gateway-SIMU status must be present" + ); + assert.ok( + blockedModel.statuses.some((item) => item.kind === "Patch Panel" && item.id === "hwlab-patch-panel"), + "Patch Panel status must be present" + ); + + assert.ok(blockedModel.records.operations.length >= 1, "operation summary must render"); + assert.ok(blockedModel.records.audit.length >= 1, "audit summary must render"); + assert.ok(blockedModel.records.evidence.length >= 1, "evidence summary must render"); + assert.ok( + blockedModel.records.evidence.some((record) => record.sourceKind === "DRY-RUN"), + "fixture evidence must stay DRY-RUN labeled" + ); + + assert.ok(blockedModel.controls.length >= 1, "placeholder controls must exist"); + for (const control of blockedModel.controls) { + assert.equal(control.disabled, true, `${control.id} must be disabled`); + assert.match(control.reason, /not wired \/ readonly \/ waiting for DEV-LIVE M3 loop/u); + } + + const misleadingGreenModel = createHardwareEvidencePanelModel({ + statuses: [ + { + id: "res_boxsimu_1", + label: "BOX-SIMU 1", + kind: "BOX-SIMU", + status: "healthy", + sourceKind: "SOURCE" + } + ], + chain: { + ...M3_TRUSTED_CHAIN, + status: "healthy", + sourceKind: "SOURCE" + }, + records: { + operations: [], + audit: [], + evidence: [] + }, + controls: [] + }); + assert.equal(misleadingGreenModel.trustedLoop.status, "blocked", "green-looking source data cannot promote M3"); + assert.equal(misleadingGreenModel.chain.status, "blocked", "chain must be blocked without DEV-LIVE evidence"); + assert.notEqual(misleadingGreenModel.statuses[0].status, "healthy", "healthy source-only status must be downgraded"); + + const trustedModel = createHardwareEvidencePanelModel({ + chain: { + ...M3_TRUSTED_CHAIN, + status: "verified", + sourceKind: "DEV-LIVE", + detail: "DEV-LIVE M3 loop observed and attached." + }, + records: { + operations: [ + { + id: "op_m3_dev_live_readonly_summary", + status: "succeeded", + sourceKind: "DEV-LIVE", + summary: "Existing trusted operation summary only." + } + ], + audit: [ + { + id: "aud_m3_dev_live_readonly_summary", + status: "recorded", + sourceKind: "DEV-LIVE", + summary: "Existing trusted audit summary only." + } + ], + evidence: [ + { + id: "evi_m3_dev_live_loop", + status: "verified", + sourceKind: "DEV-LIVE", + summary: "Trusted M3 loop evidence is already recorded." + } + ] + }, + controls: [ + { + id: "dev-live-safe-placeholder", + label: "Readonly", + disabled: false, + reason: "" + } + ] + }); + assert.equal(trustedModel.trustedLoop.status, "verified", "DEV-LIVE evidence plus exact chain can promote M3"); + assert.equal(trustedModel.sourceKind, "DEV-LIVE"); + assert.equal(trustedModel.controls[0].disabled, true, "controls must remain disabled even with DEV-LIVE evidence"); + assert.match(trustedModel.controls[0].reason, /not wired \/ readonly \/ waiting for DEV-LIVE M3 loop/u); + + const dom = createFakeDom(); + const rendered = renderHardwareEvidencePanel(dom.container, defaultHardwareEvidencePanelData); + assert.equal(rendered.model.sourceKind, "BLOCKED", "renderer must use guarded model"); + assert.equal(dom.container.children.length, 1, "renderer must replace the container with one panel root"); + assert.equal(dom.container.children[0].dataset.sourceKind, "BLOCKED", "rendered panel must expose sourceKind"); + const buttons = dom.container.querySelectorAll("button"); + assert.equal(buttons.length, blockedModel.controls.length, "renderer must include all placeholder controls"); + for (const button of buttons) { + assert.equal(button.disabled, true, "rendered placeholder controls must be disabled"); + } + + for (const pattern of FORBIDDEN_MUTATION_PATTERNS) { + assert.doesNotMatch(panelSource, pattern, `panel must not include mutation or network path ${pattern}`); + } +} + +function createFakeDom() { + const document = { + createElement(tagName) { + return new FakeElement(tagName, document); + } + }; + return { + document, + container: new FakeElement("div", document) + }; +} + +class FakeElement { + constructor(tagName, ownerDocument) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = ownerDocument; + this.children = []; + this.dataset = {}; + this.attributes = {}; + this.className = ""; + this.disabled = false; + this.textContent = ""; + this.type = ""; + } + + append(...children) { + this.children.push(...children); + } + + replaceChildren(...children) { + this.children = children; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + } + + querySelectorAll(tagName) { + const normalizedTagName = tagName.toUpperCase(); + const matches = []; + for (const child of this.children) { + if (child.tagName === normalizedTagName) { + matches.push(child); + } + matches.push(...child.querySelectorAll(tagName)); + } + return matches; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + runWorkbenchHardwarePanelContract(); + console.log("Workbench hardware evidence panel contract passed"); +} diff --git a/web/hwlab-cloud-web/workbench-hardware-panel.mjs b/web/hwlab-cloud-web/workbench-hardware-panel.mjs new file mode 100644 index 00000000..a6978fc2 --- /dev/null +++ b/web/hwlab-cloud-web/workbench-hardware-panel.mjs @@ -0,0 +1,556 @@ +const SOURCE_KIND_ORDER = Object.freeze(["SOURCE", "DRY-RUN", "DEV-LIVE", "BLOCKED"]); + +export const M3_TRUSTED_CHAIN = Object.freeze({ + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + patchPanelServiceId: "hwlab-patch-panel", + toResourceId: "res_boxsimu_2", + toPort: "DI1" +}); + +export const hardwareEvidencePanelSourceLegend = Object.freeze([ + { + sourceKind: "SOURCE", + label: "SOURCE", + detail: "Static contract, topology, or checked-in source material." + }, + { + sourceKind: "DRY-RUN", + label: "DRY-RUN", + detail: "Fixture or local validation that never touched the live hardware path." + }, + { + sourceKind: "DEV-LIVE", + label: "DEV-LIVE", + detail: "Trusted DEV observation from the M3 closed loop." + }, + { + sourceKind: "BLOCKED", + label: "BLOCKED", + detail: "Cannot promote to trusted evidence until DEV-LIVE M3 exists." + } +]); + +export const defaultHardwareEvidencePanelData = Object.freeze({ + title: "Hardware Evidence", + subtitle: "Right-side workbench panel for the M3 trusted loop", + sourceKind: "SOURCE", + observedAt: null, + statuses: [ + { + id: "res_boxsimu_1", + label: "BOX-SIMU 1", + kind: "BOX-SIMU", + role: "source DO1", + status: "degraded", + sourceKind: "BLOCKED", + detail: "No DEV-LIVE DO1 transition evidence is attached yet." + }, + { + id: "gateway_simu_1", + label: "Gateway-SIMU", + kind: "Gateway-SIMU", + role: "read-only route", + status: "degraded", + sourceKind: "SOURCE", + detail: "Gateway shape is visible, but the M3 live loop is not proven." + }, + { + id: "hwlab-patch-panel", + label: "Patch Panel", + kind: "Patch Panel", + role: "wiring authority", + status: "blocked", + sourceKind: "BLOCKED", + detail: "Waiting for a DEV-LIVE active link through hwlab-patch-panel." + }, + { + id: "res_boxsimu_2", + label: "BOX-SIMU 2", + kind: "BOX-SIMU", + role: "target DI1", + status: "degraded", + sourceKind: "BLOCKED", + detail: "No DEV-LIVE DI1 observation is attached yet." + } + ], + chain: { + ...M3_TRUSTED_CHAIN, + status: "blocked", + sourceKind: "BLOCKED", + detail: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 is not DEV-LIVE proven." + }, + records: { + operations: [ + { + id: "op_m3_waiting_dev_live", + status: "blocked", + sourceKind: "BLOCKED", + summary: "Readonly placeholder; no hardware operation is issued from this panel." + } + ], + audit: [ + { + id: "audit_m3_panel_contract", + status: "recorded", + sourceKind: "SOURCE", + summary: "Workbench panel contract requires read-only evidence before trust promotion." + } + ], + evidence: [ + { + id: "evi_m3_dry_run_fixture", + status: "dry-run", + sourceKind: "DRY-RUN", + summary: "Local smoke proves shape only; it is not DEV-LIVE trusted evidence." + } + ] + }, + controls: [ + { + id: "m3-do1-toggle", + label: "DO1 trigger", + disabled: true, + reason: "not wired / readonly / waiting for DEV-LIVE M3 loop" + }, + { + id: "patch-panel-apply", + label: "Apply wiring", + disabled: true, + reason: "not wired / readonly / waiting for DEV-LIVE M3 loop" + } + ] +}); + +export function createHardwareEvidencePanelModel(data = {}) { + const statuses = normalizeStatuses(data.statuses ?? defaultHardwareEvidencePanelData.statuses); + const records = normalizeRecords(data.records ?? defaultHardwareEvidencePanelData.records); + const controls = normalizeControls(data.controls ?? defaultHardwareEvidencePanelData.controls); + const chain = normalizeChain(data.chain ?? defaultHardwareEvidencePanelData.chain); + const hasTrustedDevLiveEvidence = records.evidence.some( + (record) => + record.sourceKind === "DEV-LIVE" && + ["pass", "passed", "verified", "succeeded", "trusted"].includes(record.status) + ); + const chainMatches = isRequiredM3Chain(chain); + const trustStatus = hasTrustedDevLiveEvidence && chainMatches ? "verified" : "blocked"; + const trustTone = trustStatus === "verified" ? "trusted" : "degraded"; + const sourceKind = trustStatus === "verified" ? "DEV-LIVE" : "BLOCKED"; + const guardedChain = { + ...chain, + status: trustStatus === "verified" ? normalizeStatus(chain.status, "verified") : "blocked", + sourceKind, + detail: + trustStatus === "verified" + ? chain.detail + : "BLOCKED: waiting for DEV-LIVE M3 loop evidence for res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1." + }; + + return { + title: data.title ?? defaultHardwareEvidencePanelData.title, + subtitle: data.subtitle ?? defaultHardwareEvidencePanelData.subtitle, + observedAt: data.observedAt ?? defaultHardwareEvidencePanelData.observedAt, + sourceKind, + sourceLegend: hardwareEvidencePanelSourceLegend, + trustedLoop: { + milestone: "M3", + status: trustStatus, + tone: trustTone, + sourceKind, + reason: + trustStatus === "verified" + ? "DEV-LIVE evidence is attached to the required M3 chain." + : "Current data lacks real DEV-LIVE evidence; the workbench must render BLOCKED/degraded, never green." + }, + statuses: statuses.map((status) => ({ + ...status, + status: trustStatus === "verified" || status.status === "blocked" ? status.status : "degraded" + })), + chain: guardedChain, + records, + controls: controls.map((control) => ({ + ...control, + disabled: true, + reason: control.reason || "not wired / readonly / waiting for DEV-LIVE M3 loop" + })) + }; +} + +export function renderHardwareEvidencePanel(container, data = {}) { + if (!container || typeof container.replaceChildren !== "function") { + throw new TypeError("renderHardwareEvidencePanel requires a DOM container with replaceChildren()."); + } + + const model = createHardwareEvidencePanelModel(data); + const doc = container.ownerDocument ?? globalThis.document; + const root = element(doc, "aside", "hwlab-workbench-hardware-panel"); + root.dataset.status = model.trustedLoop.status; + root.dataset.sourceKind = model.sourceKind; + root.setAttribute("aria-label", "Workbench hardware evidence panel"); + + root.append(panelStyles(doc)); + root.append(renderHeader(doc, model)); + root.append(renderStatusSection(doc, model)); + root.append(renderChainSection(doc, model.chain)); + root.append(renderRecordsSection(doc, model.records)); + root.append(renderControlsSection(doc, model.controls)); + root.append(renderLegendSection(doc, model.sourceLegend)); + + container.replaceChildren(root); + return { element: root, model }; +} + +function normalizeStatuses(statuses) { + return arrayFrom(statuses).map((status) => ({ + id: stringOr(status.id, "unknown-resource"), + label: stringOr(status.label, status.id ?? "Unknown resource"), + kind: stringOr(status.kind, "resource"), + role: stringOr(status.role, "status"), + status: normalizeStatus(status.status, "degraded"), + sourceKind: normalizeSourceKind(status.sourceKind), + detail: stringOr(status.detail, "No detail provided.") + })); +} + +function normalizeRecords(records) { + return { + operations: normalizeRecordList(records.operations), + audit: normalizeRecordList(records.audit), + evidence: normalizeRecordList(records.evidence) + }; +} + +function normalizeRecordList(records) { + return arrayFrom(records).map((record) => ({ + id: stringOr(record.id ?? record.operationId ?? record.auditId ?? record.evidenceId, "record"), + status: normalizeStatus(record.status ?? record.outcome, "recorded"), + sourceKind: normalizeSourceKind(record.sourceKind), + summary: stringOr(record.summary ?? record.uri ?? record.kind, "No summary provided.") + })); +} + +function normalizeControls(controls) { + return arrayFrom(controls).map((control) => ({ + id: stringOr(control.id, "readonly-control"), + label: stringOr(control.label, "Readonly control"), + disabled: true, + reason: stringOr(control.reason, "not wired / readonly / waiting for DEV-LIVE M3 loop") + })); +} + +function normalizeChain(chain) { + return { + fromResourceId: stringOr(chain.fromResourceId, M3_TRUSTED_CHAIN.fromResourceId), + fromPort: stringOr(chain.fromPort, M3_TRUSTED_CHAIN.fromPort), + patchPanelServiceId: stringOr(chain.patchPanelServiceId, M3_TRUSTED_CHAIN.patchPanelServiceId), + toResourceId: stringOr(chain.toResourceId, M3_TRUSTED_CHAIN.toResourceId), + toPort: stringOr(chain.toPort, M3_TRUSTED_CHAIN.toPort), + status: normalizeStatus(chain.status, "blocked"), + sourceKind: normalizeSourceKind(chain.sourceKind), + detail: stringOr(chain.detail, "Waiting for M3 evidence.") + }; +} + +function isRequiredM3Chain(chain) { + return ( + chain.fromResourceId === M3_TRUSTED_CHAIN.fromResourceId && + chain.fromPort === M3_TRUSTED_CHAIN.fromPort && + chain.patchPanelServiceId === M3_TRUSTED_CHAIN.patchPanelServiceId && + chain.toResourceId === M3_TRUSTED_CHAIN.toResourceId && + chain.toPort === M3_TRUSTED_CHAIN.toPort + ); +} + +function normalizeStatus(status, fallback) { + const value = String(status ?? fallback).trim().toLowerCase(); + return value || fallback; +} + +function normalizeSourceKind(sourceKind) { + const value = String(sourceKind ?? "SOURCE").trim().toUpperCase(); + return SOURCE_KIND_ORDER.includes(value) ? value : "SOURCE"; +} + +function arrayFrom(value) { + return Array.isArray(value) ? value : []; +} + +function stringOr(value, fallback) { + const text = value === undefined || value === null ? "" : String(value).trim(); + return text || fallback; +} + +function renderHeader(doc, model) { + const header = element(doc, "header", "hwlab-panel-header"); + const titleWrap = element(doc, "div", "hwlab-panel-title-wrap"); + titleWrap.append(textElement(doc, "p", "hwlab-panel-kicker", "M3 trusted loop")); + titleWrap.append(textElement(doc, "h2", "hwlab-panel-title", model.title)); + titleWrap.append(textElement(doc, "p", "hwlab-panel-subtitle", model.subtitle)); + header.append(titleWrap); + header.append(badge(doc, model.sourceKind, model.trustedLoop.tone)); + header.append(textElement(doc, "p", `hwlab-panel-trust tone-${toneClass(model.trustedLoop.tone)}`, model.trustedLoop.reason)); + return header; +} + +function renderStatusSection(doc, model) { + const section = panelSection(doc, "Hardware status", "BOX-SIMU / Gateway-SIMU / Patch Panel"); + const grid = element(doc, "div", "hwlab-status-grid"); + for (const item of model.statuses) { + const card = element(doc, "article", `hwlab-status-card tone-${toneClass(item.status)}`); + card.dataset.sourceKind = item.sourceKind; + const head = element(doc, "div", "hwlab-status-card-head"); + head.append(textElement(doc, "strong", "hwlab-status-label", item.label)); + head.append(badge(doc, item.sourceKind, item.sourceKind)); + card.append(head); + card.append(textElement(doc, "span", "hwlab-status-kind", `${item.kind} / ${item.role}`)); + card.append(textElement(doc, "span", `hwlab-status-value tone-${toneClass(item.status)}`, item.status.toUpperCase())); + card.append(textElement(doc, "p", "hwlab-status-detail", item.detail)); + grid.append(card); + } + section.append(grid); + return section; +} + +function renderChainSection(doc, chain) { + const section = panelSection(doc, "M3 chain", "Required route"); + const chainLine = element(doc, "div", `hwlab-chain-line tone-${toneClass(chain.status)}`); + chainLine.append(chainNode(doc, `${chain.fromResourceId}:${chain.fromPort}`, "BOX-SIMU DO1")); + chainLine.append(textElement(doc, "span", "hwlab-chain-arrow", "->")); + chainLine.append(chainNode(doc, chain.patchPanelServiceId, "Patch Panel")); + chainLine.append(textElement(doc, "span", "hwlab-chain-arrow", "->")); + chainLine.append(chainNode(doc, `${chain.toResourceId}:${chain.toPort}`, "BOX-SIMU DI1")); + section.append(chainLine); + const detail = element(doc, "div", "hwlab-chain-detail"); + detail.append(badge(doc, chain.sourceKind, chain.status)); + detail.append(textElement(doc, "p", "hwlab-chain-copy", chain.detail)); + section.append(detail); + return section; +} + +function renderRecordsSection(doc, records) { + const section = panelSection(doc, "Records", "operation / audit / evidence summary"); + const columns = element(doc, "div", "hwlab-record-columns"); + for (const [label, items] of [ + ["operation", records.operations], + ["audit", records.audit], + ["evidence", records.evidence] + ]) { + const column = element(doc, "article", "hwlab-record-column"); + column.append(textElement(doc, "h4", "hwlab-record-heading", `${label} (${items.length})`)); + const list = element(doc, "ul", "hwlab-record-list"); + for (const item of items) { + const row = element(doc, "li", "hwlab-record-item"); + const head = element(doc, "div", "hwlab-record-head"); + head.append(textElement(doc, "span", "hwlab-record-id", item.id)); + head.append(badge(doc, item.sourceKind, item.sourceKind)); + row.append(head); + row.append(textElement(doc, "span", `hwlab-record-status tone-${toneClass(item.status)}`, item.status)); + row.append(textElement(doc, "p", "hwlab-record-summary", item.summary)); + list.append(row); + } + column.append(list); + columns.append(column); + } + section.append(columns); + return section; +} + +function renderControlsSection(doc, controls) { + const section = panelSection(doc, "Controls", "readonly placeholders"); + const list = element(doc, "div", "hwlab-control-list"); + for (const control of controls) { + const row = element(doc, "div", "hwlab-control-row"); + const button = element(doc, "button", "hwlab-control-button"); + button.type = "button"; + button.disabled = true; + button.dataset.controlId = control.id; + button.textContent = control.label; + row.append(button); + row.append(textElement(doc, "span", "hwlab-control-reason", control.reason)); + list.append(row); + } + section.append(list); + return section; +} + +function renderLegendSection(doc, legend) { + const section = panelSection(doc, "Source labels", "promotion boundary"); + const list = element(doc, "dl", "hwlab-source-legend"); + for (const item of legend) { + list.append(textElement(doc, "dt", "hwlab-source-kind", item.label)); + list.append(textElement(doc, "dd", "hwlab-source-detail", item.detail)); + } + section.append(list); + return section; +} + +function panelSection(doc, title, caption) { + const section = element(doc, "section", "hwlab-panel-section"); + const head = element(doc, "div", "hwlab-section-head"); + head.append(textElement(doc, "h3", "hwlab-section-title", title)); + head.append(textElement(doc, "span", "hwlab-section-caption", caption)); + section.append(head); + return section; +} + +function chainNode(doc, value, label) { + const node = element(doc, "span", "hwlab-chain-node"); + node.append(textElement(doc, "strong", "hwlab-chain-value", value)); + node.append(textElement(doc, "small", "hwlab-chain-label", label)); + return node; +} + +function badge(doc, value, tone) { + const item = element(doc, "span", `hwlab-source-badge tone-${toneClass(tone ?? value)}`); + item.textContent = value; + return item; +} + +function textElement(doc, tagName, className, text) { + const item = element(doc, tagName, className); + item.textContent = text; + return item; +} + +function element(doc, tagName, className) { + const item = doc.createElement(tagName); + if (className) item.className = className; + return item; +} + +function toneClass(value) { + return String(value ?? "unknown") + .trim() + .replace(/[^a-z0-9_-]/giu, "-") + .toLowerCase(); +} + +function panelStyles(doc) { + const style = element(doc, "style"); + style.textContent = ` +.hwlab-workbench-hardware-panel { + box-sizing: border-box; + display: grid; + gap: 12px; + min-width: 320px; + max-width: 420px; + padding: 14px; + color: #d9e2df; + background: #151a1d; + border-left: 1px solid #364246; + font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +.hwlab-workbench-hardware-panel *, .hwlab-workbench-hardware-panel *::before, .hwlab-workbench-hardware-panel *::after { + box-sizing: border-box; +} +.hwlab-panel-header, .hwlab-panel-section { + display: grid; + gap: 10px; + padding: 12px; + background: #1d2427; + border: 1px solid #364246; + border-radius: 6px; +} +.hwlab-panel-title-wrap { + display: grid; + gap: 2px; +} +.hwlab-section-head, .hwlab-status-card-head, .hwlab-record-head, .hwlab-control-row, .hwlab-chain-detail { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; +} +.hwlab-panel-title, .hwlab-section-title, .hwlab-record-heading, .hwlab-panel-kicker, .hwlab-panel-subtitle, .hwlab-panel-trust, .hwlab-status-detail, .hwlab-chain-copy, .hwlab-record-summary { + margin: 0; +} +.hwlab-panel-title { + font-size: 18px; + font-weight: 700; +} +.hwlab-panel-kicker, .hwlab-section-caption, .hwlab-status-kind, .hwlab-chain-label, .hwlab-panel-subtitle, .hwlab-source-detail, .hwlab-control-reason { + color: #95a6a3; +} +.hwlab-source-badge { + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; +} +.hwlab-status-grid, .hwlab-record-columns { + display: grid; + gap: 8px; +} +.hwlab-status-card, .hwlab-record-column, .hwlab-record-item, .hwlab-chain-line { + display: grid; + gap: 6px; + padding: 10px; + background: #101416; + border: 1px solid #2c373a; + border-radius: 6px; +} +.hwlab-status-value, .hwlab-record-status, .hwlab-record-id, .hwlab-chain-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +.hwlab-chain-line { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; +} +.hwlab-chain-node { + display: grid; + gap: 2px; + min-width: 0; +} +.hwlab-chain-value { + overflow-wrap: anywhere; +} +.hwlab-chain-arrow { + color: #95a6a3; +} +.hwlab-record-list, .hwlab-source-legend { + display: grid; + gap: 8px; + margin: 0; + padding: 0; +} +.hwlab-record-list { + list-style: none; +} +.hwlab-source-legend { + grid-template-columns: auto minmax(0, 1fr); +} +.hwlab-source-kind { + margin: 0; + font-weight: 700; +} +.hwlab-source-detail { + margin: 0; +} +.hwlab-control-list { + display: grid; + gap: 8px; +} +.hwlab-control-button { + min-width: 104px; + padding: 6px 9px; + color: #95a6a3; + background: #222a2d; + border: 1px solid #3a474b; + border-radius: 4px; + cursor: not-allowed; +} +.tone-blocked, .tone-degraded { + color: #ffb86b; +} +.tone-source, .tone-dry-run { + color: #8fc7ff; +} +.tone-trusted, .tone-dev-live, .tone-verified { + color: #74d99f; +} +`; + return style; +}