Files
vna_system/vna_system/web_ui/static/js/modules/websocket.js
2025-10-06 20:45:50 +03:00

380 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = [];
}
}