Files
pikasTech-HWLAB/web/hwlab-cloud-web/app.mjs
T
2026-05-22 17:36:27 +08:00

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"));
}