Initial commit of dashboard project

This commit is contained in:
Joshua Boniface 2025-02-26 16:29:13 -05:00
commit 24ddcf5060
19 changed files with 2332 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
pvcweb-vue/node_modules

24
pvcweb-vue/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
pvcweb-vue/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

23
pvcweb-vue/package.json Normal file
View 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
View 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}`);
});

View 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
View 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>

View 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

View 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>

View 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>

View 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>

View 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
View 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');

View 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
View 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
View 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);
});
}
}
}
}
});