Initial commit of dashboard project
This commit is contained in:
		
							
								
								
									
										24
									
								
								pvcweb-vue/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pvcweb-vue/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| node_modules | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
|  | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| .DS_Store | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
							
								
								
									
										3
									
								
								pvcweb-vue/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								pvcweb-vue/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| { | ||||
|   "recommendations": ["Vue.volar"] | ||||
| } | ||||
							
								
								
									
										5
									
								
								pvcweb-vue/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								pvcweb-vue/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # Vue 3 + Vite | ||||
|  | ||||
| This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. | ||||
|  | ||||
| Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support). | ||||
							
								
								
									
										13
									
								
								pvcweb-vue/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								pvcweb-vue/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <link rel="icon" href="/favicon.ico"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>PVC Cluster Dashboard</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/main.js"></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										1372
									
								
								pvcweb-vue/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1372
									
								
								pvcweb-vue/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										23
									
								
								pvcweb-vue/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								pvcweb-vue/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "name": "pvcweb-vue", | ||||
|   "private": true, | ||||
|   "version": "0.0.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "vite build", | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fortawesome/fontawesome-free": "^6.7.2", | ||||
|     "bootstrap": "^5.3.3", | ||||
|     "chart.js": "^4.4.8", | ||||
|     "pinia": "^3.0.1", | ||||
|     "vue": "^3.5.13", | ||||
|     "vue-chartjs": "^5.3.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@vitejs/plugin-vue": "^5.2.1", | ||||
|     "vite": "^6.2.0" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										108
									
								
								pvcweb-vue/proxy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								pvcweb-vue/proxy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| const express = require('express'); | ||||
| const cors = require('cors'); | ||||
| const { createProxyMiddleware } = require('http-proxy-middleware'); | ||||
| const fs = require('fs'); | ||||
| const path = require('path'); | ||||
| const WebSocket = require('ws'); | ||||
| const http = require('http'); | ||||
|  | ||||
| const app = express(); | ||||
| const port = 3000; | ||||
|  | ||||
| // Create HTTP server | ||||
| const server = http.createServer(app); | ||||
|  | ||||
| // Create WebSocket server | ||||
| const wss = new WebSocket.Server({ server }); | ||||
|  | ||||
| // WebSocket connections | ||||
| let connections = new Set(); | ||||
|  | ||||
| // WebSocket connection handling | ||||
| wss.on('connection', (ws) => { | ||||
|     connections.add(ws); | ||||
|     console.log('[WS] Client connected'); | ||||
|  | ||||
|     ws.on('close', () => { | ||||
|         connections.delete(ws); | ||||
|         console.log('[WS] Client disconnected'); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| // Add request logging middleware | ||||
| app.use((req, res, next) => { | ||||
|     console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); | ||||
|     console.log('[INCOMING HEADERS]', req.headers);  // Log incoming headers | ||||
|     next(); | ||||
| }); | ||||
|  | ||||
| // Watch for changes to PVC.html | ||||
| fs.watch(path.join(__dirname, 'PVC.html'), (eventType, filename) => { | ||||
|     if (eventType === 'change') { | ||||
|         console.log(`[${new Date().toISOString()}] PVC.html updated`); | ||||
|         // Notify all connected clients | ||||
|         connections.forEach(client => { | ||||
|             if (client.readyState === WebSocket.OPEN) { | ||||
|                 client.send('reload'); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| // Serve static files | ||||
| app.use(express.static('./')); | ||||
|  | ||||
| // Enable CORS | ||||
| app.use(cors()); | ||||
|  | ||||
| // Proxy middleware configuration | ||||
| const apiProxy = createProxyMiddleware({ | ||||
|     router: (req) => { | ||||
|         const apiUri = req.headers['x-api-uri']; | ||||
|         if (!apiUri) { | ||||
|             throw new Error('API URI not configured'); | ||||
|         } | ||||
|         return apiUri; | ||||
|     }, | ||||
|     changeOrigin: true, | ||||
|     secure: false, | ||||
|     logLevel: 'debug', | ||||
|     pathRewrite: function (path, req) { | ||||
|         // Just strip /proxy, don't add /api/v1 since it's in the backend URI | ||||
|         return path.replace('/proxy', ''); | ||||
|     }, | ||||
|     onProxyReq: (proxyReq, req, res) => { | ||||
|         // Preserve case sensitivity of the header | ||||
|         const apiKey = req.headers['x-api-key']; | ||||
|         if (apiKey) { | ||||
|             proxyReq.setHeader('X-Api-Key', apiKey); | ||||
|             console.log('[API KEY HEADER SET]', apiKey);  // Log the key being set | ||||
|         } else { | ||||
|             console.log('[WARNING] No API key found in request headers'); | ||||
|         } | ||||
|  | ||||
|         // Log all headers being sent to backend | ||||
|         console.log('[OUTGOING HEADERS]', proxyReq.getHeaders()); | ||||
|     }, | ||||
|     onProxyRes: function(proxyRes, req, res) { | ||||
|         proxyRes.headers['Access-Control-Allow-Origin'] = '*'; | ||||
|         proxyRes.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'; | ||||
|         proxyRes.headers['Access-Control-Allow-Headers'] = 'Content-Type,Accept,X-API-URI,X-Api-Key'; | ||||
|         console.log(`[PROXY RES] ${req.method} ${req.path} -> ${proxyRes.statusCode}`); | ||||
|     }, | ||||
|     onError: (err, req, res) => { | ||||
|         console.error('[PROXY ERROR]', err); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| // Handle OPTIONS requests with correct case for header | ||||
| app.options('*', cors({ | ||||
|     allowedHeaders: ['Content-Type', 'Accept', 'X-API-URI', 'X-Api-Key'] | ||||
| })); | ||||
|  | ||||
| app.use('/proxy', apiProxy); | ||||
|  | ||||
| // Use server.listen instead of app.listen | ||||
| server.listen(port, () => { | ||||
|     console.log(`Proxy server running at http://localhost:${port}`); | ||||
| }); | ||||
							
								
								
									
										1
									
								
								pvcweb-vue/public/vite.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								pvcweb-vue/public/vite.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> | ||||
| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										154
									
								
								pvcweb-vue/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								pvcweb-vue/src/App.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| <template> | ||||
|   <nav class="navbar navbar-dark bg-dark"> | ||||
|     <div class="container"> | ||||
|       <span class="navbar-brand">PVC Cluster Dashboard</span> | ||||
|       <button class="btn btn-primary config-toggle" @click="toggleConfig"> | ||||
|         <i class="fas fa-cog"></i> Configure | ||||
|       </button> | ||||
|     </div> | ||||
|   </nav> | ||||
|  | ||||
|   <div class="container mt-4"> | ||||
|     <div v-if="showConnectionStatus" :class="['alert', connectionStatusClass]"> | ||||
|       {{ connectionStatusMessage }} | ||||
|     </div> | ||||
|  | ||||
|     <ClusterOverview :clusterData="clusterData" /> | ||||
|     <MetricsCharts :metricsData="metricsHistory" /> | ||||
|     <NodeStatus :nodeData="nodeData" /> | ||||
|   </div> | ||||
|  | ||||
|   <ConfigPanel  | ||||
|     v-model="configPanelOpen" | ||||
|     :config="api.config" | ||||
|     @save="saveConfig" | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, computed, onMounted, onUnmounted } from 'vue'; | ||||
| import ClusterOverview from './components/ClusterOverview.vue'; | ||||
| import MetricsCharts from './components/MetricsCharts.vue'; | ||||
| import NodeStatus from './components/NodeStatus.vue'; | ||||
| import ConfigPanel from './components/ConfigPanel.vue'; | ||||
| import { useApiStore } from './stores/api'; | ||||
|  | ||||
| const api = useApiStore(); | ||||
| const configPanelOpen = ref(false); | ||||
| const clusterData = ref({}); | ||||
| const nodeData = ref([]); | ||||
| const metricsHistory = ref({ | ||||
|   cpu: { labels: [], data: [] }, | ||||
|   memory: { labels: [], data: [] }, | ||||
|   health: { labels: [], data: [] } | ||||
| }); | ||||
|  | ||||
| const showConnectionStatus = ref(false); | ||||
| const connectionStatusMessage = ref(''); | ||||
| const connectionStatusClass = ref('alert-warning'); | ||||
|  | ||||
| let updateTimer = null; | ||||
|  | ||||
| const toggleConfig = () => { | ||||
|   configPanelOpen.value = !configPanelOpen.value; | ||||
| }; | ||||
|  | ||||
| const saveConfig = async (newConfig) => { | ||||
|   api.updateConfig(newConfig); | ||||
|   configPanelOpen.value = false; | ||||
|   restartDashboard(); | ||||
| }; | ||||
|  | ||||
| const updateMetricsHistory = (timestamp, status) => { | ||||
|   // Create new arrays instead of mutating | ||||
|   const metrics = { | ||||
|     cpu: status.resources?.cpu?.utilization || 0, | ||||
|     memory: status.resources?.memory?.utilization || 0, | ||||
|     health: status.cluster_health?.health || 0 | ||||
|   }; | ||||
|  | ||||
|   Object.keys(metrics).forEach(metric => { | ||||
|     const labels = [...metricsHistory.value[metric].labels, timestamp]; | ||||
|     const data = [...metricsHistory.value[metric].data, Math.round(metrics[metric])]; | ||||
|      | ||||
|     // Keep only last 180 points | ||||
|     if (labels.length > 180) { | ||||
|       labels.shift(); | ||||
|       data.shift(); | ||||
|     } | ||||
|      | ||||
|     metricsHistory.value[metric] = { | ||||
|       labels, | ||||
|       data | ||||
|     }; | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const updateDashboard = async () => { | ||||
|   if (!api.isConfigured) { | ||||
|     showConnectionStatus.value = true; | ||||
|     connectionStatusMessage.value = 'Please configure API connection settings'; | ||||
|     connectionStatusClass.value = 'alert-warning'; | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const status = await api.fetchStatus(); | ||||
|     const nodes = await api.fetchNodes(); | ||||
|      | ||||
|     // Update state with new objects instead of mutating | ||||
|     clusterData.value = { ...status }; | ||||
|     nodeData.value = [...nodes]; | ||||
|  | ||||
|     const timestamp = new Date().toLocaleTimeString(); | ||||
|     updateMetricsHistory(timestamp, status); | ||||
|  | ||||
|     showConnectionStatus.value = false; | ||||
|   } catch (error) { | ||||
|     console.error('Dashboard update error:', error); | ||||
|     showConnectionStatus.value = true; | ||||
|     connectionStatusMessage.value = 'Connection error: Please check your API configuration'; | ||||
|     connectionStatusClass.value = 'alert-danger'; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const restartDashboard = () => { | ||||
|   if (updateTimer) { | ||||
|     clearInterval(updateTimer); | ||||
|   } | ||||
|   updateDashboard(); | ||||
|   updateTimer = setInterval(updateDashboard, api.config.updateInterval); | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (api.isConfigured) { | ||||
|     restartDashboard(); | ||||
|   } else { | ||||
|     showConnectionStatus.value = true; | ||||
|     connectionStatusMessage.value = 'Please configure API connection settings'; | ||||
|     connectionStatusClass.value = 'alert-warning'; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   if (updateTimer) { | ||||
|     clearInterval(updateTimer); | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| @import 'bootstrap/dist/css/bootstrap.min.css'; | ||||
| @import '@fortawesome/fontawesome-free/css/all.min.css'; | ||||
|  | ||||
| .metric-card { | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
| .metric-card:hover { | ||||
|   transform: translateY(-5px); | ||||
|   box-shadow: 0 4px 8px rgba(0,0,0,0.1); | ||||
| } | ||||
| .status-healthy { color: #28a745; } | ||||
| .status-warning { color: #ffc107; } | ||||
| .status-error { color: #dc3545; } | ||||
| </style> | ||||
							
								
								
									
										1
									
								
								pvcweb-vue/src/assets/vue.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								pvcweb-vue/src/assets/vue.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg> | ||||
| After Width: | Height: | Size: 496 B | 
							
								
								
									
										82
									
								
								pvcweb-vue/src/components/ClusterOverview.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								pvcweb-vue/src/components/ClusterOverview.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| <template> | ||||
|   <div class="card mb-4"> | ||||
|     <div class="card-header"> | ||||
|       <h5 class="mb-0">Cluster Overview</h5> | ||||
|     </div> | ||||
|     <div class="card-body"> | ||||
|       <div class="row"> | ||||
|         <!-- Cluster Health --> | ||||
|         <div class="col-md-3"> | ||||
|           <div class="card metric-card"> | ||||
|             <div class="card-body"> | ||||
|               <h6 class="card-subtitle mb-2 text-muted">Cluster Health</h6> | ||||
|               <h4 :class="['card-title', getHealthClass(clusterData.cluster_health?.health)]"> | ||||
|                 {{ clusterData.cluster_health?.health || 0 }}% | ||||
|               </h4> | ||||
|               <small class="text-muted"> | ||||
|                 {{ clusterData.cluster_health?.messages?.join('<br>') || 'No issues' }} | ||||
|               </small> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Nodes --> | ||||
|         <div class="col-md-3"> | ||||
|           <div class="card metric-card"> | ||||
|             <div class="card-body"> | ||||
|               <h6 class="card-subtitle mb-2 text-muted">Nodes</h6> | ||||
|               <h4 class="card-title"> | ||||
|                 {{ clusterData.nodes?.total || 0 }} ({{ clusterData.primary_node || 'N/A' }}) | ||||
|               </h4> | ||||
|               <small class="text-muted">Version: {{ clusterData.pvc_version || 'Unknown' }}</small> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- VMs --> | ||||
|         <div class="col-md-3"> | ||||
|           <div class="card metric-card"> | ||||
|             <div class="card-body"> | ||||
|               <h6 class="card-subtitle mb-2 text-muted">VMs</h6> | ||||
|               <h4 class="card-title">{{ clusterData.vms?.total || 0 }} VMs</h4> | ||||
|               <small class="text-muted"> | ||||
|                 Memory: {{ Math.round(clusterData.resources?.memory?.utilization || 0) }}% used<br> | ||||
|                 CPU: {{ Math.round(clusterData.resources?.cpu?.utilization || 0) }}% used | ||||
|               </small> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Storage --> | ||||
|         <div class="col-md-3"> | ||||
|           <div class="card metric-card"> | ||||
|             <div class="card-body"> | ||||
|               <h6 class="card-subtitle mb-2 text-muted">Storage</h6> | ||||
|               <h4 class="card-title">{{ clusterData.osds?.total || 0 }} OSDs</h4> | ||||
|               <small class="text-muted"> | ||||
|                 Utilization: {{ Math.round(clusterData.resources?.disk?.utilization || 0) }}%<br> | ||||
|                 Pools: {{ clusterData.pools || 0 }} | ||||
|               </small> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| const props = defineProps({ | ||||
|   clusterData: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
|     default: () => ({}) | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const getHealthClass = (health) => { | ||||
|   if (health > 90) return 'status-healthy'; | ||||
|   if (health > 50) return 'status-warning'; | ||||
|   return 'status-error'; | ||||
| }; | ||||
| </script> | ||||
							
								
								
									
										109
									
								
								pvcweb-vue/src/components/ConfigPanel.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								pvcweb-vue/src/components/ConfigPanel.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| <template> | ||||
|   <div class="config-panel" :class="{ 'open': modelValue }"> | ||||
|     <div class="config-content"> | ||||
|       <h3>Configuration</h3> | ||||
|       <form @submit.prevent="saveConfig"> | ||||
|         <div class="mb-3"> | ||||
|           <label for="apiUri" class="form-label">API URI</label> | ||||
|           <input | ||||
|             type="text" | ||||
|             class="form-control" | ||||
|             id="apiUri" | ||||
|             v-model="formData.apiUri" | ||||
|             placeholder="http://your-pvc-api:7370/api/v1" | ||||
|             required | ||||
|           > | ||||
|         </div> | ||||
|  | ||||
|         <div class="mb-3"> | ||||
|           <label for="apiKey" class="form-label">API Key</label> | ||||
|           <input | ||||
|             type="password" | ||||
|             class="form-control" | ||||
|             id="apiKey" | ||||
|             v-model="formData.apiKey" | ||||
|             placeholder="Your API key" | ||||
|             required | ||||
|           > | ||||
|         </div> | ||||
|  | ||||
|         <div class="mb-3"> | ||||
|           <label for="updateInterval" class="form-label">Update Interval (ms)</label> | ||||
|           <input | ||||
|             type="number" | ||||
|             class="form-control" | ||||
|             id="updateInterval" | ||||
|             v-model="formData.updateInterval" | ||||
|             min="1000" | ||||
|             step="1000" | ||||
|             required | ||||
|           > | ||||
|         </div> | ||||
|  | ||||
|         <div class="d-grid gap-2"> | ||||
|           <button type="submit" class="btn btn-primary">Save</button> | ||||
|           <button type="button" class="btn btn-secondary" @click="closePanel">Cancel</button> | ||||
|         </div> | ||||
|       </form> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, watch } from 'vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   modelValue: { | ||||
|     type: Boolean, | ||||
|     required: true | ||||
|   }, | ||||
|   config: { | ||||
|     type: Object, | ||||
|     required: true | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue', 'save']); | ||||
|  | ||||
| const formData = ref({ | ||||
|   apiUri: props.config.apiUri || '', | ||||
|   apiKey: props.config.apiKey || '', | ||||
|   updateInterval: props.config.updateInterval || 5000 | ||||
| }); | ||||
|  | ||||
| const saveConfig = () => { | ||||
|   emit('save', { ...formData.value }); | ||||
| }; | ||||
|  | ||||
| const closePanel = () => { | ||||
|   emit('update:modelValue', false); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .config-panel { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   right: -400px; | ||||
|   width: 400px; | ||||
|   height: 100vh; | ||||
|   background: white; | ||||
|   box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); | ||||
|   transition: right 0.3s ease; | ||||
|   z-index: 1000; | ||||
|   padding: 20px; | ||||
|   overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .config-panel.open { | ||||
|   right: 0; | ||||
| } | ||||
|  | ||||
| .config-content { | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| h3 { | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										154
									
								
								pvcweb-vue/src/components/MetricsCharts.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								pvcweb-vue/src/components/MetricsCharts.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| <template> | ||||
|   <div class="row mb-4"> | ||||
|     <!-- CPU Usage Chart --> | ||||
|     <div class="col-md-4"> | ||||
|       <div class="card"> | ||||
|         <div class="card-header"> | ||||
|           <h5 class="card-title mb-0">CPU Usage</h5> | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|           <Line | ||||
|             :data="cpuChartData" | ||||
|             :options="chartOptions" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Memory Usage Chart --> | ||||
|     <div class="col-md-4"> | ||||
|       <div class="card"> | ||||
|         <div class="card-header"> | ||||
|           <h5 class="card-title mb-0">Memory Usage</h5> | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|           <Line | ||||
|             :data="memoryChartData" | ||||
|             :options="chartOptions" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Cluster Health Chart --> | ||||
|     <div class="col-md-4"> | ||||
|       <div class="card"> | ||||
|         <div class="card-header"> | ||||
|           <h5 class="card-title mb-0">Cluster Health</h5> | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|           <Line | ||||
|             :data="healthChartData" | ||||
|             :options="chartOptions" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { Line } from 'vue-chartjs'; | ||||
| import { | ||||
|   Chart as ChartJS, | ||||
|   CategoryScale, | ||||
|   LinearScale, | ||||
|   PointElement, | ||||
|   LineElement, | ||||
|   Title, | ||||
|   Tooltip, | ||||
|   Legend | ||||
| } from 'chart.js'; | ||||
|  | ||||
| // Register Chart.js components | ||||
| ChartJS.register( | ||||
|   CategoryScale, | ||||
|   LinearScale, | ||||
|   PointElement, | ||||
|   LineElement, | ||||
|   Title, | ||||
|   Tooltip, | ||||
|   Legend | ||||
| ); | ||||
|  | ||||
| const props = defineProps({ | ||||
|   metricsData: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
|     default: () => ({ | ||||
|       cpu: { labels: [], data: [] }, | ||||
|       memory: { labels: [], data: [] }, | ||||
|       health: { labels: [], data: [] } | ||||
|     }) | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // Common chart options | ||||
| const chartOptions = { | ||||
|   responsive: true, | ||||
|   clip: false,  // Prevent line from being cut off at top | ||||
|   scales: { | ||||
|     y: { | ||||
|       min: 0, | ||||
|       max: 100, | ||||
|       ticks: { | ||||
|         stepSize: 20 | ||||
|       } | ||||
|     }, | ||||
|     x: { | ||||
|       ticks: { | ||||
|         maxRotation: 45, | ||||
|         minRotation: 45 | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   elements: { | ||||
|     line: { | ||||
|       borderWidth: 2, | ||||
|       tension: 0.2 | ||||
|     }, | ||||
|     point: { | ||||
|       radius: 2 | ||||
|     } | ||||
|   }, | ||||
|   plugins: { | ||||
|     legend: { | ||||
|       position: 'top' | ||||
|     } | ||||
|   }, | ||||
|   animation: false, | ||||
|   spanGaps: true | ||||
| }; | ||||
|  | ||||
| // Computed chart data objects | ||||
| const cpuChartData = computed(() => ({ | ||||
|   labels: props.metricsData.cpu.labels, | ||||
|   datasets: [{ | ||||
|     label: 'CPU Usage %', | ||||
|     data: props.metricsData.cpu.data, | ||||
|     borderColor: 'rgb(75, 192, 192)', | ||||
|     fill: false | ||||
|   }] | ||||
| })); | ||||
|  | ||||
| const memoryChartData = computed(() => ({ | ||||
|   labels: props.metricsData.memory.labels, | ||||
|   datasets: [{ | ||||
|     label: 'Memory Usage %', | ||||
|     data: props.metricsData.memory.data, | ||||
|     borderColor: 'rgb(153, 102, 255)', | ||||
|     fill: false | ||||
|   }] | ||||
| })); | ||||
|  | ||||
| const healthChartData = computed(() => ({ | ||||
|   labels: props.metricsData.health.labels, | ||||
|   datasets: [{ | ||||
|     label: 'Cluster Health %', | ||||
|     data: props.metricsData.health.data, | ||||
|     borderColor: 'rgb(255, 99, 132)', | ||||
|     fill: false | ||||
|   }] | ||||
| })); | ||||
| </script> | ||||
							
								
								
									
										71
									
								
								pvcweb-vue/src/components/NodeStatus.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								pvcweb-vue/src/components/NodeStatus.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| <template> | ||||
|   <div class="card mb-4"> | ||||
|     <div class="card-header"> | ||||
|       <h5 class="mb-0">Node Status</h5> | ||||
|     </div> | ||||
|     <div class="card-body"> | ||||
|       <div class="row"> | ||||
|         <template v-for="node in nodeData" :key="node.name"> | ||||
|           <div class="col-md-4 mb-3"> | ||||
|             <div class="card"> | ||||
|               <div class="card-body"> | ||||
|                 <h5 class="card-title">{{ node.name }}</h5> | ||||
|                 <p :class="['mb-2', getHealthClass(node.health)]"> | ||||
|                   Health: {{ node.health }}% | ||||
|                 </p> | ||||
|                 <p class="card-text"> | ||||
|                   <strong>State:</strong> {{ node.state }}<br> | ||||
|                   <strong>CPU:</strong> {{ node.cpu_cores }} cores ({{ node.cpu_load }}% load)<br> | ||||
|                   <strong>Memory:</strong> {{ formatMemory(node.memory_used) }}/{{ formatMemory(node.memory_total) }} ({{ node.memory_utilization }}%)<br> | ||||
|                   <strong>VMs:</strong> {{ node.vms.length }} {{ node.vms.length === 1 ? 'VM' : 'VMs' }} | ||||
|                 </p> | ||||
|                 <div v-if="node.vms.length > 0" class="small text-muted"> | ||||
|                   {{ node.vms.join(', ') }} | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </template> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| const props = defineProps({ | ||||
|   nodeData: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
|     default: () => [] | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const getHealthClass = (health) => { | ||||
|   if (health > 90) return 'status-healthy'; | ||||
|   if (health > 50) return 'status-warning'; | ||||
|   return 'status-error'; | ||||
| }; | ||||
|  | ||||
| const formatMemory = (bytes) => { | ||||
|   if (bytes === 0) return '0GB'; | ||||
|   const gb = bytes / (1024 * 1024 * 1024); | ||||
|   return `${Math.round(gb)}GB`; | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .card-title { | ||||
|   font-size: 1.1rem; | ||||
|   margin-bottom: 0.75rem; | ||||
| } | ||||
|  | ||||
| .card-text { | ||||
|   font-size: 0.9rem; | ||||
|   line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .small { | ||||
|   font-size: 0.8rem; | ||||
|   word-break: break-all; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										13
									
								
								pvcweb-vue/src/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								pvcweb-vue/src/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { createApp } from 'vue'; | ||||
| import { createPinia } from 'pinia'; | ||||
| import App from './App.vue'; | ||||
|  | ||||
| // Create the app | ||||
| const app = createApp(App); | ||||
|  | ||||
| // Create and use Pinia | ||||
| const pinia = createPinia(); | ||||
| app.use(pinia); | ||||
|  | ||||
| // Mount the app | ||||
| app.mount('#app'); | ||||
							
								
								
									
										80
									
								
								pvcweb-vue/src/stores/api.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								pvcweb-vue/src/stores/api.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| import { defineStore } from 'pinia'; | ||||
| import { ref, computed } from 'vue'; | ||||
|  | ||||
| export const useApiStore = defineStore('api', () => { | ||||
|   // State | ||||
|   const config = ref({ | ||||
|     apiUri: localStorage.getItem('pvc_api_uri') || '', | ||||
|     apiKey: localStorage.getItem('pvc_api_key') || '', | ||||
|     updateInterval: parseInt(localStorage.getItem('pvc_update_interval')) || 5000 | ||||
|   }); | ||||
|  | ||||
|   // Computed | ||||
|   const isConfigured = computed(() => { | ||||
|     return Boolean(config.value.apiUri && config.value.apiKey); | ||||
|   }); | ||||
|  | ||||
|   // Actions | ||||
|   const updateConfig = (newConfig) => { | ||||
|     config.value = { ...newConfig }; | ||||
|      | ||||
|     // Save to localStorage | ||||
|     localStorage.setItem('pvc_api_uri', newConfig.apiUri); | ||||
|     localStorage.setItem('pvc_api_key', newConfig.apiKey); | ||||
|     localStorage.setItem('pvc_update_interval', newConfig.updateInterval.toString()); | ||||
|   }; | ||||
|  | ||||
|   const fetchStatus = async () => { | ||||
|     try { | ||||
|       const response = await fetch('/api/status', { | ||||
|         headers: { | ||||
|           'X-API-URI': config.value.apiUri, | ||||
|           'X-API-Key': config.value.apiKey | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP error! status: ${response.status}`); | ||||
|       } | ||||
|  | ||||
|       return await response.json(); | ||||
|     } catch (error) { | ||||
|       console.error('Error fetching cluster status:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const fetchNodes = async () => { | ||||
|     try { | ||||
|       const response = await fetch('/api/node', { | ||||
|         headers: { | ||||
|           'X-API-URI': config.value.apiUri, | ||||
|           'X-API-Key': config.value.apiKey | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP error! status: ${response.status}`); | ||||
|       } | ||||
|  | ||||
|       const data = await response.json(); | ||||
|       return Object.entries(data).map(([name, details]) => ({ | ||||
|         name, | ||||
|         ...details, | ||||
|         vms: Array.isArray(details.vms) ? details.vms :  | ||||
|              Object.keys(details.vms || {}) | ||||
|       })); | ||||
|     } catch (error) { | ||||
|       console.error('Error fetching node data:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     config, | ||||
|     isConfigured, | ||||
|     updateConfig, | ||||
|     fetchStatus, | ||||
|     fetchNodes | ||||
|   }; | ||||
| }); | ||||
							
								
								
									
										79
									
								
								pvcweb-vue/src/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								pvcweb-vue/src/style.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| :root { | ||||
|   font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | ||||
|   line-height: 1.5; | ||||
|   font-weight: 400; | ||||
|  | ||||
|   color-scheme: light dark; | ||||
|   color: rgba(255, 255, 255, 0.87); | ||||
|   background-color: #242424; | ||||
|  | ||||
|   font-synthesis: none; | ||||
|   text-rendering: optimizeLegibility; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
| } | ||||
|  | ||||
| a { | ||||
|   font-weight: 500; | ||||
|   color: #646cff; | ||||
|   text-decoration: inherit; | ||||
| } | ||||
| a:hover { | ||||
|   color: #535bf2; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   margin: 0; | ||||
|   display: flex; | ||||
|   place-items: center; | ||||
|   min-width: 320px; | ||||
|   min-height: 100vh; | ||||
| } | ||||
|  | ||||
| h1 { | ||||
|   font-size: 3.2em; | ||||
|   line-height: 1.1; | ||||
| } | ||||
|  | ||||
| button { | ||||
|   border-radius: 8px; | ||||
|   border: 1px solid transparent; | ||||
|   padding: 0.6em 1.2em; | ||||
|   font-size: 1em; | ||||
|   font-weight: 500; | ||||
|   font-family: inherit; | ||||
|   background-color: #1a1a1a; | ||||
|   cursor: pointer; | ||||
|   transition: border-color 0.25s; | ||||
| } | ||||
| button:hover { | ||||
|   border-color: #646cff; | ||||
| } | ||||
| button:focus, | ||||
| button:focus-visible { | ||||
|   outline: 4px auto -webkit-focus-ring-color; | ||||
| } | ||||
|  | ||||
| .card { | ||||
|   padding: 2em; | ||||
| } | ||||
|  | ||||
| #app { | ||||
|   max-width: 1280px; | ||||
|   margin: 0 auto; | ||||
|   padding: 2rem; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: light) { | ||||
|   :root { | ||||
|     color: #213547; | ||||
|     background-color: #ffffff; | ||||
|   } | ||||
|   a:hover { | ||||
|     color: #747bff; | ||||
|   } | ||||
|   button { | ||||
|     background-color: #f9f9f9; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										39
									
								
								pvcweb-vue/vite.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								pvcweb-vue/vite.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { defineConfig } from 'vite'; | ||||
| import vue from '@vitejs/plugin-vue'; | ||||
|  | ||||
| export default defineConfig({ | ||||
|   plugins: [vue()], | ||||
|   server: { | ||||
|     proxy: { | ||||
|       '/api': { | ||||
|         target: 'http://localhost:7370', | ||||
|         changeOrigin: true, | ||||
|         configure: (proxy, options) => { | ||||
|           proxy.on('proxyReq', (proxyReq, req, res) => { | ||||
|             const apiUri = req.headers['x-api-uri']; | ||||
|             if (apiUri) { | ||||
|               const targetUrl = new URL(apiUri); | ||||
|               options.target = targetUrl.origin; | ||||
|               proxyReq.setHeader('host', targetUrl.host); | ||||
|                | ||||
|               // Rewrite path to include the API URI path | ||||
|               const apiPath = targetUrl.pathname; | ||||
|               const requestPath = req.url.replace('/api', ''); | ||||
|               proxyReq.path = `${apiPath}${requestPath}`; | ||||
|  | ||||
|               console.log(`Proxying to: ${options.target}${proxyReq.path}`); | ||||
|             } | ||||
|  | ||||
|             if (req.headers['x-api-key']) { | ||||
|               proxyReq.setHeader('X-Api-Key', req.headers['x-api-key']); | ||||
|             } | ||||
|           }); | ||||
|  | ||||
|           proxy.on('error', (err, req, res) => { | ||||
|             console.error('Proxy error:', err); | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user