feat: live updates
This commit is contained in:
392
index.js
392
index.js
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|||||||
const dgram = require('dgram');
|
const dgram = require('dgram');
|
||||||
const SporeApiClient = require('./src/client');
|
const SporeApiClient = require('./src/client');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -31,6 +32,7 @@ app.use(cors({
|
|||||||
// UDP discovery configuration
|
// UDP discovery configuration
|
||||||
const UDP_PORT = 4210;
|
const UDP_PORT = 4210;
|
||||||
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
|
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
|
||||||
|
const STALE_THRESHOLD_SECONDS = 3; // 3 seconds for faster detection
|
||||||
|
|
||||||
// Initialize UDP server for auto discovery
|
// Initialize UDP server for auto discovery
|
||||||
const udpServer = dgram.createSocket('udp4');
|
const udpServer = dgram.createSocket('udp4');
|
||||||
@@ -55,34 +57,106 @@ udpServer.on('message', (msg, rinfo) => {
|
|||||||
const sourceIp = rinfo.address;
|
const sourceIp = rinfo.address;
|
||||||
const sourcePort = rinfo.port;
|
const sourcePort = rinfo.port;
|
||||||
|
|
||||||
//console.log(`UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
// Only log non-discovery messages to reduce noise
|
||||||
|
if (message !== DISCOVERY_MESSAGE) {
|
||||||
|
console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||||
|
}
|
||||||
|
|
||||||
if (message === DISCOVERY_MESSAGE) {
|
if (message === DISCOVERY_MESSAGE) {
|
||||||
//console.log(`Received CLUSTER_DISCOVERY from ${sourceIp}:${sourcePort}`);
|
//console.log(`Received CLUSTER_DISCOVERY from ${sourceIp}:${sourcePort}`);
|
||||||
|
|
||||||
// Store the discovered node
|
// Store the discovered node
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const nodeInfo = {
|
const nodeInfo = {
|
||||||
ip: sourceIp,
|
ip: sourceIp,
|
||||||
port: sourcePort,
|
port: sourcePort,
|
||||||
discoveredAt: new Date(),
|
status: 'active', // New nodes from discovery are active
|
||||||
lastSeen: new Date()
|
discoveredAt: now,
|
||||||
|
lastSeen: now
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNewNode = !discoveredNodes.has(sourceIp);
|
||||||
discoveredNodes.set(sourceIp, nodeInfo);
|
discoveredNodes.set(sourceIp, nodeInfo);
|
||||||
|
|
||||||
// Set as primary node if this is the first one or if we don't have one
|
// Set as primary node if this is the first one or if we don't have one
|
||||||
if (!primaryNodeIp) {
|
if (!primaryNodeIp) {
|
||||||
primaryNodeIp = sourceIp;
|
primaryNodeIp = sourceIp;
|
||||||
console.log(`Set primary node to ${sourceIp}`);
|
console.log(`Set primary node to ${sourceIp}`);
|
||||||
|
|
||||||
// Immediately try to initialize the client
|
// Immediately try to initialize the client
|
||||||
updateSporeClient();
|
updateSporeClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last seen timestamp
|
// Update last seen timestamp
|
||||||
discoveredNodes.get(sourceIp).lastSeen = new Date();
|
discoveredNodes.get(sourceIp).lastSeen = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
//console.log(`Node ${sourceIp} added/updated. Total discovered nodes: ${discoveredNodes.size}`);
|
// Broadcast discovery event if this is a new node
|
||||||
|
if (isNewNode) {
|
||||||
|
console.log(`🆕 NEW NODE DISCOVERED: ${sourceIp}:${sourcePort} via CLUSTER_DISCOVERY. Total nodes: ${discoveredNodes.size}`);
|
||||||
|
broadcastNodeDiscovery(sourceIp, 'discovered');
|
||||||
|
// Broadcast cluster update after a short delay to allow member data to be fetched
|
||||||
|
setTimeout(() => broadcastMemberListChange('new discovery'), 1000);
|
||||||
|
}
|
||||||
|
} else if (message.startsWith('CLUSTER_HEARTBEAT:')) {
|
||||||
|
// Handle heartbeat messages that also update member list
|
||||||
|
const hostname = message.substring('CLUSTER_HEARTBEAT:'.length);
|
||||||
|
// Update or create node entry from heartbeat
|
||||||
|
const existingNode = discoveredNodes.get(sourceIp);
|
||||||
|
const now = Math.floor(Date.now() / 1000); // Use Unix timestamp for consistency
|
||||||
|
|
||||||
|
if (existingNode) {
|
||||||
|
// Update existing node
|
||||||
|
const wasStale = existingNode.status === 'inactive';
|
||||||
|
const oldHostname = existingNode.hostname;
|
||||||
|
|
||||||
|
existingNode.lastSeen = now;
|
||||||
|
existingNode.hostname = hostname;
|
||||||
|
existingNode.status = 'active'; // Mark as active when heartbeat received
|
||||||
|
|
||||||
|
console.log(`💓 Heartbeat from ${sourceIp}:${sourcePort} (${hostname}). Total nodes: ${discoveredNodes.size}`);
|
||||||
|
|
||||||
|
// Check if hostname changed
|
||||||
|
const hostnameChanged = oldHostname !== hostname;
|
||||||
|
if (hostnameChanged) {
|
||||||
|
console.log(`🔄 Hostname updated for ${sourceIp}: "${oldHostname}" -> "${hostname}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS broadcast every heartbeat for immediate UI updates
|
||||||
|
// This ensures the UI gets real-time updates without delay
|
||||||
|
const reason = wasStale ? 'node became active' :
|
||||||
|
hostnameChanged ? 'hostname update' :
|
||||||
|
'active heartbeat';
|
||||||
|
|
||||||
|
console.log(`📡 Broadcasting heartbeat update: ${reason}`);
|
||||||
|
broadcastMemberListChange(reason);
|
||||||
|
} else {
|
||||||
|
// Create new node entry from heartbeat - NEW NODE DISCOVERED
|
||||||
|
const nodeInfo = {
|
||||||
|
ip: sourceIp,
|
||||||
|
port: sourcePort,
|
||||||
|
hostname: hostname,
|
||||||
|
status: 'active', // New nodes from heartbeat are active
|
||||||
|
discoveredAt: now,
|
||||||
|
lastSeen: now
|
||||||
|
};
|
||||||
|
discoveredNodes.set(sourceIp, nodeInfo);
|
||||||
|
|
||||||
|
console.log(`🆕 NEW NODE DISCOVERED: ${sourceIp}:${sourcePort} (${hostname}) via heartbeat. Total nodes: ${discoveredNodes.size}`);
|
||||||
|
|
||||||
|
// Set as primary node if this is the first one or if we don't have one
|
||||||
|
if (!primaryNodeIp) {
|
||||||
|
primaryNodeIp = sourceIp;
|
||||||
|
console.log(`Set primary node to ${sourceIp} (from heartbeat)`);
|
||||||
|
|
||||||
|
// Immediately try to initialize the client
|
||||||
|
updateSporeClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast discovery event for new node from heartbeat
|
||||||
|
broadcastNodeDiscovery(sourceIp, 'discovered');
|
||||||
|
// Broadcast cluster update after a short delay to allow member data to be fetched
|
||||||
|
setTimeout(() => broadcastMemberListChange('new heartbeat discovery'), 1000);
|
||||||
|
}
|
||||||
} else if (!message.startsWith('RAW:')) {
|
} else if (!message.startsWith('RAW:')) {
|
||||||
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
|
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||||
}
|
}
|
||||||
@@ -121,23 +195,46 @@ function initializeSporeClient(nodeIp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to clean up stale discovered nodes (nodes not seen in the last 5 minutes)
|
// Function to mark stale nodes as inactive (instead of removing them)
|
||||||
function cleanupStaleNodes() {
|
function markStaleNodes() {
|
||||||
const now = new Date();
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const staleThreshold = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
||||||
|
let nodesMarkedStale = false;
|
||||||
|
|
||||||
for (const [ip, node] of discoveredNodes.entries()) {
|
for (const [ip, node] of discoveredNodes.entries()) {
|
||||||
if (now - node.lastSeen > staleThreshold) {
|
const timeSinceLastSeen = now - node.lastSeen;
|
||||||
console.log(`Removing stale node: ${ip} (last seen: ${node.lastSeen.toISOString()})`);
|
|
||||||
discoveredNodes.delete(ip);
|
if (timeSinceLastSeen > STALE_THRESHOLD_SECONDS && node.status !== 'inactive') {
|
||||||
|
console.log(`💀 NODE MARKED INACTIVE: ${ip} (${node.hostname || 'Unknown'}) - last seen ${timeSinceLastSeen}s ago (threshold: ${STALE_THRESHOLD_SECONDS}s)`);
|
||||||
// If this was our primary node, clear it
|
node.status = 'inactive';
|
||||||
|
nodesMarkedStale = true;
|
||||||
|
|
||||||
|
// Broadcast stale node event immediately
|
||||||
|
console.log(`📡 Broadcasting stale node event for ${ip}`);
|
||||||
|
broadcastNodeDiscovery(ip, 'stale');
|
||||||
|
|
||||||
|
// If this was our primary node, clear it and select a new one
|
||||||
if (primaryNodeIp === ip) {
|
if (primaryNodeIp === ip) {
|
||||||
primaryNodeIp = null;
|
primaryNodeIp = null;
|
||||||
console.log('Primary node became stale, clearing primary node selection');
|
console.log('🚫 PRIMARY NODE BECAME STALE: Clearing primary node selection');
|
||||||
|
|
||||||
|
// Automatically select a new primary node from remaining healthy nodes
|
||||||
|
const newPrimary = selectBestPrimaryNode();
|
||||||
|
if (newPrimary) {
|
||||||
|
console.log(`✅ NEW PRIMARY NODE SELECTED: ${newPrimary} (auto-selected after stale cleanup)`);
|
||||||
|
// Update the SPORE client to use the new primary node
|
||||||
|
updateSporeClient();
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ No healthy nodes available for primary selection');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast cluster update if any nodes were marked stale
|
||||||
|
if (nodesMarkedStale) {
|
||||||
|
broadcastMemberListChange('nodes marked stale');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to select the best primary node
|
// Function to select the best primary node
|
||||||
@@ -165,6 +262,7 @@ function selectBestPrimaryNode() {
|
|||||||
if (bestNode && bestNode !== primaryNodeIp) {
|
if (bestNode && bestNode !== primaryNodeIp) {
|
||||||
primaryNodeIp = bestNode;
|
primaryNodeIp = bestNode;
|
||||||
console.log(`Selected new primary node: ${bestNode}`);
|
console.log(`Selected new primary node: ${bestNode}`);
|
||||||
|
broadcastMemberListChange('primary node change');
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestNode;
|
return bestNode;
|
||||||
@@ -191,7 +289,8 @@ function selectRandomPrimaryNode() {
|
|||||||
// Update primary node
|
// Update primary node
|
||||||
primaryNodeIp = randomNode;
|
primaryNodeIp = randomNode;
|
||||||
console.log(`Randomly selected new primary node: ${randomNode}`);
|
console.log(`Randomly selected new primary node: ${randomNode}`);
|
||||||
|
broadcastMemberListChange('random primary node selection');
|
||||||
|
|
||||||
return randomNode;
|
return randomNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +333,7 @@ async function performWithFailover(operation) {
|
|||||||
primaryNodeIp = ip;
|
primaryNodeIp = ip;
|
||||||
sporeClient = client;
|
sporeClient = client;
|
||||||
console.log(`Failover: switched primary node to ${ip}`);
|
console.log(`Failover: switched primary node to ${ip}`);
|
||||||
|
broadcastMemberListChange('failover primary node switch');
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -248,11 +348,11 @@ async function performWithFailover(operation) {
|
|||||||
|
|
||||||
// Set up periodic tasks
|
// Set up periodic tasks
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
cleanupStaleNodes();
|
markStaleNodes();
|
||||||
if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) {
|
if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) {
|
||||||
updateSporeClient();
|
updateSporeClient();
|
||||||
}
|
}
|
||||||
}, 5000); // Check every 5 seconds
|
}, 2000); // Check every 2 seconds for faster stale detection
|
||||||
|
|
||||||
// Serve static files from public directory
|
// Serve static files from public directory
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
@@ -266,8 +366,8 @@ app.get('/', (req, res) => {
|
|||||||
app.get('/api/discovery/nodes', (req, res) => {
|
app.get('/api/discovery/nodes', (req, res) => {
|
||||||
const nodes = Array.from(discoveredNodes.values()).map(node => ({
|
const nodes = Array.from(discoveredNodes.values()).map(node => ({
|
||||||
...node,
|
...node,
|
||||||
discoveredAt: node.discoveredAt.toISOString(),
|
discoveredAt: new Date(node.discoveredAt * 1000).toISOString(),
|
||||||
lastSeen: node.lastSeen.toISOString(),
|
lastSeen: new Date(node.lastSeen * 1000).toISOString(),
|
||||||
isPrimary: node.ip === primaryNodeIp
|
isPrimary: node.ip === primaryNodeIp
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -288,12 +388,15 @@ app.get('/api/discovery/nodes', (req, res) => {
|
|||||||
// API endpoint to manually trigger discovery refresh
|
// API endpoint to manually trigger discovery refresh
|
||||||
app.post('/api/discovery/refresh', (req, res) => {
|
app.post('/api/discovery/refresh', (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Clean up stale nodes
|
// Mark stale nodes as inactive
|
||||||
cleanupStaleNodes();
|
markStaleNodes();
|
||||||
|
|
||||||
// Try to update the client
|
// Try to update the client
|
||||||
updateSporeClient();
|
updateSporeClient();
|
||||||
|
|
||||||
|
// Broadcast cluster update via WebSocket
|
||||||
|
broadcastMemberListChange('manual refresh');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Discovery refresh completed',
|
message: 'Discovery refresh completed',
|
||||||
@@ -310,6 +413,27 @@ app.post('/api/discovery/refresh', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API endpoint to test WebSocket broadcasting
|
||||||
|
app.post('/api/test/websocket', (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('🧪 Manual WebSocket test triggered');
|
||||||
|
broadcastMemberListChange('manual test');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'WebSocket test broadcast sent',
|
||||||
|
websocketClients: wsClients.size,
|
||||||
|
totalNodes: discoveredNodes.size
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during WebSocket test:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'WebSocket test failed',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// API endpoint to randomly select a new primary node
|
// API endpoint to randomly select a new primary node
|
||||||
app.post('/api/discovery/random-primary', (req, res) => {
|
app.post('/api/discovery/random-primary', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -367,6 +491,7 @@ app.post('/api/discovery/primary/:ip', (req, res) => {
|
|||||||
|
|
||||||
primaryNodeIp = requestedIp;
|
primaryNodeIp = requestedIp;
|
||||||
updateSporeClient();
|
updateSporeClient();
|
||||||
|
broadcastMemberListChange('manual primary node setting');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -721,11 +846,216 @@ app.get('/api/health', (req, res) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// WebSocket server setup - will be initialized after HTTP server
|
||||||
|
let wss = null;
|
||||||
|
const wsClients = new Set();
|
||||||
|
|
||||||
|
// Function to broadcast cluster updates to all connected WebSocket clients
|
||||||
|
function broadcastClusterUpdate() {
|
||||||
|
if (wsClients.size === 0 || !wss) return;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`📡 [${new Date().toISOString()}] Starting cluster update broadcast to ${wsClients.size} clients`);
|
||||||
|
|
||||||
|
// Get cluster members asynchronously
|
||||||
|
getCurrentClusterMembers().then(members => {
|
||||||
|
const clusterData = {
|
||||||
|
type: 'cluster_update',
|
||||||
|
members: members,
|
||||||
|
primaryNode: primaryNodeIp,
|
||||||
|
totalNodes: discoveredNodes.size,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = JSON.stringify(clusterData);
|
||||||
|
const broadcastTime = Date.now() - startTime;
|
||||||
|
console.log(`📡 [${new Date().toISOString()}] Broadcasting cluster update to ${wsClients.size} WebSocket clients (took ${broadcastTime}ms)`);
|
||||||
|
console.log(`📊 Cluster data: ${members.length} members, primary: ${primaryNodeIp || 'none'}`);
|
||||||
|
|
||||||
|
wsClients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error broadcasting cluster update:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to broadcast node discovery events
|
||||||
|
function broadcastNodeDiscovery(nodeIp, action) {
|
||||||
|
if (wsClients.size === 0 || !wss) return;
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
type: 'node_discovery',
|
||||||
|
action: action, // 'discovered' or 'stale'
|
||||||
|
nodeIp: nodeIp,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = JSON.stringify(eventData);
|
||||||
|
wsClients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to broadcast member list changes
|
||||||
|
function broadcastMemberListChange(reason = 'update') {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
console.log(`🔄 [${timestamp}] Member list changed (${reason}), broadcasting update`);
|
||||||
|
broadcastClusterUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get current cluster members (async version)
|
||||||
|
async function getCurrentClusterMembers() {
|
||||||
|
try {
|
||||||
|
if (discoveredNodes.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch real cluster data from SPORE nodes for accurate information
|
||||||
|
console.log(`📡 Fetching real cluster data from ${discoveredNodes.size} nodes for WebSocket broadcast`);
|
||||||
|
const clusterResponse = await performWithFailover((client) => client.getClusterStatus());
|
||||||
|
const apiMembers = clusterResponse.members || [];
|
||||||
|
|
||||||
|
// Update our local discoveredNodes with fresh information from the API
|
||||||
|
let updatedNodes = false;
|
||||||
|
apiMembers.forEach(apiMember => {
|
||||||
|
const localNode = discoveredNodes.get(apiMember.ip);
|
||||||
|
if (localNode) {
|
||||||
|
// Update local node with fresh API data
|
||||||
|
const needsUpdate =
|
||||||
|
localNode.hostname !== apiMember.hostname ||
|
||||||
|
localNode.status !== apiMember.status ||
|
||||||
|
localNode.latency !== apiMember.latency ||
|
||||||
|
JSON.stringify(localNode.labels) !== JSON.stringify(apiMember.labels);
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
console.log(`🔄 Updating local node ${apiMember.ip} with fresh API data`);
|
||||||
|
localNode.hostname = apiMember.hostname;
|
||||||
|
localNode.status = apiMember.status;
|
||||||
|
localNode.latency = apiMember.latency;
|
||||||
|
localNode.labels = apiMember.labels || {};
|
||||||
|
localNode.lastSeen = Math.floor(Date.now() / 1000);
|
||||||
|
updatedNodes = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New node discovered via API - shouldn't happen but handle it
|
||||||
|
console.log(`🆕 New node discovered via API: ${apiMember.ip}`);
|
||||||
|
discoveredNodes.set(apiMember.ip, {
|
||||||
|
ip: apiMember.ip,
|
||||||
|
hostname: apiMember.hostname,
|
||||||
|
status: apiMember.status,
|
||||||
|
latency: apiMember.latency,
|
||||||
|
labels: apiMember.labels || {},
|
||||||
|
discoveredAt: new Date(),
|
||||||
|
lastSeen: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
updatedNodes = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we updated any nodes, broadcast the changes
|
||||||
|
if (updatedNodes) {
|
||||||
|
console.log(`📡 Local node data updated, triggering immediate broadcast`);
|
||||||
|
// Note: We don't call broadcastMemberListChange here because we're already in the middle of a broadcast
|
||||||
|
// The calling function will handle the broadcast
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance API data with our local status information
|
||||||
|
const enhancedMembers = apiMembers.map(apiMember => {
|
||||||
|
const localNode = discoveredNodes.get(apiMember.ip);
|
||||||
|
if (localNode) {
|
||||||
|
// Use our local status (which may be 'inactive' if the node became stale)
|
||||||
|
return {
|
||||||
|
...apiMember,
|
||||||
|
status: localNode.status || apiMember.status,
|
||||||
|
hostname: localNode.hostname || apiMember.hostname,
|
||||||
|
lastSeen: localNode.lastSeen || apiMember.lastSeen,
|
||||||
|
labels: localNode.labels || apiMember.labels || {},
|
||||||
|
resources: localNode.resources || apiMember.resources || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return apiMember;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 Returning ${enhancedMembers.length} enhanced cluster members via WebSocket`);
|
||||||
|
return enhancedMembers;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting cluster members for WebSocket:', error);
|
||||||
|
|
||||||
|
// Fallback to local data if API fails
|
||||||
|
console.log('⚠️ API failed, falling back to local discoveredNodes data');
|
||||||
|
const fallbackMembers = Array.from(discoveredNodes.values()).map(node => ({
|
||||||
|
ip: node.ip,
|
||||||
|
hostname: node.hostname || 'Unknown Device',
|
||||||
|
status: node.status || 'active', // Use stored status (may be 'inactive')
|
||||||
|
latency: node.latency || 0,
|
||||||
|
lastSeen: node.lastSeen || Math.floor(Date.now() / 1000),
|
||||||
|
labels: node.labels || {},
|
||||||
|
resources: node.resources || {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`📊 Fallback: Returning ${fallbackMembers.length} local cluster members`);
|
||||||
|
return fallbackMembers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebSocket server after HTTP server is created
|
||||||
|
function initializeWebSocketServer(httpServer) {
|
||||||
|
wss = new WebSocket.Server({ server: httpServer });
|
||||||
|
|
||||||
|
// WebSocket connection handler
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
console.log('WebSocket client connected');
|
||||||
|
wsClients.add(ws);
|
||||||
|
|
||||||
|
// Send current cluster state to newly connected client
|
||||||
|
if (discoveredNodes.size > 0) {
|
||||||
|
// Get cluster members asynchronously without blocking
|
||||||
|
getCurrentClusterMembers().then(members => {
|
||||||
|
const clusterData = {
|
||||||
|
type: 'cluster_update',
|
||||||
|
members: members,
|
||||||
|
primaryNode: primaryNodeIp,
|
||||||
|
totalNodes: discoveredNodes.size,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
console.log(`🔌 Sending initial cluster state to new WebSocket client: ${members.length} members`);
|
||||||
|
ws.send(JSON.stringify(clusterData));
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error sending initial cluster state:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle client disconnection
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('WebSocket client disconnected');
|
||||||
|
wsClients.delete(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket errors
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
wsClients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('WebSocket server initialized');
|
||||||
|
}
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server is running on http://0.0.0.0:${PORT}`);
|
console.log(`Server is running on http://0.0.0.0:${PORT}`);
|
||||||
console.log(`Accessible from: http://YOUR_COMPUTER_IP:${PORT}`);
|
console.log(`Accessible from: http://YOUR_COMPUTER_IP:${PORT}`);
|
||||||
console.log(`UDP discovery server listening on port ${UDP_PORT}`);
|
console.log(`UDP discovery server listening on port ${UDP_PORT}`);
|
||||||
|
|
||||||
|
// Initialize WebSocket server after HTTP server is running
|
||||||
|
initializeWebSocketServer(server);
|
||||||
|
console.log('WebSocket server ready for real-time updates');
|
||||||
|
|
||||||
console.log('Waiting for CLUSTER_DISCOVERY messages from SPORE nodes...');
|
console.log('Waiting for CLUSTER_DISCOVERY messages from SPORE nodes...');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-fileupload": "^1.4.3"
|
"express-fileupload": "^1.4.3",
|
||||||
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -875,6 +876,27 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-fileupload": "^1.4.3"
|
"express-fileupload": "^1.4.3",
|
||||||
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,4 +123,166 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Global API client instance
|
// 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
|
// Update status
|
||||||
const statusElement = card.querySelector('.member-status');
|
const statusElement = card.querySelector('.member-status');
|
||||||
if (statusElement) {
|
if (statusElement) {
|
||||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
|
let statusClass, statusIcon;
|
||||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? window.icon('dotGreen', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 });
|
if (member.status && member.status.toUpperCase() === 'ACTIVE') {
|
||||||
|
statusClass = 'status-online';
|
||||||
statusElement.className = `member-status ${statusClass}`;
|
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
||||||
statusElement.innerHTML = `${statusIcon}`;
|
} 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
|
// Update latency
|
||||||
@@ -405,8 +414,17 @@ class ClusterMembersComponent extends Component {
|
|||||||
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
|
logger.debug('ClusterMembersComponent: renderMembers() called with', members.length, 'members');
|
||||||
|
|
||||||
const membersHTML = members.map(member => {
|
const membersHTML = members.map(member => {
|
||||||
const statusClass = (member.status && member.status.toUpperCase() === 'ACTIVE') ? 'status-online' : 'status-offline';
|
let statusClass, statusIcon;
|
||||||
const statusIcon = (member.status && member.status.toUpperCase() === 'ACTIVE') ? window.icon('dotGreen', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 });
|
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);
|
logger.debug('ClusterMembersComponent: Rendering member:', member);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
class ClusterStatusComponent extends Component {
|
class ClusterStatusComponent extends Component {
|
||||||
constructor(container, viewModel, eventBus) {
|
constructor(container, viewModel, eventBus) {
|
||||||
super(container, viewModel, eventBus);
|
super(container, viewModel, eventBus);
|
||||||
|
this.wsConnected = false;
|
||||||
|
this.wsReconnectAttempts = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupViewModelListeners() {
|
setupViewModelListeners() {
|
||||||
@@ -9,6 +11,37 @@ class ClusterStatusComponent extends Component {
|
|||||||
this.subscribeToProperty('totalNodes', this.render.bind(this));
|
this.subscribeToProperty('totalNodes', this.render.bind(this));
|
||||||
this.subscribeToProperty('clientInitialized', this.render.bind(this));
|
this.subscribeToProperty('clientInitialized', this.render.bind(this));
|
||||||
this.subscribeToProperty('error', 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() {
|
render() {
|
||||||
@@ -17,6 +50,20 @@ class ClusterStatusComponent extends Component {
|
|||||||
const error = this.viewModel.get('error');
|
const error = this.viewModel.get('error');
|
||||||
|
|
||||||
let statusText, statusIcon, statusClass;
|
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) {
|
if (error) {
|
||||||
statusText = 'Cluster Error';
|
statusText = 'Cluster Error';
|
||||||
@@ -38,13 +85,29 @@ class ClusterStatusComponent extends Component {
|
|||||||
|
|
||||||
// Update the cluster status badge using the container passed to this component
|
// Update the cluster status badge using the container passed to this component
|
||||||
if (this.container) {
|
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
|
// Remove all existing status classes
|
||||||
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
|
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
|
||||||
|
|
||||||
// Add the appropriate status class
|
// Add the appropriate status class
|
||||||
this.container.classList.add(statusClass);
|
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(() => {
|
setTimeout(() => {
|
||||||
this.updatePrimaryNodeDisplay();
|
this.updatePrimaryNodeDisplay();
|
||||||
}, 100);
|
}, 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
|
// Update cluster members
|
||||||
async updateClusterMembers() {
|
async updateClusterMembers() {
|
||||||
try {
|
try {
|
||||||
logger.debug('ClusterViewModel: updateClusterMembers called');
|
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
|
// Store current UI state before update
|
||||||
const currentUIState = this.getAllUIState();
|
const currentUIState = this.getAllUIState();
|
||||||
const currentExpandedCards = this.get('expandedCards');
|
const currentExpandedCards = this.get('expandedCards');
|
||||||
const currentActiveTabs = this.get('activeTabs');
|
const currentActiveTabs = this.get('activeTabs');
|
||||||
|
|
||||||
this.set('isLoading', true);
|
this.set('isLoading', true);
|
||||||
this.set('error', null);
|
this.set('error', null);
|
||||||
|
|
||||||
logger.debug('ClusterViewModel: Fetching cluster members...');
|
logger.debug('ClusterViewModel: Fetching cluster members...');
|
||||||
const response = await window.apiClient.getClusterMembers();
|
const response = await window.apiClient.getClusterMembers();
|
||||||
logger.debug('ClusterViewModel: Got response:', response);
|
logger.debug('ClusterViewModel: Got response:', response);
|
||||||
|
|
||||||
const members = response.members || [];
|
const members = response.members || [];
|
||||||
const onlineNodes = Array.isArray(members)
|
const onlineNodes = Array.isArray(members)
|
||||||
? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
|
? members.filter(m => m && m.status && m.status.toUpperCase() === 'ACTIVE').length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Use batch update
|
// Use batch update
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
members: members,
|
members: members,
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
onlineNodes: onlineNodes
|
onlineNodes: onlineNodes
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore expanded cards and active tabs
|
// Restore expanded cards and active tabs
|
||||||
this.set('expandedCards', currentExpandedCards);
|
this.set('expandedCards', currentExpandedCards);
|
||||||
this.set('activeTabs', currentActiveTabs);
|
this.set('activeTabs', currentActiveTabs);
|
||||||
|
|
||||||
// Update primary node display
|
// Update primary node display
|
||||||
logger.debug('ClusterViewModel: Updating primary node display...');
|
logger.debug('ClusterViewModel: Updating primary node display...');
|
||||||
await this.updatePrimaryNodeDisplay();
|
await this.updatePrimaryNodeDisplay();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
|
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
|
||||||
this.set('error', error.message);
|
this.set('error', error.message);
|
||||||
|
|||||||
@@ -1386,6 +1386,31 @@ p {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact Cluster Status Layout */
|
||||||
|
.cluster-status-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-status-compact .cluster-status-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-status-compact .websocket-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* Cluster Status States */
|
/* Cluster Status States */
|
||||||
.cluster-status-online {
|
.cluster-status-online {
|
||||||
background: linear-gradient(135deg, rgba(0, 255, 0, 0.15) 0%, rgba(0, 255, 0, 0.08) 100%);
|
background: linear-gradient(135deg, rgba(0, 255, 0, 0.15) 0%, rgba(0, 255, 0, 0.08) 100%);
|
||||||
|
|||||||
@@ -175,6 +175,42 @@
|
|||||||
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
|
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cluster Status with WebSocket Indicator */
|
||||||
|
.cluster-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-status-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.websocket-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WebSocket connection status classes */
|
||||||
|
.ws-connected .websocket-status {
|
||||||
|
color: var(--accent-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-disconnected .websocket-status {
|
||||||
|
color: var(--accent-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-reconnecting .websocket-status {
|
||||||
|
color: var(--accent-warning);
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="light"] .cluster-status {
|
[data-theme="light"] .cluster-status {
|
||||||
background: linear-gradient(135deg, rgba(5, 150, 105, 0.15) 0%, rgba(5, 150, 105, 0.08) 100%);
|
background: linear-gradient(135deg, rgba(5, 150, 105, 0.15) 0%, rgba(5, 150, 105, 0.08) 100%);
|
||||||
backdrop-filter: blur(15px);
|
backdrop-filter: blur(15px);
|
||||||
@@ -183,6 +219,18 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .ws-connected .websocket-status {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .ws-disconnected .websocket-status {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .ws-reconnecting .websocket-status {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="light"] .theme-switcher {
|
[data-theme="light"] .theme-switcher {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
@@ -416,6 +464,31 @@
|
|||||||
border: 1px solid rgba(220, 38, 38, 0.3);
|
border: 1px solid rgba(220, 38, 38, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status classes for dark theme (default) */
|
||||||
|
.status-online {
|
||||||
|
background: rgba(74, 222, 128, 0.15);
|
||||||
|
color: var(--accent-success);
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--accent-error);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dead {
|
||||||
|
background: rgba(251, 146, 60, 0.15);
|
||||||
|
color: #fb923c;
|
||||||
|
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .status-dead {
|
||||||
|
background: rgba(139, 69, 19, 0.15);
|
||||||
|
color: #8b4513;
|
||||||
|
border: 1px solid rgba(139, 69, 19, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="light"] .status-inactive {
|
[data-theme="light"] .status-inactive {
|
||||||
background: rgba(245, 158, 11, 0.15);
|
background: rgba(245, 158, 11, 0.15);
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
|
|||||||
Reference in New Issue
Block a user