refact: use only server
This commit is contained in:
@@ -1,45 +1,5 @@
|
||||
<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>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
123
frontend/src/composables/useDarkMode.js
Normal file
123
frontend/src/composables/useDarkMode.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
export function useDarkMode() {
|
||||
const isDark = ref(false)
|
||||
const preference = ref('system') // 'light', 'dark', 'system'
|
||||
|
||||
|
||||
// Apply dark mode to document
|
||||
const applyDarkMode = (dark) => {
|
||||
const htmlElement = document.documentElement
|
||||
if (!htmlElement) return
|
||||
|
||||
console.log('Before:', htmlElement.classList.contains('dark'), 'classes:', htmlElement.className)
|
||||
|
||||
if (dark) {
|
||||
htmlElement.classList.add('dark')
|
||||
} else {
|
||||
htmlElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
console.log('After:', htmlElement.classList.contains('dark'), 'classes:', htmlElement.className)
|
||||
|
||||
// Force reflow to ensure class is applied immediately
|
||||
htmlElement.offsetHeight
|
||||
}
|
||||
|
||||
// Check system preference
|
||||
const getSystemPreference = () => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
// Update dark mode based on preference
|
||||
const updateDarkMode = () => {
|
||||
let shouldBeDark = false
|
||||
|
||||
switch (preference.value) {
|
||||
case 'light':
|
||||
shouldBeDark = false
|
||||
break
|
||||
case 'dark':
|
||||
shouldBeDark = true
|
||||
break
|
||||
case 'system':
|
||||
default:
|
||||
shouldBeDark = getSystemPreference()
|
||||
break
|
||||
}
|
||||
|
||||
console.log('UpdateDarkMode - preference:', preference.value, 'shouldBeDark:', shouldBeDark, 'system pref:', getSystemPreference())
|
||||
isDark.value = shouldBeDark
|
||||
applyDarkMode(shouldBeDark)
|
||||
}
|
||||
|
||||
// Toggle between system/light/dark (better UX order)
|
||||
const toggleDarkMode = () => {
|
||||
const modes = ['system', 'light', 'dark']
|
||||
const currentIndex = modes.indexOf(preference.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
console.log('Toggle: from', preference.value, 'to', newMode)
|
||||
preference.value = newMode
|
||||
|
||||
// Force immediate update (in case watch doesn't trigger)
|
||||
setTimeout(() => {
|
||||
updateDarkMode()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Save preference to localStorage
|
||||
const savePreference = () => {
|
||||
localStorage.setItem('theme-preference', preference.value)
|
||||
}
|
||||
|
||||
// Load preference from localStorage
|
||||
const loadPreference = () => {
|
||||
const saved = localStorage.getItem('theme-preference')
|
||||
if (saved && ['light', 'dark', 'system'].includes(saved)) {
|
||||
preference.value = saved
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for preference changes
|
||||
watch(preference, () => {
|
||||
updateDarkMode()
|
||||
savePreference()
|
||||
})
|
||||
|
||||
// Watch for system preference changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleSystemChange = () => {
|
||||
if (preference.value === 'system') {
|
||||
updateDarkMode()
|
||||
}
|
||||
}
|
||||
|
||||
// Load preference and initialize immediately with DOM ready check
|
||||
if (typeof window !== 'undefined') {
|
||||
loadPreference()
|
||||
|
||||
// Ensure DOM is ready before applying theme
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateDarkMode)
|
||||
} else {
|
||||
updateDarkMode()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Listen for system preference changes
|
||||
mediaQuery.addEventListener('change', handleSystemChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mediaQuery.removeEventListener('change', handleSystemChange)
|
||||
})
|
||||
|
||||
return {
|
||||
isDark,
|
||||
preference,
|
||||
toggleDarkMode,
|
||||
updateDarkMode
|
||||
}
|
||||
}
|
||||
146
frontend/src/composables/usePullToRefresh.js
Normal file
146
frontend/src/composables/usePullToRefresh.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
|
||||
export function usePullToRefresh() {
|
||||
const isPulling = ref(false)
|
||||
const pullDistance = ref(0)
|
||||
const isRefreshing = ref(false)
|
||||
const pullThreshold = 80
|
||||
|
||||
const touchState = reactive({
|
||||
startY: 0,
|
||||
currentY: 0,
|
||||
isDragging: false,
|
||||
startScrollTop: 0
|
||||
})
|
||||
|
||||
let currentElement = null
|
||||
let onRefreshCallback = null
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
// Only start pull-to-refresh if we're at the top of the page
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
if (scrollTop > 0) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
touchState.startY = touch.clientY
|
||||
touchState.currentY = touch.clientY
|
||||
touchState.startScrollTop = scrollTop
|
||||
touchState.isDragging = true
|
||||
}
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
if (!touchState.isDragging) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
touchState.currentY = touch.clientY
|
||||
|
||||
const deltaY = touchState.currentY - touchState.startY
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
|
||||
// Only allow pull-to-refresh when at the top and pulling down
|
||||
if (scrollTop === 0 && deltaY > 0) {
|
||||
e.preventDefault() // Prevent overscroll bounce
|
||||
|
||||
isPulling.value = true
|
||||
pullDistance.value = Math.min(deltaY * 0.5, pullThreshold * 1.2) // Damping effect
|
||||
|
||||
// Visual feedback when threshold is reached
|
||||
if (pullDistance.value >= pullThreshold) {
|
||||
// Add haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!touchState.isDragging) return
|
||||
|
||||
touchState.isDragging = false
|
||||
|
||||
if (pullDistance.value >= pullThreshold && !isRefreshing.value) {
|
||||
// Trigger refresh
|
||||
isRefreshing.value = true
|
||||
|
||||
try {
|
||||
if (onRefreshCallback) {
|
||||
await onRefreshCallback()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Pull to refresh error:', error)
|
||||
} finally {
|
||||
// Smooth animation back to normal
|
||||
setTimeout(() => {
|
||||
isRefreshing.value = false
|
||||
isPulling.value = false
|
||||
pullDistance.value = 0
|
||||
}, 300)
|
||||
}
|
||||
} else {
|
||||
// Animate back to normal position
|
||||
isPulling.value = false
|
||||
pullDistance.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const addPullToRefreshListeners = (element, onRefresh) => {
|
||||
if (!element) return
|
||||
|
||||
currentElement = element
|
||||
onRefreshCallback = onRefresh
|
||||
|
||||
const touchStartHandler = (e) => {
|
||||
handleTouchStart(e)
|
||||
}
|
||||
|
||||
const touchMoveHandler = (e) => {
|
||||
handleTouchMove(e)
|
||||
}
|
||||
|
||||
const touchEndHandler = () => {
|
||||
handleTouchEnd()
|
||||
}
|
||||
|
||||
// Add listeners to window to capture all touch events
|
||||
window.addEventListener('touchstart', touchStartHandler, { passive: false })
|
||||
window.addEventListener('touchmove', touchMoveHandler, { passive: false })
|
||||
window.addEventListener('touchend', touchEndHandler, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', touchStartHandler)
|
||||
window.removeEventListener('touchmove', touchMoveHandler)
|
||||
window.removeEventListener('touchend', touchEndHandler)
|
||||
}
|
||||
}
|
||||
|
||||
const pullToRefreshStyle = () => {
|
||||
if (!isPulling.value && !isRefreshing.value) return {}
|
||||
|
||||
return {
|
||||
transform: `translateY(${pullDistance.value}px)`,
|
||||
transition: touchState.isDragging ? 'none' : 'transform 0.3s ease-out'
|
||||
}
|
||||
}
|
||||
|
||||
const pullIndicatorStyle = () => {
|
||||
const opacity = Math.min(pullDistance.value / pullThreshold, 1)
|
||||
const rotation = (pullDistance.value / pullThreshold) * 180
|
||||
|
||||
return {
|
||||
opacity,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transition: touchState.isDragging ? 'none' : 'all 0.3s ease-out'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
isRefreshing,
|
||||
pullDistance,
|
||||
pullThreshold,
|
||||
addPullToRefreshListeners,
|
||||
pullToRefreshStyle,
|
||||
pullIndicatorStyle
|
||||
}
|
||||
}
|
||||
99
frontend/src/composables/useSwipe.js
Normal file
99
frontend/src/composables/useSwipe.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
export function useSwipe() {
|
||||
const touchStart = reactive({ x: 0, y: 0, time: 0 })
|
||||
const touchEnd = reactive({ x: 0, y: 0, time: 0 })
|
||||
const isSwipeActive = ref(false)
|
||||
|
||||
let currentElement = null
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
const touch = e.touches[0]
|
||||
touchStart.x = touch.clientX
|
||||
touchStart.y = touch.clientY
|
||||
touchStart.time = Date.now()
|
||||
isSwipeActive.value = true
|
||||
}
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
if (!isSwipeActive.value) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const deltaX = touch.clientX - touchStart.x
|
||||
const deltaY = touch.clientY - touchStart.y
|
||||
|
||||
// Prevent vertical scrolling during horizontal swipe
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 20) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
if (!isSwipeActive.value) return
|
||||
|
||||
const touch = e.changedTouches[0]
|
||||
touchEnd.x = touch.clientX
|
||||
touchEnd.y = touch.clientY
|
||||
touchEnd.time = Date.now()
|
||||
|
||||
const deltaX = touchEnd.x - touchStart.x
|
||||
const deltaY = touchEnd.y - touchStart.y
|
||||
const deltaTime = touchEnd.time - touchStart.time
|
||||
|
||||
// Determine swipe direction and distance
|
||||
const minSwipeDistance = 50
|
||||
const maxSwipeTime = 300
|
||||
|
||||
if (Math.abs(deltaX) > minSwipeDistance && deltaTime < maxSwipeTime) {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
// Horizontal swipe
|
||||
if (deltaX > 0) {
|
||||
// Swipe right
|
||||
return { direction: 'right', distance: deltaX }
|
||||
} else {
|
||||
// Swipe left
|
||||
return { direction: 'left', distance: Math.abs(deltaX) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSwipeActive.value = false
|
||||
return null
|
||||
}
|
||||
|
||||
const addSwipeListeners = (element, onSwipe) => {
|
||||
if (!element) return
|
||||
|
||||
currentElement = element
|
||||
|
||||
const touchStartHandler = (e) => {
|
||||
handleTouchStart(e)
|
||||
}
|
||||
|
||||
const touchMoveHandler = (e) => {
|
||||
handleTouchMove(e)
|
||||
}
|
||||
|
||||
const touchEndHandler = (e) => {
|
||||
const result = handleTouchEnd(e)
|
||||
if (result && onSwipe) {
|
||||
onSwipe(result)
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('touchstart', touchStartHandler, { passive: false })
|
||||
element.addEventListener('touchmove', touchMoveHandler, { passive: false })
|
||||
element.addEventListener('touchend', touchEndHandler, { passive: false })
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', touchStartHandler)
|
||||
element.removeEventListener('touchmove', touchMoveHandler)
|
||||
element.removeEventListener('touchend', touchEndHandler)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSwipeActive,
|
||||
addSwipeListeners
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ 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'
|
||||
import Home from './views/Home.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard },
|
||||
{ path: '/servers', name: 'Servers', component: Servers },
|
||||
{ path: '/proxmox', name: 'Proxmox', component: Proxmox }
|
||||
{ path: '/', name: 'Home', component: Home },
|
||||
// Redirections pour compatibilité
|
||||
{ path: '/dashboard', redirect: '/' },
|
||||
{ path: '/hosts', redirect: '/' },
|
||||
{ path: '/servers', redirect: '/' },
|
||||
{ path: '/proxmox', redirect: '/' }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -15,32 +15,28 @@ api.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
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'),
|
||||
}
|
||||
// Anciens services supprimés - maintenant unifiés dans hostsApi
|
||||
|
||||
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}`),
|
||||
export const logsApi = {
|
||||
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 const hostsApi = {
|
||||
getAll: () => api.get('/hosts'),
|
||||
create: (host) => api.post('/hosts', host),
|
||||
get: (id) => api.get(`/hosts/${id}`),
|
||||
update: (id, host) => api.put(`/hosts/${id}`, host),
|
||||
delete: (id) => api.delete(`/hosts/${id}`),
|
||||
wake: (id) => api.post(`/hosts/${id}/wake`),
|
||||
shutdown: (id) => api.post(`/hosts/${id}/shutdown`),
|
||||
getVMs: (id) => api.get(`/hosts/${id}/vms`),
|
||||
startVM: (id, vmid, node, vmType = 'qemu') =>
|
||||
api.post(`/hosts/${id}/vms/${vmid}/start?node=${node}&vm_type=${vmType}`),
|
||||
stopVM: (id, vmid, node, vmType = 'qemu') =>
|
||||
api.post(`/hosts/${id}/vms/${vmid}/stop?node=${node}&vm_type=${vmType}`),
|
||||
checkStatus: () => api.post('/hosts/check-status'),
|
||||
checkHostStatus: (id) => api.get(`/hosts/${id}/status`),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -1,3 +1,362 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* Mobile-First Touch Optimizations */
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@apply bg-white text-gray-900 transition-colors duration-300;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
@apply bg-gray-900 text-gray-100;
|
||||
background-color: #111827 !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow-x: hidden;
|
||||
@apply bg-white text-gray-900 transition-colors duration-300;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-gray-900 text-gray-100;
|
||||
background-color: #111827 !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
/* Force light mode styles to be really light */
|
||||
.bg-white {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.bg-gray-200 {
|
||||
background-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
.border-gray-100 {
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* Force dark mode styles with higher specificity */
|
||||
.dark .bg-white {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-200 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-700 {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-800 {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-100 {
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #6b7280 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-600 {
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-700 {
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Force specific components to be light */
|
||||
.card-mobile {
|
||||
background-color: #ffffff !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.dark .card-mobile {
|
||||
background-color: #1f2937 !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Force host sections to be light */
|
||||
.host-card .p-4 {
|
||||
background-color: #ffffff !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.dark .host-card .p-4 {
|
||||
background-color: #1f2937 !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Outside @layer - Maximum priority */
|
||||
html:not(.dark) .card-mobile,
|
||||
html:not(.dark) .host-card,
|
||||
html:not(.dark) .bg-gray-50,
|
||||
html:not(.dark) .bg-gray-100 {
|
||||
background-color: #ffffff !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .border-gray-100,
|
||||
html:not(.dark) .border-gray-200 {
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Dark mode with maximum priority */
|
||||
html.dark .card-mobile,
|
||||
html.dark .host-card,
|
||||
html.dark .bg-white,
|
||||
html.dark .bg-gray-50 {
|
||||
background-color: #1f2937 !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
html.dark .border-gray-100,
|
||||
html.dark .border-gray-200,
|
||||
html.dark .border-gray-700 {
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Touch-friendly buttons */
|
||||
.btn-touch {
|
||||
@apply min-h-[44px] px-4 py-3 rounded-lg font-medium transition-all duration-150 active:scale-95 select-none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn-touch bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn-touch bg-green-600 text-white hover:bg-green-700 active:bg-green-800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn-touch bg-red-600 text-white hover:bg-red-700 active:bg-red-800;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn-touch bg-orange-600 text-white hover:bg-orange-700 active:bg-orange-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn-touch bg-gray-600 text-white hover:bg-gray-700 active:bg-gray-800;
|
||||
}
|
||||
|
||||
/* Card components */
|
||||
.card-mobile {
|
||||
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply p-4 border-b border-gray-100 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-dot {
|
||||
@apply w-4 h-4 rounded-full flex-shrink-0;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-green-500 dark:bg-green-400 shadow-green-500/50 shadow-sm;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-red-500 dark:bg-red-400 shadow-red-500/50 shadow-sm;
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
@apply bg-gray-400 dark:bg-gray-500 shadow-gray-400/50 shadow-sm;
|
||||
}
|
||||
|
||||
/* Form inputs mobile-optimized */
|
||||
.input-mobile {
|
||||
@apply w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400 rounded-lg
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
.toast-enter-active {
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
@apply transition-all duration-200 ease-in;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
@apply opacity-0 transform translate-y-full scale-95;
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
@apply opacity-0 transform translate-y-full scale-95;
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
.loading-pulse {
|
||||
@apply animate-pulse bg-gray-200 rounded;
|
||||
}
|
||||
|
||||
/* Swipe indicators */
|
||||
.swipe-indicator {
|
||||
@apply absolute right-4 top-1/2 transform -translate-y-1/2
|
||||
text-gray-400 transition-transform duration-200;
|
||||
}
|
||||
|
||||
.swipe-active .swipe-indicator {
|
||||
@apply transform -translate-y-1/2 translate-x-2;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Safe area insets for mobile */
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-left {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-right {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Scroll behavior */
|
||||
.scroll-smooth {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Mobile viewport height */
|
||||
.min-h-mobile {
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
/* Touch callouts disabled */
|
||||
.no-callout {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* High contrast for accessibility */
|
||||
@media (prefers-contrast: high) {
|
||||
.status-dot {
|
||||
@apply ring-2 ring-offset-1 ring-current;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-touch {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
@apply animate-none;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
@apply animate-none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Progressive Web App styles */
|
||||
@media screen and (display-mode: standalone) {
|
||||
body {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: calc(env(safe-area-inset-top) + 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive breakpoints optimization */
|
||||
@media (min-width: 640px) {
|
||||
.card-mobile {
|
||||
@apply max-w-sm mx-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.cards-grid {
|
||||
@apply grid-cols-2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.cards-grid {
|
||||
@apply grid-cols-3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.cards-grid {
|
||||
@apply grid-cols-4;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
</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>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Hosts</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ hosts.length }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
<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>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ onlineHosts }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,8 +49,8 @@
|
||||
</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>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">VMs Total</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ allVMs.length }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,31 +61,31 @@
|
||||
<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>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Hosts Proxmox</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 v-for="host in hosts.slice(0, 5)" :key="host.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'"
|
||||
:class="host.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>
|
||||
<p class="text-sm font-medium text-gray-900">{{ host.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ host.ip_address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="wakeServer(server.id)"
|
||||
@click="wakeHost(host.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 v-if="hosts.length === 0" class="text-gray-500 text-sm">
|
||||
Aucun host configuré
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@
|
||||
<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 v-for="vm in allVMs.slice(0, 5)" :key="`${vm.hostId}-${vm.node}-${vm.vmid}`" class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
@@ -111,7 +111,7 @@
|
||||
<div class="flex space-x-1">
|
||||
<button
|
||||
v-if="vm.status !== 'running'"
|
||||
@click="startVM(vm.clusterId, vm.vmid, vm.node, vm.type)"
|
||||
@click="startVM(vm.hostId, 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"
|
||||
>
|
||||
@@ -119,7 +119,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="vm.status === 'running'"
|
||||
@click="stopVM(vm.clusterId, vm.vmid, vm.node, vm.type)"
|
||||
@click="stopVM(vm.hostId, 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"
|
||||
>
|
||||
@@ -162,16 +162,15 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { serversApi, proxmoxApi, wolApi } from '../services/api'
|
||||
import { hostsApi, logsApi } from '../services/api'
|
||||
|
||||
const servers = ref([])
|
||||
const clusters = ref([])
|
||||
const hosts = ref([])
|
||||
const logs = ref([])
|
||||
const allVMs = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const onlineServers = computed(() =>
|
||||
servers.value.filter(server => server.is_online).length
|
||||
const onlineHosts = computed(() =>
|
||||
hosts.value.filter(host => host.is_online).length
|
||||
)
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
@@ -188,32 +187,30 @@ const getLogDisplayText = (log) => {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// D'abord vérifier le statut des serveurs
|
||||
await serversApi.checkStatus()
|
||||
// D'abord vérifier le statut des hosts
|
||||
await hostsApi.checkStatus()
|
||||
|
||||
const [serversResponse, clustersResponse, logsResponse] = await Promise.all([
|
||||
serversApi.getAll(),
|
||||
proxmoxApi.getClusters(),
|
||||
wolApi.getAllLogs(10)
|
||||
const [hostsResponse, logsResponse] = await Promise.all([
|
||||
hostsApi.getAll(),
|
||||
logsApi.getAllLogs(10)
|
||||
])
|
||||
|
||||
servers.value = serversResponse.data
|
||||
clusters.value = clustersResponse.data
|
||||
hosts.value = hostsResponse.data
|
||||
logs.value = logsResponse.data
|
||||
|
||||
// Charger les VMs de tous les clusters
|
||||
// Charger les VMs de tous les hosts
|
||||
const vms = []
|
||||
for (const cluster of clustersResponse.data) {
|
||||
for (const host of hostsResponse.data) {
|
||||
try {
|
||||
const vmsResponse = await proxmoxApi.getVMs(cluster.id)
|
||||
const vmsResponse = await hostsApi.getVMs(host.id)
|
||||
for (const vm of vmsResponse.data) {
|
||||
vms.push({
|
||||
...vm,
|
||||
clusterId: cluster.id
|
||||
hostId: host.id
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du chargement des VMs du cluster ${cluster.id}:`, error)
|
||||
console.error(`Erreur lors du chargement des VMs du host ${host.id}:`, error)
|
||||
}
|
||||
}
|
||||
allVMs.value = vms
|
||||
@@ -222,22 +219,22 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const wakeServer = async (serverId) => {
|
||||
const wakeHost = async (hostId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await wolApi.wake(serverId)
|
||||
await hostsApi.wake(hostId)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du réveil du serveur:', error)
|
||||
console.error('Erreur lors du réveil du host:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startVM = async (clusterId, vmid, node, vmType) => {
|
||||
const startVM = async (hostId, vmid, node, vmType) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await proxmoxApi.startVM(clusterId, vmid, node, vmType)
|
||||
await hostsApi.startVM(hostId, vmid, node, vmType)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du démarrage de la VM:', error)
|
||||
@@ -246,10 +243,10 @@ const startVM = async (clusterId, vmid, node, vmType) => {
|
||||
}
|
||||
}
|
||||
|
||||
const stopVM = async (clusterId, vmid, node, vmType) => {
|
||||
const stopVM = async (hostId, vmid, node, vmType) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await proxmoxApi.stopVM(clusterId, vmid, node, vmType)
|
||||
await hostsApi.stopVM(hostId, vmid, node, vmType)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'arrêt de la VM:', error)
|
||||
|
||||
713
frontend/src/views/Home.vue
Normal file
713
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,713 @@
|
||||
<template>
|
||||
<div class="min-h-mobile bg-white dark:bg-black text-gray-900 dark:text-white no-callout" :style="pullToRefreshStyle()">
|
||||
<!-- Pull to refresh indicator -->
|
||||
<div
|
||||
v-if="isPulling || isRefreshing"
|
||||
class="fixed top-0 left-1/2 transform -translate-x-1/2 z-30 transition-all duration-300"
|
||||
:style="{ transform: `translateX(-50%) translateY(${Math.max(pullDistance - 40, 0)}px)` }"
|
||||
>
|
||||
<div class="bg-white rounded-full p-3 shadow-lg">
|
||||
<svg
|
||||
class="w-6 h-6 text-blue-600"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
:style="pullIndicatorStyle()"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header responsive -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sticky top-0 z-10">
|
||||
<div class="px-4 py-3 max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<img src="/tomato.svg" alt="Zebra Power" class="w-8 h-8" />
|
||||
<div>
|
||||
<h1 class="text-lg font-bold">Zebra Power</h1>
|
||||
<p class="text-xs opacity-75">{{ hosts.length }} hosts • {{ onlineHosts }} en ligne</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="toggleDarkMode"
|
||||
class="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
:title="`Mode: ${preference === 'system' ? 'Auto' : preference === 'dark' ? 'Sombre' : 'Clair'}`"
|
||||
>
|
||||
<!-- Light mode icon -->
|
||||
<svg v-if="preference === 'light'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<!-- Dark mode icon -->
|
||||
<svg v-else-if="preference === 'dark'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
<!-- System mode icon -->
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="showMenu = !showMenu"
|
||||
class="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pull to refresh area -->
|
||||
<div class="px-4 pt-2">
|
||||
<div class="text-center py-2">
|
||||
<button
|
||||
@click="refreshData"
|
||||
:disabled="loading"
|
||||
class="text-sm opacity-75 hover:opacity-100 disabled:opacity-50"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block mr-1" :class="{'animate-spin': loading}" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ loading ? 'Actualisation...' : 'Actualiser' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hosts list - Responsive grid -->
|
||||
<div class="px-4 pb-6 max-w-7xl mx-auto space-y-4 md:grid md:grid-cols-2 md:gap-6 md:space-y-0 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div v-if="hosts.length === 0" class="text-center py-12 md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<div class="text-gray-400 text-lg mb-2">🖥️</div>
|
||||
<p class="font-medium opacity-75">Aucun host configuré</p>
|
||||
<p class="text-sm mt-2 opacity-60">Utilisez le menu Configuration pour ajouter votre premier host</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="host in hosts"
|
||||
:key="host.id"
|
||||
class="card-mobile host-card"
|
||||
:data-host-id="host.id"
|
||||
>
|
||||
<!-- Host header -->
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
class="status-dot"
|
||||
:class="host.is_online ? 'status-online' : 'status-offline'"
|
||||
></div>
|
||||
<div>
|
||||
<h3 class="font-medium">{{ host.name }}</h3>
|
||||
<p class="text-sm opacity-75">{{ host.ip_address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="host.is_online ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ host.is_online ? 'En ligne' : 'Hors ligne' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Host actions - Large touch buttons -->
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="wakeHost(host.id)"
|
||||
:disabled="loading || host.is_online"
|
||||
class="flex-1 btn-success disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>⚡</span>
|
||||
<span>Wake</span>
|
||||
</button>
|
||||
<button
|
||||
@click="shutdownHost(host.id)"
|
||||
:disabled="loading || !host.is_online"
|
||||
class="flex-1 btn-warning disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>🛑</span>
|
||||
<span>Shutdown</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VMs section -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium">VMs & Containers</h4>
|
||||
<button
|
||||
v-if="!hostVMs[host.id] && host.is_online"
|
||||
@click="loadVMs(host.id)"
|
||||
:disabled="loading"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Charger
|
||||
</button>
|
||||
<span v-else-if="hostVMs[host.id]" class="text-sm opacity-75">
|
||||
{{ hostVMs[host.id].length }} VM{{ hostVMs[host.id].length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- VMs loading state -->
|
||||
<div v-if="loading && loadingHostId === host.id" class="text-center py-4">
|
||||
<div class="inline-flex items-center opacity-75">
|
||||
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Chargement des VMs...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VMs list -->
|
||||
<div v-else-if="hostVMs[host.id] && hostVMs[host.id].length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="vm in hostVMs[host.id]"
|
||||
:key="`${host.id}-${vm.vmid}`"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="vm.status === 'running' ? 'status-online' : 'status-unknown'"
|
||||
></div>
|
||||
<h5 class="font-medium">{{ vm.name }}</h5>
|
||||
</div>
|
||||
<p class="text-xs opacity-75 mt-1">
|
||||
{{ vm.type.toUpperCase() }} #{{ vm.vmid }} • {{ vm.node }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
:class="vm.status === 'running' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-200'"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ vm.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- VM actions -->
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-if="vm.status !== 'running'"
|
||||
@click="startVM(host.id, vm.vmid, vm.node, vm.type)"
|
||||
:disabled="loading"
|
||||
class="flex-1 btn-success disabled:opacity-50 text-sm"
|
||||
>
|
||||
▶️ Démarrer
|
||||
</button>
|
||||
<button
|
||||
v-if="vm.status === 'running'"
|
||||
@click="stopVM(host.id, vm.vmid, vm.node, vm.type)"
|
||||
:disabled="loading"
|
||||
class="flex-1 btn-danger disabled:opacity-50 text-sm"
|
||||
>
|
||||
⏹️ Arrêter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No VMs state -->
|
||||
<div v-else-if="hostVMs[host.id] && hostVMs[host.id].length === 0" class="text-center py-4">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">Aucune VM trouvée</p>
|
||||
</div>
|
||||
|
||||
<!-- Offline state -->
|
||||
<div v-else-if="!host.is_online" class="text-center py-4">
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm">Host hors ligne</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Side menu -->
|
||||
<div v-if="showMenu" class="fixed inset-0 z-20">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50" @click="showMenu = false"></div>
|
||||
<div class="fixed right-0 top-0 h-full w-80 max-w-sm bg-white dark:bg-gray-800 shadow-xl">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Configuration</h3>
|
||||
<button @click="showMenu = false" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<button
|
||||
@click="showAddModal = true; showMenu = false"
|
||||
class="w-full text-left p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg border border-dashed border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
<div class="font-medium text-blue-600 dark:text-blue-400">+ Ajouter un host</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Nouveau serveur Proxmox</div>
|
||||
</button>
|
||||
|
||||
<div v-if="hosts.length > 0" class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Hosts configurés</h4>
|
||||
<button
|
||||
v-for="host in hosts"
|
||||
:key="host.id"
|
||||
@click="editHost(host)"
|
||||
class="w-full text-left p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 mb-2"
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{{ host.name }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ host.ip_address }}</div>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400 mt-1">Éditer</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal - Simplified for mobile -->
|
||||
<div v-if="showAddModal || editingHost" class="fixed inset-0 z-30">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50"></div>
|
||||
<div class="fixed inset-x-4 top-8 bottom-8 bg-white dark:bg-gray-800 rounded-xl overflow-y-auto">
|
||||
<form @submit.prevent="submitHost" class="h-full flex flex-col">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ editingHost ? 'Modifier le host' : 'Nouveau host' }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-4 space-y-4">
|
||||
<!-- Simplified form - only essential fields visible -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nom *</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
class="input-mobile"
|
||||
placeholder="Mon serveur Proxmox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IP *</label>
|
||||
<input
|
||||
v-model="formData.ip_address"
|
||||
type="text"
|
||||
required
|
||||
class="input-mobile"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">MAC *</label>
|
||||
<input
|
||||
v-model="formData.mac_address"
|
||||
type="text"
|
||||
required
|
||||
class="input-mobile"
|
||||
placeholder="aa:bb:cc:dd:ee:ff"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced settings - collapsible -->
|
||||
<div class="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
class="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Paramètres Proxmox</span>
|
||||
<svg
|
||||
class="w-5 h-5 transform transition-transform"
|
||||
:class="showAdvanced ? 'rotate-180' : ''"
|
||||
fill="currentColor" viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-show="showAdvanced" class="mt-4 space-y-4">
|
||||
<div>
|
||||
<input
|
||||
v-model="formData.proxmox_host"
|
||||
type="text"
|
||||
placeholder="Host Proxmox (même IP si identique)"
|
||||
class="input-mobile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
v-model="formData.proxmox_username"
|
||||
type="text"
|
||||
placeholder="Utilisateur"
|
||||
class="px-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
v-model="formData.proxmox_password"
|
||||
type="password"
|
||||
placeholder="Mot de passe"
|
||||
class="px-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
v-model.number="formData.proxmox_port"
|
||||
type="number"
|
||||
placeholder="8006"
|
||||
class="input-mobile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700 flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelEdit"
|
||||
class="flex-1 py-3 px-4 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="flex-1 py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg"
|
||||
>
|
||||
{{ editingHost ? 'Modifier' : 'Ajouter' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingHost"
|
||||
type="button"
|
||||
@click="deleteHost(editingHost.id)"
|
||||
class="p-3 bg-red-600 hover:bg-red-700 text-white rounded-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<Transition name="toast">
|
||||
<div v-if="toast.show" class="fixed bottom-20 left-4 right-4 z-40 safe-bottom">
|
||||
<div
|
||||
:class="toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'"
|
||||
class="text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between"
|
||||
>
|
||||
<span>{{ toast.message }}</span>
|
||||
<button @click="toast.show = false" class="ml-4 text-white p-1 hover:bg-white/20 rounded">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { hostsApi } from '../services/api'
|
||||
import { useSwipe } from '../composables/useSwipe'
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh'
|
||||
import { useDarkMode } from '../composables/useDarkMode'
|
||||
|
||||
const hosts = ref([])
|
||||
const hostVMs = ref({})
|
||||
const loading = ref(false)
|
||||
const loadingHostId = ref(null)
|
||||
const showMenu = ref(false)
|
||||
const showAddModal = ref(false)
|
||||
const showAdvanced = ref(false)
|
||||
const editingHost = ref(null)
|
||||
|
||||
const toast = reactive({
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success'
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
ip_address: '',
|
||||
mac_address: '',
|
||||
proxmox_host: '',
|
||||
proxmox_username: '',
|
||||
proxmox_password: '',
|
||||
proxmox_port: 8006,
|
||||
verify_ssl: true,
|
||||
shutdown_endpoint: ''
|
||||
})
|
||||
|
||||
const onlineHosts = computed(() =>
|
||||
hosts.value.filter(host => host.is_online).length
|
||||
)
|
||||
|
||||
// Mobile interactions
|
||||
const { addSwipeListeners } = useSwipe()
|
||||
const {
|
||||
isPulling,
|
||||
isRefreshing,
|
||||
pullDistance,
|
||||
addPullToRefreshListeners,
|
||||
pullToRefreshStyle,
|
||||
pullIndicatorStyle
|
||||
} = usePullToRefresh()
|
||||
|
||||
// Dark mode
|
||||
const { isDark, preference, toggleDarkMode } = useDarkMode()
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
toast.message = message
|
||||
toast.type = type
|
||||
toast.show = true
|
||||
setTimeout(() => {
|
||||
toast.show = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
description: '',
|
||||
ip_address: '',
|
||||
mac_address: '',
|
||||
proxmox_host: '',
|
||||
proxmox_username: '',
|
||||
proxmox_password: '',
|
||||
proxmox_port: 8006,
|
||||
verify_ssl: true,
|
||||
shutdown_endpoint: ''
|
||||
})
|
||||
}
|
||||
|
||||
const refreshData = async (silent = false) => {
|
||||
if (!silent) loading.value = true
|
||||
|
||||
try {
|
||||
await hostsApi.checkStatus()
|
||||
const response = await hostsApi.getAll()
|
||||
hosts.value = response.data
|
||||
|
||||
// Auto-load VMs for online hosts
|
||||
for (const host of hosts.value.filter(h => h.is_online)) {
|
||||
if (!hostVMs.value[host.id]) {
|
||||
await loadVMs(host.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
showToast('Données actualisées')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du rafraîchissement:', error)
|
||||
showToast('Erreur lors du rafraîchissement', 'error')
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadVMs = async (hostId, showLoader = true) => {
|
||||
if (showLoader) {
|
||||
loading.value = true
|
||||
loadingHostId.value = hostId
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await hostsApi.getVMs(hostId)
|
||||
hostVMs.value[hostId] = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des VMs:', error)
|
||||
showToast('Erreur lors du chargement des VMs', 'error')
|
||||
} finally {
|
||||
if (showLoader) {
|
||||
loading.value = false
|
||||
loadingHostId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wakeHost = async (hostId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.wake(hostId)
|
||||
showToast('Paquet WOL envoyé avec succès')
|
||||
setTimeout(() => refreshData(), 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'envoi du WOL:', error)
|
||||
showToast('Erreur lors de l\'envoi du paquet WOL', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const shutdownHost = async (hostId) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir éteindre ce host et toutes ses VMs ?')) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.shutdown(hostId)
|
||||
showToast('Extinction du host initiée')
|
||||
delete hostVMs.value[hostId]
|
||||
setTimeout(() => refreshData(), 5000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'extinction:', error)
|
||||
showToast('Erreur lors de l\'extinction du host', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startVM = async (hostId, vmid, node, vmType) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.startVM(hostId, vmid, node, vmType)
|
||||
showToast(`VM ${vmid} en cours de démarrage`)
|
||||
setTimeout(() => loadVMs(hostId, false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du démarrage de la VM:', error)
|
||||
showToast('Erreur lors du démarrage de la VM', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopVM = async (hostId, vmid, node, vmType) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.stopVM(hostId, vmid, node, vmType)
|
||||
showToast(`VM ${vmid} en cours d'arrêt`)
|
||||
setTimeout(() => loadVMs(hostId, false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'arrêt de la VM:', error)
|
||||
showToast('Erreur lors de l\'arrêt de la VM', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitHost = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Set proxmox_host to ip_address if not provided
|
||||
if (!formData.proxmox_host) {
|
||||
formData.proxmox_host = formData.ip_address
|
||||
}
|
||||
|
||||
if (editingHost.value) {
|
||||
await hostsApi.update(editingHost.value.id, formData)
|
||||
showToast('Host modifié avec succès')
|
||||
} else {
|
||||
await hostsApi.create(formData)
|
||||
showToast('Host ajouté avec succès')
|
||||
}
|
||||
|
||||
cancelEdit()
|
||||
await refreshData()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde du host:', error)
|
||||
showToast('Erreur lors de la sauvegarde du host', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editHost = (host) => {
|
||||
editingHost.value = host
|
||||
showMenu.value = false
|
||||
Object.assign(formData, {
|
||||
name: host.name,
|
||||
description: host.description || '',
|
||||
ip_address: host.ip_address,
|
||||
mac_address: host.mac_address,
|
||||
proxmox_host: host.proxmox_host,
|
||||
proxmox_username: host.proxmox_username,
|
||||
proxmox_password: host.proxmox_password,
|
||||
proxmox_port: host.proxmox_port,
|
||||
verify_ssl: host.verify_ssl,
|
||||
shutdown_endpoint: host.shutdown_endpoint || ''
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
showAddModal.value = false
|
||||
editingHost.value = null
|
||||
showAdvanced.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const deleteHost = async (hostId) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce host ?')) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.delete(hostId)
|
||||
showToast('Host supprimé avec succès')
|
||||
delete hostVMs.value[hostId]
|
||||
cancelEdit()
|
||||
await refreshData()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du host:', error)
|
||||
showToast('Erreur lors de la suppression du host', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
let autoRefreshInterval
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshData()
|
||||
|
||||
// Set up pull-to-refresh
|
||||
nextTick(() => {
|
||||
const appElement = document.body
|
||||
addPullToRefreshListeners(appElement, () => refreshData(false))
|
||||
})
|
||||
|
||||
// Set up swipe listeners for host cards
|
||||
nextTick(() => {
|
||||
const hostCards = document.querySelectorAll('.host-card')
|
||||
hostCards.forEach(card => {
|
||||
addSwipeListeners(card, (swipeResult) => {
|
||||
if (swipeResult.direction === 'left') {
|
||||
// Show actions menu on swipe left
|
||||
const hostId = card.dataset.hostId
|
||||
if (hostId) {
|
||||
// You could implement quick actions here
|
||||
console.log('Swipe left on host:', hostId)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
refreshData(true) // Silent refresh every 30s
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
494
frontend/src/views/Hosts.vue
Normal file
494
frontend/src/views/Hosts.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<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">Hosts Proxmox</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">
|
||||
Gérez vos hosts Proxmox : démarrage WOL, contrôle des VMs/Containers et extinction
|
||||
</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 host
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des hosts -->
|
||||
<div class="mt-8 space-y-6" v-if="hosts.length > 0">
|
||||
<div v-for="host in hosts" :key="host.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 flex items-center">
|
||||
{{ host.name }}
|
||||
<span
|
||||
:class="host.is_online ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||
class="ml-2 inline-flex px-2 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ host.is_online ? 'En ligne' : 'Hors ligne' }}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ host.ip_address }} ({{ host.mac_address }})
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Proxmox: {{ host.proxmox_host }}:{{ host.proxmox_port }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<!-- Actions du host -->
|
||||
<button
|
||||
@click="wakeHost(host.id)"
|
||||
:disabled="loading || host.is_online"
|
||||
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"
|
||||
title="Démarrer le host avec WOL"
|
||||
>
|
||||
⚡ Wake
|
||||
</button>
|
||||
<button
|
||||
@click="loadVMs(host.id)"
|
||||
:disabled="loading || !host.is_online"
|
||||
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"
|
||||
title="Charger les VMs"
|
||||
>
|
||||
🔄 VMs
|
||||
</button>
|
||||
<button
|
||||
@click="shutdownHost(host.id)"
|
||||
:disabled="loading || !host.is_online"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-orange-600 hover:bg-orange-700 disabled:opacity-50"
|
||||
title="Éteindre le host"
|
||||
>
|
||||
🛑 Shutdown
|
||||
</button>
|
||||
<button
|
||||
@click="editHost(host)"
|
||||
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"
|
||||
>
|
||||
✏️ Éditer
|
||||
</button>
|
||||
<button
|
||||
@click="deleteHost(host.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="hostVMs[host.id] && hostVMs[host.id].length > 0" 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 hostVMs[host.id]"
|
||||
:key="`${host.id}-${vm.vmid}`"
|
||||
class="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900">{{ vm.name }}</h5>
|
||||
<p class="text-sm text-gray-500">
|
||||
ID: {{ vm.vmid }} | Type: {{ vm.type.toUpperCase() }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">Node: {{ vm.node }}</p>
|
||||
<span
|
||||
:class="vm.status === 'running' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'"
|
||||
class="inline-flex px-2 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ vm.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<button
|
||||
@click="startVM(host.id, vm.vmid, vm.node, vm.type)"
|
||||
:disabled="loading || vm.status === 'running'"
|
||||
class="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
▶️ Start
|
||||
</button>
|
||||
<button
|
||||
@click="stopVM(host.id, vm.vmid, vm.node, vm.type)"
|
||||
:disabled="loading || vm.status !== 'running'"
|
||||
class="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
⏹️ Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message si pas de VMs chargées -->
|
||||
<div v-else-if="host.is_online" class="mt-4 text-center py-4">
|
||||
<p class="text-gray-500">Cliquez sur "VMs" pour charger les machines virtuelles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message si aucun host -->
|
||||
<div v-else class="text-center py-12">
|
||||
<p class="text-gray-500 text-lg">Aucun host Proxmox configuré</p>
|
||||
<p class="text-gray-400">Ajoutez votre premier host pour commencer</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal d'ajout/modification de host -->
|
||||
<div v-if="showAddModal || editingHost" class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-screen items-center justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||
|
||||
<div class="inline-block transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<form @submit.prevent="submitHost">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
|
||||
{{ editingHost ? 'Modifier le host' : 'Ajouter un host' }}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nom</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<input
|
||||
v-model="formData.description"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Adresse IP</label>
|
||||
<input
|
||||
v-model="formData.ip_address"
|
||||
type="text"
|
||||
required
|
||||
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Adresse MAC</label>
|
||||
<input
|
||||
v-model="formData.mac_address"
|
||||
type="text"
|
||||
required
|
||||
pattern="^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Host Proxmox</label>
|
||||
<input
|
||||
v-model="formData.proxmox_host"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="Même IP ou FQDN différent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nom d'utilisateur</label>
|
||||
<input
|
||||
v-model="formData.proxmox_username"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Mot de passe</label>
|
||||
<input
|
||||
v-model="formData.proxmox_password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Port Proxmox</label>
|
||||
<input
|
||||
v-model.number="formData.proxmox_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Endpoint shutdown (optionnel)</label>
|
||||
<input
|
||||
v-model="formData.shutdown_endpoint"
|
||||
type="text"
|
||||
placeholder="/api/shutdown"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="formData.verify_ssl"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700">Vérifier le SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="inline-flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-base 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:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
>
|
||||
{{ editingHost ? 'Modifier' : 'Ajouter' }}
|
||||
</button>
|
||||
<button
|
||||
@click="cancelEdit"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import api, { hostsApi } from '@/services/api'
|
||||
|
||||
const hosts = ref([])
|
||||
const hostVMs = ref({})
|
||||
const loading = ref(false)
|
||||
const showAddModal = ref(false)
|
||||
const editingHost = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
ip_address: '',
|
||||
mac_address: '',
|
||||
proxmox_host: '',
|
||||
proxmox_username: '',
|
||||
proxmox_password: '',
|
||||
proxmox_port: 8006,
|
||||
verify_ssl: true,
|
||||
shutdown_endpoint: ''
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
formData.name = ''
|
||||
formData.description = ''
|
||||
formData.ip_address = ''
|
||||
formData.mac_address = ''
|
||||
formData.proxmox_host = ''
|
||||
formData.proxmox_username = ''
|
||||
formData.proxmox_password = ''
|
||||
formData.proxmox_port = 8006
|
||||
formData.verify_ssl = true
|
||||
formData.shutdown_endpoint = ''
|
||||
}
|
||||
|
||||
const loadHosts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await hostsApi.getAll()
|
||||
hosts.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des hosts:', error)
|
||||
alert('Erreur lors du chargement des hosts')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkHostsStatus = async () => {
|
||||
try {
|
||||
await hostsApi.checkStatus()
|
||||
await loadHosts()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la vérification du statut:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadVMs = async (hostId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await hostsApi.getVMs(hostId)
|
||||
hostVMs.value[hostId] = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des VMs:', error)
|
||||
alert('Erreur lors du chargement des VMs')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const wakeHost = async (hostId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.wake(hostId)
|
||||
alert('Paquet WOL envoyé avec succès')
|
||||
// Attendre un peu puis vérifier le statut
|
||||
setTimeout(() => checkHostsStatus(), 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'envoi du WOL:', error)
|
||||
alert('Erreur lors de l\'envoi du paquet WOL')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const shutdownHost = async (hostId) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir éteindre ce host et toutes ses VMs ?')) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.shutdown(hostId)
|
||||
alert('Extinction du host initiée')
|
||||
// Nettoyer les VMs chargées
|
||||
delete hostVMs.value[hostId]
|
||||
// Vérifier le statut après un délai
|
||||
setTimeout(() => checkHostsStatus(), 5000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'extinction:', error)
|
||||
alert('Erreur lors de l\'extinction du host')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startVM = async (hostId, vmid, node, vmType) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.startVM(hostId, vmid, node, vmType)
|
||||
alert('Commande de démarrage envoyée')
|
||||
// Recharger les VMs après un délai
|
||||
setTimeout(() => loadVMs(hostId), 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du démarrage de la VM:', error)
|
||||
alert('Erreur lors du démarrage de la VM')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopVM = async (hostId, vmid, node, vmType) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.stopVM(hostId, vmid, node, vmType)
|
||||
alert('Commande d\'arrêt envoyée')
|
||||
// Recharger les VMs après un délai
|
||||
setTimeout(() => loadVMs(hostId), 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'arrêt de la VM:', error)
|
||||
alert('Erreur lors de l\'arrêt de la VM')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitHost = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (editingHost.value) {
|
||||
await hostsApi.update(editingHost.value.id, formData)
|
||||
alert('Host modifié avec succès')
|
||||
} else {
|
||||
await hostsApi.create(formData)
|
||||
alert('Host ajouté avec succès')
|
||||
}
|
||||
|
||||
cancelEdit()
|
||||
await loadHosts()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde du host:', error)
|
||||
alert('Erreur lors de la sauvegarde du host: ' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editHost = (host) => {
|
||||
editingHost.value = host
|
||||
Object.assign(formData, {
|
||||
name: host.name,
|
||||
description: host.description || '',
|
||||
ip_address: host.ip_address,
|
||||
mac_address: host.mac_address,
|
||||
proxmox_host: host.proxmox_host,
|
||||
proxmox_username: host.proxmox_username,
|
||||
proxmox_password: host.proxmox_password,
|
||||
proxmox_port: host.proxmox_port,
|
||||
verify_ssl: host.verify_ssl,
|
||||
shutdown_endpoint: host.shutdown_endpoint || ''
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
showAddModal.value = false
|
||||
editingHost.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const deleteHost = async (hostId) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce host ?')) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await hostsApi.delete(hostId)
|
||||
alert('Host supprimé avec succès')
|
||||
await loadHosts()
|
||||
// Nettoyer les VMs chargées
|
||||
delete hostVMs.value[hostId]
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du host:', error)
|
||||
alert('Erreur lors de la suppression du host')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHosts()
|
||||
await checkHostsStatus()
|
||||
})
|
||||
</script>
|
||||
@@ -1,305 +0,0 @@
|
||||
<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>
|
||||
@@ -1,261 +0,0 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user