diff --git a/src/odin/interfaces.cjs b/src/odin/interfaces.cjs index d8a8a8c..7386c37 100644 --- a/src/odin/interfaces.cjs +++ b/src/odin/interfaces.cjs @@ -211,6 +211,40 @@ function localIpv4Prefixes() { } async function fetchEveProvider(url, providerId, manifest = null) { + const direct = await fetchEveProviderDirect(providerDeckUrl(url, providerId), providerId, manifest); + if (direct.state === "active") { + return direct; + } + + const broker = await fetchEveProviderViaBroker(url, providerId, manifest); + if (broker.state === "active") { + return broker; + } + + return broker.detail ? broker : direct; +} + +async function fetchEveProviderDirect(url, providerId, manifest = null) { + try { + const socket = await openWebSocket(url); + try { + for (let index = 0; index < 3; index += 1) { + const message = await readServerTextFrame(socket, 1000); + const state = JSON.parse(message); + if (state?.providerId === providerId) { + return activeProviderInterface(url, providerId, state, manifest); + } + } + return dashboardUnavailable(providerId, url, "direct provider stream did not publish matching state"); + } finally { + socket.destroy(); + } + } catch (error) { + return dashboardUnavailable(providerId, url, error.message); + } +} + +async function fetchEveProviderViaBroker(url, providerId, manifest = null) { try { const socket = await openWebSocket(url); try { @@ -219,18 +253,7 @@ async function fetchEveProvider(url, providerId, manifest = null) { const message = await readServerTextFrame(socket, 2500); const state = JSON.parse(message); if (state?.providerId === providerId) { - const surface = state.surface?.root ? state.surface : legacySurfaceFromNodes(state, manifest); - return { - providerId, - title: state.title || manifest?.title || providerId, - state: "active", - detail: `${surface?.root?.kind || "surface"} ${countSurfaceNodes(surface, state)} nodes`, - version: state.version, - updatedAt: state.updatedAt, - source: url, - manifest, - surface, - }; + return activeProviderInterface(url, providerId, state, manifest); } } return dashboardUnavailable(providerId, url, "provider did not publish matching state"); @@ -242,6 +265,28 @@ async function fetchEveProvider(url, providerId, manifest = null) { } } +function activeProviderInterface(source, providerId, state, manifest = null) { + const surface = state.surface?.root ? state.surface : legacySurfaceFromNodes(state, manifest); + return { + providerId, + title: state.title || manifest?.title || providerId, + state: "active", + detail: `${surface?.root?.kind || "surface"} ${countSurfaceNodes(surface, state)} nodes`, + version: state.version, + updatedAt: state.updatedAt, + source, + manifest, + surface, + }; +} + +function providerDeckUrl(url, providerId) { + const deck = new URL(url); + deck.pathname = `/eve/deck/${encodeURIComponent(providerId)}`; + deck.search = ""; + return deck.toString(); +} + function dashboardUnavailable(providerId, source, detail) { return { providerId, diff --git a/src/odin/surface.cjs b/src/odin/surface.cjs index 6ca43e3..de42d13 100644 --- a/src/odin/surface.cjs +++ b/src/odin/surface.cjs @@ -5,7 +5,7 @@ const { observationPane } = require("./observations.cjs"); const { stableId } = require("./utils.cjs"); function buildSurface({ observedAt, docker, adb, hosts, yggdrasilServices, verses, interfaces, observations, layout }) { - const activeInterfaces = interfaces.filter(hasOverviewSignal); + const activeInterfaces = interfaces.filter((entry) => entry?.surface?.root); const activeObservationStreams = observations.streams.filter((entry) => entry.state === "active"); return { schema: "gamecult.eve.surface.v1", @@ -45,6 +45,7 @@ function buildSurface({ observedAt, docker, adb, hosts, yggdrasilServices, verse source: entry.source, status: entry.state, detail: entry.detail, + overviewSignal: hasOverviewSignal(entry), version: entry.version, updatedAt: entry.updatedAt, layout: mergeLayoutIntent(layout.tiles?.[entry.providerId], entry, interfaces), @@ -94,7 +95,7 @@ function stonksHasOverviewSignal(root, text) { function marketMarqueeText(interfaces) { const stonks = interfaces.find((entry) => String(entry.providerId || "").toLowerCase() === "stonks.market"); const root = stonks?.surface?.root; - if (!root) return ""; + if (!root) return generalMarqueeText(interfaces); const explicit = String(root.props?.marqueeText || "").trim(); if (explicit) return explicit; return surfaceText(root) @@ -105,6 +106,19 @@ function marketMarqueeText(interfaces) { .join(" / "); } +function generalMarqueeText(interfaces) { + return interfaces + .filter((entry) => entry?.surface?.root && entry.state === "active") + .flatMap((entry) => surfaceText(entry.surface.root) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `${entry.title || entry.providerId}: ${line}`)) + .filter((line) => !/\b(unavailable|not discovered|playing:\s*none|queue:\s*empty)\b/i.test(line)) + .slice(0, 24) + .join(" / "); +} + function spotiverseHasOverviewSignal(root, text) { const status = String(root.props?.status || "").toLowerCase(); if (status === "warn" && /\brate limit\b/i.test(text)) return false; diff --git a/src/odin/websocket.cjs b/src/odin/websocket.cjs index 94bcaeb..df8a4b1 100644 --- a/src/odin/websocket.cjs +++ b/src/odin/websocket.cjs @@ -343,7 +343,7 @@ function openWebSocket(url) { return; } socket.off("data", onHandshake); - socket.unshift(buffer.subarray(marker + 4)); + socket.odinPendingData = buffer.subarray(marker + 4); resolve(socket); }); socket.on("timeout", () => { @@ -373,7 +373,8 @@ function sendClientFrame(socket, textValue) { function readServerTextFrame(socket, timeoutMs) { return new Promise((resolve, reject) => { - let buffer = Buffer.alloc(0); + let buffer = socket.odinPendingData || Buffer.alloc(0); + socket.odinPendingData = null; const timer = setTimeout(() => cleanup(new Error("timed out waiting for dashboard frame")), timeoutMs); function cleanup(error, value) { clearTimeout(timer); @@ -395,6 +396,13 @@ function readServerTextFrame(socket, timeoutMs) { } socket.on("data", onData); socket.on("error", onError); + const frame = tryReadFrame(buffer); + if (frame) { + buffer = buffer.subarray(frame.consumed); + socket.odinPendingData = buffer.length ? buffer : null; + if (frame.opcode === 0x1) cleanup(null, frame.payload.toString("utf8")); + if (frame.opcode === 0x8) cleanup(new Error("dashboard websocket closed")); + } }); }