feat: live updates
This commit is contained in:
@@ -123,4 +123,166 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
window.apiClient = new ApiClient();
|
||||
window.apiClient = new ApiClient();
|
||||
|
||||
// WebSocket Client for real-time updates
|
||||
class WebSocketClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000; // Start with 1 second
|
||||
this.listeners = new Map();
|
||||
this.isConnected = false;
|
||||
|
||||
// Auto-detect WebSocket URL based on current location
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// Use ws:// for HTTP and wss:// for HTTPS
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
|
||||
this.wsUrl = `${wsProtocol}//localhost:3001`;
|
||||
} else {
|
||||
this.wsUrl = `${wsProtocol}//${currentHost}:3001`;
|
||||
}
|
||||
|
||||
logger.debug('WebSocket Client initialized with URL:', this.wsUrl);
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
try {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create WebSocket connection:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.ws.onopen = () => {
|
||||
logger.debug('WebSocket connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = 1000;
|
||||
|
||||
// Notify listeners of connection
|
||||
this.emit('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
logger.debug('WebSocket message received:', data);
|
||||
logger.debug('WebSocket message type:', data.type);
|
||||
this.emit('message', data);
|
||||
this.handleMessage(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
logger.debug('WebSocket disconnected:', event.code, event.reason);
|
||||
this.isConnected = false;
|
||||
this.emit('disconnected');
|
||||
|
||||
if (event.code !== 1000) { // Not a normal closure
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
};
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'cluster_update':
|
||||
this.emit('clusterUpdate', data);
|
||||
break;
|
||||
case 'node_discovery':
|
||||
this.emit('nodeDiscovery', data);
|
||||
break;
|
||||
default:
|
||||
logger.debug('Unknown WebSocket message type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
logger.error('Max reconnection attempts reached');
|
||||
this.emit('maxReconnectAttemptsReached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
|
||||
|
||||
logger.debug(`Scheduling WebSocket reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.listeners.has(event)) {
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (error) {
|
||||
logger.error('Error in WebSocket event listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
} else {
|
||||
logger.warn('WebSocket not connected, cannot send data');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionStatus() {
|
||||
return {
|
||||
connected: this.isConnected,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
maxReconnectAttempts: this.maxReconnectAttempts,
|
||||
url: this.wsUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Global WebSocket client instance
|
||||
window.wsClient = new WebSocketClient();
|
||||
@@ -301,11 +301,20 @@ class ClusterMembersComponent extends Component {
|
||||
// Update status
|
||||
const statusElement = card.querySelector('.member-status');
|
||||
if (statusElement) {
|
||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
|
||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? window.icon('dotGreen', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 });
|
||||
|
||||
statusElement.className = `member-status ${statusClass}`;
|
||||
statusElement.innerHTML = `${statusIcon}`;
|
||||
let statusClass, statusIcon;
|
||||
if (member.status && member.status.toUpperCase() === 'ACTIVE') {
|
||||
statusClass = 'status-online';
|
||||
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
||||
} else if (member.status && member.status.toUpperCase() === 'INACTIVE') {
|
||||
statusClass = 'status-dead';
|
||||
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
||||
} else {
|
||||
statusClass = 'status-offline';
|
||||
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
||||
}
|
||||
|
||||
statusElement.className = `member-status ${statusClass}`;
|
||||
statusElement.innerHTML = `${statusIcon}`;
|
||||
}
|
||||
|
||||
// Update latency
|
||||
@@ -405,8 +414,17 @@ class ClusterMembersComponent extends Component {
|
||||
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
|
||||
|
||||
const membersHTML = members.map(member => {
|
||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
|
||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? window.icon('dotGreen', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 });
|
||||
let statusClass, statusIcon;
|
||||
if (member.status && member.status.toUpperCase() === 'ACTIVE') {
|
||||
statusClass = 'status-online';
|
||||
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
||||
} else if (member.status && member.status.toUpperCase() === 'INACTIVE') {
|
||||
statusClass = 'status-dead';
|
||||
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
||||
} else {
|
||||
statusClass = 'status-offline';
|
||||
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
||||
}
|
||||
|
||||
logger.debug('ClusterMembersComponent: Rendering member:', member);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
class ClusterStatusComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
this.wsConnected = false;
|
||||
this.wsReconnectAttempts = 0;
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
@@ -9,6 +11,37 @@ class ClusterStatusComponent extends Component {
|
||||
this.subscribeToProperty('totalNodes', this.render.bind(this));
|
||||
this.subscribeToProperty('clientInitialized', this.render.bind(this));
|
||||
this.subscribeToProperty('error', this.render.bind(this));
|
||||
|
||||
// Set up WebSocket status listeners
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) return;
|
||||
|
||||
window.wsClient.on('connected', () => {
|
||||
this.wsConnected = true;
|
||||
this.wsReconnectAttempts = 0;
|
||||
this.render();
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
this.wsConnected = false;
|
||||
this.render();
|
||||
});
|
||||
|
||||
window.wsClient.on('maxReconnectAttemptsReached', () => {
|
||||
this.wsConnected = false;
|
||||
this.wsReconnectAttempts = window.wsClient ? window.wsClient.reconnectAttempts : 0;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Initialize current WebSocket status
|
||||
if (window.wsClient) {
|
||||
const status = window.wsClient.getConnectionStatus();
|
||||
this.wsConnected = status.connected;
|
||||
this.wsReconnectAttempts = status.reconnectAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -17,6 +50,20 @@ class ClusterStatusComponent extends Component {
|
||||
const error = this.viewModel.get('error');
|
||||
|
||||
let statusText, statusIcon, statusClass;
|
||||
let wsStatusText = '';
|
||||
let wsStatusIcon = '';
|
||||
|
||||
// Determine WebSocket status
|
||||
if (this.wsConnected) {
|
||||
wsStatusIcon = window.icon('dotGreen', { width: 10, height: 10 });
|
||||
wsStatusText = 'Live';
|
||||
} else if (this.wsReconnectAttempts > 0) {
|
||||
wsStatusIcon = window.icon('dotYellow', { width: 10, height: 10 });
|
||||
wsStatusText = 'Reconnecting';
|
||||
} else {
|
||||
wsStatusIcon = window.icon('dotRed', { width: 10, height: 10 });
|
||||
wsStatusText = 'Offline';
|
||||
}
|
||||
|
||||
if (error) {
|
||||
statusText = 'Cluster Error';
|
||||
@@ -38,13 +85,29 @@ class ClusterStatusComponent extends Component {
|
||||
|
||||
// Update the cluster status badge using the container passed to this component
|
||||
if (this.container) {
|
||||
this.container.innerHTML = `${statusIcon} ${statusText}`;
|
||||
|
||||
// Create HTML with both cluster and WebSocket status on a single compact line
|
||||
this.container.innerHTML = `
|
||||
<div class="cluster-status-compact">
|
||||
<span class="cluster-status-main">${statusIcon} ${statusText}</span>
|
||||
<span class="websocket-status" title="WebSocket Connection: ${wsStatusText}">${wsStatusIcon} ${wsStatusText}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove all existing status classes
|
||||
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
|
||||
|
||||
|
||||
// Add the appropriate status class
|
||||
this.container.classList.add(statusClass);
|
||||
|
||||
// Add WebSocket connection class
|
||||
this.container.classList.remove('ws-connected', 'ws-disconnected', 'ws-reconnecting');
|
||||
if (this.wsConnected) {
|
||||
this.container.classList.add('ws-connected');
|
||||
} else if (this.wsReconnectAttempts > 0) {
|
||||
this.container.classList.add('ws-reconnecting');
|
||||
} else {
|
||||
this.container.classList.add('ws-disconnected');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,45 +21,124 @@ class ClusterViewModel extends ViewModel {
|
||||
setTimeout(() => {
|
||||
this.updatePrimaryNodeDisplay();
|
||||
}, 100);
|
||||
|
||||
// Set up WebSocket listeners for real-time updates
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
|
||||
// Set up WebSocket event listeners
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) {
|
||||
logger.warn('WebSocket client not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cluster updates
|
||||
window.wsClient.on('clusterUpdate', (data) => {
|
||||
logger.debug('ClusterViewModel: Received WebSocket cluster update:', data);
|
||||
|
||||
// Update members from WebSocket data
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
const onlineNodes = data.members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length;
|
||||
|
||||
logger.debug(`ClusterViewModel: Updating members from ${this.get('members')?.length || 0} to ${data.members.length} members`);
|
||||
|
||||
this.batchUpdate({
|
||||
members: data.members,
|
||||
lastUpdateTime: data.timestamp || new Date().toISOString(),
|
||||
onlineNodes: onlineNodes
|
||||
});
|
||||
|
||||
// Update primary node display if it changed
|
||||
if (data.primaryNode !== this.get('primaryNode')) {
|
||||
logger.debug(`ClusterViewModel: Primary node changed from ${this.get('primaryNode')} to ${data.primaryNode}`);
|
||||
this.set('primaryNode', data.primaryNode);
|
||||
this.set('totalNodes', data.totalNodes || 0);
|
||||
}
|
||||
} else {
|
||||
logger.warn('ClusterViewModel: Received cluster update but no valid members array:', data);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for node discovery events
|
||||
window.wsClient.on('nodeDiscovery', (data) => {
|
||||
logger.debug('ClusterViewModel: Received WebSocket node discovery event:', data);
|
||||
|
||||
if (data.action === 'discovered') {
|
||||
// A new node was discovered - trigger a cluster update
|
||||
setTimeout(() => {
|
||||
this.updateClusterMembers();
|
||||
}, 500);
|
||||
} else if (data.action === 'stale') {
|
||||
// A node became stale - trigger a cluster update
|
||||
setTimeout(() => {
|
||||
this.updateClusterMembers();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection status changes
|
||||
window.wsClient.on('connected', () => {
|
||||
logger.debug('ClusterViewModel: WebSocket connected');
|
||||
// Optionally trigger an immediate update when connection is restored
|
||||
setTimeout(() => {
|
||||
this.updateClusterMembers();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
logger.debug('ClusterViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
// Update cluster members
|
||||
async updateClusterMembers() {
|
||||
try {
|
||||
logger.debug('ClusterViewModel: updateClusterMembers called');
|
||||
|
||||
|
||||
// Check if we have recent WebSocket data (within last 30 seconds)
|
||||
const lastUpdateTime = this.get('lastUpdateTime');
|
||||
const now = new Date();
|
||||
const websocketDataAge = lastUpdateTime ? (now - new Date(lastUpdateTime)) : Infinity;
|
||||
|
||||
// If WebSocket data is recent, skip REST API call to avoid conflicts
|
||||
if (websocketDataAge < 30000 && this.get('members').length > 0) {
|
||||
logger.debug('ClusterViewModel: Using recent WebSocket data, skipping REST API call');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store current UI state before update
|
||||
const currentUIState = this.getAllUIState();
|
||||
const currentExpandedCards = this.get('expandedCards');
|
||||
const currentActiveTabs = this.get('activeTabs');
|
||||
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
|
||||
|
||||
logger.debug('ClusterViewModel: Fetching cluster members...');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
logger.debug('ClusterViewModel: Got response:', response);
|
||||
|
||||
|
||||
const members = response.members || [];
|
||||
const onlineNodes = Array.isArray(members)
|
||||
? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
|
||||
: 0;
|
||||
|
||||
|
||||
// Use batch update
|
||||
this.batchUpdate({
|
||||
members: members,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
onlineNodes: onlineNodes
|
||||
});
|
||||
|
||||
|
||||
// Restore expanded cards and active tabs
|
||||
this.set('expandedCards', currentExpandedCards);
|
||||
this.set('activeTabs', currentActiveTabs);
|
||||
|
||||
|
||||
// Update primary node display
|
||||
logger.debug('ClusterViewModel: Updating primary node display...');
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
|
||||
this.set('error', error.message);
|
||||
|
||||
Reference in New Issue
Block a user