feat: add HWLAB DEV console homepage

Refs #59

Merged by commander after reviewing Code Queue task codex_1779422758441_1. Source-only frontend PR; deployment/DEV verification remains as follow-up.
This commit is contained in:
Lyon
2026-05-22 12:25:41 +08:00
committed by GitHub
parent cb35ada686
commit 4ebb1e4aa6
4 changed files with 1075 additions and 264 deletions
+556 -108
View File
@@ -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);
}
+255 -102
View File
@@ -3,132 +3,285 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HWLAB DEV MVP Gate</title>
<title>HWLAB DEV Console</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./app.mjs"></script>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">HWLAB L6 Frontend / CLI MVP</p>
<h1>DEV MVP Gate</h1>
<main class="shell" data-app-shell>
<header class="masthead">
<div class="identity">
<p class="eyebrow">HWLAB Cloud Web / DEV</p>
<h1>DEV Control Console</h1>
<p class="lede">Same-origin runtime observation for cloud-api health, methods, topology contracts, operations, agent sessions, and evidence.</p>
</div>
<div class="live-card" aria-live="polite">
<span class="label">Live API</span>
<strong id="live-status">probing</strong>
<small id="live-detail">Checking /v1 and /json-rpc from this origin.</small>
</div>
<div class="status" id="status"></div>
</header>
<section class="panel compact">
<div class="panel-head">
<h2>Acceptance Source</h2>
<p>Contract-driven view of the checked-in M0-M5 gate report and M5 dry-run plan.</p>
</div>
<div class="metrics six" id="meta-grid"></div>
</section>
<nav class="module-nav" aria-label="HWLAB console modules">
<a href="#overview" data-route="overview">Overview</a>
<a href="#topology" data-route="topology">Topology / Patch Panel</a>
<a href="#hardware" data-route="hardware">Hardware Operations</a>
<a href="#agents" data-route="agents">Agent Sessions</a>
<a href="#evidence" data-route="evidence">Evidence / Audit</a>
<a href="#gate" data-route="gate">Gate / Diagnostics</a>
</nav>
<section class="panel compact">
<div class="panel-head">
<h2>M0-M5 Gates</h2>
<p>Local contract and dry-run status. Live DEV remains separate from fixture evidence.</p>
</div>
<div class="milestones" id="milestone-grid"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>DEV Health Contract</h2>
<p>Read from the M5 gate fixture; live health is not claimed while blockers are open.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Component</th>
<th>Service</th>
<th>Status</th>
<th>Observed</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody id="health-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Artifact / Deploy Commit</h2>
<p>Artifact identity fields required by the MVP DoD.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Service</th>
<th>Commit</th>
<th>Image</th>
<th>Digest</th>
<th>Health timestamp</th>
</tr>
</thead>
<tbody id="artifact-body"></tbody>
</table>
</div>
</section>
<section class="split">
<section class="panel compact">
<div class="panel-head">
<h2>2 Box / 2 Gateway / Patch Panel</h2>
<p>Topology contract for the hardware trusted loop.</p>
<section class="view" id="overview" data-view="overview" aria-labelledby="overview-title">
<div class="section-head">
<div>
<p class="eyebrow">Overview</p>
<h2 id="overview-title">Runtime Surface</h2>
</div>
<div class="metrics two" id="topology-grid"></div>
</section>
<p>Live rows are populated only from same-origin API responses. Checked-in gate records are labeled separately.</p>
</div>
<div class="metrics six" id="overview-metrics"></div>
<div class="split">
<section class="panel">
<div class="panel-head">
<h3>Live Method Registry</h3>
<p id="method-detail">Waiting for cloud-api adapter description.</p>
</div>
<div class="method-grid" id="method-grid"></div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Runtime Summary</h3>
<p id="runtime-detail">Waiting for cloud-api runtime summary.</p>
</div>
<div class="metrics two" id="runtime-metrics"></div>
</section>
</div>
</section>
<section class="panel compact">
<div class="panel-head">
<h2>Agent Session Evidence</h2>
<p>Agent and worker session IDs tied to trace, audit, evidence, and cleanup.</p>
<section class="view" id="topology" data-view="topology" aria-labelledby="topology-title" hidden>
<div class="section-head">
<div>
<p class="eyebrow">Topology / Patch Panel</p>
<h2 id="topology-title">Gateway, Box, and Patch Contract</h2>
</div>
<p>Live runtime counts come from cloud-api. The detailed wiring below is the checked-in DEV gate contract.</p>
</div>
<div class="metrics four" id="topology-live-metrics"></div>
<div class="split">
<section class="panel">
<div class="panel-head">
<h3>Gateway and Box Resources</h3>
<p>Checked-in M5 topology snapshot.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Type</th>
<th>ID</th>
<th>Service / Gateway</th>
<th>Status</th>
<th>Endpoint / Resource</th>
</tr>
</thead>
<tbody id="topology-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Patch Panel</h3>
<p>Active wiring from the checked-in gate report.</p>
</div>
<div class="metrics two" id="patch-metrics"></div>
</section>
</div>
</section>
<section class="view" id="hardware" data-view="hardware" aria-labelledby="hardware-title" hidden>
<div class="section-head">
<div>
<p class="eyebrow">Hardware Operations</p>
<h2 id="hardware-title">Operation Ledger</h2>
</div>
<p>The console does not issue mutating hardware requests. It shows live operation counts plus checked-in operation evidence.</p>
</div>
<div class="metrics four" id="hardware-live-metrics"></div>
<section class="panel">
<div class="panel-head">
<h3>Checked-In Operations</h3>
<p>Fixture-bound M5 dry-run operations; not live dispatch proof.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Operation</th>
<th>Requested by</th>
<th>Status</th>
<th>Resource</th>
<th>Agent / Worker</th>
</tr>
</thead>
<tbody id="operation-body"></tbody>
</table>
</div>
<div class="metrics two" id="agent-grid"></div>
</section>
</section>
<section class="panel">
<div class="panel-head">
<h2>Evidence Records</h2>
<p>Direct and agent hardware operations remain fixture-bound until live DEV blockers clear.</p>
<section class="view" id="agents" data-view="agents" aria-labelledby="agents-title" hidden>
<div class="section-head">
<div>
<p class="eyebrow">Agent Sessions</p>
<h2 id="agents-title">Agent and Worker Runtime</h2>
</div>
<p>Agent session detail is from the gate contract until cloud-api exposes a read-only session query.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Evidence</th>
<th>Operation</th>
<th>Service</th>
<th>URI</th>
<th>SHA-256</th>
</tr>
</thead>
<tbody id="evidence-body"></tbody>
</table>
<div class="split">
<section class="panel">
<div class="panel-head">
<h3>Session Summary</h3>
<p>Checked-in agent and worker evidence.</p>
</div>
<div class="metrics two" id="agent-metrics"></div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Trace, Audit, Cleanup</h3>
<p>Gate contract counts tied to the M5 dry-run plan.</p>
</div>
<div class="metrics two" id="agent-evidence-metrics"></div>
</section>
</div>
</section>
<section class="split">
<section class="panel compact danger">
<div class="panel-head">
<h2>Blocked Evidence</h2>
<p>Open blocker evidence that prevents live DEV acceptance.</p>
<section class="view" id="evidence" data-view="evidence" aria-labelledby="evidence-title" hidden>
<div class="section-head">
<div>
<p class="eyebrow">Evidence / Audit</p>
<h2 id="evidence-title">Evidence Chain</h2>
</div>
<p>Live audit/evidence query results are shown only when cloud-api returns them. The fixture records remain labeled as checked-in evidence.</p>
</div>
<div class="split">
<section class="panel">
<div class="panel-head">
<h3>Live Audit Query</h3>
<p id="audit-detail">Waiting for audit.event.query.</p>
</div>
<div class="table-wrap compact-table">
<table>
<thead>
<tr>
<th>Audit</th>
<th>Action</th>
<th>Target</th>
<th>Outcome</th>
</tr>
</thead>
<tbody id="audit-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Live Evidence Query</h3>
<p id="evidence-query-detail">Waiting for evidence.record.query.</p>
</div>
<div class="table-wrap compact-table">
<table>
<thead>
<tr>
<th>Evidence</th>
<th>Operation</th>
<th>Kind</th>
<th>Service</th>
</tr>
</thead>
<tbody id="live-evidence-body"></tbody>
</table>
</div>
</section>
</div>
<section class="panel">
<div class="panel-head">
<h3>Checked-In Evidence Records</h3>
<p>Direct and agent hardware operation evidence from the M5 dry-run plan.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Evidence</th>
<th>Operation</th>
<th>Service</th>
<th>URI</th>
<th>SHA-256</th>
</tr>
</thead>
<tbody id="evidence-body"></tbody>
</table>
</div>
<ul class="blockers" id="blocker-list"></ul>
</section>
</section>
<section class="view" id="gate" data-view="gate" aria-labelledby="gate-title" hidden>
<div class="section-head">
<div>
<p class="eyebrow">Gate / Diagnostics</p>
<h2 id="gate-title">DEV MVP Gate Report</h2>
</div>
<p>Original Gate data remains available here for diagnostics and acceptance review.</p>
</div>
<section class="panel compact">
<div class="panel-head">
<h2>Operator Commands</h2>
<p>Safe CLI entry points for manual acceptance.</p>
<h3>Acceptance Source</h3>
<p>Contract-driven view of the checked-in M0-M5 gate report and M5 dry-run plan.</p>
</div>
<ol class="commands" id="command-list"></ol>
<div class="metrics six" id="meta-grid"></div>
</section>
<section class="panel compact">
<div class="panel-head">
<h3>M0-M5 Gates</h3>
<p>Local contract and dry-run status. Live DEV remains separate from fixture evidence.</p>
</div>
<div class="milestones" id="milestone-grid"></div>
</section>
<section class="panel">
<div class="panel-head">
<h3>DEV Health Contract</h3>
<p>Read from the M5 gate fixture; live health is not claimed while blockers are open.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Component</th>
<th>Service</th>
<th>Status</th>
<th>Observed</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody id="health-body"></tbody>
</table>
</div>
</section>
<section class="split">
<section class="panel compact danger">
<div class="panel-head">
<h3>Blocked Evidence</h3>
<p>Open blocker evidence that prevents live DEV acceptance.</p>
</div>
<ul class="blockers" id="blocker-list"></ul>
</section>
<section class="panel compact">
<div class="panel-head">
<h3>Operator Commands</h3>
<p>Safe CLI entry points for manual acceptance.</p>
</div>
<ol class="commands" id="command-list"></ol>
</section>
</section>
</section>
</main>
+33 -5
View File
@@ -17,10 +17,38 @@ for (const file of requiredFiles) {
}
const html = fs.readFileSync(path.resolve(rootDir, "index.html"), "utf8");
assert.match(html, /HWLAB DEV MVP Gate/);
assert.match(html, /health-body/);
assert.match(html, /artifact-body/);
assert.match(html, /agent-grid/);
const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8");
const frontendSource = `${html}\n${app}`;
assert.match(html, /HWLAB DEV Console/);
assert.match(html, /DEV Control Console/);
for (const moduleName of [
"Overview",
"Topology / Patch Panel",
"Hardware Operations",
"Agent Sessions",
"Evidence / Audit",
"Gate / Diagnostics"
]) {
assert.match(html, new RegExp(moduleName.replaceAll("/", "\\/")));
}
for (const viewId of ["overview", "topology", "hardware", "agents", "evidence", "gate"]) {
assert.match(html, new RegExp(`data-view="${viewId}"`));
}
assert.match(html, /live-status/);
assert.match(html, /method-grid/);
assert.match(html, /runtime-metrics/);
assert.match(html, /operation-body/);
assert.match(html, /audit-body/);
assert.match(html, /evidence-body/);
assert.match(app, /fetchJson\("\/v1"\)/);
assert.match(app, /callRpc\("system\.health"\)/);
assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/);
assert.match(app, /callRpc\("audit\.event\.query"/);
assert.match(app, /callRpc\("evidence\.record\.query"/);
assert.match(app, /degraded/);
assert.doesNotMatch(app, /callRpc\("hardware\./);
assert.doesNotMatch(frontendSource, /74\.48\.78\.17:666[67]\b|:666[67]\b/);
const sourceSummary = loadMvpGateSummary(repoRoot);
assert.equal(gateSummary.endpoint, sourceSummary.endpoint);
@@ -40,4 +68,4 @@ assert.equal(gateSummary.agent.workerServiceId, "hwlab-agent-worker");
assert.equal(gateSummary.evidenceRecords.length, 2);
assert.equal(gateSummary.safety.allowNetwork, false);
console.log("hwlab-cloud-web check ok: gate summary reads M0-M5 report");
console.log("hwlab-cloud-web check ok: console modules and gate summary are present");
+231 -49
View File
@@ -1,16 +1,20 @@
:root {
color-scheme: dark;
--bg: #101214;
--surface: #171a1f;
--surface-2: #20242b;
--surface-3: #252a32;
--text: #edf0e8;
--muted: #a9b0aa;
--line: #373d43;
--accent: #f0c75e;
--ok: #7fc97a;
--warn: #f0a35e;
--bad: #ff6b5f;
--bg: #11120f;
--surface: #181914;
--surface-2: #202119;
--surface-3: #28291f;
--surface-4: #313226;
--text: #f2f0e8;
--muted: #b8b3a5;
--line: #464333;
--line-strong: #6e674c;
--accent: #e8bf50;
--accent-2: #7dcfc4;
--ok: #83c77b;
--warn: #e1a84b;
--bad: #f06d5f;
--info: #8ab4df;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--body: "Aptos", "Segoe UI", system-ui, sans-serif;
}
@@ -23,7 +27,11 @@ html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
background:
linear-gradient(90deg, rgba(232, 191, 80, 0.05) 1px, transparent 1px),
linear-gradient(0deg, rgba(125, 207, 196, 0.04) 1px, transparent 1px),
var(--bg);
background-size: 44px 44px;
color: var(--text);
font: 13px/1.45 var(--body);
}
@@ -33,30 +41,38 @@ body {
}
.shell {
max-width: 1380px;
max-width: 1440px;
margin: 0 auto;
display: grid;
gap: 12px;
}
.topbar,
.panel {
background: var(--surface);
.masthead,
.module-nav,
.panel,
.section-head {
background: rgba(24, 25, 20, 0.96);
border: 1px solid var(--line);
}
.topbar {
min-height: 74px;
display: flex;
justify-content: space-between;
.masthead {
min-height: 112px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 16px;
align-items: center;
padding: 14px 16px;
align-items: stretch;
padding: 16px;
}
.identity {
display: grid;
align-content: center;
gap: 6px;
}
.eyebrow {
margin: 0 0 2px;
color: var(--muted);
margin: 0;
color: var(--accent-2);
font-size: 11px;
letter-spacing: 0;
text-transform: uppercase;
@@ -64,6 +80,7 @@ body {
h1,
h2,
h3,
p,
dl,
ol,
@@ -73,22 +90,112 @@ table {
}
h1 {
font-size: 24px;
line-height: 1.1;
font-size: 28px;
line-height: 1.08;
font-weight: 780;
}
h2 {
font-size: 15px;
font-size: 18px;
line-height: 1.2;
}
.status {
h3 {
font-size: 14px;
line-height: 1.2;
}
.lede,
.section-head p,
.panel-head p,
.live-card small {
color: var(--muted);
}
.lede {
max-width: 780px;
}
.live-card {
display: grid;
align-content: center;
gap: 5px;
padding: 14px;
background: var(--surface-2);
border-left: 4px solid var(--accent);
}
.live-card .label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.live-card strong {
font-size: 22px;
line-height: 1.1;
text-transform: uppercase;
}
.module-nav {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
padding: 1px;
position: sticky;
top: 0;
z-index: 5;
}
.module-nav a {
min-height: 42px;
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: center;
padding: 8px 10px;
background: var(--surface-2);
color: var(--text);
text-align: center;
text-decoration: none;
overflow-wrap: anywhere;
}
.module-nav a:hover,
.module-nav a:focus-visible {
background: var(--surface-3);
outline: 0;
}
.module-nav a.active {
background: #35311f;
color: #fff4cd;
box-shadow: inset 0 -3px 0 var(--accent);
}
.view {
display: grid;
gap: 12px;
}
.view[hidden] {
display: none;
}
.section-head {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: end;
padding: 14px 16px;
}
.section-head p {
max-width: 620px;
text-align: right;
}
.panel {
min-width: 0;
padding: 14px 16px;
}
@@ -98,15 +205,10 @@ h2 {
.panel-head {
display: grid;
gap: 3px;
gap: 4px;
margin-bottom: 12px;
}
.panel-head p {
color: var(--muted);
max-width: 880px;
}
.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -122,6 +224,10 @@ h2 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.metrics.four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metrics.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -139,6 +245,7 @@ h2 {
color: var(--muted);
font-size: 11px;
line-height: 1.2;
text-transform: uppercase;
}
.metric-value {
@@ -149,6 +256,33 @@ h2 {
overflow-wrap: anywhere;
}
.method-grid {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.method-pill {
max-width: 100%;
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 4px 8px;
border: 1px solid var(--line);
background: var(--surface-2);
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.method-pill.read {
border-color: rgba(125, 207, 196, 0.45);
}
.method-pill.write {
border-color: rgba(232, 191, 80, 0.36);
}
.milestones {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
@@ -190,30 +324,57 @@ h2 {
background: var(--surface-3);
color: var(--text);
font-size: 11px;
font-weight: 700;
font-weight: 730;
text-transform: uppercase;
overflow-wrap: anywhere;
}
.tone-pass,
.tone-healthy {
.tone-healthy,
.tone-connected,
.tone-completed,
.tone-succeeded,
.tone-available,
.tone-active,
.tone-accepted {
color: var(--ok);
}
.tone-blocked,
.tone-network_blocker {
.tone-degraded,
.tone-network_blocker,
.tone-failed,
.tone-rejected,
.tone-unavailable {
color: var(--bad);
}
.tone-not_run {
.tone-not_run,
.tone-warn,
.tone-warning,
.tone-probing,
.tone-false {
color: var(--warn);
}
.tone-live,
.tone-recorded {
color: var(--info);
}
.tone-muted {
color: var(--muted);
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
}
.compact-table table {
min-width: 620px;
}
table {
width: 100%;
min-width: 900px;
@@ -232,7 +393,7 @@ td {
th {
color: var(--muted);
font-size: 11px;
font-weight: 700;
font-weight: 730;
text-transform: uppercase;
}
@@ -255,8 +416,20 @@ tbody tr:last-child td {
word-break: break-word;
}
.empty,
.notice-cell {
color: var(--muted);
background: var(--surface-2);
}
.empty {
width: 100%;
padding: 10px;
border: 1px solid var(--line);
}
.danger {
border-color: color-mix(in srgb, var(--bad) 45%, var(--line));
border-color: #875148;
}
.blockers,
@@ -288,36 +461,45 @@ tbody tr:last-child td {
content: counter(command) ". ";
color: var(--accent);
font-family: var(--body);
font-weight: 700;
font-weight: 730;
}
@media (max-width: 1120px) {
@media (max-width: 1180px) {
.module-nav,
.metrics.six,
.milestones {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metrics.four {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 820px) {
@media (max-width: 860px) {
body {
padding: 10px;
}
.topbar,
.split {
.masthead,
.split,
.section-head {
grid-template-columns: 1fr;
}
.topbar {
.masthead,
.section-head {
display: grid;
align-items: start;
}
.status {
justify-content: flex-start;
.section-head p {
text-align: left;
}
.module-nav,
.metrics.six,
.metrics.four,
.metrics.two,
.milestones {
grid-template-columns: 1fr;