380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
/**
|
||
* WebSocket Manager
|
||
* Handles real-time communication with the VNA backend
|
||
*/
|
||
|
||
export class WebSocketManager {
|
||
constructor(config, notifications) {
|
||
this.config = config;
|
||
this.notifications = notifications;
|
||
this.ws = null;
|
||
this.isConnected = false;
|
||
this.isConnecting = false;
|
||
this.reconnectAttempts = 0;
|
||
this.reconnectTimer = null;
|
||
this.pingTimer = null;
|
||
this.lastPing = null;
|
||
this.eventListeners = new Map();
|
||
|
||
this.messageQueue = [];
|
||
this.maxQueueSize = 100;
|
||
|
||
this.stats = {
|
||
messagesReceived: 0,
|
||
messagesPerSecond: 0,
|
||
lastMessageTime: null,
|
||
connectionTime: null,
|
||
bytesReceived: 0
|
||
};
|
||
|
||
this.messageRateCounter = [];
|
||
this.rateWindowMs = 1000;
|
||
}
|
||
|
||
async connect() {
|
||
if (this.isConnected || this.isConnecting) {
|
||
console.log('WebSocket already connected/connecting');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.isConnecting = true;
|
||
this.emit('connecting');
|
||
|
||
console.log(` Connecting to WebSocket: ${this.config.url}`);
|
||
|
||
this.ws = new WebSocket(this.config.url);
|
||
this.setupWebSocketEvents();
|
||
|
||
await this.waitForConnection(5000);
|
||
} catch (error) {
|
||
this.isConnecting = false;
|
||
console.error('WebSocket connection failed:', error);
|
||
this.handleConnectionError(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
waitForConnection(timeoutMs) {
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => reject(new Error('WebSocket connection timeout')), timeoutMs);
|
||
|
||
const check = () => {
|
||
if (this.isConnected) {
|
||
clearTimeout(timeout);
|
||
resolve();
|
||
} else if (this.ws?.readyState === WebSocket.CLOSED || this.ws?.readyState === WebSocket.CLOSING) {
|
||
clearTimeout(timeout);
|
||
reject(new Error('WebSocket connection failed'));
|
||
}
|
||
};
|
||
|
||
check();
|
||
const interval = setInterval(() => {
|
||
check();
|
||
if (this.isConnected) clearInterval(interval);
|
||
}, 100);
|
||
|
||
const wrap = fn => (...args) => { clearInterval(interval); fn(...args); };
|
||
resolve = wrap(resolve);
|
||
reject = wrap(reject);
|
||
});
|
||
}
|
||
|
||
setupWebSocketEvents() {
|
||
if (!this.ws) return;
|
||
|
||
this.ws.onopen = (event) => {
|
||
console.log('WebSocket connected');
|
||
this.isConnected = true;
|
||
this.isConnecting = false;
|
||
this.reconnectAttempts = 0;
|
||
this.stats.connectionTime = new Date();
|
||
|
||
this.startPingPong();
|
||
this.processPendingMessages();
|
||
this.emit('connected', event);
|
||
|
||
this.notifications?.show?.({
|
||
type: 'success',
|
||
title: 'Подключено',
|
||
message: 'Установлено соединение в реальном времени'
|
||
});
|
||
};
|
||
|
||
this.ws.onmessage = (event) => {
|
||
try {
|
||
this.handleMessage(event.data);
|
||
} catch (error) {
|
||
console.error('Error processing WebSocket message:', error);
|
||
this.notifications?.show?.({
|
||
type: 'error',
|
||
title: 'Ошибка сообщения',
|
||
message: 'Не удалось обработать полученные данные'
|
||
});
|
||
}
|
||
};
|
||
|
||
this.ws.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
this.handleConnectionError(error);
|
||
};
|
||
|
||
this.ws.onclose = (event) => {
|
||
console.log('WebSocket closed:', event.code, event.reason);
|
||
this.handleDisconnection(event);
|
||
};
|
||
}
|
||
|
||
handleMessage(data) {
|
||
this.stats.messagesReceived++;
|
||
this.stats.lastMessageTime = new Date();
|
||
this.stats.bytesReceived += (typeof data === 'string' ? data.length : 0);
|
||
this.updateMessageRate();
|
||
|
||
try {
|
||
if (typeof data !== 'string') {
|
||
console.warn('Received non-text message, ignoring');
|
||
return;
|
||
}
|
||
if (data === 'ping') { this.handlePing(); return; }
|
||
if (data === 'pong') { this.handlePong(); return; }
|
||
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(data);
|
||
} catch (jsonError) {
|
||
console.error('Invalid JSON format:', data);
|
||
console.error('JSON parse error:', jsonError);
|
||
this.notifications?.show?.({
|
||
type: 'error',
|
||
title: 'Ошибка разбора JSON',
|
||
message: `Не удалось разобрать JSON: ${data.substring(0, 200)}${data.length > 200 ? '...' : ''}`
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Public "data" event for everything, plus more specific ones:
|
||
this.emit('data', payload);
|
||
|
||
switch (payload.type) {
|
||
case 'processor_result':
|
||
this.emit('processor_result', payload);
|
||
break;
|
||
case 'processor_history':
|
||
this.emit('processor_history', payload);
|
||
break;
|
||
case 'processor_state':
|
||
this.emit('processor_state', payload);
|
||
break;
|
||
case 'error':
|
||
console.error('Server error:', payload);
|
||
console.error('Error details:', {
|
||
message: payload.message,
|
||
code: payload.code,
|
||
details: payload.details,
|
||
timestamp: payload.timestamp,
|
||
source: payload.source
|
||
});
|
||
this.notifications?.show?.({
|
||
type: 'error',
|
||
title: 'Ошибка сервера',
|
||
message: `${payload.message}${payload.details ? ` — ${payload.details}` : ''}${payload.source ? ` (${payload.source})` : ''}`
|
||
});
|
||
break;
|
||
default:
|
||
console.warn('Unknown payload type:', payload.type);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to parse WebSocket JSON:', e);
|
||
console.log('Raw message:', data);
|
||
this.notifications?.show?.({
|
||
type: 'error',
|
||
title: 'Ошибка обработки сообщения',
|
||
message: `Не удалось обработать сообщение: ${data.substring(0, 200)}${data.length > 200 ? '...' : ''}`
|
||
});
|
||
}
|
||
}
|
||
|
||
updateMessageRate() {
|
||
const now = Date.now();
|
||
this.messageRateCounter.push(now);
|
||
this.messageRateCounter = this.messageRateCounter.filter(t => now - t < this.rateWindowMs);
|
||
this.stats.messagesPerSecond = this.messageRateCounter.length;
|
||
}
|
||
|
||
handlePing() {
|
||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send('pong');
|
||
}
|
||
handlePong() {
|
||
if (this.lastPing) {
|
||
console.log(` WebSocket latency: ${Date.now() - this.lastPing}ms`);
|
||
this.lastPing = null;
|
||
}
|
||
}
|
||
startPingPong() {
|
||
this.pingTimer = setInterval(() => {
|
||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||
this.lastPing = Date.now();
|
||
this.ws.send('ping');
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
handleConnectionError() {
|
||
if (!this.isConnected && this.isConnecting) {
|
||
// Only show error notification during connection attempts, not after disconnection
|
||
this.notifications?.show?.({
|
||
type: 'error',
|
||
title: 'Сбой подключения',
|
||
message: 'Не удалось установить соединение в реальном времени'
|
||
});
|
||
}
|
||
}
|
||
|
||
handleDisconnection(event) {
|
||
const wasConnected = this.isConnected;
|
||
this.isConnected = false;
|
||
this.isConnecting = false;
|
||
|
||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
||
|
||
this.emit('disconnected', event);
|
||
|
||
if (wasConnected) {
|
||
this.notifications?.show?.({
|
||
type: 'warning',
|
||
title: 'Отключено',
|
||
message: 'Соединение потеряно. Пытаемся переподключиться...'
|
||
});
|
||
this.scheduleReconnect();
|
||
}
|
||
}
|
||
|
||
scheduleReconnect() {
|
||
if (this.reconnectTimer) return;
|
||
const delay = Math.min(this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts), 30000);
|
||
console.log(` Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this.reconnectTimer = null;
|
||
this.reconnect();
|
||
}, delay);
|
||
}
|
||
|
||
async reconnect() {
|
||
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
||
console.error('Max reconnection attempts reached');
|
||
this.notifications?.show?.({
|
||
type: 'error',
|
||
title: 'Сбой подключения',
|
||
message: 'Не удалось переподключиться после нескольких попыток'
|
||
});
|
||
return;
|
||
}
|
||
this.reconnectAttempts++;
|
||
if (this.ws) { this.ws.close(); this.ws = null; }
|
||
try { await this.connect(); }
|
||
catch (error) { console.error(` Reconnection attempt ${this.reconnectAttempts} failed:`, error); this.scheduleReconnect(); }
|
||
}
|
||
|
||
send(data) {
|
||
if (!this.isConnected || !this.ws) {
|
||
if (this.messageQueue.length < this.maxQueueSize) {
|
||
this.messageQueue.push(data);
|
||
console.log('Message queued (not connected)');
|
||
} else {
|
||
console.warn('Message queue full, dropping message');
|
||
}
|
||
return false;
|
||
}
|
||
try {
|
||
const msg = (typeof data === 'string') ? data : JSON.stringify(data);
|
||
this.ws.send(msg);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Failed to send message:', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
processPendingMessages() {
|
||
if (this.messageQueue.length === 0) return;
|
||
console.log(` Processing ${this.messageQueue.length} queued messages`);
|
||
const messages = [...this.messageQueue];
|
||
this.messageQueue = [];
|
||
messages.forEach(m => this.send(m));
|
||
}
|
||
|
||
// === PUBLIC API FOR BACKEND COMPATIBILITY ===
|
||
|
||
/** Request recalculation with config update (EXISTS ON BACKEND) */
|
||
recalculate(processorId, configUpdates = undefined) {
|
||
return this.send({
|
||
type: 'recalculate',
|
||
processor_id: processorId,
|
||
...(configUpdates ? { config_updates: configUpdates } : {})
|
||
});
|
||
}
|
||
|
||
/** Get processor results history (EXISTS ON BACKEND) */
|
||
getHistory(processorId, limit = 10) {
|
||
return this.send({
|
||
type: 'get_history',
|
||
processor_id: processorId,
|
||
limit
|
||
});
|
||
}
|
||
|
||
/** Get full processor state including sweep history (EXISTS ON BACKEND) */
|
||
getProcessorState(processorId) {
|
||
return this.send({
|
||
type: 'get_processor_state',
|
||
processor_id: processorId
|
||
});
|
||
}
|
||
|
||
// === Events ===
|
||
on(event, callback) {
|
||
if (!this.eventListeners.has(event)) this.eventListeners.set(event, []);
|
||
this.eventListeners.get(event).push(callback);
|
||
}
|
||
off(event, callback) {
|
||
if (!this.eventListeners.has(event)) return;
|
||
const arr = this.eventListeners.get(event);
|
||
const i = arr.indexOf(callback);
|
||
if (i > -1) arr.splice(i, 1);
|
||
}
|
||
emit(event, data) {
|
||
if (!this.eventListeners.has(event)) return;
|
||
this.eventListeners.get(event).forEach(cb => {
|
||
try { cb(data); } catch (e) { console.error(` Error in event listener for ${event}:`, e); }
|
||
});
|
||
}
|
||
|
||
disconnect() {
|
||
console.log('Disconnecting WebSocket');
|
||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
||
if (this.ws) { this.ws.close(1000, 'Manual disconnect'); this.ws = null; }
|
||
this.isConnected = false;
|
||
this.isConnecting = false;
|
||
this.reconnectAttempts = 0;
|
||
}
|
||
|
||
getStats() {
|
||
return {
|
||
...this.stats,
|
||
isConnected: this.isConnected,
|
||
isConnecting: this.isConnecting,
|
||
reconnectAttempts: this.reconnectAttempts,
|
||
queuedMessages: this.messageQueue.length
|
||
};
|
||
}
|
||
|
||
destroy() {
|
||
this.disconnect();
|
||
this.eventListeners.clear();
|
||
this.messageQueue = [];
|
||
}
|
||
}
|