Thing/web/surface.js
2026-05-31 03:33:00 +01:00

182 lines
5.7 KiB
JavaScript

const statusEl = document.querySelector("#status");
const app = document.querySelector("#app");
const surfaceId = document.querySelector("#surface-id");
const surfaceVersion = document.querySelector("#surface-version");
const voidBotTab = document.querySelector("#voidbot-tab");
const fensalirTab = document.querySelector("#fensalir-tab");
let socket;
voidBotTab.addEventListener("click", () => {
setActiveTab(voidBotTab);
openVoidBot();
});
fensalirTab.addEventListener("click", async () => {
setActiveTab(fensalirTab);
closeSocket();
const response = await fetch("./fixtures/fensalir-client-surface.json");
renderSurface(await response.json(), "fixture");
});
openVoidBot();
function setActiveTab(tab) {
for (const button of [voidBotTab, fensalirTab]) {
button.classList.toggle("active", button === tab);
}
}
function openVoidBot() {
closeSocket();
const url = "ws://127.0.0.1:8795/eve/deck";
statusEl.textContent = `connecting ${url}`;
socket = new WebSocket(url);
socket.addEventListener("open", () => {
statusEl.textContent = "connected to Mimir Eve broker";
socket.send(JSON.stringify({ type: "open-provider", providerId: "voidbot.swarm" }));
});
socket.addEventListener("message", event => {
const state = JSON.parse(event.data);
if (state.providerId === "voidbot.swarm") {
renderSurface(state, "live");
}
});
socket.addEventListener("close", () => {
statusEl.textContent = "Mimir broker disconnected";
});
socket.addEventListener("error", () => {
statusEl.textContent = "Mimir broker error; is port 8795 running?";
});
}
function closeSocket() {
if (socket) {
socket.close();
socket = undefined;
}
}
function renderSurface(state, source) {
surfaceId.textContent = state.providerId || "surface unknown";
surfaceVersion.textContent = `v${state.version ?? "?"}`;
statusEl.textContent = `${state.title || "surface"} (${source})`;
if (state.providerId === "voidbot.swarm") {
renderVoidBot(state);
} else {
renderGraphSurface(state);
}
}
function renderVoidBot(state) {
const nodes = state.nodes || [];
const ctb = nodes.filter(n => n.kind === "ctb-turn");
const leaves = nodes.filter(n => n.kind === "state-leaf");
const summary = nodeById(nodes, "voidbot-summary");
const agent = nodeById(nodes, "agent-detail");
const detail = nodeById(nodes, state.selectedNodeId) || nodeById(nodes, "state-detail") || agent || summary;
app.innerHTML = "";
const root = el("section", "cockpit");
const rail = el("div", "ctb");
for (const turn of ctb) {
const card = el("button", "card");
card.type = "button";
if (turn.avatarUrl) {
const img = el("img", "avatar");
img.src = turn.avatarUrl;
img.alt = "";
card.append(img);
}
card.append(el("div", "card-title", turn.label || turn.id));
card.append(el("div", "health", turn.health || ""));
rail.append(card);
}
const left = el("section", "pane");
left.append(el("h2", "", "Controls / Selected Face"));
left.append(el("div", "detail", `${summary?.label || "VoidBot Swarm"}\n${summary?.detail || ""}`));
left.append(metricStack());
left.append(el("div", "detail", agent?.detail || "No selected Face detail."));
const middle = el("section", "pane");
middle.append(el("h2", "", "State Graph"));
const list = el("div", "list");
for (const leaf of leaves) {
const card = el("div", `card ${leaf.id === state.selectedNodeId ? "selected" : ""}`);
card.append(el("div", "card-title", leaf.label || leaf.id));
card.append(el("div", "health", leaf.health || leaf.statePath || ""));
list.append(card);
}
middle.append(list);
const right = el("section", "pane");
right.append(el("h2", "", "State Detail"));
right.append(el("div", "detail", `${detail?.label || "detail"}\n\n${detail?.detail || ""}`));
root.append(rail, left, middle, right);
app.append(root);
}
function renderGraphSurface(state) {
app.innerHTML = "";
const graph = el("section", "graph");
for (const node of state.nodes || []) {
const view = el("article", `node ${node.id === state.selectedNodeId ? "selected" : ""}`);
const width = Math.max(160, graphWidth(node.width));
const height = Math.max(92, graphHeight(node.height));
view.style.width = `${width}px`;
view.style.minHeight = `${height}px`;
view.style.left = `${((node.x ?? 0) + 1) * 50}%`;
view.style.top = `${((node.y ?? 0) + 1) * 50}%`;
view.append(el("div", "card-title", node.label || node.id));
view.append(el("div", "health", `${node.kind || "node"} ${node.health || ""}`));
if (node.detail) {
view.append(el("div", "detail", node.detail));
}
graph.append(view);
}
app.append(graph);
}
function graphWidth(value) {
return Math.round((value || 0.25) * Math.max(900, window.innerWidth - 32));
}
function graphHeight(value) {
return Math.round((value || 0.16) * Math.max(520, window.innerHeight - 150));
}
function metricStack() {
const wrapper = el("div", "metrics");
const metrics = [
["TURN", 0.96],
["MEMORY", 0.70],
["PRESSURE", 0.42],
["HEAT", 0.34],
["LOAD", 0.99],
["SPEED", 0.88],
];
for (const [label, value] of metrics) {
const metric = el("div", "metric");
metric.append(el("label", "", `${label} ${Math.round(value * 100)}%`));
const bar = el("div", "bar");
const fill = el("span");
fill.style.width = `${Math.round(value * 100)}%`;
bar.append(fill);
metric.append(bar);
wrapper.append(metric);
}
return wrapper;
}
function nodeById(nodes, id) {
return nodes.find(node => node.id === id);
}
function el(tag, className = "", text = "") {
const element = document.createElement(tag);
if (className) element.className = className;
if (text) element.textContent = text;
return element;
}