166a7763a4
Refs #99 #78
792 lines
25 KiB
JavaScript
792 lines
25 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 viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
|
let rpcSequence = 0;
|
|
|
|
const el = {
|
|
shell: query("[data-app-shell]"),
|
|
explorerToggle: byId("explorer-toggle"),
|
|
explorerStatus: byId("explorer-status"),
|
|
sourceStrip: byId("source-strip"),
|
|
treeCount: byId("tree-count"),
|
|
resourceTree: byId("resource-tree"),
|
|
blockerCount: byId("blocker-count"),
|
|
explorerBlockers: byId("explorer-blockers"),
|
|
routePath: byId("route-path"),
|
|
liveStatus: byId("live-status"),
|
|
liveDetail: byId("live-detail"),
|
|
conversationList: byId("conversation-list"),
|
|
traceList: byId("trace-list"),
|
|
gateMetadata: byId("gate-metadata"),
|
|
milestoneGrid: byId("milestone-grid"),
|
|
healthBody: byId("health-body"),
|
|
gateBlockers: byId("gate-blockers"),
|
|
validationList: byId("validation-list"),
|
|
commandForm: byId("command-form"),
|
|
commandInput: byId("command-input"),
|
|
commandClear: byId("command-clear"),
|
|
hardwareList: byId("hardware-list"),
|
|
controlList: byId("control-list"),
|
|
wiringList: byId("wiring-list"),
|
|
recordsList: byId("records-list"),
|
|
diagnosticsList: byId("diagnostics-list"),
|
|
methodList: byId("method-list")
|
|
};
|
|
|
|
const state = {
|
|
drafts: []
|
|
};
|
|
|
|
initRoutes();
|
|
initExplorerToggle();
|
|
initSideTabs();
|
|
initCommandBar();
|
|
renderStaticWorkbench();
|
|
renderProbePending();
|
|
loadLiveSurface().then(renderLiveSurface);
|
|
|
|
function byId(id) {
|
|
const element = document.getElementById(id);
|
|
if (!element) {
|
|
throw new Error(`missing required element #${id}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function query(selector) {
|
|
const element = document.querySelector(selector);
|
|
if (!element) {
|
|
throw new Error(`missing required element ${selector}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function initRoutes() {
|
|
window.addEventListener("hashchange", () => showView(routeFromLocation()));
|
|
for (const button of document.querySelectorAll("[data-route]")) {
|
|
button.addEventListener("click", () => {
|
|
window.location.hash = button.dataset.route;
|
|
});
|
|
}
|
|
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 "workspace";
|
|
}
|
|
|
|
function showView(route) {
|
|
const activeRoute = viewIds.has(route) ? route : "workspace";
|
|
for (const view of document.querySelectorAll("[data-view]")) {
|
|
view.hidden = view.dataset.view !== activeRoute;
|
|
}
|
|
for (const button of document.querySelectorAll("[data-route]")) {
|
|
const active = button.dataset.route === activeRoute;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-current", active ? "page" : "false");
|
|
}
|
|
}
|
|
|
|
function initExplorerToggle() {
|
|
el.explorerToggle.addEventListener("click", () => {
|
|
const collapsed = !el.shell.classList.contains("explorer-collapsed");
|
|
el.shell.classList.toggle("explorer-collapsed", collapsed);
|
|
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
function initSideTabs() {
|
|
for (const tab of document.querySelectorAll("[data-side-tab]")) {
|
|
tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab));
|
|
}
|
|
for (const jump of document.querySelectorAll("[data-side-tab-jump]")) {
|
|
jump.addEventListener("click", () => selectSideTab(jump.dataset.sideTabJump));
|
|
}
|
|
}
|
|
|
|
function selectSideTab(tabId) {
|
|
for (const tab of document.querySelectorAll("[data-side-tab]")) {
|
|
const active = tab.dataset.sideTab === tabId;
|
|
tab.classList.toggle("active", active);
|
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
|
}
|
|
for (const panel of document.querySelectorAll("[data-side-panel]")) {
|
|
const active = panel.dataset.sidePanel === tabId;
|
|
panel.classList.toggle("active", active);
|
|
panel.hidden = !active;
|
|
}
|
|
}
|
|
|
|
function initCommandBar() {
|
|
el.commandForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const value = el.commandInput.value.trim();
|
|
if (!value) return;
|
|
state.drafts.unshift({
|
|
text: value,
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
el.commandInput.value = "";
|
|
renderDrafts();
|
|
});
|
|
|
|
el.commandClear.addEventListener("click", () => {
|
|
state.drafts = [];
|
|
renderDrafts();
|
|
});
|
|
}
|
|
|
|
function renderStaticWorkbench() {
|
|
el.explorerStatus.textContent = gateSummary.blocked ? "blocked" : gateSummary.gateStatus;
|
|
el.explorerStatus.className = `status-dot tone-${toneClass(gateSummary.blocked ? "blocked" : gateSummary.gateStatus)}`;
|
|
el.routePath.textContent = runtime.serviceRoute.join(" -> ");
|
|
|
|
renderStateStrip(false);
|
|
renderResourceTree();
|
|
renderBlockerLists();
|
|
renderConversation();
|
|
renderTrace();
|
|
renderHardwareStatus(null);
|
|
renderDrafts();
|
|
renderWiringList();
|
|
renderRecords(null);
|
|
renderDiagnostics(null);
|
|
renderGateDiagnostics();
|
|
}
|
|
|
|
function renderProbePending() {
|
|
el.liveStatus.textContent = "probing";
|
|
el.liveStatus.className = "tone-probing";
|
|
el.liveDetail.textContent = "Checking /health/live, /v1, and read-only /json-rpc from this origin.";
|
|
renderMethodList(rpcReadMethods, "pending");
|
|
}
|
|
|
|
async function loadLiveSurface() {
|
|
const projectId = gateSummary.topology.projectId;
|
|
const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([
|
|
fetchJson("/health/live"),
|
|
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(),
|
|
healthLive,
|
|
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-workbench-${Date.now()}-${rpcSequence}`;
|
|
}
|
|
|
|
function renderLiveSurface(live) {
|
|
const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
|
|
const reachable = coreProbes.some((probe) => probe.ok);
|
|
const healthStatus = live.health.data?.status ?? live.healthLive.data?.status ?? "degraded";
|
|
const runtimeSummary = runtimeSummaryFrom(live);
|
|
const methods = methodsFrom(live);
|
|
const dbReady = dbReadyFrom(live);
|
|
const devLiveReady = reachable && healthStatus === "ok" && dbReady === true && !gateSummary.blocked;
|
|
const blocked = gateSummary.blocked || !devLiveReady;
|
|
|
|
el.liveStatus.textContent = devLiveReady ? "dev-live ready" : reachable ? "blocked" : "degraded";
|
|
el.liveStatus.className = `tone-${toneClass(devLiveReady ? "dev-live" : reachable ? "blocked" : "degraded")}`;
|
|
el.liveDetail.textContent = reachable
|
|
? `Same-origin read surface responded; health=${healthStatus}, db.ready=${String(dbReady)}, DEV-LIVE=${devLiveReady ? "true" : "false"}.`
|
|
: `Same-origin API unavailable: ${probeErrors(coreProbes).join(" | ")}`;
|
|
|
|
renderStateStrip(devLiveReady);
|
|
renderHardwareStatus(runtimeSummary);
|
|
renderRecords(live);
|
|
renderDiagnostics(live);
|
|
renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, reachable ? "live" : "degraded");
|
|
|
|
if (blocked) {
|
|
el.explorerStatus.textContent = "blocked";
|
|
el.explorerStatus.className = "status-dot tone-blocked";
|
|
}
|
|
}
|
|
|
|
function renderStateStrip(devLiveReady) {
|
|
replaceChildren(
|
|
el.sourceStrip,
|
|
stateTag("SOURCE", `report ${shortHash(gateSummary.reportCommitId)}`),
|
|
stateTag("DRY-RUN", gateSummary.dryRun.status),
|
|
stateTag("DEV-LIVE", devLiveReady ? "ready" : "not claimed"),
|
|
stateTag("BLOCKED", gateSummary.blocked ? `${gateSummary.blockers.length} open` : "clear")
|
|
);
|
|
}
|
|
|
|
function renderResourceTree() {
|
|
const items = [
|
|
{
|
|
label: gateSummary.topology.projectId,
|
|
meta: `${gateSummary.environment} project`,
|
|
tone: "source",
|
|
children: [
|
|
...gateSummary.topology.gateways.map((gateway) => ({
|
|
label: gateway.gatewaySessionId,
|
|
meta: `${gateway.serviceId} / ${gateway.status}`,
|
|
tone: gateway.status
|
|
})),
|
|
...gateSummary.topology.boxResources.map((resource) => ({
|
|
label: resource.resourceId,
|
|
meta: `${resource.resourceType} / ${resource.state}`,
|
|
tone: resource.state
|
|
})),
|
|
{
|
|
label: gateSummary.topology.patchPanel.patchPanelStatusId,
|
|
meta: `${gateSummary.topology.patchPanel.serviceId} / ${gateSummary.topology.patchPanel.state}`,
|
|
tone: gateSummary.topology.patchPanel.state
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: gateSummary.agent.agentSessionId,
|
|
meta: `${gateSummary.agent.agentServiceId} / ${gateSummary.agent.agentStatus}`,
|
|
tone: gateSummary.agent.agentStatus,
|
|
children: [
|
|
{
|
|
label: gateSummary.agent.workerSessionId,
|
|
meta: `${gateSummary.agent.workerServiceId} / ${gateSummary.agent.workerStatus}`,
|
|
tone: gateSummary.agent.workerStatus
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: "diagnostics",
|
|
meta: "secondary Gate view",
|
|
tone: "source",
|
|
children: gateSummary.milestones.map((milestone) => ({
|
|
label: milestone.id,
|
|
meta: milestone.status,
|
|
tone: milestone.status
|
|
}))
|
|
}
|
|
];
|
|
|
|
el.treeCount.textContent = String(items.reduce((count, item) => count + 1 + (item.children?.length ?? 0), 0));
|
|
replaceChildren(el.resourceTree, ...items.map(treeGroup));
|
|
}
|
|
|
|
function treeGroup(item) {
|
|
const details = document.createElement("details");
|
|
details.open = true;
|
|
const summary = document.createElement("summary");
|
|
summary.append(statusLight(item.tone), textSpan(item.label, "tree-label"), textSpan(item.meta, "tree-meta"));
|
|
details.append(summary);
|
|
for (const child of item.children ?? []) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "tree-row";
|
|
row.append(statusLight(child.tone), textSpan(child.label, "tree-label"), textSpan(child.meta, "tree-meta"));
|
|
details.append(row);
|
|
}
|
|
return details;
|
|
}
|
|
|
|
function renderBlockerLists() {
|
|
el.blockerCount.textContent = String(gateSummary.blockers.length);
|
|
replaceChildren(el.explorerBlockers, ...gateSummary.blockers.map(blockerCard));
|
|
replaceChildren(el.gateBlockers, ...gateSummary.blockers.map(blockerCard));
|
|
}
|
|
|
|
function renderConversation() {
|
|
const messages = [
|
|
{
|
|
role: "system",
|
|
title: "Surface mode",
|
|
text: "This shell is scoped to M3 trusted-loop users. It shows route, topology, trace, and diagnostics without dispatching hardware mutations."
|
|
},
|
|
{
|
|
role: "agent",
|
|
title: "Live acceptance state",
|
|
text: gateSummary.devPreconditions.summary
|
|
},
|
|
...gateSummary.blockers.map((blocker) => ({
|
|
role: "blocker",
|
|
title: blocker.scope,
|
|
text: blocker.summary
|
|
})),
|
|
{
|
|
role: "user",
|
|
title: "Available action",
|
|
text: "Draft an instruction in the bottom command bar. Drafts remain local in this browser session until a trusted backend workflow exists."
|
|
}
|
|
];
|
|
replaceChildren(el.conversationList, ...messages.map(messageCard));
|
|
}
|
|
|
|
function renderTrace() {
|
|
replaceChildren(
|
|
el.traceList,
|
|
...gateSummary.steps.map((step) => {
|
|
const article = document.createElement("article");
|
|
article.className = "trace-item";
|
|
article.append(textSpan(String(step.order).padStart(2, "0"), "trace-order"));
|
|
const body = document.createElement("div");
|
|
body.append(textSpan(step.title, "trace-title"));
|
|
body.append(textSpan(`${step.kind} / ${step.outputs.length} outputs`, "trace-meta"));
|
|
article.append(body, badge(step.requires.length === 0 ? "entry" : "requires previous", step.requires.length === 0 ? "source" : "dry-run"));
|
|
return article;
|
|
})
|
|
);
|
|
}
|
|
|
|
function renderHardwareStatus(summary) {
|
|
const counts = summary?.counts ?? {};
|
|
const cards = [
|
|
{
|
|
title: "Gateway-SIMU",
|
|
value: valueOrUnavailable(counts.gatewaySessions, gateSummary.gatewaySessionCount),
|
|
meta: "read count; source fallback",
|
|
tone: summary ? "dev-live" : "source"
|
|
},
|
|
{
|
|
title: "BOX-SIMU",
|
|
value: valueOrUnavailable(counts.boxResources, gateSummary.boxResourceCount),
|
|
meta: "read count; source fallback",
|
|
tone: summary ? "dev-live" : "source"
|
|
},
|
|
{
|
|
title: "Patch Panel",
|
|
value: gateSummary.topology.patchPanel.state,
|
|
meta: `${gateSummary.topology.patchPanel.activeConnectionCount} source links`,
|
|
tone: gateSummary.blocked ? "blocked" : gateSummary.topology.patchPanel.state
|
|
},
|
|
{
|
|
title: "Mutation Surface",
|
|
value: "disabled",
|
|
meta: "no hardware write RPC from web",
|
|
tone: "blocked"
|
|
}
|
|
];
|
|
replaceChildren(el.hardwareList, ...cards.map(statusCard));
|
|
}
|
|
|
|
function controlRows() {
|
|
return [
|
|
{
|
|
title: "Hardware operation",
|
|
detail: "Not exposed in this web shell. Use patch-panel governed workflows after blockers clear.",
|
|
tone: "blocked"
|
|
},
|
|
{
|
|
title: "Agent automation",
|
|
detail: "Visible as trusted trace and dry-run evidence; live scheduling remains blocked by DB readiness.",
|
|
tone: "dry-run"
|
|
},
|
|
{
|
|
title: "Read probes",
|
|
detail: "Same-origin /health/live, /v1, and read-only /json-rpc diagnostics are allowed.",
|
|
tone: "source"
|
|
}
|
|
];
|
|
}
|
|
|
|
function renderDrafts() {
|
|
const rows = [
|
|
...state.drafts.map((draft) => ({
|
|
title: `Local draft ${shortTime(draft.createdAt)}`,
|
|
detail: draft.text,
|
|
tone: "dry-run"
|
|
})),
|
|
...(state.drafts.length === 0
|
|
? [
|
|
{
|
|
title: "Draft queue",
|
|
detail: "No local drafts. The input bar records text locally and does not call cloud-api mutation methods.",
|
|
tone: "source"
|
|
}
|
|
]
|
|
: []),
|
|
...controlRows()
|
|
];
|
|
replaceChildren(el.controlList, ...rows.map(infoCard));
|
|
}
|
|
|
|
function renderWiringList() {
|
|
const patchPanel = gateSummary.topology.patchPanel;
|
|
const cards = [
|
|
infoCard({
|
|
title: patchPanel.serviceId,
|
|
detail: `${patchPanel.patchPanelStatusId} / ${patchPanel.state} / ${patchPanel.environment}`,
|
|
tone: patchPanel.state
|
|
}),
|
|
...patchPanel.activeConnections.map((link) =>
|
|
infoCard({
|
|
title: `${link.fromResourceId}:${link.fromPort}`,
|
|
detail: `patched to ${link.toResourceId}:${link.toPort}`,
|
|
tone: "source"
|
|
})
|
|
)
|
|
];
|
|
replaceChildren(el.wiringList, ...cards);
|
|
}
|
|
|
|
function renderRecords(live) {
|
|
const sourceCards = gateSummary.evidenceRecords.map((record) =>
|
|
infoCard({
|
|
title: record.evidenceId,
|
|
detail: `${record.kind} / ${record.operationId} / ${record.dryRun ? "DRY-RUN" : "unknown source"}`,
|
|
tone: record.dryRun ? "dry-run" : "source"
|
|
})
|
|
);
|
|
|
|
const liveRecords = live?.evidence.ok ? live.evidence.data?.records ?? [] : [];
|
|
const liveCards = liveRecords.map((record) =>
|
|
infoCard({
|
|
title: record.evidenceId,
|
|
detail: `${record.kind ?? "record"} / ${record.operationId ?? "n/a"} / ${record.serviceId ?? "unknown service"}`,
|
|
tone: "dev-live"
|
|
})
|
|
);
|
|
if (live && !live.evidence.ok) {
|
|
liveCards.push(infoCard({ title: "evidence.record.query", detail: live.evidence.error, tone: "blocked" }));
|
|
}
|
|
|
|
replaceChildren(el.recordsList, ...liveCards, ...sourceCards);
|
|
}
|
|
|
|
function renderDiagnostics(live) {
|
|
const diagnostics = [
|
|
{
|
|
title: "Frontend",
|
|
detail: runtime.endpoints.frontend,
|
|
tone: "source"
|
|
},
|
|
{
|
|
title: "API edge",
|
|
detail: runtime.endpoints.api,
|
|
tone: "source"
|
|
},
|
|
{
|
|
title: "Runtime route",
|
|
detail: runtime.serviceRoute.join(" -> "),
|
|
tone: "source"
|
|
}
|
|
];
|
|
|
|
if (live) {
|
|
diagnostics.push(
|
|
{
|
|
title: "/health/live",
|
|
detail: live.healthLive.ok ? `HTTP ${live.healthLive.status} ${live.healthLive.data?.status ?? "ok"}` : live.healthLive.error,
|
|
tone: live.healthLive.ok ? live.healthLive.data?.status ?? "dev-live" : "blocked"
|
|
},
|
|
{
|
|
title: "/v1",
|
|
detail: live.restIndex.ok ? `HTTP ${live.restIndex.status} ${live.restIndex.data?.status ?? "ok"}` : live.restIndex.error,
|
|
tone: live.restIndex.ok ? live.restIndex.data?.status ?? "dev-live" : "blocked"
|
|
},
|
|
{
|
|
title: "system.health",
|
|
detail: live.health.ok ? `RPC ${live.health.data?.status ?? "ok"}` : live.health.error,
|
|
tone: live.health.ok ? live.health.data?.status ?? "dev-live" : "blocked"
|
|
},
|
|
{
|
|
title: "audit.event.query",
|
|
detail: live.audit.ok ? `${live.audit.data?.count ?? live.audit.data?.events?.length ?? 0} records` : live.audit.error,
|
|
tone: live.audit.ok ? "dev-live" : "blocked"
|
|
}
|
|
);
|
|
} else {
|
|
diagnostics.push(
|
|
{
|
|
title: "/health/live",
|
|
detail: "probing same-origin web/API health",
|
|
tone: "dev-live"
|
|
},
|
|
{
|
|
title: "/v1 + /json-rpc",
|
|
detail: "read-only diagnostics pending",
|
|
tone: "source"
|
|
}
|
|
);
|
|
}
|
|
|
|
replaceChildren(el.diagnosticsList, ...diagnostics.map(infoCard));
|
|
}
|
|
|
|
function renderGateDiagnostics() {
|
|
appendMetricGrid(el.gateMetadata, [
|
|
["Environment", gateSummary.environment, "source"],
|
|
["Frontend", runtime.endpoints.frontend, "source"],
|
|
["API", runtime.endpoints.api, "source"],
|
|
["Gate", gateSummary.gateStatus, gateSummary.gateStatus],
|
|
["Dry-run", gateSummary.dryRun.status, "dry-run"],
|
|
["Report", shortHash(gateSummary.reportCommitId), "source"]
|
|
]);
|
|
|
|
replaceChildren(
|
|
el.milestoneGrid,
|
|
...gateSummary.milestones.map((milestone) => {
|
|
const item = document.createElement("article");
|
|
item.className = "milestone-card";
|
|
const header = document.createElement("div");
|
|
header.className = "panel-title-row";
|
|
header.append(textSpan(milestone.id, "milestone-id"), badge(milestone.status, milestone.status));
|
|
item.append(header);
|
|
item.append(textSpan(milestone.summary, "milestone-summary"));
|
|
item.append(textSpan(`${milestone.commandCount} commands / ${milestone.evidenceCount} evidence`, "milestone-meta"));
|
|
return 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 status = document.createElement("td");
|
|
status.append(badge(row.status, row.status));
|
|
tr.append(status);
|
|
tr.append(cell(row.observedBy));
|
|
tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap"));
|
|
el.healthBody.append(tr);
|
|
}
|
|
|
|
replaceChildren(
|
|
el.validationList,
|
|
...[
|
|
"node --check web/hwlab-cloud-web/app.mjs",
|
|
"node --check web/hwlab-cloud-web/scripts/check.mjs",
|
|
"node web/hwlab-cloud-web/scripts/check.mjs",
|
|
"node scripts/l6-cli-web-smoke.mjs",
|
|
"git diff --check"
|
|
].map((command) => {
|
|
const li = document.createElement("li");
|
|
li.textContent = command;
|
|
return li;
|
|
})
|
|
);
|
|
}
|
|
|
|
function renderMethodList(methods, tone) {
|
|
replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone)));
|
|
}
|
|
|
|
function blockerCard(blocker) {
|
|
return infoCard({
|
|
title: blocker.scope,
|
|
detail: blocker.summary,
|
|
tone: blocker.status === "open" ? "blocked" : blocker.status
|
|
});
|
|
}
|
|
|
|
function messageCard(message) {
|
|
const article = document.createElement("article");
|
|
article.className = `message-card message-${toneClass(message.role)}`;
|
|
article.append(textSpan(message.role, "message-role"));
|
|
article.append(textSpan(message.title, "message-title"));
|
|
article.append(textSpan(message.text, "message-copy"));
|
|
return article;
|
|
}
|
|
|
|
function statusCard(item) {
|
|
const article = document.createElement("article");
|
|
article.className = "status-card";
|
|
article.append(textSpan(item.title, "metric-label"));
|
|
article.append(textSpan(String(item.value), `status-value tone-${toneClass(item.tone)}`));
|
|
article.append(textSpan(item.meta, "status-meta"));
|
|
return article;
|
|
}
|
|
|
|
function infoCard(item) {
|
|
const article = document.createElement("article");
|
|
article.className = "info-card";
|
|
article.append(badge(item.tone, item.tone));
|
|
const body = document.createElement("div");
|
|
body.append(textSpan(item.title, "info-title"));
|
|
body.append(textSpan(item.detail, "info-detail"));
|
|
article.append(body);
|
|
return article;
|
|
}
|
|
|
|
function stateTag(label, detail) {
|
|
const span = document.createElement("span");
|
|
span.className = `state-tag tone-${toneClass(label)}`;
|
|
span.textContent = `${label} ${detail}`;
|
|
return span;
|
|
}
|
|
|
|
function statusLight(tone) {
|
|
const span = document.createElement("span");
|
|
span.className = `tree-light tone-bg-${toneClass(tone)}`;
|
|
return span;
|
|
}
|
|
|
|
function badge(text, tone = text) {
|
|
const span = document.createElement("span");
|
|
span.className = `badge tone-${toneClass(tone)}`;
|
|
span.textContent = text ?? "n/a";
|
|
return span;
|
|
}
|
|
|
|
function appendMetricGrid(parent, entries) {
|
|
replaceChildren(
|
|
parent,
|
|
...entries.map(([label, value, tone]) => {
|
|
const item = document.createElement("div");
|
|
item.className = "metric";
|
|
item.append(textSpan(label, "metric-label"), textSpan(String(value ?? "n/a"), `metric-value tone-${toneClass(tone ?? "source")}`));
|
|
return item;
|
|
})
|
|
);
|
|
}
|
|
|
|
function textSpan(text, className) {
|
|
const span = document.createElement("span");
|
|
span.textContent = text ?? "n/a";
|
|
if (className) span.className = className;
|
|
return span;
|
|
}
|
|
|
|
function cell(text, className) {
|
|
const td = document.createElement("td");
|
|
td.textContent = text ?? "n/a";
|
|
if (className) td.className = className;
|
|
return td;
|
|
}
|
|
|
|
function runtimeSummaryFrom(live) {
|
|
return live.health.data?.runtime ?? live.healthLive.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 dbReadyFrom(live) {
|
|
return live.healthLive.data?.db?.ready ?? live.health.data?.db?.ready ?? live.restIndex.data?.db?.ready ?? null;
|
|
}
|
|
|
|
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(liveValue, sourceValue) {
|
|
if (liveValue !== undefined && liveValue !== null) return liveValue;
|
|
if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} source`;
|
|
return "unavailable";
|
|
}
|
|
|
|
function shortTime(value) {
|
|
return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
|
|
}
|
|
|
|
function shortHash(value) {
|
|
return String(value ?? "unknown").slice(0, 7);
|
|
}
|
|
|
|
function toneClass(tone) {
|
|
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
|
|
}
|
|
|
|
function replaceChildren(parent, ...children) {
|
|
parent.replaceChildren(...children.filter((child) => child && typeof child !== "string"));
|
|
}
|