mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
5d544126ee
* fix(ui): unbreak viz.html — OrbitControls importmap, WS URL, toast NPE (#760) Three independent bugs were stacking to make ui/viz.html unusable from `main`: 1. Three.js r160 removed `examples/js/OrbitControls.js`, so the script-tag load 404'd and `new THREE.OrbitControls(...)` threw. Switch to an importmap that pulls the ES module build, then re-expose `window.THREE` and `THREE.OrbitControls` so the existing component modules (scene.js, body-model.js, …) keep working without a wider refactor. 2. The WebSocket client was hardcoded to `ws://localhost:8000/ws/pose`, but the sensing-server listens on `--ws-port` (8765 default, 3001 in the Docker image) at `/ws/sensing`. Reuse the existing `buildSensingWsUrl()` helper from `sensing.service.js` so port pairings are handled centrally, and add a `?ws=…` query-string override for non-standard setups. The websocket-client.js default is also updated to derive from `window.location` instead of the dead `:8000/ws/pose` literal. 3. `ToastManager.show()` called `this.container.appendChild(...)` even when `init()` had never been called, throwing a TypeError that killed the rest of page initialization. Auto-init the container lazily on first show (patch from issue reporter). Closes #760. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(ui): single module script + mutable THREE — OrbitControls validated Browser validation against the previous commit caught two stacked issues: 1. `import * as THREE from 'three'` returns a frozen Module Namespace Object — assignment `THREE.OrbitControls = OrbitControls` silently no-ops, so the global never gets the OrbitControls reference. 2. Two separate `<script type="module">` blocks (one installing the THREE global, one consuming it via Scene) are independently async-resolved. The second can finish dependency loading first and call `new THREE.OrbitControls(...)` before the first script has run. Fixed by spreading the namespace into a plain mutable object and merging all initialization into a single module script with `await import()` for component modules. Order is now strictly: import THREE → install window.THREE → import components → run init(). Validated via agent-browser: page logs `[VIZ] Initialization complete`, WebSocket targets the correct `ws://127.0.0.1:3001/ws/sensing` endpoint (derived from buildSensingWsUrl), toast lazy-init confirmed via eval. Co-Authored-By: claude-flow <ruv@ruv.net>
269 lines
7.6 KiB
JavaScript
269 lines
7.6 KiB
JavaScript
// WebSocket Client for Three.js Visualization - WiFi DensePose
|
|
// Default endpoint is `/ws/sensing` on the same host the page was served from.
|
|
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
|
|
// `buildSensingWsUrl()` so HTTP/WS port pairings are handled centrally.
|
|
|
|
function _defaultWsUrl() {
|
|
if (typeof window === 'undefined' || !window.location) {
|
|
return 'ws://localhost:8765/ws/sensing';
|
|
}
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${window.location.host}/ws/sensing`;
|
|
}
|
|
|
|
export class WebSocketClient {
|
|
constructor(options = {}) {
|
|
this.url = options.url || _defaultWsUrl();
|
|
this.ws = null;
|
|
this.state = 'disconnected'; // disconnected, connecting, connected, error
|
|
this.isRealData = false;
|
|
|
|
// Reconnection settings
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = options.maxReconnectAttempts || 15;
|
|
this.reconnectDelays = [500, 1000, 2000, 4000, 8000, 15000, 30000];
|
|
this.reconnectTimer = null;
|
|
this.autoReconnect = options.autoReconnect !== false;
|
|
|
|
// Heartbeat
|
|
this.heartbeatInterval = null;
|
|
this.heartbeatFrequency = options.heartbeatFrequency || 25000;
|
|
this.lastPong = 0;
|
|
|
|
// Metrics
|
|
this.metrics = {
|
|
messageCount: 0,
|
|
errorCount: 0,
|
|
connectTime: null,
|
|
lastMessageTime: null,
|
|
latency: 0,
|
|
bytesReceived: 0
|
|
};
|
|
|
|
// Callbacks
|
|
this._onMessage = options.onMessage || (() => {});
|
|
this._onStateChange = options.onStateChange || (() => {});
|
|
this._onError = options.onError || (() => {});
|
|
}
|
|
|
|
// Attempt to connect
|
|
connect() {
|
|
if (this.state === 'connecting' || this.state === 'connected') {
|
|
console.warn('[WS-VIZ] Already connected or connecting');
|
|
return;
|
|
}
|
|
|
|
this._setState('connecting');
|
|
console.log(`[WS-VIZ] Connecting to ${this.url}`);
|
|
|
|
try {
|
|
this.ws = new WebSocket(this.url);
|
|
this.ws.binaryType = 'arraybuffer';
|
|
|
|
this.ws.onopen = () => this._handleOpen();
|
|
this.ws.onmessage = (event) => this._handleMessage(event);
|
|
this.ws.onerror = (event) => this._handleError(event);
|
|
this.ws.onclose = (event) => this._handleClose(event);
|
|
|
|
// Connection timeout
|
|
this._connectTimeout = setTimeout(() => {
|
|
if (this.state === 'connecting') {
|
|
console.warn('[WS-VIZ] Connection timeout');
|
|
this.ws.close();
|
|
this._setState('error');
|
|
this._scheduleReconnect();
|
|
}
|
|
}, 8000);
|
|
|
|
} catch (err) {
|
|
console.error('[WS-VIZ] Failed to create WebSocket:', err);
|
|
this._setState('error');
|
|
this._onError(err);
|
|
this._scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
this.autoReconnect = false;
|
|
this._clearTimers();
|
|
|
|
if (this.ws) {
|
|
this.ws.onclose = null; // Prevent reconnect on intentional close
|
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
this.ws.close(1000, 'Client disconnect');
|
|
}
|
|
this.ws = null;
|
|
}
|
|
|
|
this._setState('disconnected');
|
|
this.isRealData = false;
|
|
console.log('[WS-VIZ] Disconnected');
|
|
}
|
|
|
|
// Send a message
|
|
send(data) {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
console.warn('[WS-VIZ] Cannot send - not connected');
|
|
return false;
|
|
}
|
|
|
|
const msg = typeof data === 'string' ? data : JSON.stringify(data);
|
|
this.ws.send(msg);
|
|
return true;
|
|
}
|
|
|
|
_handleOpen() {
|
|
clearTimeout(this._connectTimeout);
|
|
this.reconnectAttempts = 0;
|
|
this.metrics.connectTime = Date.now();
|
|
this._setState('connected');
|
|
console.log('[WS-VIZ] Connected successfully');
|
|
|
|
// Start heartbeat
|
|
this._startHeartbeat();
|
|
|
|
// Request initial state
|
|
this.send({ type: 'get_status', timestamp: Date.now() });
|
|
}
|
|
|
|
_handleMessage(event) {
|
|
this.metrics.messageCount++;
|
|
this.metrics.lastMessageTime = Date.now();
|
|
|
|
const rawSize = typeof event.data === 'string' ? event.data.length : event.data.byteLength;
|
|
this.metrics.bytesReceived += rawSize;
|
|
|
|
try {
|
|
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
|
|
// Handle pong
|
|
if (data.type === 'pong') {
|
|
this.lastPong = Date.now();
|
|
if (data.timestamp) {
|
|
this.metrics.latency = Date.now() - data.timestamp;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle connection_established
|
|
if (data.type === 'connection_established') {
|
|
console.log('[WS-VIZ] Server confirmed connection:', data.payload);
|
|
return;
|
|
}
|
|
|
|
// Detect real vs mock data from metadata
|
|
if (data.data && data.data.metadata) {
|
|
this.isRealData = data.data.metadata.mock_data === false && data.data.metadata.source !== 'mock';
|
|
} else if (data.metadata) {
|
|
this.isRealData = data.metadata.mock_data === false;
|
|
}
|
|
|
|
// Calculate latency from message timestamp
|
|
if (data.timestamp) {
|
|
const msgTime = new Date(data.timestamp).getTime();
|
|
if (!isNaN(msgTime)) {
|
|
this.metrics.latency = Date.now() - msgTime;
|
|
}
|
|
}
|
|
|
|
// Forward to callback
|
|
this._onMessage(data);
|
|
|
|
} catch (err) {
|
|
this.metrics.errorCount++;
|
|
console.error('[WS-VIZ] Failed to parse message:', err);
|
|
}
|
|
}
|
|
|
|
_handleError(event) {
|
|
this.metrics.errorCount++;
|
|
console.error('[WS-VIZ] WebSocket error:', event);
|
|
this._onError(event);
|
|
}
|
|
|
|
_handleClose(event) {
|
|
clearTimeout(this._connectTimeout);
|
|
this._stopHeartbeat();
|
|
this.ws = null;
|
|
|
|
const wasConnected = this.state === 'connected';
|
|
console.log(`[WS-VIZ] Connection closed: code=${event.code}, reason=${event.reason}, clean=${event.wasClean}`);
|
|
|
|
if (event.wasClean || !this.autoReconnect) {
|
|
this._setState('disconnected');
|
|
} else {
|
|
this._setState('error');
|
|
this._scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
_setState(newState) {
|
|
if (this.state === newState) return;
|
|
const oldState = this.state;
|
|
this.state = newState;
|
|
this._onStateChange(newState, oldState);
|
|
}
|
|
|
|
_startHeartbeat() {
|
|
this._stopHeartbeat();
|
|
this.heartbeatInterval = setInterval(() => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.send({ type: 'ping', timestamp: Date.now() });
|
|
}
|
|
}, this.heartbeatFrequency);
|
|
}
|
|
|
|
_stopHeartbeat() {
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
this.heartbeatInterval = null;
|
|
}
|
|
}
|
|
|
|
_scheduleReconnect() {
|
|
if (!this.autoReconnect) return;
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.error('[WS-VIZ] Max reconnect attempts reached');
|
|
this._setState('error');
|
|
return;
|
|
}
|
|
|
|
const delayIdx = Math.min(this.reconnectAttempts, this.reconnectDelays.length - 1);
|
|
const delay = this.reconnectDelays[delayIdx];
|
|
this.reconnectAttempts++;
|
|
|
|
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
_clearTimers() {
|
|
clearTimeout(this._connectTimeout);
|
|
clearTimeout(this.reconnectTimer);
|
|
this._stopHeartbeat();
|
|
}
|
|
|
|
getMetrics() {
|
|
return {
|
|
...this.metrics,
|
|
state: this.state,
|
|
isRealData: this.isRealData,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
uptime: this.metrics.connectTime ? (Date.now() - this.metrics.connectTime) / 1000 : 0
|
|
};
|
|
}
|
|
|
|
isConnected() {
|
|
return this.state === 'connected';
|
|
}
|
|
|
|
dispose() {
|
|
this.disconnect();
|
|
this._onMessage = () => {};
|
|
this._onStateChange = () => {};
|
|
this._onError = () => {};
|
|
}
|
|
}
|