4ebb1e4aa6
Refs #59 Merged by commander after reviewing Code Queue task codex_1779422758441_1. Source-only frontend PR; deployment/DEV verification remains as follow-up.
597 lines
21 KiB
JavaScript
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);
|
|
}
|