Add new graphs for states
This commit is contained in:
		| @@ -119,7 +119,7 @@ | ||||
|                 </h4> | ||||
|               </div> | ||||
|               <div class="chart-wrapper"> | ||||
|                 <Line :data="healthChartData" :options="chartOptions" /> | ||||
|                 <Line :data="healthChartData" :options="healthChartOptions" /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
| @@ -175,6 +175,7 @@ | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Health messages card --> | ||||
|         <div class="metric-card"> | ||||
|           <div class="card-header"> | ||||
|             <h6 class="card-title mb-0 metric-label">Cluster Health Messages</h6> | ||||
| @@ -204,13 +205,52 @@ | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- States Graphs Row --> | ||||
|         <div class="states-graphs-row"> | ||||
|           <!-- Node States Graph --> | ||||
|           <div class="metric-card"> | ||||
|             <div class="card-header"> | ||||
|               <h6 class="card-title mb-0"> | ||||
|                 <span class="metric-label">Node States</span> | ||||
|               </h6> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|               <Line :data="nodeStatesChartData" :options="statesChartOptions" /> | ||||
|             </div> | ||||
|           </div> | ||||
|            | ||||
|           <!-- VM States Graph --> | ||||
|           <div class="metric-card"> | ||||
|             <div class="card-header"> | ||||
|               <h6 class="card-title mb-0"> | ||||
|                 <span class="metric-label">VM States</span> | ||||
|               </h6> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|               <Line :data="vmStatesChartData" :options="statesChartOptions" /> | ||||
|             </div> | ||||
|           </div> | ||||
|            | ||||
|           <!-- OSD States Graph --> | ||||
|           <div class="metric-card"> | ||||
|             <div class="card-header"> | ||||
|               <h6 class="card-title mb-0"> | ||||
|                 <span class="metric-label">OSD States</span> | ||||
|               </h6> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|               <Line :data="osdStatesChartData" :options="statesChartOptions" /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue'; | ||||
| import { computed, ref, onMounted, watch } from 'vue'; | ||||
| import { Line } from 'vue-chartjs'; | ||||
| import { | ||||
|   Chart as ChartJS, | ||||
| @@ -523,6 +563,428 @@ const isSpecialMessage = (msg) => { | ||||
|   return msg.maintenance === true ||  | ||||
|          msg.id === "No issues detected"; | ||||
| }; | ||||
|  | ||||
| // Helper function to group objects by state | ||||
| const groupByState = (items, stateExtractor) => { | ||||
|   const groups = {}; | ||||
|   if (!items || !Array.isArray(items)) return groups; | ||||
|    | ||||
|   items.forEach(item => { | ||||
|     const state = stateExtractor(item); | ||||
|     if (!groups[state]) { | ||||
|       groups[state] = []; | ||||
|     } | ||||
|     groups[state].push(item); | ||||
|   }); | ||||
|    | ||||
|   return groups; | ||||
| }; | ||||
|  | ||||
| // Helper function to capitalize state strings | ||||
| const capitalizeState = (state) => { | ||||
|   return state.split(', ') | ||||
|     .map(part => part.charAt(0).toUpperCase() + part.slice(1)) | ||||
|     .join(', '); | ||||
| }; | ||||
|  | ||||
| // Add state history tracking | ||||
| const nodeStateHistory = ref([]); | ||||
| const vmStateHistory = ref([]); | ||||
| const osdStateHistory = ref([]); | ||||
|  | ||||
| // Function to update state history when new data arrives | ||||
| const updateStateHistory = (newStates, historyArray, maxPoints = 20) => { | ||||
|   // Skip empty state objects or those with no items | ||||
|   if (!newStates || Object.keys(newStates).length === 0) { | ||||
|     return; | ||||
|   } | ||||
|    | ||||
|   // Check if there are any actual items in the states | ||||
|   const hasItems = Object.values(newStates).some(items => items && items.length > 0); | ||||
|   if (!hasItems) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Add new data point to history | ||||
|   historyArray.push({ | ||||
|     timestamp: Date.now(), | ||||
|     states: JSON.parse(JSON.stringify(newStates)) // Deep copy to prevent reference issues | ||||
|   }); | ||||
|    | ||||
|   // Limit history length | ||||
|   if (historyArray.length > maxPoints) { | ||||
|     historyArray.shift(); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| // Helper function to process historical state data | ||||
| const processStateHistory = (currentStateGroups, historyArray, timeLabels, colorFunction) => { | ||||
|   // Create a map to track all states that have appeared | ||||
|   const allStates = new Map(); | ||||
|   const datasets = []; | ||||
|   const stateData = {}; | ||||
|    | ||||
|   // Skip processing if no history data yet | ||||
|   if (historyArray.length === 0) { | ||||
|     return []; | ||||
|   } | ||||
|    | ||||
|   // Skip processing if current state groups are empty | ||||
|   if (!currentStateGroups || Object.keys(currentStateGroups).length === 0) { | ||||
|     return []; | ||||
|   } | ||||
|    | ||||
|   // Initialize data structure for all time points | ||||
|   timeLabels.forEach((_, index) => { | ||||
|     stateData[index] = {}; | ||||
|   }); | ||||
|    | ||||
|   // Process historical data | ||||
|   if (historyArray.length > 0) { | ||||
|     // Map history entries to time points in the chart | ||||
|     const historyStep = historyArray.length / timeLabels.length; | ||||
|      | ||||
|     timeLabels.forEach((_, timeIndex) => { | ||||
|       // Find the corresponding history entry for this time point | ||||
|       const historyIndex = Math.min( | ||||
|         Math.floor(timeIndex * historyStep), | ||||
|         historyArray.length - 1 | ||||
|       ); | ||||
|        | ||||
|       const historyEntry = historyArray[historyIndex]; | ||||
|       if (historyEntry && historyEntry.states) { | ||||
|         // Record states at this time point | ||||
|         Object.entries(historyEntry.states).forEach(([state, items]) => { | ||||
|           stateData[timeIndex][state] = items.length; | ||||
|            | ||||
|           // Track this state | ||||
|           if (!allStates.has(state)) { | ||||
|             allStates.set(state, { | ||||
|               color: colorFunction(state), | ||||
|               currentCount: (currentStateGroups[state] || []).length | ||||
|             }); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   // Add current states that might not be in history yet | ||||
|   Object.entries(currentStateGroups).forEach(([state, items]) => { | ||||
|     const lastIndex = timeLabels.length - 1; | ||||
|     stateData[lastIndex][state] = items.length; | ||||
|      | ||||
|     if (!allStates.has(state)) { | ||||
|       allStates.set(state, { | ||||
|         color: colorFunction(state), | ||||
|         currentCount: items.length | ||||
|       }); | ||||
|     } else { | ||||
|       // Update current count | ||||
|       allStates.get(state).currentCount = items.length; | ||||
|     } | ||||
|   }); | ||||
|    | ||||
|   // Create datasets for Chart.js | ||||
|   const totalCount = Object.values(currentStateGroups) | ||||
|     .reduce((sum, items) => sum + items.length, 0); | ||||
|    | ||||
|   // Skip if total count is 0 | ||||
|   if (totalCount === 0) { | ||||
|     return []; | ||||
|   } | ||||
|    | ||||
|   datasets.push({ | ||||
|     label: 'Total', | ||||
|     data: Array(timeLabels.length).fill(totalCount), | ||||
|     borderColor: 'rgba(0, 0, 0, 0.3)', | ||||
|     borderWidth: 1, | ||||
|     fill: false, | ||||
|     tension: 0.1, | ||||
|     pointRadius: 0, | ||||
|     order: 999, | ||||
|     currentCount: totalCount | ||||
|   }); | ||||
|    | ||||
|   // Add a line for each state | ||||
|   allStates.forEach((stateInfo, state) => { | ||||
|     // Create data array from historical state data | ||||
|     const data = timeLabels.map((_, index) => { | ||||
|       return stateData[index][state] || 0; | ||||
|     }); | ||||
|      | ||||
|     // Skip drawing lines for states with all zero values | ||||
|     if (data.every(value => value === 0)) { | ||||
|       // Still add to legend if current count is not zero | ||||
|       if (stateInfo.currentCount > 0) { | ||||
|         datasets.push({ | ||||
|           label: capitalizeState(state), | ||||
|           data: data, | ||||
|           borderColor: stateInfo.color, | ||||
|           borderWidth: 2, | ||||
|           fill: false, | ||||
|           tension: 0.1, | ||||
|           pointRadius: 0, | ||||
|           currentCount: stateInfo.currentCount, | ||||
|           hidden: true  // Hide from chart but keep in legend | ||||
|         }); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     datasets.push({ | ||||
|       label: capitalizeState(state), | ||||
|       data: data, | ||||
|       borderColor: stateInfo.color, | ||||
|       borderWidth: 2, | ||||
|       fill: false, | ||||
|       tension: 0.1, | ||||
|       pointRadius: 0, | ||||
|       currentCount: stateInfo.currentCount, | ||||
|       // Hide line segments with zero values | ||||
|       segment: { | ||||
|         borderColor: ctx => ctx.p0.parsed.y === 0 && ctx.p1.parsed.y === 0 ? 'transparent' : undefined | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|    | ||||
|   return datasets; | ||||
| }; | ||||
|  | ||||
| // Node states processing | ||||
| const nodeStateGroups = computed(() => { | ||||
|   return groupByState(props.clusterData.detail?.node, (node) => { | ||||
|     return `${node.daemon_state}, ${node.domain_state}`; | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| const nodeStateColors = (state) => { | ||||
|   if (state === 'run, ready') return '#28a745'; // Green | ||||
|   if (['run, flush', 'run, unflush', 'run, flushed'].includes(state)) return '#0d6efd'; // Blue | ||||
|   if (state.includes('dead') || state.includes('fenced') || state.includes('stop')) return '#dc3545'; // Red | ||||
|   return '#ffc107'; // Yellow for all others | ||||
| }; | ||||
|  | ||||
| // Node states processing | ||||
| const nodeStatesChartData = computed(() => { | ||||
|   const labels = props.metricsData.health.labels; | ||||
|    | ||||
|   // Only process if we have history data | ||||
|   if (nodeStateHistory.value.length === 0) { | ||||
|     return { labels, datasets: [] }; | ||||
|   } | ||||
|    | ||||
|   // Process node states with history | ||||
|   const datasets = processStateHistory( | ||||
|     nodeStateGroups.value, | ||||
|     nodeStateHistory.value, | ||||
|     labels, | ||||
|     nodeStateColors | ||||
|   ); | ||||
|    | ||||
|   return { labels, datasets }; | ||||
| }); | ||||
|  | ||||
| // VM states processing | ||||
| const vmStateGroups = computed(() => { | ||||
|   return groupByState(props.clusterData.detail?.vm, (vm) => vm.state); | ||||
| }); | ||||
|  | ||||
| const vmStateColors = (state) => { | ||||
|   if (state === 'start') return '#28a745'; // Green | ||||
|   if (['migrate', 'disable', 'provision'].includes(state)) return '#0d6efd'; // Blue | ||||
|   if (state === 'mirror') return '#6f42c1'; // Purple | ||||
|   if (['stop', 'fail'].includes(state)) return '#dc3545'; // Red | ||||
|   return '#ffc107'; // Yellow for all others | ||||
| }; | ||||
|  | ||||
| // VM states processing | ||||
| const vmStatesChartData = computed(() => { | ||||
|   const labels = props.metricsData.health.labels; | ||||
|    | ||||
|   // Only process if we have history data | ||||
|   if (vmStateHistory.value.length === 0) { | ||||
|     return { labels, datasets: [] }; | ||||
|   } | ||||
|    | ||||
|   // Process VM states with history | ||||
|   const datasets = processStateHistory( | ||||
|     vmStateGroups.value, | ||||
|     vmStateHistory.value, | ||||
|     labels, | ||||
|     vmStateColors | ||||
|   ); | ||||
|    | ||||
|   return { labels, datasets }; | ||||
| }); | ||||
|  | ||||
| // OSD states processing | ||||
| const osdStateGroups = computed(() => { | ||||
|   return groupByState(props.clusterData.detail?.osd, (osd) => { | ||||
|     return `${osd.up}, ${osd.in}`; | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| const osdStateColors = (state) => { | ||||
|   if (state === 'up, in') return '#28a745'; // Green | ||||
|   if (state === 'down, out') return '#dc3545'; // Red | ||||
|   return '#ffc107'; // Yellow for all others | ||||
| }; | ||||
|  | ||||
| // OSD states processing | ||||
| const osdStatesChartData = computed(() => { | ||||
|   const labels = props.metricsData.health.labels; | ||||
|    | ||||
|   // Only process if we have history data | ||||
|   if (osdStateHistory.value.length === 0) { | ||||
|     return { labels, datasets: [] }; | ||||
|   } | ||||
|    | ||||
|   // Process OSD states with history | ||||
|   const datasets = processStateHistory( | ||||
|     osdStateGroups.value, | ||||
|     osdStateHistory.value, | ||||
|     labels, | ||||
|     osdStateColors | ||||
|   ); | ||||
|    | ||||
|   return { labels, datasets }; | ||||
| }); | ||||
|  | ||||
| // Chart options for state graphs | ||||
| const statesChartOptions = { | ||||
|   responsive: true, | ||||
|   maintainAspectRatio: false, | ||||
|   clip: false, | ||||
|   plugins: { | ||||
|     legend: { | ||||
|       display: true, | ||||
|       position: 'bottom', | ||||
|       labels: { | ||||
|         boxWidth: 12, | ||||
|         padding: 10, | ||||
|         font: { | ||||
|           size: 11 | ||||
|         }, | ||||
|         generateLabels: (chart) => { | ||||
|           // Custom legend labels with counts | ||||
|           const datasets = chart.data.datasets; | ||||
|           return datasets.map(dataset => ({ | ||||
|             text: dataset.currentCount !== undefined ?  | ||||
|               `${dataset.label}: ${dataset.currentCount}` :  | ||||
|               dataset.label, | ||||
|             fillStyle: dataset.borderColor, | ||||
|             strokeStyle: dataset.borderColor, | ||||
|             lineWidth: dataset.borderWidth, | ||||
|             hidden: !chart.isDatasetVisible(datasets.indexOf(dataset)), | ||||
|             index: datasets.indexOf(dataset), | ||||
|             // Darker text color for better readability | ||||
|             fontColor: '#333' | ||||
|           })); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     tooltip: { | ||||
|       callbacks: { | ||||
|         label: (context) => { | ||||
|           // Just show the count without duplicating the state name | ||||
|           return `${context.dataset.label}: ${context.parsed.y}`; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   scales: { | ||||
|     x: { | ||||
|       display: false, | ||||
|       grid: { | ||||
|         display: false | ||||
|       } | ||||
|     }, | ||||
|     y: { | ||||
|       beginAtZero: true, | ||||
|       grid: { | ||||
|         color: 'rgba(0, 0, 0, 0.05)' | ||||
|       }, | ||||
|       ticks: { | ||||
|         precision: 0 | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   animation: false, | ||||
|   interaction: { | ||||
|     mode: 'index', | ||||
|     intersect: false | ||||
|   } | ||||
| }; | ||||
|  | ||||
| // Watch for changes in cluster data to update history | ||||
| watch(() => props.clusterData, (newData) => { | ||||
|   if (newData) { | ||||
|     // Only update history if we have actual data | ||||
|     if (!newData.detail) { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Update node state history | ||||
|     const nodeStates = groupByState(newData.detail?.node, (node) => { | ||||
|       return `${node.daemon_state}, ${node.domain_state}`; | ||||
|     }); | ||||
|      | ||||
|     // Only update if we have actual node states | ||||
|     if (Object.keys(nodeStates).length > 0) { | ||||
|       updateStateHistory(nodeStates, nodeStateHistory.value); | ||||
|     } | ||||
|      | ||||
|     // Update VM state history | ||||
|     const vmStates = groupByState(newData.detail?.vm, (vm) => vm.state); | ||||
|      | ||||
|     // Only update if we have actual VM states | ||||
|     if (Object.keys(vmStates).length > 0) { | ||||
|       updateStateHistory(vmStates, vmStateHistory.value); | ||||
|     } | ||||
|      | ||||
|     // Update OSD state history | ||||
|     const osdStates = groupByState(newData.detail?.osd, (osd) => { | ||||
|       return `${osd.up}, ${osd.in}`; | ||||
|     }); | ||||
|      | ||||
|     // Only update if we have actual OSD states | ||||
|     if (Object.keys(osdStates).length > 0) { | ||||
|       updateStateHistory(osdStates, osdStateHistory.value); | ||||
|     } | ||||
|   } | ||||
| }, { deep: true }); | ||||
|  | ||||
| // Initialize history on component mount | ||||
| onMounted(() => { | ||||
|   // Initialize with current state if available | ||||
|   if (props.clusterData && props.clusterData.detail) { | ||||
|     const nodeStates = groupByState(props.clusterData.detail?.node, (node) => { | ||||
|       return `${node.daemon_state}, ${node.domain_state}`; | ||||
|     }); | ||||
|      | ||||
|     // Only initialize if we have actual node states | ||||
|     if (Object.keys(nodeStates).length > 0) { | ||||
|       updateStateHistory(nodeStates, nodeStateHistory.value); | ||||
|     } | ||||
|      | ||||
|     const vmStates = groupByState(props.clusterData.detail?.vm, (vm) => vm.state); | ||||
|      | ||||
|     // Only initialize if we have actual VM states | ||||
|     if (Object.keys(vmStates).length > 0) { | ||||
|       updateStateHistory(vmStates, vmStateHistory.value); | ||||
|     } | ||||
|      | ||||
|     const osdStates = groupByState(props.clusterData.detail?.osd, (osd) => { | ||||
|       return `${osd.up}, ${osd.in}`; | ||||
|     }); | ||||
|      | ||||
|     // Only initialize if we have actual OSD states | ||||
|     if (Object.keys(osdStates).length > 0) { | ||||
|       updateStateHistory(osdStates, osdStateHistory.value); | ||||
|     } | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -837,4 +1299,47 @@ const isSpecialMessage = (msg) => { | ||||
|   border: 1px solid rgba(13, 110, 253, 0.1); | ||||
|   color: #0d6efd; | ||||
| } | ||||
|  | ||||
| /* Add some spacing for the legend */ | ||||
| .metric-card .card-body { | ||||
|   padding: 0.5rem;  /* Consistent padding on all sides */ | ||||
| } | ||||
|  | ||||
| /* New row for the 3 state graphs with wider minimum width */ | ||||
| .states-graphs-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(3, 1fr);  /* 3 equal columns */ | ||||
|   gap: 0.5rem; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| /* Responsive behavior for state graphs */ | ||||
| @media (max-width: 1500px) { | ||||
|   .states-graphs-row { | ||||
|     grid-template-columns: repeat(3, 1fr);  /* Keep 3 columns at this breakpoint */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 1148px) { | ||||
|   .states-graphs-row { | ||||
|     grid-template-columns: repeat(2, 1fr);  /* 2 columns at medium size */ | ||||
|   } | ||||
|    | ||||
|   /* Make state graphs have a wider minimum width */ | ||||
|   .states-graphs-row .metric-card { | ||||
|     min-width: 216px;  /* 20% wider than the 180px for other cards */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 740px) { | ||||
|   .states-graphs-row { | ||||
|     grid-template-columns: 1fr;  /* 1 column at small size */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Darker legend text for better readability */ | ||||
| :deep(.chartjs-legend-item) { | ||||
|   color: #333 !important; | ||||
|   font-weight: 500 !important; | ||||
| } | ||||
| </style> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user