@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user