feat: live updates

This commit is contained in:
2025-10-14 20:51:48 +02:00
parent 6db56e470c
commit 25911a183c
9 changed files with 825 additions and 52 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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');
}
}
}
}

View File

@@ -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);