Files
spore-ui/public/scripts/view-models.js
2025-10-26 18:58:53 +01:00

1381 lines
49 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,
topologyMode: 'mesh', // 'mesh' or 'star'
starCenterNode: null // IP of the center node in star mode
});
}
// 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, primary: ${data.primaryNode}`);
// Build enhanced graph data from updated members with primary node info
this.buildEnhancedGraphData(data.members, data.primaryNode).then(({ nodes, links }) => {
this.batchUpdate({
nodes: nodes,
links: links,
primaryNode: data.primaryNode,
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);
// Trigger animation for 'discovered' or 'active' actions (node is alive)
// Skip animation for 'stale' or 'inactive' actions
if (data.action === 'discovered' || data.action === 'active') {
// Emit discovery animation event
this.set('discoveryEvent', {
nodeIp: data.nodeIp,
timestamp: data.timestamp || new Date().toISOString(),
action: data.action
});
}
});
// 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);
// Get discovery info to find the primary node
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
logger.debug('TopologyViewModel: Got discovery info:', discoveryInfo);
const members = response.members || [];
const primaryNode = discoveryInfo.primaryNode || null;
logger.debug(`TopologyViewModel: Building graph with ${members.length} members, primary: ${primaryNode}`);
// Build enhanced graph data with actual node connections
const { nodes, links } = await this.buildEnhancedGraphData(members, primaryNode);
logger.debug(`TopologyViewModel: Built graph with ${nodes.length} nodes and ${links.length} links`);
this.batchUpdate({
nodes: nodes,
links: links,
primaryNode: primaryNode,
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
// Creates either a mesh topology (all nodes connected) or star topology (center node to all others)
async buildEnhancedGraphData(members, primaryNode) {
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);
const isPrimary = member.ip === primaryNode;
nodes.push({
id: member.ip,
hostname: member.hostname || member.ip,
ip: member.ip,
status: member.status || 'UNKNOWN',
latency: member.latency || 0,
isPrimary: isPrimary,
// 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 based on topology mode
const topologyMode = this.get('topologyMode') || 'mesh';
if (topologyMode === 'mesh') {
// Full mesh - connect all nodes to all other nodes
logger.debug('TopologyViewModel: Creating full mesh topology');
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];
links.push({
source: sourceNode.id,
target: targetNode.id,
latency: this.estimateLatency(sourceNode, targetNode),
sourceNode: sourceNode,
targetNode: targetNode,
bidirectional: true
});
}
}
logger.debug(`TopologyViewModel: Created ${links.length} links in mesh topology`);
} else if (topologyMode === 'star') {
// Star topology - center node connects to all others
const centerNode = this.get('starCenterNode') || primaryNode;
if (centerNode) {
logger.debug(`TopologyViewModel: Creating star topology with center ${centerNode}`);
nodes.forEach(node => {
if (node.ip !== centerNode) {
const member = members.find(m => m.ip === node.ip);
const latency = member?.latency || this.estimateLatency(node, { ip: centerNode });
links.push({
source: centerNode,
target: node.id,
latency: latency,
sourceNode: nodes.find(n => n.ip === centerNode),
targetNode: node,
bidirectional: false
});
}
});
logger.debug(`TopologyViewModel: Created ${links.length} links from center node`);
} else {
logger.warn('TopologyViewModel: No center node specified for star topology');
}
}
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);
}
// Switch to a specific node as the new primary
async switchToPrimaryNode(nodeIp) {
try {
logger.debug(`TopologyViewModel: Switching primary node to ${nodeIp}`);
const result = await window.apiClient.setPrimaryNode(nodeIp);
if (result.success) {
logger.info(`TopologyViewModel: Successfully switched primary to ${nodeIp}`);
// Update topology after a short delay to allow backend to update
setTimeout(async () => {
logger.debug('TopologyViewModel: Refreshing topology after primary switch...');
await this.updateNetworkTopology();
}, 1000);
return result;
} else {
throw new Error(result.message || 'Failed to switch primary node');
}
} catch (error) {
logger.error('TopologyViewModel: Failed to switch primary node:', error);
throw error;
}
}
// Toggle topology mode or set star mode with specific center node
async setTopologyMode(mode, centerNodeIp = null) {
logger.debug(`TopologyViewModel: Setting topology mode to ${mode}`, centerNodeIp ? `with center ${centerNodeIp}` : '');
this.setMultiple({
topologyMode: mode,
starCenterNode: centerNodeIp
});
// Rebuild the graph with new topology
await this.updateNetworkTopology();
}
// Toggle between mesh and star modes
async toggleTopologyMode(nodeIp) {
const currentMode = this.get('topologyMode');
const currentCenter = this.get('starCenterNode');
if (currentMode === 'mesh') {
// Switch to star mode with this node as center
await this.setTopologyMode('star', nodeIp);
} else if (currentMode === 'star' && currentCenter === nodeIp) {
// Clicking same center node - switch back to mesh
await this.setTopologyMode('mesh', null);
} else {
// Clicking different node - change star center
await this.setTopologyMode('star', nodeIp);
}
}
}
// 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;
// Events View Model for websocket event visualization
class EventViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
isLoading: false,
error: null,
lastUpdateTime: null
});
// Set up WebSocket listeners for real-time updates
this.setupWebSocketListeners();
}
// Set up WebSocket event listeners
setupWebSocketListeners() {
if (!window.wsClient) {
// Retry after a short delay to allow wsClient to initialize
setTimeout(() => this.setupWebSocketListeners(), 1000);
return;
}
// Listen for all websocket messages
window.wsClient.on('message', (data) => {
const topic = data.topic || data.type;
if (topic) {
this.addTopic(topic, data);
}
});
// Listen for connection status changes
window.wsClient.on('connected', () => {
logger.info('EventViewModel: WebSocket connected');
});
window.wsClient.on('disconnected', () => {
logger.debug('EventViewModel: WebSocket disconnected');
});
}
// Add a topic (parsed by "/" separator)
addTopic(topic, data = null) {
// Get current events as a new Map to ensure change detection
const events = new Map(this.get('events'));
// Handle nested events from cluster/event
let fullTopic = topic;
if (topic === 'cluster/event' && data && data.data) {
try {
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
if (parsedData && parsedData.event) {
// Create nested topic chain: cluster/event/api/neopattern
fullTopic = `${topic}/${parsedData.event}`;
}
} catch (e) {
// If parsing fails, just use the original topic
}
}
const parts = fullTopic.split('/').filter(p => p);
if (events.has(fullTopic)) {
// Update existing event - create new object to ensure change detection
const existing = events.get(fullTopic);
events.set(fullTopic, {
topic: existing.topic,
parts: existing.parts,
count: existing.count + 1,
firstSeen: existing.firstSeen,
lastSeen: new Date().toISOString(),
lastData: data
});
} else {
// Create new event entry
events.set(fullTopic, {
topic: fullTopic,
parts: parts,
count: 1,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
lastData: data
});
}
// Use set to trigger change notification
this.set('events', events);
this.set('lastUpdateTime', new Date().toISOString());
}
// Get all topic parts (unique segments after splitting by "/")
getTopicParts() {
const events = this.get('events');
const allParts = new Set();
for (const [topic, data] of events) {
data.parts.forEach(part => allParts.add(part));
}
return Array.from(allParts).map(part => {
// Count how many events contain this part
let count = 0;
for (const [topic, data] of events) {
if (data.parts.includes(part)) {
count += data.count;
}
}
return { part, count };
});
}
// Clear all events
clearTopics() {
this.set('events', new Map());
this.set('lastUpdateTime', new Date().toISOString());
}
// Get connections between topic parts (for graph edges)
getTopicConnections() {
const topics = this.get('topics');
const connections = [];
for (const [topic, data] of topics) {
const parts = data.parts;
// Create connections between adjacent parts
for (let i = 0; i < parts.length - 1; i++) {
connections.push({
source: parts[i],
target: parts[i + 1],
topic: topic,
count: data.count
});
}
}
return connections;
}
}
window.EventViewModel = EventViewModel;