diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs
index 6a12d72a..c77aef44 100644
--- a/web/hwlab-cloud-web/app.mjs
+++ b/web/hwlab-cloud-web/app.mjs
@@ -1,15 +1,534 @@
import { gateSummary } from "./gate-summary.mjs";
+import { runtime } from "./runtime.mjs";
-const status = document.querySelector("#status");
-const metaGrid = document.querySelector("#meta-grid");
-const milestoneGrid = document.querySelector("#milestone-grid");
-const healthBody = document.querySelector("#health-body");
-const artifactBody = document.querySelector("#artifact-body");
-const topologyGrid = document.querySelector("#topology-grid");
-const agentGrid = document.querySelector("#agent-grid");
-const evidenceBody = document.querySelector("#evidence-body");
-const blockerList = document.querySelector("#blocker-list");
-const commandList = document.querySelector("#command-list");
+const API_TIMEOUT_MS = 4500;
+const rpcReadMethods = Object.freeze([
+ "system.health",
+ "cloud.adapter.describe",
+ "audit.event.query",
+ "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));
+let rpcSequence = 0;
+
+const el = {
+ 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"),
+ milestoneGrid: byId("milestone-grid"),
+ healthBody: byId("health-body"),
+ blockerList: byId("blocker-list"),
+ commandList: byId("command-list")
+};
+
+initRoutes();
+renderStaticConsole();
+renderLivePending();
+loadLiveSurface().then(renderLiveConsole);
+
+function byId(id) {
+ const element = document.getElementById(id);
+ if (!element) {
+ throw new Error(`missing required element #${id}`);
+ }
+ return element;
+}
+
+function initRoutes() {
+ window.addEventListener("hashchange", () => showView(routeFromLocation()));
+ showView(routeFromLocation());
+}
+
+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";
+}
+
+function showView(route) {
+ const activeRoute = viewIds.has(route) ? route : "overview";
+ for (const view of views) {
+ 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");
+ }
+}
+
+function renderStaticConsole() {
+ renderTopologyContract();
+ renderHardwareContract();
+ renderAgentContract();
+ renderEvidenceContract();
+ renderGateDiagnostics();
+}
+
+function renderLivePending() {
+ appendMetricGrid(el.overviewMetrics, [
+ ["API origin", "same-origin /v1 + /json-rpc"],
+ ["Configured DEV", runtime.endpoints.dev, "warn"],
+ ["Health", "probing", "warn"],
+ ["Runtime", "probing", "warn"],
+ ["DB", "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.");
+}
+
+async function loadLiveSurface() {
+ const projectId = gateSummary.topology.projectId;
+ const [restIndex, health, adapter, audit, evidence] = await Promise.all([
+ fetchJson("/v1"),
+ callRpc("system.health"),
+ callRpc("cloud.adapter.describe"),
+ callRpc("audit.event.query", { projectId, limit: 6 }),
+ callRpc("evidence.record.query", { projectId, limit: 6 })
+ ]);
+
+ return {
+ observedAt: new Date().toISOString(),
+ restIndex,
+ health,
+ adapter,
+ audit,
+ evidence
+ };
+}
+
+async function fetchJson(path, options = {}) {
+ const controller = new AbortController();
+ const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
+ try {
+ const response = await fetch(path, {
+ ...options,
+ headers: {
+ Accept: "application/json",
+ ...(options.headers ?? {})
+ },
+ signal: controller.signal
+ });
+ const text = await response.text();
+ const data = text ? JSON.parse(text) : null;
+ if (!response.ok) {
+ throw new Error(`${path} HTTP ${response.status}: ${messageFromPayload(data)}`);
+ }
+ return { ok: true, path, status: response.status, data };
+ } catch (error) {
+ return {
+ ok: false,
+ path,
+ error: error.name === "AbortError" ? `${path} timed out after ${API_TIMEOUT_MS}ms` : error.message
+ };
+ } finally {
+ window.clearTimeout(timer);
+ }
+}
+
+async function callRpc(method, params = {}) {
+ const id = nextProtocolId("req");
+ const traceId = nextProtocolId("trc");
+ const response = await fetchJson("/json-rpc", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id,
+ method,
+ params,
+ meta: {
+ traceId,
+ serviceId: "hwlab-cloud-web",
+ environment: "dev"
+ }
+ })
+ });
+
+ if (!response.ok) {
+ return { ...response, method };
+ }
+ if (response.data?.error) {
+ return {
+ ok: false,
+ path: response.path,
+ status: response.status,
+ method,
+ error: `${method}: ${response.data.error.message}`,
+ envelope: response.data
+ };
+ }
+ return {
+ ok: true,
+ path: response.path,
+ status: response.status,
+ method,
+ data: response.data?.result,
+ envelope: response.data
+ };
+}
+
+function nextProtocolId(prefix) {
+ rpcSequence += 1;
+ return `${prefix}_cloud-web-console-${Date.now()}-${rpcSequence}`;
+}
+
+function renderLiveConsole(live) {
+ const coreProbes = [live.restIndex, live.health, live.adapter];
+ const reachable = coreProbes.some((probe) => probe.ok);
+ const runtimeSummary = runtimeSummaryFrom(live);
+ const methodList = methodsFrom(live);
+ const healthStatus = live.health.ok ? live.health.data.status : "degraded";
+ const liveTone = reachable ? healthStatus : "degraded";
+
+ el.liveStatus.textContent = reachable ? healthStatus : "degraded";
+ el.liveStatus.className = `tone-${toneClass(liveTone)}`;
+ el.liveDetail.textContent = reachable
+ ? `Same-origin cloud-api responded; runtime status is ${healthStatus}.`
+ : `Same-origin API unavailable: ${probeErrors(coreProbes).join(" | ")}`;
+
+ appendMetricGrid(el.overviewMetrics, [
+ ["API origin", "same-origin /v1 + /json-rpc"],
+ ["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"],
+ ["DB", live.health.ok ? live.health.data.db?.status : "unavailable", live.health.ok ? live.health.data.db?.status : "degraded"],
+ ["Observed", reachable ? shortTime(live.observedAt) : "not available", reachable ? "pass" : "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}; ${methods.length} methods returned.`;
+ const fragment = document.createDocumentFragment();
+ for (const method of methods) {
+ const item = document.createElement("span");
+ item.className = `method-pill ${rpcReadMethods.includes(method) ? "read" : "write"}`;
+ 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);
+ }
+}
+
+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 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);
+ }
+
+ 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(", ")
+ ]
+ ]);
+}
+
+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 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 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 renderGateDiagnostics() {
+ appendMetricGrid(el.metaGrid, [
+ ["Environment", gateSummary.environment],
+ ["Endpoint", gateSummary.endpoint],
+ ["Namespace", gateSummary.namespace],
+ ["Report commit", gateSummary.reportCommitId],
+ ["Gate report", gateSummary.generatedFrom.gateReport],
+ ["M5 plan", gateSummary.generatedFrom.m5Plan]
+ ]);
+
+ 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);
+ }
+
+ 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);
+ 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 tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp",
+ "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production",
+ "node scripts/m5-mvp-e2e-dry-run.mjs"
+ ]) {
+ appendText(el.commandList, "li", command, "mono");
+ }
+}
+
+function runtimeSummaryFrom(live) {
+ return live.health.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
+}
+
+function methodsFrom(live) {
+ const restMethods = live.restIndex.data?.methods;
+ const adapterMethods = live.adapter.data?.methods;
+ if (Array.isArray(restMethods)) return restMethods;
+ if (Array.isArray(adapterMethods)) return adapterMethods;
+ return [];
+}
+
+function probeErrors(probes) {
+ return probes.filter((probe) => !probe.ok).map((probe) => `${probe.method ?? probe.path}: ${probe.error}`);
+}
+
+function messageFromPayload(payload) {
+ if (!payload || typeof payload !== "object") return "non-JSON or empty response";
+ 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 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);
@@ -21,23 +540,32 @@ function appendText(parent, tagName, text, className) {
return element;
}
-function appendMetricGrid(parent, pairs) {
- for (const [label, value] of pairs) {
+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");
- appendText(item, "strong", String(value ?? "n/a"), "metric-value");
+ 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-${String(tone).replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`;
- span.textContent = text;
+ span.className = `badge tone-${toneClass(tone)}`;
+ span.textContent = text ?? "n/a";
return span;
}
+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";
@@ -47,102 +575,22 @@ function cell(text, className) {
return td;
}
-status.replaceChildren(badge(gateSummary.gateStatus, gateSummary.gateStatus));
-
-appendMetricGrid(metaGrid, [
- ["Environment", gateSummary.environment],
- ["Endpoint", gateSummary.endpoint],
- ["Namespace", gateSummary.namespace],
- ["Report commit", gateSummary.reportCommitId],
- ["Gate report", gateSummary.generatedFrom.gateReport],
- ["M5 plan", gateSummary.generatedFrom.m5Plan]
-]);
-
-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`);
- milestoneGrid.append(item);
-}
-
-for (const row of gateSummary.health) {
+function rowNotice(tbody, colSpan, text, tone = "warn") {
+ tbody.replaceChildren();
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);
- tr.append(cell(row.observedBy));
- tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap"));
- healthBody.append(tr);
+ const td = cell(text, `notice-cell tone-${toneClass(tone)}`);
+ td.colSpan = colSpan;
+ tr.append(td);
+ tbody.append(tr);
}
-for (const artifact of gateSummary.artifacts) {
- const tr = document.createElement("tr");
- tr.append(cell(artifact.serviceId, "mono"));
- tr.append(cell(artifact.commitId, "mono"));
- tr.append(cell(`${artifact.image}:${artifact.tag}`, "mono wrap"));
- tr.append(cell(artifact.digest, "mono wrap"));
- tr.append(cell(artifact.healthTimestamp, "mono"));
- artifactBody.append(tr);
+function emptyNotice(text) {
+ const div = document.createElement("div");
+ div.className = "empty";
+ div.textContent = text;
+ return div;
}
-appendMetricGrid(topologyGrid, [
- ["Project", gateSummary.topology.projectId],
- ["Gateways", gateSummary.gatewaySessionCount],
- ["Box resources", gateSummary.boxResourceCount],
- ["Patch panel", gateSummary.topology.patchPanel.patchPanelStatusId],
- ["Active links", gateSummary.topology.patchPanel.activeConnectionCount],
- [
- "Connection",
- gateSummary.topology.patchPanel.activeConnections
- .map((link) => `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`)
- .join(", ")
- ]
-]);
-
-appendMetricGrid(agentGrid, [
- ["Agent session", gateSummary.agent.agentSessionId],
- ["Agent service", gateSummary.agent.agentServiceId],
- ["Worker session", gateSummary.agent.workerSessionId],
- ["Worker service", gateSummary.agent.workerServiceId],
- ["Trace events", gateSummary.traceCount],
- ["Audit events", gateSummary.auditCount],
- ["Cleanup records", gateSummary.cleanupCount],
- ["Cleanup id", gateSummary.agent.cleanupId]
-]);
-
-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"));
- evidenceBody.append(tr);
-}
-
-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);
- blockerList.append(li);
-}
-
-for (const command of [
- "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp",
- "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production",
- "node scripts/m5-mvp-e2e-dry-run.mjs"
-]) {
- appendText(commandList, "li", command, "mono");
+function replaceChildren(parent, child) {
+ parent.replaceChildren(child);
}
diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html
index f71a9bf5..6bcf31c4 100644
--- a/web/hwlab-cloud-web/index.html
+++ b/web/hwlab-cloud-web/index.html
@@ -3,132 +3,285 @@
- HWLAB DEV MVP Gate
+ HWLAB DEV Console
-
-