Initial commit of dashboard project
This commit is contained in:
commit
24ddcf5060
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
pvcweb-vue/node_modules
|
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user