Files
pikasTech-HWLAB/web/hwlab-cloud-web/app.mjs
T
Lyon 4ebb1e4aa6 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.
2026-05-22 12:25:41 +08:00

597 lines
21 KiB
JavaScript

import { gateSummary } from "./gate-summary.mjs";
import { runtime } from "./runtime.mjs";
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);
element.textContent = text;
if (className) {
element.className = className;
}
parent.append(element);
return element;
}
function appendMetricGrid(parent, entries) {
parent.replaceChildren();
for (const entry of entries) {
const [label, value, tone] = entry;
const item = document.createElement("div");
item.className = "metric";
appendText(item, "span", label, "metric-label");
const strong = appendText(item, "strong", String(value ?? "n/a"), "metric-value");
if (tone) {
strong.classList.add(`tone-${toneClass(tone)}`);
}
parent.append(item);
}
}
function badge(text, tone = text) {
const span = document.createElement("span");
span.className = `badge tone-${toneClass(tone)}`;
span.textContent = text ?? "n/a";
return span;
}
function toneClass(tone) {
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
}
function cell(text, className) {
const td = document.createElement("td");
td.textContent = text ?? "n/a";
if (className) {
td.className = className;
}
return td;
}
function rowNotice(tbody, colSpan, text, tone = "warn") {
tbody.replaceChildren();
const tr = document.createElement("tr");
const td = cell(text, `notice-cell tone-${toneClass(tone)}`);
td.colSpan = colSpan;
tr.append(td);
tbody.append(tr);
}
function emptyNotice(text) {
const div = document.createElement("div");
div.className = "empty";
div.textContent = text;
return div;
}
function replaceChildren(parent, child) {
parent.replaceChildren(child);
}