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:
+556
-108
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user