diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 6a12d72a..c77aef44 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1,15 +1,534 @@ import { gateSummary } from "./gate-summary.mjs"; +import { runtime } from "./runtime.mjs"; -const status = document.querySelector("#status"); -const metaGrid = document.querySelector("#meta-grid"); -const milestoneGrid = document.querySelector("#milestone-grid"); -const healthBody = document.querySelector("#health-body"); -const artifactBody = document.querySelector("#artifact-body"); -const topologyGrid = document.querySelector("#topology-grid"); -const agentGrid = document.querySelector("#agent-grid"); -const evidenceBody = document.querySelector("#evidence-body"); -const blockerList = document.querySelector("#blocker-list"); -const commandList = document.querySelector("#command-list"); +const API_TIMEOUT_MS = 4500; +const rpcReadMethods = Object.freeze([ + "system.health", + "cloud.adapter.describe", + "audit.event.query", + "evidence.record.query" +]); + +const navLinks = [...document.querySelectorAll("[data-route]")]; +const views = [...document.querySelectorAll("[data-view]")]; +const viewIds = new Set(views.map((view) => view.dataset.view)); +let rpcSequence = 0; + +const el = { + liveStatus: byId("live-status"), + liveDetail: byId("live-detail"), + overviewMetrics: byId("overview-metrics"), + methodDetail: byId("method-detail"), + methodGrid: byId("method-grid"), + runtimeDetail: byId("runtime-detail"), + runtimeMetrics: byId("runtime-metrics"), + topologyLiveMetrics: byId("topology-live-metrics"), + topologyBody: byId("topology-body"), + patchMetrics: byId("patch-metrics"), + hardwareLiveMetrics: byId("hardware-live-metrics"), + operationBody: byId("operation-body"), + agentMetrics: byId("agent-metrics"), + agentEvidenceMetrics: byId("agent-evidence-metrics"), + auditDetail: byId("audit-detail"), + auditBody: byId("audit-body"), + evidenceQueryDetail: byId("evidence-query-detail"), + liveEvidenceBody: byId("live-evidence-body"), + evidenceBody: byId("evidence-body"), + metaGrid: byId("meta-grid"), + milestoneGrid: byId("milestone-grid"), + healthBody: byId("health-body"), + blockerList: byId("blocker-list"), + commandList: byId("command-list") +}; + +initRoutes(); +renderStaticConsole(); +renderLivePending(); +loadLiveSurface().then(renderLiveConsole); + +function byId(id) { + const element = document.getElementById(id); + if (!element) { + throw new Error(`missing required element #${id}`); + } + return element; +} + +function initRoutes() { + window.addEventListener("hashchange", () => showView(routeFromLocation())); + showView(routeFromLocation()); +} + +function routeFromLocation() { + const hashRoute = window.location.hash.replace(/^#/, ""); + if (viewIds.has(hashRoute)) return hashRoute; + if (window.location.pathname.replace(/\/+$/, "").endsWith("/gate")) return "gate"; + return "overview"; +} + +function showView(route) { + const activeRoute = viewIds.has(route) ? route : "overview"; + for (const view of views) { + view.hidden = view.dataset.view !== activeRoute; + } + for (const link of navLinks) { + link.classList.toggle("active", link.dataset.route === activeRoute); + link.setAttribute("aria-current", link.dataset.route === activeRoute ? "page" : "false"); + } +} + +function renderStaticConsole() { + renderTopologyContract(); + renderHardwareContract(); + renderAgentContract(); + renderEvidenceContract(); + renderGateDiagnostics(); +} + +function renderLivePending() { + appendMetricGrid(el.overviewMetrics, [ + ["API origin", "same-origin /v1 + /json-rpc"], + ["Configured DEV", runtime.endpoints.dev, "warn"], + ["Health", "probing", "warn"], + ["Runtime", "probing", "warn"], + ["DB", "probing", "warn"], + ["Gate", gateSummary.gateStatus, gateSummary.gateStatus] + ]); + replaceChildren(el.methodGrid, emptyNotice("Waiting for live cloud-api method list.")); + appendMetricGrid(el.runtimeMetrics, [ + ["Store", "probing", "warn"], + ["Durable", "probing", "warn"], + ["Gateway sessions", "probing", "warn"], + ["Evidence records", "probing", "warn"] + ]); + appendMetricGrid(el.topologyLiveMetrics, [ + ["Live gateway sessions", "probing", "warn"], + ["Live box resources", "probing", "warn"], + ["Live capabilities", "probing", "warn"], + ["Live patch state", "not exposed read-only", "warn"] + ]); + appendMetricGrid(el.hardwareLiveMetrics, [ + ["Live operations", "probing", "warn"], + ["Live audit events", "probing", "warn"], + ["Live evidence records", "probing", "warn"], + ["Mutation surface", "not called by console", "pass"] + ]); + rowNotice(el.auditBody, 4, "Waiting for audit.event.query."); + rowNotice(el.liveEvidenceBody, 4, "Waiting for evidence.record.query."); +} + +async function loadLiveSurface() { + const projectId = gateSummary.topology.projectId; + const [restIndex, health, adapter, audit, evidence] = await Promise.all([ + fetchJson("/v1"), + callRpc("system.health"), + callRpc("cloud.adapter.describe"), + callRpc("audit.event.query", { projectId, limit: 6 }), + callRpc("evidence.record.query", { projectId, limit: 6 }) + ]); + + return { + observedAt: new Date().toISOString(), + restIndex, + health, + adapter, + audit, + evidence + }; +} + +async function fetchJson(path, options = {}) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + const response = await fetch(path, { + ...options, + headers: { + Accept: "application/json", + ...(options.headers ?? {}) + }, + signal: controller.signal + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + if (!response.ok) { + throw new Error(`${path} HTTP ${response.status}: ${messageFromPayload(data)}`); + } + return { ok: true, path, status: response.status, data }; + } catch (error) { + return { + ok: false, + path, + error: error.name === "AbortError" ? `${path} timed out after ${API_TIMEOUT_MS}ms` : error.message + }; + } finally { + window.clearTimeout(timer); + } +} + +async function callRpc(method, params = {}) { + const id = nextProtocolId("req"); + const traceId = nextProtocolId("trc"); + const response = await fetchJson("/json-rpc", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params, + meta: { + traceId, + serviceId: "hwlab-cloud-web", + environment: "dev" + } + }) + }); + + if (!response.ok) { + return { ...response, method }; + } + if (response.data?.error) { + return { + ok: false, + path: response.path, + status: response.status, + method, + error: `${method}: ${response.data.error.message}`, + envelope: response.data + }; + } + return { + ok: true, + path: response.path, + status: response.status, + method, + data: response.data?.result, + envelope: response.data + }; +} + +function nextProtocolId(prefix) { + rpcSequence += 1; + return `${prefix}_cloud-web-console-${Date.now()}-${rpcSequence}`; +} + +function renderLiveConsole(live) { + const coreProbes = [live.restIndex, live.health, live.adapter]; + const reachable = coreProbes.some((probe) => probe.ok); + const runtimeSummary = runtimeSummaryFrom(live); + const methodList = methodsFrom(live); + const healthStatus = live.health.ok ? live.health.data.status : "degraded"; + const liveTone = reachable ? healthStatus : "degraded"; + + el.liveStatus.textContent = reachable ? healthStatus : "degraded"; + el.liveStatus.className = `tone-${toneClass(liveTone)}`; + el.liveDetail.textContent = reachable + ? `Same-origin cloud-api responded; runtime status is ${healthStatus}.` + : `Same-origin API unavailable: ${probeErrors(coreProbes).join(" | ")}`; + + appendMetricGrid(el.overviewMetrics, [ + ["API origin", "same-origin /v1 + /json-rpc"], + ["Configured DEV", runtime.endpoints.dev, "warn"], + ["Health", live.health.ok ? live.health.data.status : "degraded", live.health.ok ? live.health.data.status : "degraded"], + ["Runtime", runtimeSummary?.status ?? "unavailable", runtimeSummary?.status ?? "degraded"], + ["DB", live.health.ok ? live.health.data.db?.status : "unavailable", live.health.ok ? live.health.data.db?.status : "degraded"], + ["Observed", reachable ? shortTime(live.observedAt) : "not available", reachable ? "pass" : "degraded"] + ]); + + renderMethods(methodList, live); + renderRuntimeSummary(runtimeSummary, live); + renderLiveTopology(runtimeSummary); + renderLiveHardware(runtimeSummary); + renderLiveAudit(live.audit); + renderLiveEvidence(live.evidence); +} + +function renderMethods(methods, live) { + if (methods.length === 0) { + el.methodDetail.textContent = `degraded: no live method list returned. ${probeErrors([live.restIndex, live.adapter]).join(" | ")}`; + replaceChildren(el.methodGrid, emptyNotice("No live cloud-api methods available.")); + return; + } + + const source = live.restIndex.ok && Array.isArray(live.restIndex.data.methods) ? "GET /v1" : "cloud.adapter.describe"; + el.methodDetail.textContent = `Live method list from ${source}; ${methods.length} methods returned.`; + const fragment = document.createDocumentFragment(); + for (const method of methods) { + const item = document.createElement("span"); + item.className = `method-pill ${rpcReadMethods.includes(method) ? "read" : "write"}`; + item.textContent = method; + fragment.append(item); + } + replaceChildren(el.methodGrid, fragment); +} + +function renderRuntimeSummary(summary, live) { + if (!summary) { + el.runtimeDetail.textContent = `degraded: runtime summary unavailable. ${probeErrors([live.restIndex, live.health, live.adapter]).join(" | ")}`; + appendMetricGrid(el.runtimeMetrics, [ + ["Store", "unavailable", "degraded"], + ["Durable", "unavailable", "degraded"], + ["Gateway sessions", "unavailable", "degraded"], + ["Evidence records", "unavailable", "degraded"] + ]); + return; + } + + el.runtimeDetail.textContent = summary.reason || "Live cloud-api returned a runtime summary."; + const counts = summary.counts ?? {}; + appendMetricGrid(el.runtimeMetrics, [ + ["Store", summary.adapter ?? "unknown", summary.status ?? "degraded"], + ["Durable", summary.durable === true ? "true" : "false", summary.durable === true ? "pass" : "warn"], + ["Gateway sessions", counts.gatewaySessions ?? 0, "pass"], + ["Evidence records", counts.evidenceRecords ?? 0, "pass"] + ]); +} + +function renderLiveTopology(summary) { + const counts = summary?.counts; + appendMetricGrid(el.topologyLiveMetrics, [ + ["Live gateway sessions", valueOrUnavailable(counts?.gatewaySessions), summary ? "pass" : "degraded"], + ["Live box resources", valueOrUnavailable(counts?.boxResources), summary ? "pass" : "degraded"], + ["Live capabilities", valueOrUnavailable(counts?.boxCapabilities), summary ? "pass" : "degraded"], + ["Live patch state", "not exposed read-only", "warn"] + ]); +} + +function renderLiveHardware(summary) { + const counts = summary?.counts; + appendMetricGrid(el.hardwareLiveMetrics, [ + ["Live operations", valueOrUnavailable(counts?.hardwareOperations), summary ? "pass" : "degraded"], + ["Live audit events", valueOrUnavailable(counts?.auditEvents), summary ? "pass" : "degraded"], + ["Live evidence records", valueOrUnavailable(counts?.evidenceRecords), summary ? "pass" : "degraded"], + ["Mutation surface", "not called by console", "pass"] + ]); +} + +function renderLiveAudit(auditProbe) { + el.auditBody.replaceChildren(); + if (!auditProbe.ok) { + el.auditDetail.textContent = `degraded: ${auditProbe.error}`; + rowNotice(el.auditBody, 4, "No live audit records returned.", "degraded"); + return; + } + + const events = auditProbe.data?.events ?? []; + el.auditDetail.textContent = `Live audit.event.query returned ${auditProbe.data?.count ?? events.length} records.`; + if (events.length === 0) { + rowNotice(el.auditBody, 4, "cloud-api returned zero live audit records.", "muted"); + return; + } + for (const event of events) { + const tr = document.createElement("tr"); + tr.append(cell(event.auditId, "mono")); + tr.append(cell(event.action)); + tr.append(cell(`${event.targetType ?? "target"}:${event.targetId ?? "n/a"}`, "mono wrap")); + const statusCell = document.createElement("td"); + statusCell.append(badge(event.outcome ?? "recorded", event.outcome ?? "pass")); + tr.append(statusCell); + el.auditBody.append(tr); + } +} + +function renderLiveEvidence(evidenceProbe) { + el.liveEvidenceBody.replaceChildren(); + if (!evidenceProbe.ok) { + el.evidenceQueryDetail.textContent = `degraded: ${evidenceProbe.error}`; + rowNotice(el.liveEvidenceBody, 4, "No live evidence records returned.", "degraded"); + return; + } + + const records = evidenceProbe.data?.records ?? []; + el.evidenceQueryDetail.textContent = `Live evidence.record.query returned ${evidenceProbe.data?.count ?? records.length} records.`; + if (records.length === 0) { + rowNotice(el.liveEvidenceBody, 4, "cloud-api returned zero live evidence records.", "muted"); + return; + } + for (const record of records) { + const tr = document.createElement("tr"); + tr.append(cell(record.evidenceId, "mono")); + tr.append(cell(record.operationId, "mono")); + tr.append(cell(record.kind)); + tr.append(cell(record.serviceId, "mono")); + el.liveEvidenceBody.append(tr); + } +} + +function renderTopologyContract() { + el.topologyBody.replaceChildren(); + for (const gateway of gateSummary.topology.gateways) { + const tr = document.createElement("tr"); + tr.append(cell("Gateway")); + tr.append(cell(gateway.gatewaySessionId, "mono")); + tr.append(cell(gateway.serviceId, "mono")); + const statusCell = document.createElement("td"); + statusCell.append(badge(gateway.status, gateway.status)); + tr.append(statusCell); + tr.append(cell(gateway.endpoint ?? gateway.gatewayId, "mono wrap")); + el.topologyBody.append(tr); + } + for (const resource of gateSummary.topology.boxResources) { + const tr = document.createElement("tr"); + tr.append(cell("Box resource")); + tr.append(cell(resource.resourceId, "mono")); + tr.append(cell(resource.gatewaySessionId, "mono")); + const statusCell = document.createElement("td"); + statusCell.append(badge(resource.state, resource.state)); + tr.append(statusCell); + tr.append(cell(`${resource.resourceType}: ${resource.name ?? resource.boxId}`, "wrap")); + el.topologyBody.append(tr); + } + + appendMetricGrid(el.patchMetrics, [ + ["Patch panel", gateSummary.topology.patchPanel.patchPanelStatusId], + ["Service", gateSummary.topology.patchPanel.serviceId], + ["State", gateSummary.topology.patchPanel.state, gateSummary.topology.patchPanel.state], + [ + "Active links", + gateSummary.topology.patchPanel.activeConnections + .map((link) => `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`) + .join(", ") + ] + ]); +} + +function renderHardwareContract() { + el.operationBody.replaceChildren(); + for (const operation of gateSummary.operations) { + const tr = document.createElement("tr"); + tr.append(cell(operation.operationId, "mono")); + tr.append(cell(operation.requestedBy, "mono")); + const statusCell = document.createElement("td"); + statusCell.append(badge(operation.status, operation.status)); + tr.append(statusCell); + tr.append(cell(`${operation.resourceId} / ${operation.capabilityId}`, "mono wrap")); + tr.append(cell([operation.agentSessionId, operation.workerSessionId].filter(Boolean).join(" / ") || "direct", "mono")); + el.operationBody.append(tr); + } +} + +function renderAgentContract() { + appendMetricGrid(el.agentMetrics, [ + ["Agent session", gateSummary.agent.agentSessionId], + ["Agent service", gateSummary.agent.agentServiceId], + ["Worker session", gateSummary.agent.workerSessionId], + ["Worker service", gateSummary.agent.workerServiceId], + ["Agent status", gateSummary.agent.agentStatus, gateSummary.agent.agentStatus], + ["Worker status", gateSummary.agent.workerStatus, gateSummary.agent.workerStatus] + ]); + appendMetricGrid(el.agentEvidenceMetrics, [ + ["Trace events", gateSummary.traceCount], + ["Audit events", gateSummary.auditCount], + ["Evidence records", gateSummary.evidenceCount], + ["Cleanup records", gateSummary.cleanupCount], + ["Cleanup id", gateSummary.agent.cleanupId], + ["Source", "checked-in gate report", "warn"] + ]); +} + +function renderEvidenceContract() { + el.evidenceBody.replaceChildren(); + for (const record of gateSummary.evidenceRecords) { + const tr = document.createElement("tr"); + tr.append(cell(record.evidenceId, "mono")); + tr.append(cell(record.operationId, "mono")); + tr.append(cell(record.serviceId, "mono")); + tr.append(cell(record.uri, "mono wrap")); + tr.append(cell(record.sha256, "mono wrap")); + el.evidenceBody.append(tr); + } +} + +function renderGateDiagnostics() { + appendMetricGrid(el.metaGrid, [ + ["Environment", gateSummary.environment], + ["Endpoint", gateSummary.endpoint], + ["Namespace", gateSummary.namespace], + ["Report commit", gateSummary.reportCommitId], + ["Gate report", gateSummary.generatedFrom.gateReport], + ["M5 plan", gateSummary.generatedFrom.m5Plan] + ]); + + el.milestoneGrid.replaceChildren(); + for (const milestone of gateSummary.milestones) { + const item = document.createElement("article"); + item.className = "milestone"; + const head = document.createElement("div"); + head.className = "milestone-head"; + appendText(head, "strong", milestone.id); + head.append(badge(milestone.status, milestone.status)); + item.append(head); + appendText(item, "p", milestone.summary); + appendText(item, "small", `${milestone.commandCount} commands / ${milestone.evidenceCount} evidence lines`); + el.milestoneGrid.append(item); + } + + el.healthBody.replaceChildren(); + for (const row of gateSummary.health) { + const tr = document.createElement("tr"); + tr.append(cell(row.component)); + tr.append(cell(row.serviceId, "mono")); + const statusCell = document.createElement("td"); + statusCell.append(badge(row.status, row.status)); + tr.append(statusCell); + tr.append(cell(row.observedBy)); + tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap")); + el.healthBody.append(tr); + } + + el.blockerList.replaceChildren(); + for (const blocker of gateSummary.blockers) { + const li = document.createElement("li"); + const head = document.createElement("div"); + head.className = "blocker-head"; + head.append(badge(blocker.type, "blocked")); + appendText(head, "strong", blocker.scope); + li.append(head); + appendText(li, "p", blocker.summary); + el.blockerList.append(li); + } + + el.commandList.replaceChildren(); + for (const command of [ + "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", + "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production", + "node scripts/m5-mvp-e2e-dry-run.mjs" + ]) { + appendText(el.commandList, "li", command, "mono"); + } +} + +function runtimeSummaryFrom(live) { + return live.health.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null; +} + +function methodsFrom(live) { + const restMethods = live.restIndex.data?.methods; + const adapterMethods = live.adapter.data?.methods; + if (Array.isArray(restMethods)) return restMethods; + if (Array.isArray(adapterMethods)) return adapterMethods; + return []; +} + +function probeErrors(probes) { + return probes.filter((probe) => !probe.ok).map((probe) => `${probe.method ?? probe.path}: ${probe.error}`); +} + +function messageFromPayload(payload) { + if (!payload || typeof payload !== "object") return "non-JSON or empty response"; + return payload.error?.message || payload.error?.code || payload.message || JSON.stringify(payload).slice(0, 160); +} + +function valueOrUnavailable(value) { + return value === undefined || value === null ? "unavailable" : value; +} + +function shortTime(value) { + return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z"); +} function appendText(parent, tagName, text, className) { const element = document.createElement(tagName); @@ -21,23 +540,32 @@ function appendText(parent, tagName, text, className) { return element; } -function appendMetricGrid(parent, pairs) { - for (const [label, value] of pairs) { +function appendMetricGrid(parent, entries) { + parent.replaceChildren(); + for (const entry of entries) { + const [label, value, tone] = entry; const item = document.createElement("div"); item.className = "metric"; appendText(item, "span", label, "metric-label"); - appendText(item, "strong", String(value ?? "n/a"), "metric-value"); + const strong = appendText(item, "strong", String(value ?? "n/a"), "metric-value"); + if (tone) { + strong.classList.add(`tone-${toneClass(tone)}`); + } parent.append(item); } } function badge(text, tone = text) { const span = document.createElement("span"); - span.className = `badge tone-${String(tone).replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`; - span.textContent = text; + span.className = `badge tone-${toneClass(tone)}`; + span.textContent = text ?? "n/a"; return span; } +function toneClass(tone) { + return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase(); +} + function cell(text, className) { const td = document.createElement("td"); td.textContent = text ?? "n/a"; @@ -47,102 +575,22 @@ function cell(text, className) { return td; } -status.replaceChildren(badge(gateSummary.gateStatus, gateSummary.gateStatus)); - -appendMetricGrid(metaGrid, [ - ["Environment", gateSummary.environment], - ["Endpoint", gateSummary.endpoint], - ["Namespace", gateSummary.namespace], - ["Report commit", gateSummary.reportCommitId], - ["Gate report", gateSummary.generatedFrom.gateReport], - ["M5 plan", gateSummary.generatedFrom.m5Plan] -]); - -for (const milestone of gateSummary.milestones) { - const item = document.createElement("article"); - item.className = "milestone"; - const head = document.createElement("div"); - head.className = "milestone-head"; - appendText(head, "strong", milestone.id); - head.append(badge(milestone.status, milestone.status)); - item.append(head); - appendText(item, "p", milestone.summary); - appendText(item, "small", `${milestone.commandCount} commands / ${milestone.evidenceCount} evidence lines`); - milestoneGrid.append(item); -} - -for (const row of gateSummary.health) { +function rowNotice(tbody, colSpan, text, tone = "warn") { + tbody.replaceChildren(); const tr = document.createElement("tr"); - tr.append(cell(row.component)); - tr.append(cell(row.serviceId, "mono")); - const statusCell = document.createElement("td"); - statusCell.append(badge(row.status, row.status)); - tr.append(statusCell); - tr.append(cell(row.observedBy)); - tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap")); - healthBody.append(tr); + const td = cell(text, `notice-cell tone-${toneClass(tone)}`); + td.colSpan = colSpan; + tr.append(td); + tbody.append(tr); } -for (const artifact of gateSummary.artifacts) { - const tr = document.createElement("tr"); - tr.append(cell(artifact.serviceId, "mono")); - tr.append(cell(artifact.commitId, "mono")); - tr.append(cell(`${artifact.image}:${artifact.tag}`, "mono wrap")); - tr.append(cell(artifact.digest, "mono wrap")); - tr.append(cell(artifact.healthTimestamp, "mono")); - artifactBody.append(tr); +function emptyNotice(text) { + const div = document.createElement("div"); + div.className = "empty"; + div.textContent = text; + return div; } -appendMetricGrid(topologyGrid, [ - ["Project", gateSummary.topology.projectId], - ["Gateways", gateSummary.gatewaySessionCount], - ["Box resources", gateSummary.boxResourceCount], - ["Patch panel", gateSummary.topology.patchPanel.patchPanelStatusId], - ["Active links", gateSummary.topology.patchPanel.activeConnectionCount], - [ - "Connection", - gateSummary.topology.patchPanel.activeConnections - .map((link) => `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`) - .join(", ") - ] -]); - -appendMetricGrid(agentGrid, [ - ["Agent session", gateSummary.agent.agentSessionId], - ["Agent service", gateSummary.agent.agentServiceId], - ["Worker session", gateSummary.agent.workerSessionId], - ["Worker service", gateSummary.agent.workerServiceId], - ["Trace events", gateSummary.traceCount], - ["Audit events", gateSummary.auditCount], - ["Cleanup records", gateSummary.cleanupCount], - ["Cleanup id", gateSummary.agent.cleanupId] -]); - -for (const record of gateSummary.evidenceRecords) { - const tr = document.createElement("tr"); - tr.append(cell(record.evidenceId, "mono")); - tr.append(cell(record.operationId, "mono")); - tr.append(cell(record.serviceId, "mono")); - tr.append(cell(record.uri, "mono wrap")); - tr.append(cell(record.sha256, "mono wrap")); - evidenceBody.append(tr); -} - -for (const blocker of gateSummary.blockers) { - const li = document.createElement("li"); - const head = document.createElement("div"); - head.className = "blocker-head"; - head.append(badge(blocker.type, "blocked")); - appendText(head, "strong", blocker.scope); - li.append(head); - appendText(li, "p", blocker.summary); - blockerList.append(li); -} - -for (const command of [ - "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", - "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production", - "node scripts/m5-mvp-e2e-dry-run.mjs" -]) { - appendText(commandList, "li", command, "mono"); +function replaceChildren(parent, child) { + parent.replaceChildren(child); } diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index f71a9bf5..6bcf31c4 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -3,132 +3,285 @@ - HWLAB DEV MVP Gate + HWLAB DEV Console -
-
-
-

HWLAB L6 Frontend / CLI MVP

-

DEV MVP Gate

+
+
+
+

HWLAB Cloud Web / DEV

+

DEV Control Console

+

Same-origin runtime observation for cloud-api health, methods, topology contracts, operations, agent sessions, and evidence.

+
+
+ Live API + probing + Checking /v1 and /json-rpc from this origin.
-
-
-
-

Acceptance Source

-

Contract-driven view of the checked-in M0-M5 gate report and M5 dry-run plan.

-
-
-
+ -
-
-

M0-M5 Gates

-

Local contract and dry-run status. Live DEV remains separate from fixture evidence.

-
-
-
- -
-
-

DEV Health Contract

-

Read from the M5 gate fixture; live health is not claimed while blockers are open.

-
-
- - - - - - - - - - - -
ComponentServiceStatusObservedEndpoint
-
-
- -
-
-

Artifact / Deploy Commit

-

Artifact identity fields required by the MVP DoD.

-
-
- - - - - - - - - - - -
ServiceCommitImageDigestHealth timestamp
-
-
- -
-
-
-

2 Box / 2 Gateway / Patch Panel

-

Topology contract for the hardware trusted loop.

+
+
+
+

Overview

+

Runtime Surface

-
-
+

Live rows are populated only from same-origin API responses. Checked-in gate records are labeled separately.

+
+
+
+
+
+

Live Method Registry

+

Waiting for cloud-api adapter description.

+
+
+
+
+
+

Runtime Summary

+

Waiting for cloud-api runtime summary.

+
+
+
+
+
-
-
-

Agent Session Evidence

-

Agent and worker session IDs tied to trace, audit, evidence, and cleanup.

+ + + -
-
-

Evidence Records

-

Direct and agent hardware operations remain fixture-bound until live DEV blockers clear.

+ -
-
-
-

Blocked Evidence

-

Open blocker evidence that prevents live DEV acceptance.

+ +
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 0ff86020..d695b187 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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"); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 51c6b500..0b5d163e 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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;