1140 lines
40 KiB
JavaScript
1140 lines
40 KiB
JavaScript
// View Models for SPORE UI Components
|
|
|
|
// Cluster View Model
|
|
class ClusterViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
members: [],
|
|
primaryNode: null,
|
|
totalNodes: 0,
|
|
clientInitialized: false,
|
|
isLoading: false,
|
|
error: null,
|
|
expandedCards: new Map(),
|
|
activeTabs: new Map(), // Store active tab for each node
|
|
lastUpdateTime: null,
|
|
onlineNodes: 0
|
|
});
|
|
|
|
// Initialize cluster status after a short delay to allow components to subscribe
|
|
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);
|
|
} finally {
|
|
this.set('isLoading', false);
|
|
logger.debug('ClusterViewModel: updateClusterMembers completed');
|
|
}
|
|
}
|
|
|
|
// Update primary node display
|
|
async updatePrimaryNodeDisplay() {
|
|
try {
|
|
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
|
|
|
// Use batch update
|
|
const updates = {};
|
|
|
|
if (discoveryInfo.primaryNode) {
|
|
updates.primaryNode = discoveryInfo.primaryNode;
|
|
updates.clientInitialized = discoveryInfo.clientInitialized;
|
|
updates.totalNodes = discoveryInfo.totalNodes;
|
|
} else if (discoveryInfo.totalNodes > 0) {
|
|
updates.primaryNode = discoveryInfo.nodes[0]?.ip;
|
|
updates.clientInitialized = false;
|
|
updates.totalNodes = discoveryInfo.totalNodes;
|
|
} else {
|
|
updates.primaryNode = null;
|
|
updates.clientInitialized = false;
|
|
updates.totalNodes = 0;
|
|
}
|
|
|
|
this.batchUpdate(updates);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch discovery info:', error);
|
|
this.set('error', error.message);
|
|
}
|
|
}
|
|
|
|
// Select random primary node
|
|
async selectRandomPrimaryNode() {
|
|
try {
|
|
const result = await window.apiClient.selectRandomPrimaryNode();
|
|
|
|
if (result.success) {
|
|
// Update the display after a short delay
|
|
setTimeout(() => {
|
|
this.updatePrimaryNodeDisplay();
|
|
}, 1500);
|
|
|
|
return result;
|
|
} else {
|
|
throw new Error(result.message || 'Random selection failed');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to select random primary node:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Store expanded card state
|
|
storeExpandedCard(memberIp, content) {
|
|
const expandedCards = this.get('expandedCards');
|
|
expandedCards.set(memberIp, content);
|
|
this.set('expandedCards', expandedCards);
|
|
|
|
// Also store in UI state for persistence
|
|
this.setUIState(`expanded_${memberIp}`, content);
|
|
}
|
|
|
|
// Get expanded card state
|
|
getExpandedCard(memberIp) {
|
|
const expandedCards = this.get('expandedCards');
|
|
return expandedCards.get(memberIp);
|
|
}
|
|
|
|
// Clear expanded card state
|
|
clearExpandedCard(memberIp) {
|
|
const expandedCards = this.get('expandedCards');
|
|
expandedCards.delete(memberIp);
|
|
this.set('expandedCards', expandedCards);
|
|
|
|
// Also clear from UI state
|
|
this.clearUIState(`expanded_${memberIp}`);
|
|
}
|
|
|
|
// Store active tab for a specific node
|
|
storeActiveTab(memberIp, tabName) {
|
|
const activeTabs = this.get('activeTabs');
|
|
activeTabs.set(memberIp, tabName);
|
|
this.set('activeTabs', activeTabs);
|
|
|
|
// Also store in UI state for persistence
|
|
this.setUIState(`activeTab_${memberIp}`, tabName);
|
|
}
|
|
|
|
// Get active tab for a specific node
|
|
getActiveTab(memberIp) {
|
|
const activeTabs = this.get('activeTabs');
|
|
return activeTabs.get(memberIp) || 'status'; // Default to 'status' tab
|
|
}
|
|
|
|
// Check if data has actually changed to avoid unnecessary updates
|
|
hasDataChanged(newData, dataType) {
|
|
const currentData = this.get(dataType);
|
|
|
|
if (Array.isArray(newData) && Array.isArray(currentData)) {
|
|
if (newData.length !== currentData.length) return true;
|
|
|
|
// Compare each member's key properties
|
|
return newData.some((newMember, index) => {
|
|
const currentMember = currentData[index];
|
|
return !currentMember ||
|
|
newMember.ip !== currentMember.ip ||
|
|
newMember.status !== currentMember.status ||
|
|
newMember.latency !== currentMember.latency;
|
|
});
|
|
}
|
|
|
|
return newData !== currentData;
|
|
}
|
|
|
|
// Smart update that only updates changed data
|
|
async smartUpdate() {
|
|
try {
|
|
logger.debug('ClusterViewModel: Performing smart update...');
|
|
|
|
// Fetch new data
|
|
const response = await window.apiClient.getClusterMembers();
|
|
const newMembers = response.members || [];
|
|
|
|
// Check if members data has actually changed
|
|
if (this.hasDataChanged(newMembers, 'members')) {
|
|
logger.debug('ClusterViewModel: Members data changed, updating...');
|
|
await this.updateClusterMembers();
|
|
} else {
|
|
logger.debug('ClusterViewModel: Members data unchanged, skipping update');
|
|
// Still update primary node display as it might have changed
|
|
await this.updatePrimaryNodeDisplay();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('ClusterViewModel: Smart update failed:', error);
|
|
this.set('error', error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Node Details View Model
|
|
class NodeDetailsViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
nodeStatus: null,
|
|
tasks: [],
|
|
isLoading: false,
|
|
error: null,
|
|
activeTab: 'status',
|
|
nodeIp: null,
|
|
endpoints: null,
|
|
tasksSummary: null,
|
|
monitoringResources: null
|
|
});
|
|
}
|
|
|
|
// Load node details
|
|
async loadNodeDetails(ip) {
|
|
try {
|
|
// Store current UI state
|
|
const currentActiveTab = this.get('activeTab');
|
|
|
|
this.set('isLoading', true);
|
|
this.set('error', null);
|
|
this.set('nodeIp', ip);
|
|
|
|
const nodeStatus = await window.apiClient.getNodeStatus(ip);
|
|
|
|
// Use batch update
|
|
this.batchUpdate({
|
|
nodeStatus: nodeStatus
|
|
});
|
|
|
|
// Restore active tab
|
|
this.set('activeTab', currentActiveTab);
|
|
|
|
// Load tasks data
|
|
await this.loadTasksData();
|
|
|
|
// Load endpoints data
|
|
await this.loadEndpointsData();
|
|
|
|
// Load monitoring resources data
|
|
await this.loadMonitoringResources();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load node details:', error);
|
|
this.set('error', error.message);
|
|
} finally {
|
|
this.set('isLoading', false);
|
|
}
|
|
}
|
|
|
|
// Load tasks data
|
|
async loadTasksData() {
|
|
try {
|
|
const ip = this.get('nodeIp');
|
|
const response = await window.apiClient.getTasksStatus(ip);
|
|
this.set('tasks', (response && Array.isArray(response.tasks)) ? response.tasks : []);
|
|
this.set('tasksSummary', response && response.summary ? response.summary : null);
|
|
} catch (error) {
|
|
console.error('Failed to load tasks:', error);
|
|
this.set('tasks', []);
|
|
this.set('tasksSummary', null);
|
|
}
|
|
}
|
|
|
|
// Load endpoints data
|
|
async loadEndpointsData() {
|
|
try {
|
|
const ip = this.get('nodeIp');
|
|
const response = await window.apiClient.getEndpoints(ip);
|
|
// Handle both real API (wrapped in endpoints) and mock API (direct array)
|
|
const endpointsData = (response && response.endpoints) ? response : { endpoints: response };
|
|
this.set('endpoints', endpointsData || null);
|
|
} catch (error) {
|
|
console.error('Failed to load endpoints:', error);
|
|
this.set('endpoints', null);
|
|
}
|
|
}
|
|
|
|
// Load monitoring resources data
|
|
async loadMonitoringResources() {
|
|
try {
|
|
const ip = this.get('nodeIp');
|
|
const response = await window.apiClient.getMonitoringResources(ip);
|
|
// Handle both real API (wrapped in data) and mock API (direct response)
|
|
const monitoringData = (response && response.data) ? response.data : response;
|
|
this.set('monitoringResources', monitoringData);
|
|
} catch (error) {
|
|
console.error('Failed to load monitoring resources:', error);
|
|
this.set('monitoringResources', null);
|
|
}
|
|
}
|
|
|
|
// Invoke an endpoint against this node
|
|
async callEndpoint(method, uri, params) {
|
|
const ip = this.get('nodeIp');
|
|
return window.apiClient.callEndpoint({ ip, method, uri, params });
|
|
}
|
|
|
|
// Set active tab with state persistence
|
|
setActiveTab(tabName) {
|
|
logger.debug('NodeDetailsViewModel: Setting activeTab to:', tabName);
|
|
this.set('activeTab', tabName);
|
|
|
|
// Store in UI state for persistence
|
|
this.setUIState('activeTab', tabName);
|
|
}
|
|
|
|
// Upload firmware
|
|
async uploadFirmware(file, nodeIp) {
|
|
try {
|
|
const result = await window.apiClient.uploadFirmware(file, nodeIp);
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Firmware upload failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Firmware View Model
|
|
class FirmwareViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
selectedFile: null,
|
|
targetType: 'all',
|
|
specificNode: null,
|
|
availableNodes: [],
|
|
uploadProgress: null,
|
|
uploadResults: [],
|
|
isUploading: false,
|
|
selectedLabels: [],
|
|
availableLabels: []
|
|
});
|
|
}
|
|
|
|
// Set selected file
|
|
setSelectedFile(file) {
|
|
this.set('selectedFile', file);
|
|
}
|
|
|
|
// Set target type
|
|
setTargetType(type) {
|
|
this.set('targetType', type);
|
|
// Clear any previously selected labels when switching options
|
|
const currentLabels = this.get('selectedLabels') || [];
|
|
if (currentLabels.length > 0) {
|
|
this.set('selectedLabels', []);
|
|
}
|
|
}
|
|
|
|
// Set specific node
|
|
setSpecificNode(nodeIp) {
|
|
this.set('specificNode', nodeIp);
|
|
}
|
|
|
|
// Update available nodes
|
|
updateAvailableNodes(nodes) {
|
|
this.set('availableNodes', nodes);
|
|
// Compute availableLabels as unique key=value pairs from nodes' labels
|
|
try {
|
|
const labelSet = new Set();
|
|
(nodes || []).forEach(n => {
|
|
const labels = n && n.labels ? n.labels : {};
|
|
Object.entries(labels).forEach(([k, v]) => {
|
|
labelSet.add(`${k}=${v}`);
|
|
});
|
|
});
|
|
const availableLabels = Array.from(labelSet).sort((a, b) => a.localeCompare(b));
|
|
this.set('availableLabels', availableLabels);
|
|
// Prune selected labels that are no longer available
|
|
const selected = this.get('selectedLabels') || [];
|
|
const pruned = selected.filter(x => availableLabels.includes(x));
|
|
if (pruned.length !== selected.length) {
|
|
this.set('selectedLabels', pruned);
|
|
}
|
|
} catch (_) {
|
|
this.set('availableLabels', []);
|
|
this.set('selectedLabels', []);
|
|
}
|
|
}
|
|
|
|
// Start upload
|
|
startUpload() {
|
|
this.set('isUploading', true);
|
|
this.set('uploadProgress', {
|
|
current: 0,
|
|
total: 0,
|
|
status: 'Preparing...'
|
|
});
|
|
this.set('uploadResults', []);
|
|
}
|
|
|
|
// Update upload progress
|
|
updateUploadProgress(current, total, status) {
|
|
this.set('uploadProgress', {
|
|
current,
|
|
total,
|
|
status
|
|
});
|
|
}
|
|
|
|
// Add upload result
|
|
addUploadResult(result) {
|
|
const results = this.get('uploadResults');
|
|
results.push(result);
|
|
this.set('uploadResults', results);
|
|
}
|
|
|
|
// Complete upload
|
|
completeUpload() {
|
|
this.set('isUploading', false);
|
|
}
|
|
|
|
// Reset upload state
|
|
resetUploadState() {
|
|
this.set('selectedFile', null);
|
|
this.set('uploadProgress', null);
|
|
this.set('uploadResults', []);
|
|
this.set('isUploading', false);
|
|
}
|
|
|
|
// Set selected labels
|
|
setSelectedLabels(labels) {
|
|
this.set('selectedLabels', Array.isArray(labels) ? labels : []);
|
|
}
|
|
|
|
// Return nodes matching ALL selected label pairs
|
|
getAffectedNodesByLabels() {
|
|
const selected = this.get('selectedLabels') || [];
|
|
if (!selected.length) return [];
|
|
const selectedPairs = selected.map(s => {
|
|
const idx = String(s).indexOf('=');
|
|
return idx > -1 ? { key: s.slice(0, idx), value: s.slice(idx + 1) } : null;
|
|
}).filter(Boolean);
|
|
const nodes = this.get('availableNodes') || [];
|
|
return nodes.filter(n => {
|
|
const labels = n && n.labels ? n.labels : {};
|
|
return selectedPairs.every(p => String(labels[p.key]) === String(p.value));
|
|
});
|
|
}
|
|
|
|
// Check if deploy button should be enabled
|
|
isDeployEnabled() {
|
|
const hasFile = this.get('selectedFile') !== null;
|
|
const availableNodes = this.get('availableNodes');
|
|
const hasAvailableNodes = availableNodes && availableNodes.length > 0;
|
|
|
|
let isValidTarget = false;
|
|
if (this.get('targetType') === 'all') {
|
|
isValidTarget = hasAvailableNodes;
|
|
} else if (this.get('targetType') === 'specific') {
|
|
isValidTarget = hasAvailableNodes && this.get('specificNode');
|
|
} else if (this.get('targetType') === 'labels') {
|
|
const affected = this.getAffectedNodesByLabels();
|
|
isValidTarget = hasAvailableNodes && (this.get('selectedLabels') || []).length > 0 && affected.length > 0;
|
|
}
|
|
|
|
return hasFile && isValidTarget && !this.get('isUploading');
|
|
}
|
|
}
|
|
|
|
// Navigation View Model
|
|
class NavigationViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
activeView: 'cluster',
|
|
views: ['cluster', 'topology', 'firmware', 'monitoring']
|
|
});
|
|
}
|
|
|
|
// Set active view
|
|
setActiveView(viewName) {
|
|
this.set('activeView', viewName);
|
|
}
|
|
|
|
// Get active view
|
|
getActiveView() {
|
|
return this.get('activeView');
|
|
}
|
|
}
|
|
|
|
// Topology View Model for network topology visualization
|
|
class TopologyViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
nodes: [],
|
|
links: [],
|
|
isLoading: false,
|
|
error: null,
|
|
lastUpdateTime: null,
|
|
selectedNode: null
|
|
});
|
|
}
|
|
|
|
// Set up WebSocket event listeners for real-time topology updates
|
|
setupWebSocketListeners() {
|
|
if (!window.wsClient) {
|
|
logger.warn('TopologyViewModel: WebSocket client not available');
|
|
return;
|
|
}
|
|
|
|
// Listen for cluster updates
|
|
window.wsClient.on('clusterUpdate', (data) => {
|
|
logger.debug('TopologyViewModel: Received WebSocket cluster update:', data);
|
|
|
|
// Update topology from WebSocket data
|
|
if (data.members && Array.isArray(data.members)) {
|
|
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members`);
|
|
|
|
// Build enhanced graph data from updated members
|
|
this.buildEnhancedGraphData(data.members).then(({ nodes, links }) => {
|
|
this.batchUpdate({
|
|
nodes: nodes,
|
|
links: links,
|
|
lastUpdateTime: data.timestamp || new Date().toISOString()
|
|
});
|
|
}).catch(error => {
|
|
logger.error('TopologyViewModel: Failed to build graph data from websocket update:', error);
|
|
});
|
|
} else {
|
|
logger.warn('TopologyViewModel: Received cluster update but no valid members array:', data);
|
|
}
|
|
});
|
|
|
|
// Listen for node discovery events
|
|
window.wsClient.on('nodeDiscovery', (data) => {
|
|
logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data);
|
|
|
|
// Node discovery events are logged but no action needed
|
|
// The subsequent clusterUpdate event will provide the updated member list
|
|
// and update the topology through the websocket data flow
|
|
if (data.action === 'discovered') {
|
|
logger.debug('TopologyViewModel: Node discovered, waiting for clusterUpdate event');
|
|
} else if (data.action === 'stale') {
|
|
logger.debug('TopologyViewModel: Node became stale, waiting for clusterUpdate event');
|
|
}
|
|
});
|
|
|
|
// Listen for connection status changes
|
|
window.wsClient.on('connected', () => {
|
|
logger.debug('TopologyViewModel: WebSocket connected');
|
|
// Connection restored - the server will send a clusterUpdate event shortly
|
|
// No need to make an API call, just wait for the websocket data
|
|
});
|
|
|
|
window.wsClient.on('disconnected', () => {
|
|
logger.debug('TopologyViewModel: WebSocket disconnected');
|
|
});
|
|
}
|
|
|
|
// Update network topology data
|
|
// NOTE: This method makes an API call and should only be used for initial load
|
|
// All subsequent updates should come from websocket events (clusterUpdate)
|
|
async updateNetworkTopology() {
|
|
try {
|
|
logger.debug('TopologyViewModel: updateNetworkTopology called (API call)');
|
|
|
|
this.set('isLoading', true);
|
|
this.set('error', null);
|
|
|
|
// Get cluster members from the primary node
|
|
const response = await window.apiClient.getClusterMembers();
|
|
logger.debug('TopologyViewModel: Got cluster members response:', response);
|
|
|
|
const members = response.members || [];
|
|
|
|
// Build enhanced graph data with actual node connections
|
|
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
|
|
|
this.batchUpdate({
|
|
nodes: nodes,
|
|
links: links,
|
|
lastUpdateTime: new Date().toISOString()
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('TopologyViewModel: Failed to fetch network topology:', error);
|
|
this.set('error', error.message);
|
|
} finally {
|
|
this.set('isLoading', false);
|
|
logger.debug('TopologyViewModel: updateNetworkTopology completed');
|
|
}
|
|
}
|
|
|
|
// Build enhanced graph data with actual node connections
|
|
async buildEnhancedGraphData(members) {
|
|
const nodes = [];
|
|
const links = [];
|
|
|
|
// Get existing nodes to preserve their positions
|
|
const existingNodes = this.get('nodes') || [];
|
|
const existingNodeMap = new Map(existingNodes.map(n => [n.id, n]));
|
|
|
|
// Create nodes from members
|
|
members.forEach((member, index) => {
|
|
if (member && member.ip) {
|
|
const existingNode = existingNodeMap.get(member.ip);
|
|
|
|
nodes.push({
|
|
id: member.ip,
|
|
hostname: member.hostname || member.ip,
|
|
ip: member.ip,
|
|
status: member.status || 'UNKNOWN',
|
|
latency: member.latency || 0,
|
|
// Preserve both legacy 'resources' and preferred 'labels'
|
|
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
|
resources: member.resources || {},
|
|
// Preserve existing position if node already exists, otherwise assign random position
|
|
x: existingNode ? existingNode.x : Math.random() * 1200 + 100,
|
|
y: existingNode ? existingNode.y : Math.random() * 800 + 100,
|
|
// Preserve velocity if it exists (for D3 simulation)
|
|
vx: existingNode ? existingNode.vx : undefined,
|
|
vy: existingNode ? existingNode.vy : undefined,
|
|
// Preserve fixed position if it was dragged
|
|
fx: existingNode ? existingNode.fx : undefined,
|
|
fy: existingNode ? existingNode.fy : undefined
|
|
});
|
|
}
|
|
});
|
|
|
|
// Build links - create a mesh topology from the members data
|
|
// All connections are inferred from the cluster membership
|
|
// No additional API calls needed - all data comes from websocket updates
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
const sourceNode = nodes[i];
|
|
const targetNode = nodes[j];
|
|
|
|
// Use the latency from the member data if available, otherwise estimate
|
|
const sourceMember = members.find(m => m.ip === sourceNode.ip);
|
|
const targetMember = members.find(m => m.ip === targetNode.ip);
|
|
const latency = targetMember?.latency || sourceMember?.latency || this.estimateLatency(sourceNode, targetNode);
|
|
|
|
links.push({
|
|
source: sourceNode.id,
|
|
target: targetNode.id,
|
|
latency: latency,
|
|
sourceNode: sourceNode,
|
|
targetNode: targetNode,
|
|
bidirectional: true
|
|
});
|
|
}
|
|
}
|
|
|
|
return { nodes, links };
|
|
}
|
|
|
|
// Estimate latency between two nodes
|
|
estimateLatency(sourceNode, targetNode) {
|
|
// Simple estimation - in a real implementation, you'd get actual measurements
|
|
const baseLatency = 5; // Base latency in ms
|
|
const randomVariation = Math.random() * 10; // Random variation
|
|
return Math.round(baseLatency + randomVariation);
|
|
}
|
|
|
|
// Select a node in the graph
|
|
selectNode(nodeId) {
|
|
this.set('selectedNode', nodeId);
|
|
}
|
|
|
|
// Clear node selection
|
|
clearSelection() {
|
|
this.set('selectedNode', null);
|
|
}
|
|
}
|
|
|
|
// Monitoring View Model for cluster resource monitoring
|
|
class MonitoringViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
clusterMembers: [],
|
|
nodeResources: new Map(), // Map of node IP -> resource data
|
|
clusterSummary: {
|
|
totalCpu: 0,
|
|
totalMemory: 0,
|
|
totalStorage: 0,
|
|
totalNodes: 0,
|
|
availableCpu: 0,
|
|
availableMemory: 0,
|
|
availableStorage: 0
|
|
},
|
|
isLoading: false,
|
|
lastUpdated: null,
|
|
error: null
|
|
});
|
|
}
|
|
|
|
// Load cluster members and their resource data
|
|
async loadClusterData() {
|
|
this.set('isLoading', true);
|
|
this.set('error', null);
|
|
|
|
try {
|
|
// Get cluster members
|
|
const response = await window.apiClient.getClusterMembers();
|
|
// Extract members array from response object
|
|
const members = response.members || [];
|
|
this.set('clusterMembers', members);
|
|
|
|
// Load resource data for each node
|
|
await this.loadNodeResources(members);
|
|
|
|
// Calculate cluster summary
|
|
this.calculateClusterSummary();
|
|
|
|
this.set('lastUpdated', new Date());
|
|
} catch (error) {
|
|
console.error('Failed to load cluster monitoring data:', error);
|
|
this.set('error', error.message || 'Failed to load monitoring data');
|
|
} finally {
|
|
this.set('isLoading', false);
|
|
}
|
|
}
|
|
|
|
// Load resource data for all nodes
|
|
async loadNodeResources(members) {
|
|
const nodeResources = new Map();
|
|
|
|
// Process nodes in parallel
|
|
const resourcePromises = members.map(async (member) => {
|
|
try {
|
|
const resources = await window.apiClient.getMonitoringResources(member.ip);
|
|
// Handle both real API (wrapped in data) and mock API (direct response)
|
|
const resourceData = (resources && resources.data) ? resources.data : resources;
|
|
nodeResources.set(member.ip, {
|
|
...member,
|
|
resources: resourceData,
|
|
hasResources: true,
|
|
resourceSource: 'monitoring'
|
|
});
|
|
} catch (error) {
|
|
console.warn(`Failed to load monitoring resources for node ${member.ip}:`, error);
|
|
// Fall back to basic resource data from cluster members API
|
|
const basicResources = member.resources ? this.convertBasicResources(member.resources) : null;
|
|
nodeResources.set(member.ip, {
|
|
...member,
|
|
resources: basicResources,
|
|
basic: member.resources, // Preserve original basic data
|
|
hasResources: !!basicResources,
|
|
resourceSource: basicResources ? 'basic' : 'none',
|
|
error: basicResources ? null : error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
await Promise.all(resourcePromises);
|
|
this.set('nodeResources', nodeResources);
|
|
}
|
|
|
|
// Convert basic resource data from cluster members API to monitoring format
|
|
convertBasicResources(basicResources) {
|
|
// Convert ESP32 basic resources to monitoring format
|
|
const freeHeap = basicResources.freeHeap || 0;
|
|
const flashSize = basicResources.flashChipSize || 0;
|
|
const cpuFreq = basicResources.cpuFreqMHz || 80;
|
|
|
|
// Estimate total heap (ESP32 typically has ~300KB heap)
|
|
const estimatedTotalHeap = 300 * 1024; // 300KB in bytes
|
|
const usedHeap = estimatedTotalHeap - freeHeap;
|
|
|
|
return {
|
|
cpu: {
|
|
total: cpuFreq, // Total CPU frequency in MHz
|
|
available: cpuFreq * 0.8, // Estimate 80% available
|
|
used: cpuFreq * 0.2
|
|
},
|
|
memory: {
|
|
total: estimatedTotalHeap,
|
|
available: freeHeap,
|
|
used: usedHeap
|
|
},
|
|
storage: {
|
|
total: flashSize,
|
|
available: flashSize * 0.5, // Estimate 50% available
|
|
used: flashSize * 0.5
|
|
},
|
|
// Include original basic resources for reference
|
|
basic: basicResources
|
|
};
|
|
}
|
|
|
|
// Calculate cluster resource summary
|
|
calculateClusterSummary() {
|
|
const nodeResources = this.get('nodeResources');
|
|
const members = this.get('clusterMembers');
|
|
|
|
let totalCpu = 0;
|
|
let totalMemory = 0;
|
|
let totalStorage = 0;
|
|
let availableCpu = 0;
|
|
let availableMemory = 0;
|
|
let availableStorage = 0;
|
|
let totalNodes = 0;
|
|
|
|
for (const [ip, nodeData] of nodeResources) {
|
|
if (nodeData.hasResources && nodeData.resources) {
|
|
totalNodes++;
|
|
|
|
// Extract resource data (handle both monitoring API and basic data formats)
|
|
const resources = nodeData.resources;
|
|
|
|
// CPU resources
|
|
if (resources.cpu) {
|
|
const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources
|
|
if (nodeData.resourceSource === 'monitoring') {
|
|
// Real monitoring API format
|
|
totalCpu += cpuFreqMHz; // Total CPU frequency in MHz
|
|
availableCpu += cpuFreqMHz * (100 - (resources.cpu.average_usage || 0)) / 100; // Available frequency
|
|
} else {
|
|
// Basic data format
|
|
totalCpu += cpuFreqMHz; // Total CPU frequency in MHz
|
|
availableCpu += cpuFreqMHz * (resources.cpu.available || 0.8); // Available frequency
|
|
}
|
|
}
|
|
|
|
// Memory resources
|
|
if (resources.memory) {
|
|
if (nodeData.resourceSource === 'monitoring') {
|
|
// Real monitoring API format
|
|
totalMemory += resources.memory.total_heap || 0;
|
|
availableMemory += resources.memory.free_heap || 0;
|
|
} else {
|
|
// Basic data format
|
|
totalMemory += resources.memory.total || 0;
|
|
availableMemory += resources.memory.available || 0;
|
|
}
|
|
}
|
|
|
|
// Storage resources
|
|
if (resources.filesystem || resources.storage) {
|
|
const storage = resources.filesystem || resources.storage;
|
|
if (nodeData.resourceSource === 'monitoring') {
|
|
// Real monitoring API format
|
|
totalStorage += storage.total_bytes || 0;
|
|
availableStorage += storage.free_bytes || 0;
|
|
} else {
|
|
// Basic data format
|
|
totalStorage += storage.total || 0;
|
|
availableStorage += storage.available || 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.set('clusterSummary', {
|
|
totalCpu,
|
|
totalMemory,
|
|
totalStorage,
|
|
totalNodes,
|
|
availableCpu,
|
|
availableMemory,
|
|
availableStorage
|
|
});
|
|
}
|
|
|
|
// Get resource utilization percentage
|
|
getResourceUtilization(resourceType) {
|
|
const summary = this.get('clusterSummary');
|
|
const total = summary[`total${resourceType}`];
|
|
const available = summary[`available${resourceType}`];
|
|
|
|
if (total === 0) return 0;
|
|
return Math.round(((total - available) / total) * 100);
|
|
}
|
|
|
|
// Get formatted resource value
|
|
formatResourceValue(value, type) {
|
|
if (type === 'memory' || type === 'storage') {
|
|
// Convert bytes to human readable format
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let size = value;
|
|
let unitIndex = 0;
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex++;
|
|
}
|
|
|
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
} else if (type === 'cpu') {
|
|
// CPU is typically in cores or percentage
|
|
return `${value} cores`;
|
|
}
|
|
|
|
return value.toString();
|
|
}
|
|
|
|
// Refresh monitoring data
|
|
async refresh() {
|
|
await this.loadClusterData();
|
|
}
|
|
}
|
|
|
|
// Cluster Firmware Upload View Model
|
|
class ClusterFirmwareViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.setMultiple({
|
|
selectedFile: null,
|
|
targetNodes: [],
|
|
uploadProgress: null,
|
|
uploadResults: [],
|
|
isUploading: false
|
|
});
|
|
}
|
|
|
|
// Set selected file
|
|
setSelectedFile(file) {
|
|
this.set('selectedFile', file);
|
|
}
|
|
|
|
// Set target nodes (filtered from cluster view)
|
|
setTargetNodes(nodes) {
|
|
this.set('targetNodes', nodes);
|
|
}
|
|
|
|
// Start upload
|
|
startUpload() {
|
|
this.set('isUploading', true);
|
|
this.set('uploadProgress', {
|
|
current: 0,
|
|
total: 0,
|
|
status: 'Preparing...'
|
|
});
|
|
this.set('uploadResults', []);
|
|
}
|
|
|
|
// Update upload progress
|
|
updateUploadProgress(current, total, status) {
|
|
this.set('uploadProgress', {
|
|
current,
|
|
total,
|
|
status
|
|
});
|
|
}
|
|
|
|
// Add upload result
|
|
addUploadResult(result) {
|
|
const results = this.get('uploadResults');
|
|
results.push(result);
|
|
this.set('uploadResults', results);
|
|
}
|
|
|
|
// Complete upload
|
|
completeUpload() {
|
|
this.set('isUploading', false);
|
|
}
|
|
|
|
// Reset upload state
|
|
resetUploadState() {
|
|
this.set('selectedFile', null);
|
|
this.set('uploadProgress', null);
|
|
this.set('uploadResults', []);
|
|
this.set('isUploading', false);
|
|
}
|
|
|
|
// Check if deploy is enabled
|
|
isDeployEnabled() {
|
|
const file = this.get('selectedFile');
|
|
const targetNodes = this.get('targetNodes');
|
|
const isUploading = this.get('isUploading');
|
|
|
|
return file && targetNodes && targetNodes.length > 0 && !isUploading;
|
|
}
|
|
}
|
|
|
|
// WiFi Configuration View Model
|
|
class WiFiConfigViewModel extends ViewModel {
|
|
constructor() {
|
|
super();
|
|
this.set('targetNodes', []);
|
|
this.set('ssid', '');
|
|
this.set('password', '');
|
|
this.set('isConfiguring', false);
|
|
this.set('configProgress', null);
|
|
this.set('configResults', []);
|
|
}
|
|
|
|
// Set target nodes (filtered from cluster view)
|
|
setTargetNodes(nodes) {
|
|
this.set('targetNodes', nodes);
|
|
}
|
|
|
|
// Set WiFi credentials
|
|
setCredentials(ssid, password) {
|
|
this.set('ssid', ssid);
|
|
this.set('password', password);
|
|
}
|
|
|
|
// Start configuration
|
|
startConfiguration() {
|
|
this.set('isConfiguring', true);
|
|
this.set('configProgress', {
|
|
current: 0,
|
|
total: 0,
|
|
status: 'Preparing...'
|
|
});
|
|
this.set('configResults', []);
|
|
}
|
|
|
|
// Update configuration progress
|
|
updateConfigProgress(current, total, status) {
|
|
this.set('configProgress', {
|
|
current,
|
|
total,
|
|
status
|
|
});
|
|
}
|
|
|
|
// Add configuration result
|
|
addConfigResult(result) {
|
|
const results = this.get('configResults');
|
|
results.push(result);
|
|
this.set('configResults', results);
|
|
}
|
|
|
|
// Complete configuration
|
|
completeConfiguration() {
|
|
this.set('isConfiguring', false);
|
|
this.set('configProgress', null);
|
|
}
|
|
|
|
// Reset configuration state
|
|
resetConfiguration() {
|
|
this.set('ssid', '');
|
|
this.set('password', '');
|
|
this.set('configProgress', null);
|
|
this.set('configResults', []);
|
|
this.set('isConfiguring', false);
|
|
}
|
|
|
|
// Check if apply is enabled
|
|
isApplyEnabled() {
|
|
const ssid = this.get('ssid');
|
|
const password = this.get('password');
|
|
const targetNodes = this.get('targetNodes');
|
|
const isConfiguring = this.get('isConfiguring');
|
|
|
|
return ssid && password && targetNodes && targetNodes.length > 0 && !isConfiguring;
|
|
}
|
|
}
|
|
|
|
window.WiFiConfigViewModel = WiFiConfigViewModel;
|