From 166a7763a4d2fd7be9a801caf0a51b4be3e59137 Mon Sep 17 00:00:00 2001
From: Lyon <88232613+pikasTech@users.noreply.github.com>
Date: Fri, 22 May 2026 17:36:27 +0800
Subject: [PATCH] feat: add HWLAB Cloud workbench shell
Refs #99 #78
---
web/hwlab-cloud-web/app.mjs | 952 +++++++++++++---------
web/hwlab-cloud-web/index.html | 438 +++++-----
web/hwlab-cloud-web/scripts/check.mjs | 46 +-
web/hwlab-cloud-web/styles.css | 1072 +++++++++++++++++--------
4 files changed, 1522 insertions(+), 986 deletions(-)
diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs
index fa368a65..e5e9d00b 100644
--- a/web/hwlab-cloud-web/app.mjs
+++ b/web/hwlab-cloud-web/app.mjs
@@ -9,42 +9,50 @@ const rpcReadMethods = Object.freeze([
"evidence.record.query"
]);
-const navLinks = [...document.querySelectorAll("[data-route]")];
-const views = [...document.querySelectorAll("[data-view]")];
-const viewIds = new Set(views.map((view) => view.dataset.view));
+const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
let rpcSequence = 0;
const el = {
+ shell: query("[data-app-shell]"),
+ explorerToggle: byId("explorer-toggle"),
+ explorerStatus: byId("explorer-status"),
+ sourceStrip: byId("source-strip"),
+ treeCount: byId("tree-count"),
+ resourceTree: byId("resource-tree"),
+ blockerCount: byId("blocker-count"),
+ explorerBlockers: byId("explorer-blockers"),
+ routePath: byId("route-path"),
liveStatus: byId("live-status"),
liveDetail: byId("live-detail"),
- overviewMetrics: byId("overview-metrics"),
- methodDetail: byId("method-detail"),
- methodGrid: byId("method-grid"),
- runtimeDetail: byId("runtime-detail"),
- runtimeMetrics: byId("runtime-metrics"),
- topologyLiveMetrics: byId("topology-live-metrics"),
- topologyBody: byId("topology-body"),
- patchMetrics: byId("patch-metrics"),
- hardwareLiveMetrics: byId("hardware-live-metrics"),
- operationBody: byId("operation-body"),
- agentMetrics: byId("agent-metrics"),
- agentEvidenceMetrics: byId("agent-evidence-metrics"),
- auditDetail: byId("audit-detail"),
- auditBody: byId("audit-body"),
- evidenceQueryDetail: byId("evidence-query-detail"),
- liveEvidenceBody: byId("live-evidence-body"),
- evidenceBody: byId("evidence-body"),
- metaGrid: byId("meta-grid"),
+ conversationList: byId("conversation-list"),
+ traceList: byId("trace-list"),
+ gateMetadata: byId("gate-metadata"),
milestoneGrid: byId("milestone-grid"),
healthBody: byId("health-body"),
- blockerList: byId("blocker-list"),
- commandList: byId("command-list")
+ gateBlockers: byId("gate-blockers"),
+ validationList: byId("validation-list"),
+ commandForm: byId("command-form"),
+ commandInput: byId("command-input"),
+ commandClear: byId("command-clear"),
+ hardwareList: byId("hardware-list"),
+ controlList: byId("control-list"),
+ wiringList: byId("wiring-list"),
+ recordsList: byId("records-list"),
+ diagnosticsList: byId("diagnostics-list"),
+ methodList: byId("method-list")
+};
+
+const state = {
+ drafts: []
};
initRoutes();
-renderStaticConsole();
-renderLivePending();
-loadLiveSurface().then(renderLiveConsole);
+initExplorerToggle();
+initSideTabs();
+initCommandBar();
+renderStaticWorkbench();
+renderProbePending();
+loadLiveSurface().then(renderLiveSurface);
function byId(id) {
const element = document.getElementById(id);
@@ -54,8 +62,21 @@ function byId(id) {
return element;
}
+function query(selector) {
+ const element = document.querySelector(selector);
+ if (!element) {
+ throw new Error(`missing required element ${selector}`);
+ }
+ return element;
+}
+
function initRoutes() {
window.addEventListener("hashchange", () => showView(routeFromLocation()));
+ for (const button of document.querySelectorAll("[data-route]")) {
+ button.addEventListener("click", () => {
+ window.location.hash = button.dataset.route;
+ });
+ }
showView(routeFromLocation());
}
@@ -63,64 +84,99 @@ function routeFromLocation() {
const hashRoute = window.location.hash.replace(/^#/, "");
if (viewIds.has(hashRoute)) return hashRoute;
if (window.location.pathname.replace(/\/+$/, "").endsWith("/gate")) return "gate";
- return "overview";
+ return "workspace";
}
function showView(route) {
- const activeRoute = viewIds.has(route) ? route : "overview";
- for (const view of views) {
+ const activeRoute = viewIds.has(route) ? route : "workspace";
+ for (const view of document.querySelectorAll("[data-view]")) {
view.hidden = view.dataset.view !== activeRoute;
}
- for (const link of navLinks) {
- link.classList.toggle("active", link.dataset.route === activeRoute);
- link.setAttribute("aria-current", link.dataset.route === activeRoute ? "page" : "false");
+ for (const button of document.querySelectorAll("[data-route]")) {
+ const active = button.dataset.route === activeRoute;
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-current", active ? "page" : "false");
}
}
-function renderStaticConsole() {
- renderTopologyContract();
- renderHardwareContract();
- renderAgentContract();
- renderEvidenceContract();
+function initExplorerToggle() {
+ el.explorerToggle.addEventListener("click", () => {
+ const collapsed = !el.shell.classList.contains("explorer-collapsed");
+ el.shell.classList.toggle("explorer-collapsed", collapsed);
+ el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
+ });
+}
+
+function initSideTabs() {
+ for (const tab of document.querySelectorAll("[data-side-tab]")) {
+ tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab));
+ }
+ for (const jump of document.querySelectorAll("[data-side-tab-jump]")) {
+ jump.addEventListener("click", () => selectSideTab(jump.dataset.sideTabJump));
+ }
+}
+
+function selectSideTab(tabId) {
+ for (const tab of document.querySelectorAll("[data-side-tab]")) {
+ const active = tab.dataset.sideTab === tabId;
+ tab.classList.toggle("active", active);
+ tab.setAttribute("aria-selected", active ? "true" : "false");
+ }
+ for (const panel of document.querySelectorAll("[data-side-panel]")) {
+ const active = panel.dataset.sidePanel === tabId;
+ panel.classList.toggle("active", active);
+ panel.hidden = !active;
+ }
+}
+
+function initCommandBar() {
+ el.commandForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const value = el.commandInput.value.trim();
+ if (!value) return;
+ state.drafts.unshift({
+ text: value,
+ createdAt: new Date().toISOString()
+ });
+ el.commandInput.value = "";
+ renderDrafts();
+ });
+
+ el.commandClear.addEventListener("click", () => {
+ state.drafts = [];
+ renderDrafts();
+ });
+}
+
+function renderStaticWorkbench() {
+ el.explorerStatus.textContent = gateSummary.blocked ? "blocked" : gateSummary.gateStatus;
+ el.explorerStatus.className = `status-dot tone-${toneClass(gateSummary.blocked ? "blocked" : gateSummary.gateStatus)}`;
+ el.routePath.textContent = runtime.serviceRoute.join(" -> ");
+
+ renderStateStrip(false);
+ renderResourceTree();
+ renderBlockerLists();
+ renderConversation();
+ renderTrace();
+ renderHardwareStatus(null);
+ renderDrafts();
+ renderWiringList();
+ renderRecords(null);
+ renderDiagnostics(null);
renderGateDiagnostics();
}
-function renderLivePending() {
- appendMetricGrid(el.overviewMetrics, [
- ["API origin", "same-origin /v1 + /json-rpc"],
- ["Frontend", runtime.endpoints.frontend, "warn"],
- ["Configured DEV", runtime.endpoints.dev, "warn"],
- ["Health", "probing", "warn"],
- ["Runtime", "probing", "warn"],
- ["M3", "probing", "warn"],
- ["Gate", gateSummary.gateStatus, gateSummary.gateStatus]
- ]);
- replaceChildren(el.methodGrid, emptyNotice("Waiting for live cloud-api method list."));
- appendMetricGrid(el.runtimeMetrics, [
- ["Store", "probing", "warn"],
- ["Durable", "probing", "warn"],
- ["Gateway sessions", "probing", "warn"],
- ["Evidence records", "probing", "warn"]
- ]);
- appendMetricGrid(el.topologyLiveMetrics, [
- ["Live gateway sessions", "probing", "warn"],
- ["Live box resources", "probing", "warn"],
- ["Live capabilities", "probing", "warn"],
- ["Live patch state", "not exposed read-only", "warn"]
- ]);
- appendMetricGrid(el.hardwareLiveMetrics, [
- ["Live operations", "probing", "warn"],
- ["Live audit events", "probing", "warn"],
- ["Live evidence records", "probing", "warn"],
- ["Mutation surface", "not called by console", "pass"]
- ]);
- rowNotice(el.auditBody, 4, "Waiting for audit.event.query.");
- rowNotice(el.liveEvidenceBody, 4, "Waiting for evidence.record.query.");
+function renderProbePending() {
+ el.liveStatus.textContent = "probing";
+ el.liveStatus.className = "tone-probing";
+ el.liveDetail.textContent = "Checking /health/live, /v1, and read-only /json-rpc from this origin.";
+ renderMethodList(rpcReadMethods, "pending");
}
async function loadLiveSurface() {
const projectId = gateSummary.topology.projectId;
- const [restIndex, health, adapter, audit, evidence] = await Promise.all([
+ const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([
+ fetchJson("/health/live"),
fetchJson("/v1"),
callRpc("system.health"),
callRpc("cloud.adapter.describe"),
@@ -130,6 +186,7 @@ async function loadLiveSurface() {
return {
observedAt: new Date().toISOString(),
+ healthLive,
restIndex,
health,
adapter,
@@ -213,305 +270,481 @@ async function callRpc(method, params = {}) {
function nextProtocolId(prefix) {
rpcSequence += 1;
- return `${prefix}_cloud-web-console-${Date.now()}-${rpcSequence}`;
+ return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`;
}
-function renderLiveConsole(live) {
- const coreProbes = [live.restIndex, live.health, live.adapter];
+function renderLiveSurface(live) {
+ const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
const reachable = coreProbes.some((probe) => probe.ok);
+ const healthStatus = live.health.data?.status ?? live.healthLive.data?.status ?? "degraded";
const runtimeSummary = runtimeSummaryFrom(live);
- const methodList = methodsFrom(live);
- const healthStatus = live.health.ok ? live.health.data.status : "degraded";
- const liveTone = reachable ? healthStatus : "degraded";
+ const methods = methodsFrom(live);
+ const dbReady = dbReadyFrom(live);
+ const devLiveReady = reachable && healthStatus === "ok" && dbReady === true && !gateSummary.blocked;
+ const blocked = gateSummary.blocked || !devLiveReady;
- el.liveStatus.textContent = reachable ? healthStatus : "degraded";
- el.liveStatus.className = `tone-${toneClass(liveTone)}`;
+ el.liveStatus.textContent = devLiveReady ? "dev-live ready" : reachable ? "blocked" : "degraded";
+ el.liveStatus.className = `tone-${toneClass(devLiveReady ? "dev-live" : reachable ? "blocked" : "degraded")}`;
el.liveDetail.textContent = reachable
- ? `Same-origin cloud-api responded; runtime status is ${healthStatus}.`
+ ? `Same-origin read surface responded; health=${healthStatus}, db.ready=${String(dbReady)}, DEV-LIVE=${devLiveReady ? "true" : "false"}.`
: `Same-origin API unavailable: ${probeErrors(coreProbes).join(" | ")}`;
- appendMetricGrid(el.overviewMetrics, [
- ["API origin", "same-origin /v1 + /json-rpc"],
- ["Frontend", runtime.endpoints.frontend, "warn"],
- ["Configured DEV", runtime.endpoints.dev, "warn"],
- ["Health", live.health.ok ? live.health.data.status : "degraded", live.health.ok ? live.health.data.status : "degraded"],
- ["Runtime", runtimeSummary?.status ?? "unavailable", runtimeSummary?.status ?? "degraded"],
- ["M3", m3Status(), m3Status()],
- ["Observed", reachable ? shortTime(live.observedAt) : "not available", reachable ? "pass" : "degraded"]
- ]);
+ renderStateStrip(devLiveReady);
+ renderHardwareStatus(runtimeSummary);
+ renderRecords(live);
+ renderDiagnostics(live);
+ renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, reachable ? "live" : "degraded");
- renderMethods(methodList, live);
- renderRuntimeSummary(runtimeSummary, live);
- renderLiveTopology(runtimeSummary);
- renderLiveHardware(runtimeSummary);
- renderLiveAudit(live.audit);
- renderLiveEvidence(live.evidence);
-}
-
-function renderMethods(methods, live) {
- if (methods.length === 0) {
- el.methodDetail.textContent = `degraded: no live method list returned. ${probeErrors([live.restIndex, live.adapter]).join(" | ")}`;
- replaceChildren(el.methodGrid, emptyNotice("No live cloud-api methods available."));
- return;
- }
-
- const source = live.restIndex.ok && Array.isArray(live.restIndex.data.methods) ? "GET /v1" : "cloud.adapter.describe";
- el.methodDetail.textContent = `Live method list from ${source}; showing read-only diagnostics methods from ${methods.length} returned methods.`;
- const fragment = document.createDocumentFragment();
- for (const method of methods) {
- if (!rpcReadMethods.includes(method)) continue;
- const item = document.createElement("span");
- item.className = "method-pill read";
- item.textContent = method;
- fragment.append(item);
- }
- replaceChildren(el.methodGrid, fragment);
-}
-
-function renderRuntimeSummary(summary, live) {
- if (!summary) {
- el.runtimeDetail.textContent = `degraded: runtime summary unavailable. ${probeErrors([live.restIndex, live.health, live.adapter]).join(" | ")}`;
- appendMetricGrid(el.runtimeMetrics, [
- ["Store", "unavailable", "degraded"],
- ["Durable", "unavailable", "degraded"],
- ["Gateway sessions", "unavailable", "degraded"],
- ["Evidence records", "unavailable", "degraded"]
- ]);
- return;
- }
-
- el.runtimeDetail.textContent = summary.reason || "Live cloud-api returned a runtime summary.";
- const counts = summary.counts ?? {};
- appendMetricGrid(el.runtimeMetrics, [
- ["Store", summary.adapter ?? "unknown", summary.status ?? "degraded"],
- ["Durable", summary.durable === true ? "true" : "false", summary.durable === true ? "pass" : "warn"],
- ["Gateway sessions", counts.gatewaySessions ?? 0, "pass"],
- ["Evidence records", counts.evidenceRecords ?? 0, "pass"]
- ]);
-}
-
-function renderLiveTopology(summary) {
- const counts = summary?.counts;
- appendMetricGrid(el.topologyLiveMetrics, [
- ["Live gateway sessions", valueOrUnavailable(counts?.gatewaySessions), summary ? "pass" : "degraded"],
- ["Live box resources", valueOrUnavailable(counts?.boxResources), summary ? "pass" : "degraded"],
- ["Live capabilities", valueOrUnavailable(counts?.boxCapabilities), summary ? "pass" : "degraded"],
- ["Live patch state", "not exposed read-only", "warn"]
- ]);
-}
-
-function renderLiveHardware(summary) {
- const counts = summary?.counts;
- appendMetricGrid(el.hardwareLiveMetrics, [
- ["Live operations", valueOrUnavailable(counts?.hardwareOperations), summary ? "pass" : "degraded"],
- ["Live audit events", valueOrUnavailable(counts?.auditEvents), summary ? "pass" : "degraded"],
- ["Live evidence records", valueOrUnavailable(counts?.evidenceRecords), summary ? "pass" : "degraded"],
- ["Mutation surface", "not called by console", "pass"]
- ]);
-}
-
-function renderLiveAudit(auditProbe) {
- el.auditBody.replaceChildren();
- if (!auditProbe.ok) {
- el.auditDetail.textContent = `degraded: ${auditProbe.error}`;
- rowNotice(el.auditBody, 4, "No live audit records returned.", "degraded");
- return;
- }
-
- const events = auditProbe.data?.events ?? [];
- el.auditDetail.textContent = `Live audit.event.query returned ${auditProbe.data?.count ?? events.length} records.`;
- if (events.length === 0) {
- rowNotice(el.auditBody, 4, "cloud-api returned zero live audit records.", "muted");
- return;
- }
- for (const event of events) {
- const tr = document.createElement("tr");
- tr.append(cell(event.auditId, "mono"));
- tr.append(cell(event.action));
- tr.append(cell(`${event.targetType ?? "target"}:${event.targetId ?? "n/a"}`, "mono wrap"));
- const statusCell = document.createElement("td");
- statusCell.append(badge(event.outcome ?? "recorded", event.outcome ?? "pass"));
- tr.append(statusCell);
- el.auditBody.append(tr);
+ if (blocked) {
+ el.explorerStatus.textContent = "blocked";
+ el.explorerStatus.className = "status-dot tone-blocked";
}
}
-function renderLiveEvidence(evidenceProbe) {
- el.liveEvidenceBody.replaceChildren();
- if (!evidenceProbe.ok) {
- el.evidenceQueryDetail.textContent = `degraded: ${evidenceProbe.error}`;
- rowNotice(el.liveEvidenceBody, 4, "No live evidence records returned.", "degraded");
- return;
- }
-
- const records = evidenceProbe.data?.records ?? [];
- el.evidenceQueryDetail.textContent = `Live evidence.record.query returned ${evidenceProbe.data?.count ?? records.length} records.`;
- if (records.length === 0) {
- rowNotice(el.liveEvidenceBody, 4, "cloud-api returned zero live evidence records.", "muted");
- return;
- }
- for (const record of records) {
- const tr = document.createElement("tr");
- tr.append(cell(record.evidenceId, "mono"));
- tr.append(cell(record.operationId, "mono"));
- tr.append(cell(record.kind));
- tr.append(cell(record.serviceId, "mono"));
- el.liveEvidenceBody.append(tr);
- }
+function renderStateStrip(devLiveReady) {
+ replaceChildren(
+ el.sourceStrip,
+ stateTag("SOURCE", `report ${shortHash(gateSummary.reportCommitId)}`),
+ stateTag("DRY-RUN", gateSummary.dryRun.status),
+ stateTag("DEV-LIVE", devLiveReady ? "ready" : "not claimed"),
+ stateTag("BLOCKED", gateSummary.blocked ? `${gateSummary.blockers.length} open` : "clear")
+ );
}
-function renderTopologyContract() {
- el.topologyBody.replaceChildren();
- for (const gateway of gateSummary.topology.gateways) {
- const tr = document.createElement("tr");
- tr.append(cell("Gateway"));
- tr.append(cell(gateway.gatewaySessionId, "mono"));
- tr.append(cell(gateway.serviceId, "mono"));
- const statusCell = document.createElement("td");
- statusCell.append(badge(gateway.status, gateway.status));
- tr.append(statusCell);
- tr.append(cell(gateway.endpoint ?? gateway.gatewayId, "mono wrap"));
- el.topologyBody.append(tr);
- }
- for (const resource of gateSummary.topology.boxResources) {
- const tr = document.createElement("tr");
- tr.append(cell("Box resource"));
- tr.append(cell(resource.resourceId, "mono"));
- tr.append(cell(resource.gatewaySessionId, "mono"));
- const statusCell = document.createElement("td");
- statusCell.append(badge(resource.state, resource.state));
- tr.append(statusCell);
- tr.append(cell(`${resource.resourceType}: ${resource.name ?? resource.boxId}`, "wrap"));
- el.topologyBody.append(tr);
- }
+function renderResourceTree() {
+ const items = [
+ {
+ label: gateSummary.topology.projectId,
+ meta: `${gateSummary.environment} project`,
+ tone: "source",
+ children: [
+ ...gateSummary.topology.gateways.map((gateway) => ({
+ label: gateway.gatewaySessionId,
+ meta: `${gateway.serviceId} / ${gateway.status}`,
+ tone: gateway.status
+ })),
+ ...gateSummary.topology.boxResources.map((resource) => ({
+ label: resource.resourceId,
+ meta: `${resource.resourceType} / ${resource.state}`,
+ tone: resource.state
+ })),
+ {
+ label: gateSummary.topology.patchPanel.patchPanelStatusId,
+ meta: `${gateSummary.topology.patchPanel.serviceId} / ${gateSummary.topology.patchPanel.state}`,
+ tone: gateSummary.topology.patchPanel.state
+ }
+ ]
+ },
+ {
+ label: gateSummary.agent.agentSessionId,
+ meta: `${gateSummary.agent.agentServiceId} / ${gateSummary.agent.agentStatus}`,
+ tone: gateSummary.agent.agentStatus,
+ children: [
+ {
+ label: gateSummary.agent.workerSessionId,
+ meta: `${gateSummary.agent.workerServiceId} / ${gateSummary.agent.workerStatus}`,
+ tone: gateSummary.agent.workerStatus
+ }
+ ]
+ },
+ {
+ label: "diagnostics",
+ meta: "secondary Gate view",
+ tone: "source",
+ children: gateSummary.milestones.map((milestone) => ({
+ label: milestone.id,
+ meta: milestone.status,
+ tone: milestone.status
+ }))
+ }
+ ];
- appendMetricGrid(el.patchMetrics, [
- ["Patch panel", gateSummary.topology.patchPanel.patchPanelStatusId],
- ["Service", gateSummary.topology.patchPanel.serviceId],
- ["State", gateSummary.topology.patchPanel.state, gateSummary.topology.patchPanel.state],
- [
- "Active links",
- gateSummary.topology.patchPanel.activeConnections
- .map((link) => `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`)
- .join(", ")
- ]
- ]);
+ el.treeCount.textContent = String(items.reduce((count, item) => count + 1 + (item.children?.length ?? 0), 0));
+ replaceChildren(el.resourceTree, ...items.map(treeGroup));
}
-function renderHardwareContract() {
- el.operationBody.replaceChildren();
- for (const operation of gateSummary.operations) {
- const tr = document.createElement("tr");
- tr.append(cell(operation.operationId, "mono"));
- tr.append(cell(operation.requestedBy, "mono"));
- const statusCell = document.createElement("td");
- statusCell.append(badge(operation.status, operation.status));
- tr.append(statusCell);
- tr.append(cell(`${operation.resourceId} / ${operation.capabilityId}`, "mono wrap"));
- tr.append(cell([operation.agentSessionId, operation.workerSessionId].filter(Boolean).join(" / ") || "direct", "mono"));
- el.operationBody.append(tr);
+function treeGroup(item) {
+ const details = document.createElement("details");
+ details.open = true;
+ const summary = document.createElement("summary");
+ summary.append(statusLight(item.tone), textSpan(item.label, "tree-label"), textSpan(item.meta, "tree-meta"));
+ details.append(summary);
+ for (const child of item.children ?? []) {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "tree-row";
+ row.append(statusLight(child.tone), textSpan(child.label, "tree-label"), textSpan(child.meta, "tree-meta"));
+ details.append(row);
}
+ return details;
}
-function renderAgentContract() {
- appendMetricGrid(el.agentMetrics, [
- ["Agent session", gateSummary.agent.agentSessionId],
- ["Agent service", gateSummary.agent.agentServiceId],
- ["Worker session", gateSummary.agent.workerSessionId],
- ["Worker service", gateSummary.agent.workerServiceId],
- ["Agent status", gateSummary.agent.agentStatus, gateSummary.agent.agentStatus],
- ["Worker status", gateSummary.agent.workerStatus, gateSummary.agent.workerStatus]
- ]);
- appendMetricGrid(el.agentEvidenceMetrics, [
- ["Trace events", gateSummary.traceCount],
- ["Audit events", gateSummary.auditCount],
- ["Evidence records", gateSummary.evidenceCount],
- ["Cleanup records", gateSummary.cleanupCount],
- ["Cleanup id", gateSummary.agent.cleanupId],
- ["Source", "checked-in gate report", "warn"]
- ]);
+function renderBlockerLists() {
+ el.blockerCount.textContent = String(gateSummary.blockers.length);
+ replaceChildren(el.explorerBlockers, ...gateSummary.blockers.map(blockerCard));
+ replaceChildren(el.gateBlockers, ...gateSummary.blockers.map(blockerCard));
}
-function renderEvidenceContract() {
- el.evidenceBody.replaceChildren();
- for (const record of gateSummary.evidenceRecords) {
- const tr = document.createElement("tr");
- tr.append(cell(record.evidenceId, "mono"));
- tr.append(cell(record.operationId, "mono"));
- tr.append(cell(record.serviceId, "mono"));
- tr.append(cell(record.uri, "mono wrap"));
- tr.append(cell(record.sha256, "mono wrap"));
- el.evidenceBody.append(tr);
+function renderConversation() {
+ const messages = [
+ {
+ role: "system",
+ title: "Surface mode",
+ text: "This shell is scoped to M3 trusted-loop users. It shows route, topology, trace, and diagnostics without dispatching hardware mutations."
+ },
+ {
+ role: "agent",
+ title: "Live acceptance state",
+ text: gateSummary.devPreconditions.summary
+ },
+ ...gateSummary.blockers.map((blocker) => ({
+ role: "blocker",
+ title: blocker.scope,
+ text: blocker.summary
+ })),
+ {
+ role: "user",
+ title: "Available action",
+ text: "Draft an instruction in the bottom command bar. Drafts remain local in this browser session until a trusted backend workflow exists."
+ }
+ ];
+ replaceChildren(el.conversationList, ...messages.map(messageCard));
+}
+
+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(step.title, "trace-title"));
+ body.append(textSpan(`${step.kind} / ${step.outputs.length} outputs`, "trace-meta"));
+ article.append(body, badge(step.requires.length === 0 ? "entry" : "requires previous", step.requires.length === 0 ? "source" : "dry-run"));
+ return article;
+ })
+ );
+}
+
+function renderHardwareStatus(summary) {
+ const counts = summary?.counts ?? {};
+ const cards = [
+ {
+ title: "Gateway-SIMU",
+ value: valueOrUnavailable(counts.gatewaySessions, gateSummary.gatewaySessionCount),
+ meta: "read count; source fallback",
+ tone: summary ? "dev-live" : "source"
+ },
+ {
+ title: "BOX-SIMU",
+ value: valueOrUnavailable(counts.boxResources, gateSummary.boxResourceCount),
+ meta: "read count; source fallback",
+ tone: summary ? "dev-live" : "source"
+ },
+ {
+ title: "Patch Panel",
+ value: gateSummary.topology.patchPanel.state,
+ meta: `${gateSummary.topology.patchPanel.activeConnectionCount} source links`,
+ tone: gateSummary.blocked ? "blocked" : gateSummary.topology.patchPanel.state
+ },
+ {
+ title: "Mutation Surface",
+ value: "disabled",
+ meta: "no hardware write RPC from web",
+ tone: "blocked"
+ }
+ ];
+ replaceChildren(el.hardwareList, ...cards.map(statusCard));
+}
+
+function controlRows() {
+ return [
+ {
+ title: "Hardware operation",
+ detail: "Not exposed in this web shell. Use patch-panel governed workflows after blockers clear.",
+ tone: "blocked"
+ },
+ {
+ title: "Agent automation",
+ detail: "Visible as trusted trace and dry-run evidence; live scheduling remains blocked by DB readiness.",
+ tone: "dry-run"
+ },
+ {
+ title: "Read probes",
+ detail: "Same-origin /health/live, /v1, and read-only /json-rpc diagnostics are allowed.",
+ tone: "source"
+ }
+ ];
+}
+
+function renderDrafts() {
+ const rows = [
+ ...state.drafts.map((draft) => ({
+ title: `Local draft ${shortTime(draft.createdAt)}`,
+ detail: draft.text,
+ tone: "dry-run"
+ })),
+ ...(state.drafts.length === 0
+ ? [
+ {
+ title: "Draft queue",
+ detail: "No local drafts. The input bar records text locally and does not call cloud-api mutation methods.",
+ tone: "source"
+ }
+ ]
+ : []),
+ ...controlRows()
+ ];
+ replaceChildren(el.controlList, ...rows.map(infoCard));
+}
+
+function renderWiringList() {
+ const patchPanel = gateSummary.topology.patchPanel;
+ const cards = [
+ infoCard({
+ title: patchPanel.serviceId,
+ detail: `${patchPanel.patchPanelStatusId} / ${patchPanel.state} / ${patchPanel.environment}`,
+ tone: patchPanel.state
+ }),
+ ...patchPanel.activeConnections.map((link) =>
+ infoCard({
+ title: `${link.fromResourceId}:${link.fromPort}`,
+ detail: `patched to ${link.toResourceId}:${link.toPort}`,
+ tone: "source"
+ })
+ )
+ ];
+ replaceChildren(el.wiringList, ...cards);
+}
+
+function renderRecords(live) {
+ const sourceCards = gateSummary.evidenceRecords.map((record) =>
+ infoCard({
+ title: record.evidenceId,
+ detail: `${record.kind} / ${record.operationId} / ${record.dryRun ? "DRY-RUN" : "unknown source"}`,
+ tone: record.dryRun ? "dry-run" : "source"
+ })
+ );
+
+ const liveRecords = live?.evidence.ok ? live.evidence.data?.records ?? [] : [];
+ const liveCards = liveRecords.map((record) =>
+ infoCard({
+ title: record.evidenceId,
+ detail: `${record.kind ?? "record"} / ${record.operationId ?? "n/a"} / ${record.serviceId ?? "unknown service"}`,
+ tone: "dev-live"
+ })
+ );
+ if (live && !live.evidence.ok) {
+ liveCards.push(infoCard({ title: "evidence.record.query", detail: live.evidence.error, tone: "blocked" }));
}
+
+ replaceChildren(el.recordsList, ...liveCards, ...sourceCards);
+}
+
+function renderDiagnostics(live) {
+ const diagnostics = [
+ {
+ title: "Frontend",
+ detail: runtime.endpoints.frontend,
+ tone: "source"
+ },
+ {
+ title: "API edge",
+ detail: runtime.endpoints.api,
+ tone: "source"
+ },
+ {
+ title: "Runtime route",
+ detail: runtime.serviceRoute.join(" -> "),
+ tone: "source"
+ }
+ ];
+
+ 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} records` : live.audit.error,
+ tone: live.audit.ok ? "dev-live" : "blocked"
+ }
+ );
+ } else {
+ diagnostics.push(
+ {
+ title: "/health/live",
+ detail: "probing same-origin web/API health",
+ tone: "dev-live"
+ },
+ {
+ title: "/v1 + /json-rpc",
+ detail: "read-only diagnostics pending",
+ tone: "source"
+ }
+ );
+ }
+
+ replaceChildren(el.diagnosticsList, ...diagnostics.map(infoCard));
}
function renderGateDiagnostics() {
- appendMetricGrid(el.metaGrid, [
- ["Environment", gateSummary.environment],
- ["Frontend", runtime.endpoints.frontend],
- ["Endpoint", gateSummary.endpoint],
- ["Namespace", gateSummary.namespace],
- ["Report commit", gateSummary.reportCommitId],
- ["Route", runtime.serviceRoute.join(" -> ")],
- ["Gate report", gateSummary.generatedFrom.gateReport]
+ appendMetricGrid(el.gateMetadata, [
+ ["Environment", gateSummary.environment, "source"],
+ ["Frontend", runtime.endpoints.frontend, "source"],
+ ["API", runtime.endpoints.api, "source"],
+ ["Gate", gateSummary.gateStatus, gateSummary.gateStatus],
+ ["Dry-run", gateSummary.dryRun.status, "dry-run"],
+ ["Report", shortHash(gateSummary.reportCommitId), "source"]
]);
- el.milestoneGrid.replaceChildren();
- for (const milestone of gateSummary.milestones) {
- const item = document.createElement("article");
- item.className = "milestone";
- const head = document.createElement("div");
- head.className = "milestone-head";
- appendText(head, "strong", milestone.id);
- head.append(badge(milestone.status, milestone.status));
- item.append(head);
- appendText(item, "p", milestone.summary);
- appendText(item, "small", `${milestone.commandCount} commands / ${milestone.evidenceCount} evidence lines`);
- el.milestoneGrid.append(item);
- }
+ 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(milestone.status, milestone.status));
+ item.append(header);
+ item.append(textSpan(milestone.summary, "milestone-summary"));
+ item.append(textSpan(`${milestone.commandCount} commands / ${milestone.evidenceCount} evidence`, "milestone-meta"));
+ return item;
+ })
+ );
el.healthBody.replaceChildren();
for (const row of gateSummary.health) {
const tr = document.createElement("tr");
tr.append(cell(row.component));
tr.append(cell(row.serviceId, "mono"));
- const statusCell = document.createElement("td");
- statusCell.append(badge(row.status, row.status));
- tr.append(statusCell);
+ const status = document.createElement("td");
+ status.append(badge(row.status, row.status));
+ tr.append(status);
tr.append(cell(row.observedBy));
tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap"));
el.healthBody.append(tr);
}
- el.blockerList.replaceChildren();
- for (const blocker of gateSummary.blockers) {
- const li = document.createElement("li");
- const head = document.createElement("div");
- head.className = "blocker-head";
- head.append(badge(blocker.type, "blocked"));
- appendText(head, "strong", blocker.scope);
- li.append(head);
- appendText(li, "p", blocker.summary);
- el.blockerList.append(li);
- }
-
- el.commandList.replaceChildren();
- for (const command of [
- "node web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs",
- "node web/hwlab-cloud-web/scripts/check.mjs",
- "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp",
- "node scripts/m5-mvp-e2e-dry-run.mjs"
- ]) {
- appendText(el.commandList, "li", command, "mono");
- }
+ 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/l6-cli-web-smoke.mjs",
+ "git diff --check"
+ ].map((command) => {
+ const li = document.createElement("li");
+ li.textContent = command;
+ return li;
+ })
+ );
}
-function m3Status() {
- return gateSummary.milestones.find((milestone) => milestone.id === "M3")?.status ?? "unknown";
+function renderMethodList(methods, tone) {
+ replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone)));
+}
+
+function blockerCard(blocker) {
+ return infoCard({
+ title: blocker.scope,
+ detail: 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)}`;
+ article.append(textSpan(message.role, "message-role"));
+ article.append(textSpan(message.title, "message-title"));
+ article.append(textSpan(message.text, "message-copy"));
+ return article;
+}
+
+function statusCard(item) {
+ const article = document.createElement("article");
+ article.className = "status-card";
+ article.append(textSpan(item.title, "metric-label"));
+ article.append(textSpan(String(item.value), `status-value tone-${toneClass(item.tone)}`));
+ article.append(textSpan(item.meta, "status-meta"));
+ return article;
+}
+
+function infoCard(item) {
+ const article = document.createElement("article");
+ article.className = "info-card";
+ article.append(badge(item.tone, item.tone));
+ const body = document.createElement("div");
+ body.append(textSpan(item.title, "info-title"));
+ body.append(textSpan(item.detail, "info-detail"));
+ article.append(body);
+ return article;
+}
+
+function stateTag(label, detail) {
+ const span = document.createElement("span");
+ span.className = `state-tag tone-${toneClass(label)}`;
+ span.textContent = `${label} ${detail}`;
+ return span;
+}
+
+function statusLight(tone) {
+ const span = document.createElement("span");
+ span.className = `tree-light tone-bg-${toneClass(tone)}`;
+ return span;
+}
+
+function badge(text, tone = text) {
+ const span = document.createElement("span");
+ span.className = `badge tone-${toneClass(tone)}`;
+ span.textContent = text ?? "n/a";
+ 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 ?? "n/a"), `metric-value tone-${toneClass(tone ?? "source")}`));
+ return item;
+ })
+ );
+}
+
+function textSpan(text, className) {
+ const span = document.createElement("span");
+ span.textContent = text ?? "n/a";
+ if (className) span.className = className;
+ return span;
+}
+
+function cell(text, className) {
+ const td = document.createElement("td");
+ td.textContent = text ?? "n/a";
+ if (className) td.className = className;
+ return td;
}
function runtimeSummaryFrom(live) {
- return live.health.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
+ return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
}
function methodsFrom(live) {
@@ -522,6 +755,10 @@ function methodsFrom(live) {
return [];
}
+function dbReadyFrom(live) {
+ return live.healthLive.data?.db?.ready ?? live.health.data?.db?.ready ?? live.restIndex.data?.db?.ready ?? null;
+}
+
function probeErrors(probes) {
return probes.filter((probe) => !probe.ok).map((probe) => `${probe.method ?? probe.path}: ${probe.error}`);
}
@@ -531,75 +768,24 @@ function messageFromPayload(payload) {
return payload.error?.message || payload.error?.code || payload.message || JSON.stringify(payload).slice(0, 160);
}
-function valueOrUnavailable(value) {
- return value === undefined || value === null ? "unavailable" : value;
+function valueOrUnavailable(liveValue, sourceValue) {
+ if (liveValue !== undefined && liveValue !== null) return liveValue;
+ if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} source`;
+ return "unavailable";
}
function shortTime(value) {
return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
}
-function appendText(parent, tagName, text, className) {
- const element = document.createElement(tagName);
- element.textContent = text;
- if (className) {
- element.className = className;
- }
- parent.append(element);
- return element;
-}
-
-function appendMetricGrid(parent, entries) {
- parent.replaceChildren();
- for (const entry of entries) {
- const [label, value, tone] = entry;
- const item = document.createElement("div");
- item.className = "metric";
- appendText(item, "span", label, "metric-label");
- const strong = appendText(item, "strong", String(value ?? "n/a"), "metric-value");
- if (tone) {
- strong.classList.add(`tone-${toneClass(tone)}`);
- }
- parent.append(item);
- }
-}
-
-function badge(text, tone = text) {
- const span = document.createElement("span");
- span.className = `badge tone-${toneClass(tone)}`;
- span.textContent = text ?? "n/a";
- return span;
+function shortHash(value) {
+ return String(value ?? "unknown").slice(0, 7);
}
function toneClass(tone) {
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
}
-function cell(text, className) {
- const td = document.createElement("td");
- td.textContent = text ?? "n/a";
- if (className) {
- td.className = className;
- }
- return td;
-}
-
-function rowNotice(tbody, colSpan, text, tone = "warn") {
- tbody.replaceChildren();
- const tr = document.createElement("tr");
- const td = cell(text, `notice-cell tone-${toneClass(tone)}`);
- td.colSpan = colSpan;
- tr.append(td);
- tbody.append(tr);
-}
-
-function emptyNotice(text) {
- const div = document.createElement("div");
- div.className = "empty";
- div.textContent = text;
- return div;
-}
-
-function replaceChildren(parent, child) {
- parent.replaceChildren(child);
+function replaceChildren(parent, ...children) {
+ parent.replaceChildren(...children.filter((child) => child && typeof child !== "string"));
}
diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html
index 0f32e236..d390d5c6 100644
--- a/web/hwlab-cloud-web/index.html
+++ b/web/hwlab-cloud-web/index.html
@@ -3,287 +3,221 @@
- HWLAB DEV Console
+ HWLAB Cloud Workbench
-
-
+
+
-
-
-
-
-
-
-
-
-
Live Method Registry
-
Waiting for cloud-api adapter description.
-
-
-
-
-
-
Runtime Summary
-
Waiting for cloud-api runtime summary.
-
-
-
-
-
+ blocked
+
+
+ SOURCE loading
+ DRY-RUN loading
+ DEV-LIVE probing
+ BLOCKED pending
+
+
+
+
Resource Tree
+ 0
+
+
+
+
+
+
Current Blockers
+ 0
+
+
+
+
-
-
-
-
Topology / Patch Panel
-
Gateway, Box, and Patch Contract
+
+
+
+
M3 Trusted Loop User Surface
+
Agent Conversation / Trace Workspace
+
browser/CLI/gateway -> cloud-web/cloud-api
- Live runtime counts come from cloud-api. The detailed wiring below is the checked-in DEV gate contract.
-
-
-
-
-
-
Gateway and Box Resources
-
Checked-in M5 topology snapshot.
+
+ Same-Origin Read Probe
+ probing
+ Checking /health/live, /v1, and read-only /json-rpc.
+
+
+
+
+
+
+
+
+
Agent Workspace
+
Conversation
+
+
BLOCKED surfaced
+
+
+
+
+
+
+
+
Trace
+
M0-M5 Plan Trace
+
+
DRY-RUN plan
+
+
+
+
+
+
+
+
+
+
+
Secondary Entry
+
Gate / Diagnostics
+
+
SOURCE report
+
+
+ Gate data remains available for diagnostics and acceptance review. The default route stays on the user workbench.
+
+
+
+
+
+
M0-M5 Gates
+ BLOCKED if live prerequisites fail
+
+
+
+
+
+
DEV Health Contract
+ SOURCE fixture
- | Type |
- ID |
- Service / Gateway |
- Status |
- Endpoint / Resource |
-
-
-
-
-
-
-
-
-
Patch Panel
-
Active wiring from the checked-in gate report.
-
-
-
-
-
-
-
-
-
-
M3 Evidence
-
M3 Evidence Ledger
-
-
The console does not issue mutating hardware requests. It shows live operation counts plus checked-in operation evidence.
-
-
-
-
-
Checked-In Evidence
-
Fixture-bound M5 dry-run operations; not live dispatch proof.
-
-
-
-
-
- | Operation |
- Requested by |
- Status |
- Resource |
- Agent / Worker |
-
-
-
-
-
-
-
-
-
-
-
-
Agent Sessions
-
Agent and Worker Runtime
-
-
Agent session detail is from the gate contract until cloud-api exposes a read-only session query.
-
-
-
-
-
Session Summary
-
Checked-in agent and worker evidence.
-
-
-
-
-
-
Trace, Audit, Cleanup
-
Gate contract counts tied to the M5 dry-run plan.
-
-
-
-
-
-
-
-
-
-
Evidence / Audit
-
Evidence Chain
-
-
Live audit/evidence query results are shown only when cloud-api returns them. The fixture records remain labeled as checked-in evidence.
-
-
-
-
-
Live Audit Query
-
Waiting for audit.event.query.
-
-
-
-
-
- | Audit |
- Action |
- Target |
- Outcome |
-
-
-
-
-
-
-
-
-
Live Evidence Query
-
Waiting for evidence.record.query.
-
-
-
-
-
- | Evidence |
- Operation |
- Kind |
+ Component |
Service |
+ Status |
+ Observed |
+ Endpoint |
-
+
-
-
-
-
-
Checked-In Evidence Records
-
Direct and agent hardware operation evidence from the M5 dry-run plan.
-
-
-
-
- | Evidence |
- Operation |
- Service |
- URI |
- SHA-256 |
-
-
-
-
+
+
+
+
Open Blockers
+ BLOCKED
+
+
+
+
+
+
Light Validation
+ SOURCE checks
+
+
+
+
+
-
-
-
-
Gate / Diagnostics
-
DEV MVP Gate Report
-
-
Gate data remains available here for read-only diagnostics and acceptance review.
-
-
-
-
Acceptance Source
-
Contract-driven view of the checked-in M0-M5 gate report and M5 dry-run plan.
-
-
-
-
-
-
M0-M5 Gates
-
Local contract and dry-run status. Live DEV remains separate from fixture evidence.
-
-
-
-
-
-
DEV Health Contract
-
Read from the M5 gate fixture; live health is not claimed while blockers are open.
-
-
-
-
-
- | Component |
- Service |
- Status |
- Observed |
- Endpoint |
-
-
-
-
-
-
-
-
-
-
Blocked Evidence
-
Open blocker evidence that prevents live DEV acceptance.
+