feat: première version fonctionnelle

This commit is contained in:
2025-08-27 06:30:16 +02:00
commit cf8a37f183
39 changed files with 2730 additions and 0 deletions

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/tomato-favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zebra Power - WOL & Proxmox Manager</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "zebra-power-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.2",
"@headlessui/vue": "^1.7.16",
"@heroicons/vue": "^2.0.18"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.8",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
🍅

View File

@@ -0,0 +1,43 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Tomate verte zebra optimisée pour favicon -->
<defs>
<radialGradient id="tomatoGradient" cx="0.3" cy="0.3" r="0.8">
<stop offset="0%" stop-color="#86efac"/>
<stop offset="40%" stop-color="#4ade80"/>
<stop offset="70%" stop-color="#22c55e"/>
<stop offset="100%" stop-color="#16a34a"/>
</radialGradient>
<pattern id="stripes" patternUnits="userSpaceOnUse" width="4" height="4" patternTransform="rotate(15)">
<rect width="4" height="2" fill="#4ade80"/>
<rect y="2" width="4" height="2" fill="#22c55e"/>
</pattern>
</defs>
<!-- Ombre -->
<ellipse cx="33" cy="42" rx="18" ry="15" fill="#000000" opacity="0.15"/>
<!-- Corps principal de la tomate -->
<ellipse cx="32" cy="38" rx="20" ry="16" fill="url(#tomatoGradient)"/>
<!-- Rayures zebra -->
<ellipse cx="32" cy="38" rx="20" ry="16" fill="url(#stripes)" opacity="0.4"/>
<!-- Highlight principal -->
<ellipse cx="26" cy="32" rx="8" ry="6" fill="#86efac" opacity="0.7"/>
<!-- Petit highlight -->
<ellipse cx="24" cy="30" rx="3" ry="2" fill="#bbf7d0" opacity="0.8"/>
<!-- Tige -->
<rect x="30" y="20" width="4" height="8" fill="#16a34a" rx="2"/>
<rect x="29" y="19" width="6" height="3" fill="#15803d" rx="1"/>
<!-- Feuilles stylisées -->
<path d="M20 24 Q15 18 12 22 Q16 20 22 24 Q20 22 20 24" fill="#22c55e"/>
<path d="M44 24 Q49 18 52 22 Q48 20 42 24 Q44 22 44 24" fill="#22c55e"/>
<path d="M32 20 Q28 14 24 18 Q28 16 32 20" fill="#15803d"/>
<path d="M32 20 Q36 14 40 18 Q36 16 32 20" fill="#15803d"/>
<!-- Contour subtil -->
<ellipse cx="32" cy="38" rx="20" ry="16" fill="none" stroke="#16a34a" stroke-width="1" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,78 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Grande image de tomate verte zebra -->
<defs>
<radialGradient id="mainGradient" cx="0.35" cy="0.25" r="0.9">
<stop offset="0%" stop-color="#bbf7d0"/>
<stop offset="20%" stop-color="#86efac"/>
<stop offset="50%" stop-color="#4ade80"/>
<stop offset="75%" stop-color="#22c55e"/>
<stop offset="100%" stop-color="#16a34a"/>
</radialGradient>
<pattern id="zebraStripes" patternUnits="userSpaceOnUse" width="8" height="8" patternTransform="rotate(20)">
<rect width="8" height="4" fill="#4ade80"/>
<rect y="4" width="8" height="4" fill="#16a34a"/>
</pattern>
<radialGradient id="leafGradient" cx="0.3" cy="0.3" r="0.8">
<stop offset="0%" stop-color="#4ade80"/>
<stop offset="100%" stop-color="#15803d"/>
</radialGradient>
</defs>
<!-- Ombre portée -->
<ellipse cx="135" cy="180" rx="80" ry="60" fill="#000000" opacity="0.1"/>
<!-- Corps principal de la tomate -->
<ellipse cx="128" cy="160" rx="90" ry="70" fill="url(#mainGradient)"/>
<!-- Rayures zebra -->
<ellipse cx="128" cy="160" rx="90" ry="70" fill="url(#zebraStripes)" opacity="0.3"/>
<!-- Highlights pour le volume -->
<ellipse cx="105" cy="130" rx="35" ry="25" fill="#86efac" opacity="0.6"/>
<ellipse cx="95" cy="120" rx="18" ry="12" fill="#bbf7d0" opacity="0.8"/>
<ellipse cx="88" cy="115" rx="8" ry="5" fill="#dcfce7"/>
<!-- Tige principale -->
<rect x="120" y="70" width="16" height="35" fill="#16a34a" rx="8"/>
<rect x="118" y="65" width="20" height="12" fill="#15803d" rx="4"/>
<!-- Sépales à la base de la tige -->
<path d="M128 90 L118 95 Q110 98 108 105 Q115 100 128 95 Q141 100 148 105 Q146 98 138 95 Z" fill="#15803d"/>
<!-- Feuilles complexes -->
<g transform="translate(128,80)">
<!-- Feuille gauche -->
<path d="M-25 -10 Q-45 -25 -55 -15 Q-50 -30 -35 -35 Q-30 -25 -25 -20 Q-20 -15 -25 -10"
fill="url(#leafGradient)"/>
<path d="M-30 -20 Q-40 -15 -35 -10" stroke="#15803d" stroke-width="2" fill="none"/>
<!-- Feuille droite -->
<path d="M25 -10 Q45 -25 55 -15 Q50 -30 35 -35 Q30 -25 25 -20 Q20 -15 25 -10"
fill="url(#leafGradient)"/>
<path d="M30 -20 Q40 -15 35 -10" stroke="#15803d" stroke-width="2" fill="none"/>
<!-- Feuille arrière gauche -->
<path d="M-15 -25 Q-35 -40 -45 -30 Q-40 -45 -25 -50 Q-20 -40 -15 -35 Q-10 -30 -15 -25"
fill="url(#leafGradient)" opacity="0.8"/>
<!-- Feuille arrière droite -->
<path d="M15 -25 Q35 -40 45 -30 Q40 -45 25 -50 Q20 -40 15 -35 Q10 -30 15 -25"
fill="url(#leafGradient)" opacity="0.8"/>
</g>
<!-- Nervures des feuilles principales -->
<path d="M103 70 Q88 55 73 70" stroke="#15803d" stroke-width="1.5" fill="none" opacity="0.7"/>
<path d="M153 70 Q168 55 183 70" stroke="#15803d" stroke-width="1.5" fill="none" opacity="0.7"/>
<!-- Détails de texture sur la tomate -->
<path d="M70 140 Q80 135 90 140 Q85 145 70 140" fill="#16a34a" opacity="0.2"/>
<path d="M166 140 Q176 135 186 140 Q181 145 166 140" fill="#16a34a" opacity="0.2"/>
<path d="M128 210 Q138 205 148 210 Q143 215 128 210" fill="#16a34a" opacity="0.2"/>
<!-- Contour subtil -->
<ellipse cx="128" cy="160" rx="90" ry="70" fill="none" stroke="#16a34a" stroke-width="2" opacity="0.3"/>
<!-- Reflets finaux -->
<ellipse cx="100" cy="125" rx="12" ry="8" fill="#f0fdf4" opacity="0.5"/>
<ellipse cx="98" cy="123" rx="4" ry="2" fill="#ffffff" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,28 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Tomate verte zebra avec rayures -->
<defs>
<pattern id="zebraPattern" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="2" height="1" fill="#4ade80"/>
<rect y="1" width="2" height="1" fill="#22c55e"/>
</pattern>
</defs>
<!-- Corps de la tomate -->
<ellipse cx="16" cy="18" rx="12" ry="10" fill="url(#zebraPattern)" stroke="#16a34a" stroke-width="0.5"/>
<!-- Highlight pour donner du volume -->
<ellipse cx="13" cy="15" rx="4" ry="3" fill="#86efac" opacity="0.6"/>
<!-- Tige et feuilles -->
<rect x="15" y="8" width="2" height="4" fill="#16a34a" rx="1"/>
<!-- Feuilles -->
<path d="M12 10 Q10 8 8 10 Q10 9 12 10" fill="#22c55e"/>
<path d="M20 10 Q22 8 24 10 Q22 9 20 10" fill="#22c55e"/>
<path d="M16 8 Q14 6 12 8 Q14 7 16 8" fill="#15803d"/>
<path d="M16 8 Q18 6 20 8 Q18 7 16 8" fill="#15803d"/>
<!-- Détails des rayures zebra -->
<path d="M6 18 Q8 16 10 18 Q8 20 6 18" fill="#16a34a" opacity="0.3"/>
<path d="M22 18 Q24 16 26 18 Q24 20 22 18" fill="#16a34a" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

46
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,46 @@
<template>
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<div class="flex items-center space-x-2">
<img src="/tomato.svg" alt="Zebra Power" class="w-8 h-8" />
<h1 class="text-xl font-bold text-gray-900">Zebra Power</h1>
</div>
</div>
<div class="flex space-x-8">
<router-link
to="/"
class="inline-flex items-center px-1 pt-1 text-sm font-medium"
:class="$route.name === 'Dashboard' ? 'border-b-2 border-blue-500 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
>
Dashboard
</router-link>
<router-link
to="/servers"
class="inline-flex items-center px-1 pt-1 text-sm font-medium"
:class="$route.name === 'Servers' ? 'border-b-2 border-blue-500 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
>
Serveurs
</router-link>
<router-link
to="/proxmox"
class="inline-flex items-center px-1 pt-1 text-sm font-medium"
:class="$route.name === 'Proxmox' ? 'border-b-2 border-blue-500 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
>
Proxmox
</router-link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<router-view />
</main>
</div>
</template>
<script setup>
</script>

27
frontend/src/main.js Normal file
View File

@@ -0,0 +1,27 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import './style.css'
import Dashboard from './views/Dashboard.vue'
import Servers from './views/Servers.vue'
import Proxmox from './views/Proxmox.vue'
const routes = [
{ path: '/', name: 'Dashboard', component: Dashboard },
{ path: '/servers', name: 'Servers', component: Servers },
{ path: '/proxmox', name: 'Proxmox', component: Proxmox }
]
const router = createRouter({
history: createWebHistory(),
routes
})
const pinia = createPinia()
createApp(App)
.use(pinia)
.use(router)
.mount('#app')

View File

@@ -0,0 +1,46 @@
import axios from 'axios'
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const api = axios.create({
baseURL: apiUrl,
timeout: 10000,
})
api.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error)
return Promise.reject(error)
}
)
export const serversApi = {
getAll: () => api.get('/servers'),
create: (server) => api.post('/servers', server),
update: (id, server) => api.put(`/servers/${id}`, server),
delete: (id) => api.delete(`/servers/${id}`),
checkStatus: () => api.post('/servers/check-status'),
}
export const wolApi = {
wake: (serverId) => api.post(`/wol/wake/${serverId}`),
ping: (serverId) => api.post(`/wol/ping/${serverId}`),
getLogs: (limit = 50) => api.get(`/wol/logs?limit=${limit}`),
getServerLogs: (serverId, limit = 20) => api.get(`/wol/logs/${serverId}?limit=${limit}`),
getAllLogs: (limit = 100) => api.get(`/wol/all-logs?limit=${limit}`),
getLogsByType: (type, limit = 50) => api.get(`/wol/all-logs/${type}?limit=${limit}`),
}
export const proxmoxApi = {
getClusters: () => api.get('/proxmox/clusters'),
createCluster: (cluster) => api.post('/proxmox/clusters', cluster),
deleteCluster: (id) => api.delete(`/proxmox/clusters/${id}`),
getVMs: (clusterId) => api.get(`/proxmox/clusters/${clusterId}/vms`),
startVM: (clusterId, vmid, node, vmType = 'qemu') =>
api.post(`/proxmox/clusters/${clusterId}/vms/${vmid}/start?node=${node}&vm_type=${vmType}`),
stopVM: (clusterId, vmid, node, vmType = 'qemu') =>
api.post(`/proxmox/clusters/${clusterId}/vms/${vmid}/stop?node=${node}&vm_type=${vmType}`),
}
export default api

3
frontend/src/style.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

View File

@@ -0,0 +1,274 @@
<template>
<div class="px-4 py-6">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
<span class="text-white font-medium">S</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Serveurs</dt>
<dd class="text-lg font-medium text-gray-900">{{ servers.length }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
<span class="text-white font-medium"></span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">En ligne</dt>
<dd class="text-lg font-medium text-gray-900">{{ onlineServers }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-purple-500 rounded-md flex items-center justify-center">
<span class="text-white font-medium">P</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Clusters Proxmox</dt>
<dd class="text-lg font-medium text-gray-900">{{ clusters.length }}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Serveurs</h3>
<div class="space-y-3">
<div v-for="server in servers.slice(0, 5)" :key="server.id" class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div
class="w-3 h-3 rounded-full"
:class="server.is_online ? 'bg-green-400' : 'bg-red-400'"
></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ server.name }}</p>
<p class="text-sm text-gray-500">{{ server.ip_address }}</p>
</div>
</div>
<button
@click="wakeServer(server.id)"
:disabled="loading"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Wake
</button>
</div>
<div v-if="servers.length === 0" class="text-gray-500 text-sm">
Aucun serveur configuré
</div>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">VMs/Containers</h3>
<div class="space-y-3">
<div v-for="vm in allVMs.slice(0, 5)" :key="`${vm.clusterId}-${vm.node}-${vm.vmid}`" class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div
class="w-3 h-3 rounded-full"
:class="vm.status === 'running' ? 'bg-green-400' : 'bg-red-400'"
></div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ vm.name }}</p>
<p class="text-sm text-gray-500">{{ vm.type.toUpperCase() }} #{{ vm.vmid }} - {{ vm.node }}</p>
</div>
</div>
<div class="flex space-x-1">
<button
v-if="vm.status !== 'running'"
@click="startVM(vm.clusterId, vm.vmid, vm.node, vm.type)"
:disabled="loading"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
Start
</button>
<button
v-if="vm.status === 'running'"
@click="stopVM(vm.clusterId, vm.vmid, vm.node, vm.type)"
:disabled="loading"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
Stop
</button>
</div>
</div>
<div v-if="allVMs.length === 0" class="text-gray-500 text-sm">
Aucune VM/Container disponible
</div>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Logs récents</h3>
<div class="space-y-3">
<div v-for="log in logs.slice(0, 5)" :key="log.id" class="flex items-center">
<div class="flex-shrink-0">
<div
class="w-3 h-3 rounded-full"
:class="log.success ? 'bg-green-400' : 'bg-red-400'"
></div>
</div>
<div class="ml-3 min-w-0 flex-1">
<p class="text-sm text-gray-900">{{ getLogDisplayText(log) }}</p>
<p class="text-sm text-gray-500">{{ formatDate(log.timestamp) }}</p>
</div>
</div>
<div v-if="logs.length === 0" class="text-gray-500 text-sm">
Aucun log disponible
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { serversApi, proxmoxApi, wolApi } from '../services/api'
const servers = ref([])
const clusters = ref([])
const logs = ref([])
const allVMs = ref([])
const loading = ref(false)
const onlineServers = computed(() =>
servers.value.filter(server => server.is_online).length
)
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('fr-FR')
}
const getLogDisplayText = (log) => {
if (log.target_name) {
return `${log.action} - ${log.target_name} (${log.action_type})`
}
// Fallback pour les anciens logs WOL
return `${log.action} - Serveur #${log.server_id || log.target_id}`
}
const loadData = async () => {
try {
// D'abord vérifier le statut des serveurs
await serversApi.checkStatus()
const [serversResponse, clustersResponse, logsResponse] = await Promise.all([
serversApi.getAll(),
proxmoxApi.getClusters(),
wolApi.getAllLogs(10)
])
servers.value = serversResponse.data
clusters.value = clustersResponse.data
logs.value = logsResponse.data
// Charger les VMs de tous les clusters
const vms = []
for (const cluster of clustersResponse.data) {
try {
const vmsResponse = await proxmoxApi.getVMs(cluster.id)
for (const vm of vmsResponse.data) {
vms.push({
...vm,
clusterId: cluster.id
})
}
} catch (error) {
console.error(`Erreur lors du chargement des VMs du cluster ${cluster.id}:`, error)
}
}
allVMs.value = vms
} catch (error) {
console.error('Erreur lors du chargement des données:', error)
}
}
const wakeServer = async (serverId) => {
loading.value = true
try {
await wolApi.wake(serverId)
await loadData()
} catch (error) {
console.error('Erreur lors du réveil du serveur:', error)
} finally {
loading.value = false
}
}
const startVM = async (clusterId, vmid, node, vmType) => {
loading.value = true
try {
await proxmoxApi.startVM(clusterId, vmid, node, vmType)
await loadData()
} catch (error) {
console.error('Erreur lors du démarrage de la VM:', error)
} finally {
loading.value = false
}
}
const stopVM = async (clusterId, vmid, node, vmType) => {
loading.value = true
try {
await proxmoxApi.stopVM(clusterId, vmid, node, vmType)
await loadData()
} catch (error) {
console.error('Erreur lors de l\'arrêt de la VM:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
// Mise à jour automatique toutes les 30 secondes
const interval = setInterval(() => {
loadData()
}, 30000)
// Nettoyer l'interval quand le composant est démonté
onBeforeUnmount(() => {
clearInterval(interval)
})
})
</script>

View File

@@ -0,0 +1,305 @@
<template>
<div class="px-4 py-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">Clusters Proxmox</h1>
<p class="mt-2 text-sm text-gray-700">
Gérez vos clusters Proxmox et contrôlez vos VMs/Containers
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
@click="showAddModal = true"
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:w-auto"
>
Ajouter un cluster
</button>
</div>
</div>
<!-- Liste des clusters -->
<div class="mt-8 space-y-6">
<div v-for="cluster in clusters" :key="cluster.id" class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-medium text-gray-900">{{ cluster.name }}</h3>
<p class="text-sm text-gray-500">{{ cluster.host }}:{{ cluster.port }}</p>
</div>
<div class="flex space-x-2">
<button
@click="loadVMs(cluster.id)"
:disabled="loading"
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Actualiser
</button>
<button
@click="deleteCluster(cluster.id)"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
Supprimer
</button>
</div>
</div>
<!-- Liste des VMs -->
<div v-if="clusterVMs[cluster.id]" class="mt-4">
<h4 class="text-md font-medium text-gray-900 mb-3">VMs et Containers</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="vm in clusterVMs[cluster.id]"
:key="`${vm.node}-${vm.vmid}`"
class="border rounded-lg p-4"
:class="vm.status === 'running' ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50'"
>
<div class="flex items-center justify-between">
<div>
<h5 class="text-sm font-medium text-gray-900">{{ vm.name }}</h5>
<p class="text-xs text-gray-500">{{ vm.type.toUpperCase() }} #{{ vm.vmid }}</p>
<p class="text-xs text-gray-500">Node: {{ vm.node }}</p>
</div>
<div>
<span
class="inline-flex px-2 py-1 text-xs font-medium rounded-full"
:class="vm.status === 'running' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
>
{{ vm.status }}
</span>
</div>
</div>
<div class="mt-3 flex space-x-2">
<button
v-if="vm.status !== 'running'"
@click="startVM(cluster.id, vm.vmid, vm.node, vm.type)"
:disabled="loading"
class="flex-1 inline-flex justify-center items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
Démarrer
</button>
<button
v-if="vm.status === 'running'"
@click="stopVM(cluster.id, vm.vmid, vm.node, vm.type)"
:disabled="loading"
class="flex-1 inline-flex justify-center items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
>
Arrêter
</button>
</div>
</div>
</div>
</div>
<div v-else class="mt-4 text-center text-gray-500">
<button
@click="loadVMs(cluster.id)"
:disabled="loading"
class="text-blue-600 hover:text-blue-500 disabled:opacity-50"
>
Charger les VMs/Containers
</button>
</div>
</div>
</div>
<div v-if="clusters.length === 0" class="text-center text-gray-500 py-8">
Aucun cluster Proxmox configuré
</div>
</div>
<!-- Modal d'ajout de cluster -->
<div v-if="showAddModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 mb-4">Ajouter un cluster Proxmox</h3>
<form @submit.prevent="saveCluster">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Nom du cluster</label>
<input
v-model="clusterForm.name"
type="text"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Host</label>
<input
v-model="clusterForm.host"
type="text"
placeholder="192.168.1.100"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Nom d'utilisateur</label>
<input
v-model="clusterForm.username"
type="text"
placeholder="root@pam"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Mot de passe</label>
<input
v-model="clusterForm.password"
type="password"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Port</label>
<input
v-model="clusterForm.port"
type="number"
value="8006"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="flex items-center">
<input
v-model="clusterForm.verify_ssl"
type="checkbox"
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
>
<span class="ml-2 text-sm text-gray-700">Vérifier SSL</span>
</label>
</div>
<div class="flex items-center justify-end space-x-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
:disabled="loading"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Ajouter
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { proxmoxApi } from '../services/api'
const clusters = ref([])
const clusterVMs = ref({})
const loading = ref(false)
const showAddModal = ref(false)
const clusterForm = reactive({
name: '',
host: '',
username: '',
password: '',
port: 8006,
verify_ssl: true
})
const loadClusters = async () => {
try {
const response = await proxmoxApi.getClusters()
clusters.value = response.data
// Auto-charger les VMs pour chaque cluster
for (const cluster of response.data) {
await loadVMs(cluster.id)
}
} catch (error) {
console.error('Erreur lors du chargement des clusters:', error)
}
}
const saveCluster = async () => {
loading.value = true
try {
await proxmoxApi.createCluster(clusterForm)
await loadClusters()
closeModal()
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
alert('Erreur lors de la connexion au cluster Proxmox. Vérifiez les paramètres.')
} finally {
loading.value = false
}
}
const deleteCluster = async (clusterId) => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce cluster ?')) {
try {
await proxmoxApi.deleteCluster(clusterId)
delete clusterVMs.value[clusterId]
await loadClusters()
} catch (error) {
console.error('Erreur lors de la suppression:', error)
}
}
}
const loadVMs = async (clusterId) => {
loading.value = true
try {
const response = await proxmoxApi.getVMs(clusterId)
clusterVMs.value[clusterId] = response.data
} catch (error) {
console.error('Erreur lors du chargement des VMs:', error)
} finally {
loading.value = false
}
}
const startVM = async (clusterId, vmid, node, vmType) => {
loading.value = true
try {
await proxmoxApi.startVM(clusterId, vmid, node, vmType)
await loadVMs(clusterId)
} catch (error) {
console.error('Erreur lors du démarrage de la VM:', error)
} finally {
loading.value = false
}
}
const stopVM = async (clusterId, vmid, node, vmType) => {
loading.value = true
try {
await proxmoxApi.stopVM(clusterId, vmid, node, vmType)
await loadVMs(clusterId)
} catch (error) {
console.error('Erreur lors de l\'arrêt de la VM:', error)
} finally {
loading.value = false
}
}
const closeModal = () => {
showAddModal.value = false
Object.assign(clusterForm, {
name: '',
host: '',
username: '',
password: '',
port: 8006,
verify_ssl: true
})
}
onMounted(() => {
loadClusters()
})
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div class="px-4 py-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-bold text-gray-900">Serveurs</h1>
<p class="mt-2 text-sm text-gray-700">
Gérez vos serveurs et envoyez des paquets Wake-on-LAN
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
@click="showAddModal = true"
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:w-auto"
>
Ajouter un serveur
</button>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">
Nom
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
IP
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
MAC
</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Statut
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="server in servers" :key="server.id">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{{ server.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{{ server.ip_address }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{{ server.mac_address }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span
class="inline-flex px-2 py-1 text-xs font-medium rounded-full"
:class="server.is_online ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
>
{{ server.is_online ? 'En ligne' : 'Hors ligne' }}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button
@click="wakeServer(server.id)"
:disabled="loading"
class="text-blue-600 hover:text-blue-900 mr-4 disabled:opacity-50"
>
Wake
</button>
<button
@click="pingServer(server.id)"
:disabled="loading"
class="text-green-600 hover:text-green-900 mr-4 disabled:opacity-50"
>
Ping
</button>
<button
@click="editServer(server)"
class="text-indigo-600 hover:text-indigo-900 mr-4"
>
Modifier
</button>
<button
@click="deleteServer(server.id)"
class="text-red-600 hover:text-red-900"
>
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal d'ajout/modification -->
<div v-if="showAddModal || editingServer" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 mb-4">
{{ editingServer ? 'Modifier le serveur' : 'Ajouter un serveur' }}
</h3>
<form @submit.prevent="saveServer">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Nom</label>
<input
v-model="serverForm.name"
type="text"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Adresse IP</label>
<input
v-model="serverForm.ip_address"
type="text"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Adresse MAC</label>
<input
v-model="serverForm.mac_address"
type="text"
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea
v-model="serverForm.description"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
></textarea>
</div>
<div class="flex items-center justify-end space-x-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
:disabled="loading"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ editingServer ? 'Modifier' : 'Ajouter' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { serversApi, wolApi } from '../services/api'
const servers = ref([])
const loading = ref(false)
const showAddModal = ref(false)
const editingServer = ref(null)
const serverForm = reactive({
name: '',
ip_address: '',
mac_address: '',
description: ''
})
const loadServers = async () => {
try {
const response = await serversApi.getAll()
servers.value = response.data
} catch (error) {
console.error('Erreur lors du chargement des serveurs:', error)
}
}
const saveServer = async () => {
loading.value = true
try {
if (editingServer.value) {
await serversApi.update(editingServer.value.id, serverForm)
} else {
await serversApi.create(serverForm)
}
await loadServers()
closeModal()
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
} finally {
loading.value = false
}
}
const editServer = (server) => {
editingServer.value = server
Object.assign(serverForm, server)
}
const deleteServer = async (serverId) => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce serveur ?')) {
try {
await serversApi.delete(serverId)
await loadServers()
} catch (error) {
console.error('Erreur lors de la suppression:', error)
}
}
}
const wakeServer = async (serverId) => {
loading.value = true
try {
await wolApi.wake(serverId)
await loadServers()
} catch (error) {
console.error('Erreur lors du réveil du serveur:', error)
} finally {
loading.value = false
}
}
const pingServer = async (serverId) => {
loading.value = true
try {
await wolApi.ping(serverId)
await loadServers()
} catch (error) {
console.error('Erreur lors du ping:', error)
} finally {
loading.value = false
}
}
const closeModal = () => {
showAddModal.value = false
editingServer.value = null
Object.assign(serverForm, {
name: '',
ip_address: '',
mac_address: '',
description: ''
})
}
onMounted(() => {
loadServers()
})
</script>

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 3000
}
})