refact: use only server
This commit is contained in:
85
.gitignore
vendored
Normal file
85
.gitignore
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Node.js / Vue.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
.yarn/
|
||||
dist/
|
||||
.nuxt/
|
||||
.vite/
|
||||
|
||||
# IDE / Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/*.db
|
||||
data/*.sqlite
|
||||
data/*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
.temp/
|
||||
|
||||
# Package files
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
|
||||
# Development artifacts
|
||||
coverage/
|
||||
.nyc_output/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Hot reload
|
||||
*.hot-update.*
|
||||
165
CLAUDE.md
165
CLAUDE.md
@@ -2,29 +2,31 @@
|
||||
|
||||
## Contexte du Projet
|
||||
|
||||
**Zebra Power** (nommée d'après la tomate verte zebra) est une application web dockerisée pour la gestion de serveurs via Wake-on-LAN et le contrôle de machines virtuelles Proxmox. Il s'agit d'un outil d'administration réseau légitime à usage défensif uniquement.
|
||||
**Zebra Power** (nommée d'après la tomate verte zebra) est une application web dockerisée pour la gestion unifiée d'hosts Proxmox via Wake-on-LAN, contrôle de VMs/Containers et extinction. Il s'agit d'un outil d'administration réseau légitime à usage défensif uniquement.
|
||||
|
||||
## Architecture et Technologies
|
||||
|
||||
### Backend (FastAPI + SQLite)
|
||||
|
||||
- **Langages**: Python 3.11+
|
||||
- **Framework**: FastAPI 0.104.1 avec SQLAlchemy 2.0.23
|
||||
- **Langages**: Python 3.13 + uv
|
||||
- **Framework**: FastAPI 0.115.0 avec SQLAlchemy 2.0.35
|
||||
- **Base de données**: SQLite (fichier local `./data/zebra.db`)
|
||||
- **Services**: Wake-on-LAN, API Proxmox, logging centralisé
|
||||
- **Services**: Wake-on-LAN, API Proxmox unifiée, logging centralisé
|
||||
- **Point d'entrée**: `backend/app/main.py`
|
||||
|
||||
### Frontend (Vue.js 3)
|
||||
|
||||
- **Framework**: Vue.js 3.4.0 + Vue Router + Pinia
|
||||
- **Build**: Vite 5.0.8
|
||||
- **Styling**: Tailwind CSS 3.3.6
|
||||
- **Styling**: Tailwind CSS 3.3.6 avec système de thème personnalisé
|
||||
- **Composants**: Headless UI + Heroicons
|
||||
- **Fonctionnalités**: Dark/Light mode, Pull-to-refresh, Swipe gestures
|
||||
- **Point d'entrée**: `frontend/src/main.js`
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Containerisation**: Docker Compose
|
||||
- **Images**: Python 3.13, Node.js 20, Nginx 1.25
|
||||
- **Proxy**: Nginx (port 80)
|
||||
- **Réseau**: Mode host pour backend (requis WOL)
|
||||
|
||||
@@ -48,31 +50,24 @@ backend/app/
|
||||
|
||||
frontend/src/
|
||||
├── components/ # Composants Vue réutilisables
|
||||
├── views/ # Pages/routes principales
|
||||
├── composables/ # Hooks Vue (useDarkMode, usePullToRefresh, useSwipe)
|
||||
├── views/ # Pages/routes principales (Home.vue, Hosts.vue)
|
||||
├── services/ # API client
|
||||
├── style.css # Styles globaux + système de thème
|
||||
└── main.js # Bootstrap Vue
|
||||
```
|
||||
|
||||
## Commandes de Développement
|
||||
|
||||
### Backend
|
||||
### Développement (avec hot reload)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Développement (port 3000)
|
||||
npm run build # Production
|
||||
```
|
||||
|
||||
### Docker (Production)
|
||||
### Production
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
@@ -80,6 +75,16 @@ docker-compose logs -f [service]
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Manuel (développement local)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
## Directives Spécifiques pour les Agents
|
||||
|
||||
### 🔒 Sécurité - OBLIGATOIRE
|
||||
@@ -106,6 +111,8 @@ docker-compose down
|
||||
- État global via Pinia stores
|
||||
- Requêtes API via `services/api.js`
|
||||
- Composants atomiques réutilisables
|
||||
- **Système de thème** : composable `useDarkMode.js` avec 3 états (light/dark/system)
|
||||
- **Interactions mobiles** : composables `usePullToRefresh.js` et `useSwipe.js`
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -131,15 +138,20 @@ docker-compose down
|
||||
|
||||
#### API Endpoints
|
||||
|
||||
- `backend/app/api/servers.py` - Gestion serveurs WOL
|
||||
- `backend/app/api/wol.py` - Wake-on-LAN et ping
|
||||
- `backend/app/api/proxmox.py` - Clusters et VMs Proxmox
|
||||
- `backend/app/api/hosts.py` - API unifiée hosts Proxmox (WOL + VMs)
|
||||
- `backend/app/api/wol.py` - Logs centralisés (legacy)
|
||||
|
||||
#### Services Métier
|
||||
|
||||
- `backend/app/services/wol_service.py` - Logique Wake-on-LAN
|
||||
- `backend/app/services/proxmox_service.py` - Intégration Proxmox
|
||||
- `backend/app/services/logging_service.py` - Journalisation
|
||||
- `backend/app/services/proxmox_host_service.py` - Service unifié host Proxmox
|
||||
- `backend/app/services/wol_service.py` - Utilitaires Wake-on-LAN
|
||||
- `backend/app/services/logging_service.py` - Journalisation centralisée
|
||||
|
||||
#### Frontend Composables
|
||||
|
||||
- `frontend/src/composables/useDarkMode.js` - Système de thème 3 états avec persistance
|
||||
- `frontend/src/composables/usePullToRefresh.js` - Pull-to-refresh mobile natif
|
||||
- `frontend/src/composables/useSwipe.js` - Détection de gestes tactiles
|
||||
|
||||
### 🔧 Debugging et Logs
|
||||
|
||||
@@ -167,10 +179,9 @@ docker-compose logs nginx
|
||||
|
||||
#### Tables Principales
|
||||
|
||||
- `servers` - Serveurs Wake-on-LAN
|
||||
- `proxmox_clusters` - Configuration clusters
|
||||
- `action_logs` - Historique des actions
|
||||
- `wol_logs` - Logs spécifiques WOL (legacy)
|
||||
- `proxmox_hosts` - Configuration unifiée hosts (WOL + Proxmox)
|
||||
- `action_logs` - Historique centralisé toutes actions
|
||||
- `wol_logs` - Logs legacy (rétrocompatibilité)
|
||||
|
||||
#### Migrations
|
||||
|
||||
@@ -183,17 +194,39 @@ docker-compose logs nginx
|
||||
#### Standards API
|
||||
|
||||
- REST endpoints avec préfixes `/api/`
|
||||
- Codes status HTTP standards
|
||||
- JSON uniquement
|
||||
- CORS permissif (à restreindre)
|
||||
- `/api/hosts/*` - API principale unifiée
|
||||
- `/api/wol/*` - Logs centralisés
|
||||
- Codes status HTTP standards, JSON uniquement
|
||||
|
||||
#### Client Frontend
|
||||
|
||||
- Axios dans `services/api.js`
|
||||
- Base URL via variable d'environnement
|
||||
- `hostsApi` et `logsApi` dans `services/api.js`
|
||||
- Base URL via variable d'environnement
|
||||
- Error handling centralisé
|
||||
- Loading states dans les composants
|
||||
|
||||
### 🎨 Système de Thème
|
||||
|
||||
#### Configuration
|
||||
|
||||
- **3 modes** : Light, Dark, System (suit l'OS)
|
||||
- **Persistance** : localStorage `theme-preference`
|
||||
- **Application** : classe `dark` sur `<html>` via `useDarkMode.js`
|
||||
- **Tailwind** : `darkMode: 'class'` dans `tailwind.config.js`
|
||||
|
||||
#### Implémentation
|
||||
|
||||
- **Composable** : `useDarkMode()` retourne `{ isDark, preference, toggleDarkMode }`
|
||||
- **CSS personnalisé** : styles forcés avec `!important` dans `style.css`
|
||||
- **Spécificité maximale** : `html:not(.dark)` et `html.dark` pour surcharger Tailwind
|
||||
- **Bouton de thème** : cycle System → Light → Dark dans l'interface
|
||||
|
||||
#### Dépannage
|
||||
|
||||
- **Vérifier** : classe `dark` sur `<html>` dans DevTools
|
||||
- **Logs** : console.log dans `useDarkMode.js` pour debugging
|
||||
- **CSS** : styles forcés hors `@layer` pour priorité maximale
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
#### Backend
|
||||
@@ -208,22 +241,22 @@ docker-compose logs nginx
|
||||
- Composition API pour réactivité
|
||||
- Tailwind purge pour CSS optimisé
|
||||
- Vite pour build rapide
|
||||
- **Système de thème** : transitions CSS fluides 300ms
|
||||
|
||||
### 🚀 Déploiement
|
||||
|
||||
#### Développement Local
|
||||
#### Développement
|
||||
|
||||
1. Clone du repo
|
||||
2. `mkdir -p data` pour SQLite
|
||||
3. `docker-compose up -d`
|
||||
4. Interface sur http://localhost
|
||||
1. `docker-compose -f docker-compose.dev.yml up -d`
|
||||
2. Frontend: http://localhost:3000
|
||||
3. Backend API: http://localhost:8000
|
||||
|
||||
#### Production
|
||||
|
||||
- Même stack Docker
|
||||
- Variables d'environnement sécurisées
|
||||
- HTTPS recommandé (nginx SSL)
|
||||
- Sauvegarde régulière de `./data/`
|
||||
1. `docker-compose up -d`
|
||||
2. Interface: http://localhost
|
||||
3. Health checks automatiques
|
||||
4. Sauvegarde régulière de `./data/`
|
||||
|
||||
### 💡 Bonnes Pratiques Agents Claude
|
||||
|
||||
@@ -235,3 +268,47 @@ docker-compose logs nginx
|
||||
6. **Sécurité first** - valider les entrées, gérer les erreurs
|
||||
7. **Performance** - éviter les requêtes N+1, optimiser les queries
|
||||
8. **UX** - interfaces intuitives, feedback utilisateur
|
||||
9. **Système de thème** - utiliser le composable `useDarkMode()`, ne pas modifier les styles CSS forcés
|
||||
|
||||
### 🔧 Notes Techniques Importantes
|
||||
|
||||
#### Système de Thème - État Actuel (2025-08-27)
|
||||
|
||||
**Problèmes résolus** :
|
||||
- Classe `dark` s'applique correctement sur `<html>`
|
||||
- Light mode maintenant vraiment lumineux (blanc pur)
|
||||
- Dark mode cohérent avec couleurs sombres
|
||||
- Fond de page couvre toute la hauteur
|
||||
|
||||
**Architecture CSS** :
|
||||
```css
|
||||
/* Styles de base dans @layer base */
|
||||
html, body { bg-white/bg-gray-900 selon mode }
|
||||
|
||||
/* Styles forcés HORS @layer pour priorité maximale */
|
||||
html:not(.dark) .card-mobile { background: #ffffff !important }
|
||||
html.dark .card-mobile { background: #1f2937 !important }
|
||||
```
|
||||
|
||||
**Spécificités critiques** :
|
||||
- Ne PAS modifier les styles forcés dans `style.css` lignes 140-167
|
||||
- Les styles Tailwind seuls ne suffisent pas (problème de spécificité)
|
||||
- `!important` + sélecteurs spécifiques requis pour surcharger Tailwind
|
||||
|
||||
#### Composables Vue
|
||||
|
||||
**useDarkMode.js** :
|
||||
- Gère 3 états : 'system', 'light', 'dark'
|
||||
- Persistance automatique localStorage
|
||||
- Application DOM via `document.documentElement.classList`
|
||||
- Écoute changements système `prefers-color-scheme`
|
||||
|
||||
**usePullToRefresh.js** :
|
||||
- Implémentation native pull-to-refresh mobile
|
||||
- Détection tactile avec seuils configurables
|
||||
- Animation fluide avec indicateur visuel
|
||||
|
||||
**useSwipe.js** :
|
||||
- Détection gestes swipe (left/right)
|
||||
- Touch events natifs avec debounce
|
||||
- Utilisé pour interactions host cards
|
||||
|
||||
145
DOCKER.md
Normal file
145
DOCKER.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 🐳 Docker Configuration - Zebra Power
|
||||
|
||||
## Images mises à jour
|
||||
|
||||
### Backend
|
||||
- **Python 3.13** (version la plus récente)
|
||||
- **uv** pour installation rapide des dépendances
|
||||
- **4 workers** en production
|
||||
- Health checks intégrés
|
||||
|
||||
### Frontend
|
||||
- **Node.js 20** (LTS)
|
||||
- Build optimisé pour production
|
||||
- Health checks intégrés
|
||||
|
||||
### Nginx
|
||||
- **Version 1.25**
|
||||
- Sécurité renforcée
|
||||
- Health checks intégrés
|
||||
|
||||
## Configurations disponibles
|
||||
|
||||
### 🔧 Développement
|
||||
```bash
|
||||
# Démarrer en mode développement (avec hot reload)
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Voir les logs
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Arrêter
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
**Fonctionnalités développement :**
|
||||
- Auto-reload backend et frontend
|
||||
- Volumes montés pour édition en temps réel
|
||||
- Ports exposés directement (backend: 8000, frontend: 3000)
|
||||
|
||||
### 🚀 Production
|
||||
```bash
|
||||
# Démarrer en production (via nginx)
|
||||
docker-compose up -d
|
||||
|
||||
# Voir les logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Arrêter
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
**Fonctionnalités production :**
|
||||
- Applications buildées et optimisées
|
||||
- Reverse proxy nginx sur port 80
|
||||
- Health checks et redémarrages automatiques
|
||||
- 4 workers backend pour performance
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
### Build des images
|
||||
```bash
|
||||
# Reconstruire toutes les images
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Reconstruire une image spécifique
|
||||
docker-compose build backend
|
||||
```
|
||||
|
||||
### Migration des données
|
||||
```bash
|
||||
# Accéder au container backend
|
||||
docker exec -it zebra_backend bash
|
||||
|
||||
# Lancer la migration
|
||||
uv run python migrate_to_unified_hosts.py
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
# Tester l'API depuis le container
|
||||
docker exec -it zebra_backend uv run python test_api.py
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
```bash
|
||||
# Status des services
|
||||
docker-compose ps
|
||||
|
||||
# Health check status
|
||||
docker inspect --format='{{.State.Health.Status}}' zebra_backend
|
||||
|
||||
# Métriques ressources
|
||||
docker stats
|
||||
```
|
||||
|
||||
## Structure des volumes
|
||||
|
||||
```
|
||||
data/
|
||||
├── zebra.db # Base de données SQLite
|
||||
└── backup_*.json # Sauvegardes migration (si appliquée)
|
||||
```
|
||||
|
||||
## Réseau
|
||||
|
||||
### Mode Host (Backend)
|
||||
Le backend utilise `network_mode: host` pour envoyer les paquets Wake-on-LAN. Ceci est **requis** pour le fonctionnement WOL.
|
||||
|
||||
### Ports exposés
|
||||
- **Production** : Port 80 (nginx)
|
||||
- **Développement** :
|
||||
- Backend : 8000
|
||||
- Frontend : 3000
|
||||
- Nginx : 80
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Tokens nginx cachés (`server_tokens off`)
|
||||
- Health checks pour haute disponibilité
|
||||
- Variables d'environnement pour configuration
|
||||
- Volumes restreints aux données nécessaires
|
||||
|
||||
## Dépannage
|
||||
|
||||
### Backend ne démarre pas
|
||||
```bash
|
||||
# Vérifier les logs
|
||||
docker-compose logs backend
|
||||
|
||||
# Vérifier les dépendances
|
||||
docker exec -it zebra_backend uv pip list
|
||||
```
|
||||
|
||||
### Frontend ne compile pas
|
||||
```bash
|
||||
# Vérifier les logs de build
|
||||
docker-compose logs frontend
|
||||
|
||||
# Rebuild sans cache
|
||||
docker-compose build --no-cache frontend
|
||||
```
|
||||
|
||||
### WOL ne fonctionne pas
|
||||
- Vérifier que `network_mode: host` est activé pour le backend
|
||||
- Tester depuis l'host : `wakeonlan 00:11:22:33:44:55`
|
||||
265
README.md
265
README.md
@@ -1,136 +1,195 @@
|
||||
# Zebra Power 🍅
|
||||
|
||||
Application dockerisée pour la gestion Wake-on-LAN et le contrôle des VMs/Containers Proxmox.
|
||||
*Nommée d'après la tomate verte zebra*
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Wake-on-LAN** : Réveil de serveurs via paquets magiques
|
||||
- **Monitoring** : Ping automatique et statut des serveurs
|
||||
- **Proxmox** : Gestion des clusters, VMs et containers
|
||||
- **Dashboard** : Vue d'ensemble avec statistiques
|
||||
- **Interface moderne** : Vue.js 3 avec Tailwind CSS
|
||||
Application web dockerisée pour la gestion unifiée d'hosts Proxmox avec Wake-on-LAN, contrôle de VMs/Containers et extinction complète.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend** : FastAPI + SQLite
|
||||
- **Frontend** : Vue.js 3 + Tailwind CSS
|
||||
- **Proxy** : Nginx
|
||||
- **Base de données** : SQLite (fichier local)
|
||||
### Modèle unifié ProxmoxHost
|
||||
- **Wake-on-LAN** : Démarrage via IP/MAC
|
||||
- **Proxmox** : Gestion VMs/Containers
|
||||
- **Shutdown** : Extinction host complet
|
||||
- **Une seule configuration** par host physique
|
||||
|
||||
## Installation
|
||||
### Stack technique
|
||||
- **Backend** : Python 3.13 + FastAPI 0.115.0 + SQLAlchemy 2.0.35 + uv
|
||||
- **Frontend** : Vue.js 3.4.0 + Vite 5.0.8 + Tailwind CSS 3.3.6
|
||||
- **Base de données** : SQLite
|
||||
- **Infrastructure** : Docker Compose + Nginx 1.25
|
||||
|
||||
### Prérequis
|
||||
## Installation et lancement
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Réseau local pour WOL
|
||||
|
||||
### Démarrage rapide
|
||||
|
||||
1. Cloner le projet :
|
||||
### Développement (avec hot reload)
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd zebra_power
|
||||
# Lancer l'application en mode développement
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Voir les logs
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Arrêter
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
2. Créer le répertoire de données :
|
||||
```bash
|
||||
mkdir -p data
|
||||
```
|
||||
**Accès développement :**
|
||||
- Frontend : http://localhost:3000
|
||||
- Backend API : http://localhost:8000
|
||||
- Documentation API : http://localhost:8000/docs
|
||||
|
||||
3. Lancer l'application :
|
||||
### Production
|
||||
```bash
|
||||
# Lancer l'application en production
|
||||
docker-compose up -d
|
||||
|
||||
# Voir les logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Arrêter
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
4. Accéder à l'interface :
|
||||
- **Application** : http://localhost (via Nginx)
|
||||
- **Frontend direct** : http://localhost:3000
|
||||
- **API** : http://localhost:8000
|
||||
- **Documentation API** : http://localhost:8000/docs
|
||||
**Accès production :**
|
||||
- Application complète : http://localhost
|
||||
|
||||
## Configuration
|
||||
## Interface utilisateur
|
||||
|
||||
### Serveurs Wake-on-LAN
|
||||
### Page Hosts unifiée (`/hosts`)
|
||||
**Workflow intégré par host :**
|
||||
1. ⚡ **Wake** - Démarrage WOL
|
||||
2. 🔄 **VMs** - Chargement et contrôle VMs/Containers
|
||||
3. 🛑 **Shutdown** - Extinction host et toutes VMs
|
||||
|
||||
1. Accédez à la section "Serveurs"
|
||||
2. Ajoutez un serveur avec :
|
||||
- Nom
|
||||
- Adresse IP
|
||||
- Adresse MAC
|
||||
- Description (optionnelle)
|
||||
**Fonctionnalités :**
|
||||
- Statut temps réel (en ligne/hors ligne)
|
||||
- Configuration complète (WOL + Proxmox)
|
||||
- Actions groupées par host physique
|
||||
|
||||
### Clusters Proxmox
|
||||
### Dashboard (`/`)
|
||||
**Vue d'ensemble :**
|
||||
- Nombre total d'hosts et statut
|
||||
- VMs/Containers actives
|
||||
- Actions rapides Wake/Start/Stop
|
||||
- Logs récents centralisés
|
||||
|
||||
1. Accédez à la section "Proxmox"
|
||||
2. Ajoutez un cluster avec :
|
||||
- Nom du cluster
|
||||
- Host/IP du serveur Proxmox
|
||||
- Nom d'utilisateur (ex: root@pam)
|
||||
- Mot de passe
|
||||
- Port (défaut: 8006)
|
||||
- Vérification SSL
|
||||
## API
|
||||
|
||||
## API Endpoints
|
||||
### Endpoints principaux
|
||||
- `GET /api/hosts` - Liste des hosts
|
||||
- `POST /api/hosts` - Créer un host
|
||||
- `POST /api/hosts/{id}/wake` - Wake-on-LAN
|
||||
- `GET /api/hosts/{id}/vms` - VMs du host
|
||||
- `POST /api/hosts/{id}/vms/{vmid}/start` - Démarrer VM
|
||||
- `POST /api/hosts/{id}/vms/{vmid}/stop` - Arrêter VM
|
||||
- `POST /api/hosts/{id}/shutdown` - Éteindre host
|
||||
- `GET /api/wol/all-logs` - Logs centralisés
|
||||
|
||||
### Serveurs
|
||||
- `GET /api/servers` - Liste des serveurs
|
||||
- `POST /api/servers` - Créer un serveur
|
||||
- `PUT /api/servers/{id}` - Modifier un serveur
|
||||
- `DELETE /api/servers/{id}` - Supprimer un serveur
|
||||
### Structure ProxmoxHost
|
||||
```json
|
||||
{
|
||||
"name": "Mon Host",
|
||||
"description": "Description optionnelle",
|
||||
"ip_address": "192.168.1.100",
|
||||
"mac_address": "00:11:22:33:44:55",
|
||||
"proxmox_host": "192.168.1.100",
|
||||
"proxmox_username": "root",
|
||||
"proxmox_password": "password",
|
||||
"proxmox_port": 8006,
|
||||
"verify_ssl": false,
|
||||
"shutdown_endpoint": "/api/shutdown"
|
||||
}
|
||||
```
|
||||
|
||||
### Wake-on-LAN
|
||||
- `POST /api/wol/wake/{server_id}` - Réveiller un serveur
|
||||
- `POST /api/wol/ping/{server_id}` - Ping un serveur
|
||||
- `GET /api/wol/logs` - Logs WOL
|
||||
## Migration depuis ancienne architecture
|
||||
|
||||
### Proxmox
|
||||
- `GET /api/proxmox/clusters` - Liste des clusters
|
||||
- `POST /api/proxmox/clusters` - Ajouter un cluster
|
||||
- `GET /api/proxmox/clusters/{id}/vms` - VMs d'un cluster
|
||||
- `POST /api/proxmox/clusters/{id}/vms/{vmid}/start` - Démarrer VM
|
||||
- `POST /api/proxmox/clusters/{id}/vms/{vmid}/stop` - Arrêter VM
|
||||
### Migration des données
|
||||
```bash
|
||||
# Migrer serveurs et clusters vers hosts unifiés
|
||||
docker exec -it zebra_backend uv run python migrate_to_unified_hosts.py
|
||||
|
||||
# Optionnel : nettoyer anciennes tables après validation
|
||||
docker exec -it zebra_backend uv run python cleanup_old_tables.py
|
||||
```
|
||||
|
||||
### Redirections automatiques
|
||||
- `/servers` → `/hosts`
|
||||
- `/proxmox` → `/hosts`
|
||||
|
||||
## Base de données
|
||||
|
||||
### Table principale
|
||||
- **`proxmox_hosts`** - Configuration unifiée hosts
|
||||
- **`action_logs`** - Historique centralisé toutes actions
|
||||
- **`wol_logs`** - Legacy (rétrocompatibilité)
|
||||
|
||||
### Sauvegarde
|
||||
```bash
|
||||
# Sauvegarder la base
|
||||
cp ./data/zebra.db ./data/zebra.db.backup
|
||||
|
||||
# Restaurer
|
||||
cp ./data/zebra.db.backup ./data/zebra.db
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
### Structure backend
|
||||
```
|
||||
backend/app/
|
||||
├── api/hosts.py # API unifiée hosts
|
||||
├── api/wol.py # Logs (legacy)
|
||||
├── models/schemas.py # ProxmoxHost schemas
|
||||
├── services/
|
||||
│ ├── proxmox_host_service.py # Service unifié
|
||||
│ ├── logging_service.py # Logs centralisés
|
||||
│ └── wol_service.py # Utilitaires WOL
|
||||
├── database.py # Modèles SQLAlchemy
|
||||
└── main.py # Application FastAPI
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
### Structure frontend
|
||||
```
|
||||
frontend/src/
|
||||
├── views/
|
||||
│ ├── Hosts.vue # Interface unifiée
|
||||
│ └── Dashboard.vue # Vue d'ensemble
|
||||
├── services/api.js # hostsApi, logsApi
|
||||
└── main.js # Routes et configuration
|
||||
```
|
||||
|
||||
## Notes techniques
|
||||
|
||||
- Le backend utilise `network_mode: host` pour envoyer des paquets WOL
|
||||
- Les données sont persistées dans `./data/zebra.db`
|
||||
- L'interface supporte les thèmes sombres/clairs
|
||||
- Monitoring temps réel des statuts serveurs
|
||||
|
||||
## Dépannage
|
||||
|
||||
### WOL ne fonctionne pas
|
||||
- Vérifiez que WOL est activé sur le serveur cible
|
||||
- Assurez-vous que le conteneur utilise le réseau host
|
||||
- Vérifiez l'adresse MAC
|
||||
|
||||
### Connexion Proxmox échoue
|
||||
- Vérifiez les credentials et l'IP
|
||||
- Testez l'accès via navigateur
|
||||
- Vérifiez les certificats SSL
|
||||
|
||||
### Port déjà utilisé
|
||||
### Commandes utiles
|
||||
```bash
|
||||
docker-compose down
|
||||
# Modifier les ports dans docker-compose.yml si nécessaire
|
||||
docker-compose up -d
|
||||
```
|
||||
# Tests backend
|
||||
docker exec -it zebra_backend uv run python test_api.py
|
||||
|
||||
# Build images
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Logs spécifiques
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
docker-compose logs nginx
|
||||
|
||||
# Health checks
|
||||
docker inspect --format='{{.State.Health.Status}}' zebra_backend
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Mode host requis pour Wake-on-LAN
|
||||
- Validation Pydantic des entrées API
|
||||
- Logging centralisé de toutes actions
|
||||
- Gestion des erreurs et timeouts
|
||||
- Health checks Docker intégrés
|
||||
|
||||
## Réseau
|
||||
|
||||
### Ports
|
||||
- **80** : Nginx (production)
|
||||
- **3000** : Frontend (développement)
|
||||
- **8000** : Backend API (développement)
|
||||
|
||||
### Exigences WOL
|
||||
- Backend en `network_mode: host`
|
||||
- Paquet `wakeonlan` installé
|
||||
- Accès réseau local pour broadcast
|
||||
|
||||
L'application est maintenant entièrement unifiée avec une architecture simplifiée et moderne.
|
||||
@@ -1,17 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wakeonlan \
|
||||
iputils-ping \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install uv for faster package management
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Copy requirements and install dependencies with uv
|
||||
COPY requirements.txt .
|
||||
RUN uv pip install --system --no-cache -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY migrate_to_unified_hosts.py ./
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
# Production server without auto-reload
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
30
backend/Dockerfile.dev
Normal file
30
backend/Dockerfile.dev
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wakeonlan \
|
||||
iputils-ping \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv for faster package management
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Copy requirements and install dependencies with uv
|
||||
COPY requirements.txt .
|
||||
RUN uv pip install --system --no-cache -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY migrate_to_unified_hosts.py ./
|
||||
COPY test_api.py ./
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Development server with auto-reload
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
247
backend/app/api/hosts.py
Normal file
247
backend/app/api/hosts.py
Normal file
@@ -0,0 +1,247 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db, ProxmoxHost
|
||||
from app.models.schemas import ProxmoxHost as ProxmoxHostSchema, ProxmoxHostCreate, ProxmoxVM
|
||||
from app.services.proxmox_host_service import ProxmoxHostService
|
||||
from app.services.logging_service import LoggingService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[ProxmoxHostSchema])
|
||||
async def get_hosts(db: Session = Depends(get_db)):
|
||||
"""Get all Proxmox hosts"""
|
||||
hosts = db.query(ProxmoxHost).all()
|
||||
return hosts
|
||||
|
||||
@router.post("/", response_model=ProxmoxHostSchema)
|
||||
async def create_host(host: ProxmoxHostCreate, db: Session = Depends(get_db)):
|
||||
"""Create a new Proxmox host"""
|
||||
try:
|
||||
# Test Proxmox connection before creating
|
||||
temp_host = ProxmoxHost(**host.dict())
|
||||
host_service = ProxmoxHostService(temp_host)
|
||||
|
||||
if not await host_service.test_proxmox_connection():
|
||||
raise HTTPException(status_code=400, detail="Cannot connect to Proxmox host")
|
||||
|
||||
# Create the host in database
|
||||
db_host = ProxmoxHost(**host.dict())
|
||||
db.add(db_host)
|
||||
db.commit()
|
||||
db.refresh(db_host)
|
||||
|
||||
# Log host creation
|
||||
LoggingService.log_host_action(
|
||||
db=db,
|
||||
action="create",
|
||||
host_id=db_host.id,
|
||||
host_name=db_host.name,
|
||||
success=True,
|
||||
message=f"Proxmox host '{db_host.name}' created successfully"
|
||||
)
|
||||
|
||||
return db_host
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error creating host: {str(e)}")
|
||||
|
||||
@router.get("/{host_id}", response_model=ProxmoxHostSchema)
|
||||
async def get_host(host_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a specific Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
return host
|
||||
|
||||
@router.put("/{host_id}", response_model=ProxmoxHostSchema)
|
||||
async def update_host(host_id: int, host_update: ProxmoxHostCreate, db: Session = Depends(get_db)):
|
||||
"""Update a Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
# Test new connection if Proxmox settings changed
|
||||
temp_host = ProxmoxHost(**host_update.dict(), id=host_id)
|
||||
host_service = ProxmoxHostService(temp_host)
|
||||
|
||||
if not await host_service.test_proxmox_connection():
|
||||
raise HTTPException(status_code=400, detail="Cannot connect to Proxmox with new settings")
|
||||
|
||||
old_name = host.name
|
||||
for key, value in host_update.dict().items():
|
||||
setattr(host, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(host)
|
||||
|
||||
# Log host update
|
||||
LoggingService.log_host_action(
|
||||
db=db,
|
||||
action="update",
|
||||
host_id=host.id,
|
||||
host_name=host.name,
|
||||
success=True,
|
||||
message=f"Proxmox host '{old_name}' updated successfully"
|
||||
)
|
||||
|
||||
return host
|
||||
|
||||
@router.delete("/{host_id}")
|
||||
async def delete_host(host_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete a Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
# Log host deletion before deleting
|
||||
LoggingService.log_host_action(
|
||||
db=db,
|
||||
action="delete",
|
||||
host_id=host.id,
|
||||
host_name=host.name,
|
||||
success=True,
|
||||
message=f"Proxmox host '{host.name}' deleted successfully"
|
||||
)
|
||||
|
||||
db.delete(host)
|
||||
db.commit()
|
||||
return {"message": "Host deleted successfully"}
|
||||
|
||||
@router.post("/{host_id}/wake")
|
||||
async def wake_host(host_id: int, db: Session = Depends(get_db)):
|
||||
"""Wake up a Proxmox host using Wake-on-LAN"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
host_service = ProxmoxHostService(host)
|
||||
success = await host_service.wake_host(db)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to wake host")
|
||||
|
||||
return {"message": f"WOL packet sent to {host.name}", "success": True}
|
||||
|
||||
@router.post("/{host_id}/shutdown")
|
||||
async def shutdown_host(host_id: int, db: Session = Depends(get_db)):
|
||||
"""Shutdown a Proxmox host (and all its VMs)"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
host_service = ProxmoxHostService(host)
|
||||
success = await host_service.shutdown_host(db)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to shutdown host")
|
||||
|
||||
return {"message": f"Shutdown initiated for {host.name}", "success": True}
|
||||
|
||||
@router.get("/{host_id}/vms", response_model=List[ProxmoxVM])
|
||||
async def get_host_vms(host_id: int, db: Session = Depends(get_db)):
|
||||
"""Get all VMs/containers from a Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
try:
|
||||
host_service = ProxmoxHostService(host)
|
||||
vms = await host_service.get_vms()
|
||||
return vms
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error getting VMs: {str(e)}")
|
||||
|
||||
@router.post("/{host_id}/vms/{vmid}/start")
|
||||
async def start_vm(host_id: int, vmid: str, node: str, vm_type: str = "qemu", db: Session = Depends(get_db)):
|
||||
"""Start a VM/container on a Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
host_service = ProxmoxHostService(host)
|
||||
success = await host_service.start_vm(node, vmid, vm_type)
|
||||
|
||||
# Get VM name for logging
|
||||
try:
|
||||
vms = await host_service.get_vms()
|
||||
vm_name = next((vm.name for vm in vms if vm.vmid == vmid), f"VM-{vmid}")
|
||||
except:
|
||||
vm_name = f"VM-{vmid}"
|
||||
|
||||
# Log VM start action
|
||||
LoggingService.log_proxmox_vm_action(
|
||||
db=db,
|
||||
action="start",
|
||||
vmid=vmid,
|
||||
vm_name=vm_name,
|
||||
node=node,
|
||||
success=success,
|
||||
message=f"VM {vm_name} ({'started' if success else 'failed to start'}) on node {node}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to start VM")
|
||||
|
||||
return {"message": f"VM {vmid} start command sent", "success": True}
|
||||
|
||||
@router.post("/{host_id}/vms/{vmid}/stop")
|
||||
async def stop_vm(host_id: int, vmid: str, node: str, vm_type: str = "qemu", db: Session = Depends(get_db)):
|
||||
"""Stop a VM/container on a Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
host_service = ProxmoxHostService(host)
|
||||
success = await host_service.stop_vm(node, vmid, vm_type)
|
||||
|
||||
# Get VM name for logging
|
||||
try:
|
||||
vms = await host_service.get_vms()
|
||||
vm_name = next((vm.name for vm in vms if vm.vmid == vmid), f"VM-{vmid}")
|
||||
except:
|
||||
vm_name = f"VM-{vmid}"
|
||||
|
||||
# Log VM stop action
|
||||
LoggingService.log_proxmox_vm_action(
|
||||
db=db,
|
||||
action="stop",
|
||||
vmid=vmid,
|
||||
vm_name=vm_name,
|
||||
node=node,
|
||||
success=success,
|
||||
message=f"VM {vm_name} ({'stopped' if success else 'failed to stop'}) on node {node}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to stop VM")
|
||||
|
||||
return {"message": f"VM {vmid} stop command sent", "success": True}
|
||||
|
||||
@router.post("/check-status")
|
||||
async def check_all_hosts_status(db: Session = Depends(get_db)):
|
||||
"""Check online status of all Proxmox hosts"""
|
||||
await ProxmoxHostService.check_all_hosts_status(db)
|
||||
return {"message": "Host status checked for all hosts"}
|
||||
|
||||
@router.get("/{host_id}/status")
|
||||
async def check_host_status(host_id: int, db: Session = Depends(get_db)):
|
||||
"""Check online status of a specific Proxmox host"""
|
||||
host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first()
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
is_online = await ProxmoxHostService.ping_host(host.ip_address)
|
||||
|
||||
from datetime import datetime
|
||||
host.is_online = is_online
|
||||
host.last_ping = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"host_id": host_id,
|
||||
"host_name": host.name,
|
||||
"is_online": is_online,
|
||||
"last_ping": host.last_ping
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db, ProxmoxCluster
|
||||
from app.models.schemas import ProxmoxCluster as ProxmoxClusterSchema, ProxmoxClusterCreate, ProxmoxVM
|
||||
from app.services.proxmox_service import ProxmoxService
|
||||
from app.services.logging_service import LoggingService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/clusters", response_model=List[ProxmoxClusterSchema])
|
||||
async def get_clusters(db: Session = Depends(get_db)):
|
||||
clusters = db.query(ProxmoxCluster).all()
|
||||
return clusters
|
||||
|
||||
@router.post("/clusters", response_model=ProxmoxClusterSchema)
|
||||
async def create_cluster(cluster: ProxmoxClusterCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
proxmox_service = ProxmoxService(
|
||||
host=cluster.host,
|
||||
user=cluster.username,
|
||||
password=cluster.password,
|
||||
port=cluster.port,
|
||||
verify_ssl=cluster.verify_ssl
|
||||
)
|
||||
|
||||
if not await proxmox_service.test_connection():
|
||||
raise HTTPException(status_code=400, detail="Cannot connect to Proxmox cluster")
|
||||
|
||||
db_cluster = ProxmoxCluster(**cluster.dict())
|
||||
db.add(db_cluster)
|
||||
db.commit()
|
||||
db.refresh(db_cluster)
|
||||
|
||||
# Log cluster creation
|
||||
LoggingService.log_proxmox_cluster_action(
|
||||
db=db,
|
||||
action="create",
|
||||
cluster_id=db_cluster.id,
|
||||
cluster_name=db_cluster.name,
|
||||
success=True,
|
||||
message=f"Proxmox cluster '{db_cluster.name}' created successfully"
|
||||
)
|
||||
|
||||
return db_cluster
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error creating cluster: {str(e)}")
|
||||
|
||||
@router.get("/clusters/{cluster_id}", response_model=ProxmoxClusterSchema)
|
||||
async def get_cluster(cluster_id: int, db: Session = Depends(get_db)):
|
||||
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
return cluster
|
||||
|
||||
@router.delete("/clusters/{cluster_id}")
|
||||
async def delete_cluster(cluster_id: int, db: Session = Depends(get_db)):
|
||||
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
# Log cluster deletion before deleting
|
||||
LoggingService.log_proxmox_cluster_action(
|
||||
db=db,
|
||||
action="delete",
|
||||
cluster_id=cluster.id,
|
||||
cluster_name=cluster.name,
|
||||
success=True,
|
||||
message=f"Proxmox cluster '{cluster.name}' deleted successfully"
|
||||
)
|
||||
|
||||
db.delete(cluster)
|
||||
db.commit()
|
||||
return {"message": "Cluster deleted successfully"}
|
||||
|
||||
@router.get("/clusters/{cluster_id}/vms", response_model=List[ProxmoxVM])
|
||||
async def get_cluster_vms(cluster_id: int, db: Session = Depends(get_db)):
|
||||
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
try:
|
||||
proxmox_service = ProxmoxService(
|
||||
host=cluster.host,
|
||||
user=cluster.username,
|
||||
password=cluster.password,
|
||||
port=cluster.port,
|
||||
verify_ssl=cluster.verify_ssl
|
||||
)
|
||||
|
||||
vms = await proxmox_service.get_vms()
|
||||
return vms
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error getting VMs: {str(e)}")
|
||||
|
||||
@router.post("/clusters/{cluster_id}/vms/{vmid}/start")
|
||||
async def start_vm(cluster_id: int, vmid: str, node: str, vm_type: str = "qemu", db: Session = Depends(get_db)):
|
||||
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
proxmox_service = ProxmoxService(
|
||||
host=cluster.host,
|
||||
user=cluster.username,
|
||||
password=cluster.password,
|
||||
port=cluster.port,
|
||||
verify_ssl=cluster.verify_ssl
|
||||
)
|
||||
|
||||
success = await proxmox_service.start_vm(node, vmid, vm_type)
|
||||
|
||||
# Get VM name from the cluster's VMs
|
||||
vms = await proxmox_service.get_vms()
|
||||
vm_name = next((vm.name for vm in vms if vm.vmid == vmid), f"VM-{vmid}")
|
||||
|
||||
# Log VM start action
|
||||
LoggingService.log_proxmox_vm_action(
|
||||
db=db,
|
||||
action="start",
|
||||
vmid=vmid,
|
||||
vm_name=vm_name,
|
||||
node=node,
|
||||
success=success,
|
||||
message=f"VM {vm_name} ({'started' if success else 'failed to start'}) on node {node}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to start VM")
|
||||
|
||||
return {"message": f"VM {vmid} start command sent", "success": True}
|
||||
|
||||
@router.post("/clusters/{cluster_id}/vms/{vmid}/stop")
|
||||
async def stop_vm(cluster_id: int, vmid: str, node: str, vm_type: str = "qemu", db: Session = Depends(get_db)):
|
||||
cluster = db.query(ProxmoxCluster).filter(ProxmoxCluster.id == cluster_id).first()
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
|
||||
proxmox_service = ProxmoxService(
|
||||
host=cluster.host,
|
||||
user=cluster.username,
|
||||
password=cluster.password,
|
||||
port=cluster.port,
|
||||
verify_ssl=cluster.verify_ssl
|
||||
)
|
||||
|
||||
success = await proxmox_service.stop_vm(node, vmid, vm_type)
|
||||
|
||||
# Get VM name from the cluster's VMs
|
||||
vms = await proxmox_service.get_vms()
|
||||
vm_name = next((vm.name for vm in vms if vm.vmid == vmid), f"VM-{vmid}")
|
||||
|
||||
# Log VM stop action
|
||||
LoggingService.log_proxmox_vm_action(
|
||||
db=db,
|
||||
action="stop",
|
||||
vmid=vmid,
|
||||
vm_name=vm_name,
|
||||
node=node,
|
||||
success=success,
|
||||
message=f"VM {vm_name} ({'stopped' if success else 'failed to stop'}) on node {node}"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to stop VM")
|
||||
|
||||
return {"message": f"VM {vmid} stop command sent", "success": True}
|
||||
@@ -1,90 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db, Server
|
||||
from app.models.schemas import Server as ServerSchema, ServerCreate
|
||||
from app.services.wol_service import WolService
|
||||
from app.services.logging_service import LoggingService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[ServerSchema])
|
||||
async def get_servers(db: Session = Depends(get_db)):
|
||||
servers = db.query(Server).all()
|
||||
return servers
|
||||
|
||||
@router.post("/", response_model=ServerSchema)
|
||||
async def create_server(server: ServerCreate, db: Session = Depends(get_db)):
|
||||
db_server = Server(**server.dict())
|
||||
db.add(db_server)
|
||||
db.commit()
|
||||
db.refresh(db_server)
|
||||
|
||||
# Log server creation
|
||||
LoggingService.log_server_action(
|
||||
db=db,
|
||||
action="create",
|
||||
server_id=db_server.id,
|
||||
server_name=db_server.name,
|
||||
success=True,
|
||||
message=f"Server '{db_server.name}' created successfully"
|
||||
)
|
||||
|
||||
return db_server
|
||||
|
||||
@router.get("/{server_id}", response_model=ServerSchema)
|
||||
async def get_server(server_id: int, db: Session = Depends(get_db)):
|
||||
server = db.query(Server).filter(Server.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
return server
|
||||
|
||||
@router.put("/{server_id}", response_model=ServerSchema)
|
||||
async def update_server(server_id: int, server_update: ServerCreate, db: Session = Depends(get_db)):
|
||||
server = db.query(Server).filter(Server.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
old_name = server.name
|
||||
for key, value in server_update.dict().items():
|
||||
setattr(server, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(server)
|
||||
|
||||
# Log server update
|
||||
LoggingService.log_server_action(
|
||||
db=db,
|
||||
action="update",
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
success=True,
|
||||
message=f"Server '{old_name}' updated successfully"
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
@router.delete("/{server_id}")
|
||||
async def delete_server(server_id: int, db: Session = Depends(get_db)):
|
||||
server = db.query(Server).filter(Server.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
# Log server deletion before deleting
|
||||
LoggingService.log_server_action(
|
||||
db=db,
|
||||
action="delete",
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
success=True,
|
||||
message=f"Server '{server.name}' deleted successfully"
|
||||
)
|
||||
|
||||
db.delete(server)
|
||||
db.commit()
|
||||
return {"message": "Server deleted successfully"}
|
||||
|
||||
@router.post("/check-status")
|
||||
async def check_all_servers_status(db: Session = Depends(get_db)):
|
||||
await WolService.check_all_servers_status(db)
|
||||
return {"message": "Server status checked for all servers"}
|
||||
@@ -34,6 +34,28 @@ class ProxmoxCluster(Base):
|
||||
verify_ssl = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class ProxmoxHost(Base):
|
||||
__tablename__ = "proxmox_hosts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True, nullable=False)
|
||||
description = Column(String)
|
||||
# Network configuration for WOL
|
||||
ip_address = Column(String, nullable=False)
|
||||
mac_address = Column(String, nullable=False)
|
||||
# Proxmox configuration
|
||||
proxmox_host = Column(String, nullable=False) # Can be same as ip_address
|
||||
proxmox_username = Column(String, nullable=False)
|
||||
proxmox_password = Column(String, nullable=False)
|
||||
proxmox_port = Column(Integer, default=8006)
|
||||
verify_ssl = Column(Boolean, default=True)
|
||||
# Host status
|
||||
is_online = Column(Boolean, default=False)
|
||||
last_ping = Column(DateTime)
|
||||
# Optional shutdown endpoint for host shutdown
|
||||
shutdown_endpoint = Column(String, nullable=True) # e.g., /api/hosts/shutdown
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class ActionLog(Base):
|
||||
__tablename__ = "action_logs"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.database import init_db
|
||||
from app.api import servers, proxmox, wol
|
||||
from app.api import wol, hosts
|
||||
import traceback
|
||||
|
||||
app = FastAPI(title="Zebra Power", description="Wake-on-LAN and Proxmox Management API")
|
||||
@@ -27,9 +27,12 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
}
|
||||
)
|
||||
|
||||
app.include_router(servers.router, prefix="/api/servers", tags=["servers"])
|
||||
app.include_router(proxmox.router, prefix="/api/proxmox", tags=["proxmox"])
|
||||
app.include_router(wol.router, prefix="/api/wol", tags=["wol"])
|
||||
# Anciens endpoints conservés temporairement pour transition
|
||||
# app.include_router(servers.router, prefix="/api/servers", tags=["servers"]) - SUPPRIMÉ
|
||||
# app.include_router(proxmox.router, prefix="/api/proxmox", tags=["proxmox"]) - SUPPRIMÉ
|
||||
|
||||
app.include_router(wol.router, prefix="/api/wol", tags=["wol"]) # Conservé pour les logs
|
||||
app.include_router(hosts.router, prefix="/api/hosts", tags=["hosts"]) # Nouvelle API unifiée
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
|
||||
@@ -71,5 +71,29 @@ class ActionLog(ActionLogCreate):
|
||||
id: int
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ProxmoxHostBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
ip_address: str
|
||||
mac_address: str
|
||||
proxmox_host: str
|
||||
proxmox_username: str
|
||||
proxmox_password: str
|
||||
proxmox_port: int = 8006
|
||||
verify_ssl: bool = True
|
||||
shutdown_endpoint: Optional[str] = None
|
||||
|
||||
class ProxmoxHostCreate(ProxmoxHostBase):
|
||||
pass
|
||||
|
||||
class ProxmoxHost(ProxmoxHostBase):
|
||||
id: int
|
||||
is_online: bool
|
||||
last_ping: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -79,4 +79,17 @@ class LoggingService:
|
||||
message=message,
|
||||
target_id=server_id,
|
||||
target_name=server_name
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_host_action(db: Session, action: str, host_id: int, host_name: str, success: bool, message: str):
|
||||
"""Log Proxmox host actions (create/update/delete/wake/shutdown)"""
|
||||
LoggingService.log_action(
|
||||
db=db,
|
||||
action_type="host",
|
||||
action=action,
|
||||
success=success,
|
||||
message=message,
|
||||
target_id=host_id,
|
||||
target_name=host_name
|
||||
)
|
||||
253
backend/app/services/proxmox_host_service.py
Normal file
253
backend/app/services/proxmox_host_service.py
Normal file
@@ -0,0 +1,253 @@
|
||||
import asyncio
|
||||
import subprocess
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from proxmoxer import ProxmoxAPI
|
||||
|
||||
from app.database import ProxmoxHost
|
||||
from app.models.schemas import ProxmoxVM
|
||||
from app.services.logging_service import LoggingService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProxmoxHostService:
|
||||
def __init__(self, host_config: ProxmoxHost):
|
||||
self.host_config = host_config
|
||||
self._proxmox = None
|
||||
|
||||
def _get_proxmox_connection(self):
|
||||
if not self._proxmox:
|
||||
try:
|
||||
self._proxmox = ProxmoxAPI(
|
||||
self.host_config.proxmox_host,
|
||||
user=self.host_config.proxmox_username,
|
||||
password=self.host_config.proxmox_password,
|
||||
port=self.host_config.proxmox_port,
|
||||
verify_ssl=self.host_config.verify_ssl,
|
||||
timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Proxmox {self.host_config.proxmox_host}: {str(e)}")
|
||||
raise ConnectionError(f"Cannot connect to Proxmox: {str(e)}")
|
||||
return self._proxmox
|
||||
|
||||
@staticmethod
|
||||
async def send_wol_packet(mac_address: str) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wakeonlan", mac_address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
return result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"WOL timeout for MAC: {mac_address}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"WOL error for MAC {mac_address}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def ping_host(ip_address: str) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "3", ip_address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ping error for IP {ip_address}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def wake_host(self, db: Session) -> bool:
|
||||
success = await self.send_wol_packet(self.host_config.mac_address)
|
||||
|
||||
# Log WOL action
|
||||
LoggingService.log_host_action(
|
||||
db=db,
|
||||
action="wake",
|
||||
host_id=self.host_config.id,
|
||||
host_name=self.host_config.name,
|
||||
success=success,
|
||||
message=f"WOL packet {'sent successfully' if success else 'failed'} to {self.host_config.name} ({self.host_config.mac_address})"
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
async def shutdown_host(self, db: Session) -> bool:
|
||||
try:
|
||||
# Try using shutdown endpoint if configured
|
||||
if self.host_config.shutdown_endpoint:
|
||||
try:
|
||||
response = requests.post(
|
||||
f"http://{self.host_config.ip_address}{self.host_config.shutdown_endpoint}",
|
||||
timeout=10
|
||||
)
|
||||
success = response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Shutdown endpoint failed: {str(e)}")
|
||||
success = False
|
||||
else:
|
||||
# Try Proxmox API shutdown (shutdown all VMs then the node)
|
||||
proxmox = self._get_proxmox_connection()
|
||||
nodes = proxmox.nodes.get()
|
||||
success = True
|
||||
|
||||
for node_data in nodes:
|
||||
node_name = node_data['node']
|
||||
try:
|
||||
# Stop all VMs/containers first
|
||||
vms = await self.get_vms_for_node(node_name)
|
||||
for vm in vms:
|
||||
if vm.status == 'running':
|
||||
await self.stop_vm(node_name, vm.vmid, vm.type)
|
||||
|
||||
# Then shutdown the node
|
||||
proxmox.nodes(node_name).status.post(command='shutdown')
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to shutdown node {node_name}: {str(e)}")
|
||||
success = False
|
||||
|
||||
# Log shutdown action
|
||||
LoggingService.log_host_action(
|
||||
db=db,
|
||||
action="shutdown",
|
||||
host_id=self.host_config.id,
|
||||
host_name=self.host_config.name,
|
||||
success=success,
|
||||
message=f"Host {self.host_config.name} {'shutdown initiated' if success else 'shutdown failed'}"
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Host shutdown failed: {str(e)}")
|
||||
LoggingService.log_host_action(
|
||||
db=db,
|
||||
action="shutdown",
|
||||
host_id=self.host_config.id,
|
||||
host_name=self.host_config.name,
|
||||
success=False,
|
||||
message=f"Host shutdown failed: {str(e)}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def test_proxmox_connection(self) -> bool:
|
||||
try:
|
||||
proxmox = self._get_proxmox_connection()
|
||||
proxmox.version.get()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Proxmox connection test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_nodes(self) -> List[dict]:
|
||||
try:
|
||||
proxmox = self._get_proxmox_connection()
|
||||
return proxmox.nodes.get()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get nodes: {str(e)}")
|
||||
return []
|
||||
|
||||
async def get_vms(self) -> List[ProxmoxVM]:
|
||||
try:
|
||||
proxmox = self._get_proxmox_connection()
|
||||
vms = []
|
||||
|
||||
nodes_list = await self.get_nodes()
|
||||
nodes = [n['node'] for n in nodes_list]
|
||||
|
||||
for node_name in nodes:
|
||||
vms.extend(await self.get_vms_for_node(node_name))
|
||||
|
||||
return vms
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VMs: {str(e)}")
|
||||
return []
|
||||
|
||||
async def get_vms_for_node(self, node_name: str) -> List[ProxmoxVM]:
|
||||
try:
|
||||
proxmox = self._get_proxmox_connection()
|
||||
vms = []
|
||||
|
||||
# Get QEMU VMs
|
||||
try:
|
||||
qemu_vms = proxmox.nodes(node_name).qemu.get()
|
||||
for vm in qemu_vms:
|
||||
if vm.get('template', 0) != 1:
|
||||
vms.append(ProxmoxVM(
|
||||
vmid=str(vm['vmid']),
|
||||
name=vm.get('name', f"VM-{vm['vmid']}"),
|
||||
status=vm.get('status', 'unknown'),
|
||||
node=node_name,
|
||||
type='qemu'
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get QEMU VMs from node {node_name}: {str(e)}")
|
||||
|
||||
# Get LXC Containers
|
||||
try:
|
||||
lxc_containers = proxmox.nodes(node_name).lxc.get()
|
||||
for container in lxc_containers:
|
||||
vms.append(ProxmoxVM(
|
||||
vmid=str(container['vmid']),
|
||||
name=container.get('name', f"CT-{container['vmid']}"),
|
||||
status=container.get('status', 'unknown'),
|
||||
node=node_name,
|
||||
type='lxc'
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get LXC containers from node {node_name}: {str(e)}")
|
||||
|
||||
return vms
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VMs for node {node_name}: {str(e)}")
|
||||
return []
|
||||
|
||||
async def start_vm(self, node: str, vmid: str, vm_type: str = 'qemu') -> bool:
|
||||
try:
|
||||
proxmox = self._get_proxmox_connection()
|
||||
if vm_type == 'lxc':
|
||||
proxmox.nodes(node).lxc(vmid).status.start.post()
|
||||
else:
|
||||
proxmox.nodes(node).qemu(vmid).status.start.post()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start VM {vmid}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def stop_vm(self, node: str, vmid: str, vm_type: str = 'qemu') -> bool:
|
||||
try:
|
||||
proxmox = self._get_proxmox_connection()
|
||||
if vm_type == 'lxc':
|
||||
proxmox.nodes(node).lxc(vmid).status.shutdown.post()
|
||||
else:
|
||||
proxmox.nodes(node).qemu(vmid).status.shutdown.post()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop VM {vmid}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def check_all_hosts_status(db: Session) -> None:
|
||||
hosts = db.query(ProxmoxHost).all()
|
||||
|
||||
tasks = []
|
||||
for host in hosts:
|
||||
tasks.append(ProxmoxHostService.ping_host(host.ip_address))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
for host, is_online in zip(hosts, results):
|
||||
host.is_online = is_online
|
||||
host.last_ping = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
@@ -1,114 +0,0 @@
|
||||
from proxmoxer import ProxmoxAPI
|
||||
from typing import List, Optional
|
||||
from app.models.schemas import ProxmoxVM
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProxmoxService:
|
||||
def __init__(self, host: str, user: str, password: str, port: int = 8006, verify_ssl: bool = True):
|
||||
self.host = host
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.port = port
|
||||
self.verify_ssl = verify_ssl
|
||||
self._proxmox = None
|
||||
|
||||
def _get_connection(self):
|
||||
if not self._proxmox:
|
||||
try:
|
||||
self._proxmox = ProxmoxAPI(
|
||||
self.host,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
port=self.port,
|
||||
verify_ssl=self.verify_ssl,
|
||||
timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Proxmox {self.host}: {str(e)}")
|
||||
raise ConnectionError(f"Cannot connect to Proxmox: {str(e)}")
|
||||
return self._proxmox
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
try:
|
||||
proxmox = self._get_connection()
|
||||
proxmox.version.get()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Proxmox connection test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_nodes(self) -> List[dict]:
|
||||
try:
|
||||
proxmox = self._get_connection()
|
||||
return proxmox.nodes.get()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get nodes: {str(e)}")
|
||||
return []
|
||||
|
||||
async def get_vms(self, node: Optional[str] = None) -> List[ProxmoxVM]:
|
||||
try:
|
||||
proxmox = self._get_connection()
|
||||
vms = []
|
||||
|
||||
if node:
|
||||
nodes = [node]
|
||||
else:
|
||||
nodes_list = await self.get_nodes()
|
||||
nodes = [n['node'] for n in nodes_list]
|
||||
|
||||
for node_name in nodes:
|
||||
try:
|
||||
qemu_vms = proxmox.nodes(node_name).qemu.get()
|
||||
for vm in qemu_vms:
|
||||
if vm.get('template', 0) != 1:
|
||||
vms.append(ProxmoxVM(
|
||||
vmid=str(vm['vmid']),
|
||||
name=vm.get('name', f"VM-{vm['vmid']}"),
|
||||
status=vm.get('status', 'unknown'),
|
||||
node=node_name,
|
||||
type='qemu'
|
||||
))
|
||||
|
||||
lxc_containers = proxmox.nodes(node_name).lxc.get()
|
||||
for container in lxc_containers:
|
||||
vms.append(ProxmoxVM(
|
||||
vmid=str(container['vmid']),
|
||||
name=container.get('name', f"CT-{container['vmid']}"),
|
||||
status=container.get('status', 'unknown'),
|
||||
node=node_name,
|
||||
type='lxc'
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VMs from node {node_name}: {str(e)}")
|
||||
continue
|
||||
|
||||
return vms
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get VMs: {str(e)}")
|
||||
return []
|
||||
|
||||
async def start_vm(self, node: str, vmid: str, vm_type: str = 'qemu') -> bool:
|
||||
try:
|
||||
proxmox = self._get_connection()
|
||||
if vm_type == 'lxc':
|
||||
proxmox.nodes(node).lxc(vmid).status.start.post()
|
||||
else:
|
||||
proxmox.nodes(node).qemu(vmid).status.start.post()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start VM {vmid}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def stop_vm(self, node: str, vmid: str, vm_type: str = 'qemu') -> bool:
|
||||
try:
|
||||
proxmox = self._get_connection()
|
||||
if vm_type == 'lxc':
|
||||
proxmox.nodes(node).lxc(vmid).status.shutdown.post()
|
||||
else:
|
||||
proxmox.nodes(node).qemu(vmid).status.shutdown.post()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop VM {vmid}: {str(e)}")
|
||||
return False
|
||||
99
backend/cleanup_old_tables.py
Executable file
99
backend/cleanup_old_tables.py
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour supprimer les anciennes tables après migration réussie
|
||||
⚠️ ATTENTION: Cette opération est irréversible !
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./zebra.db")
|
||||
|
||||
def cleanup_old_tables():
|
||||
print("🗑️ Nettoyage des anciennes tables")
|
||||
print("=" * 50)
|
||||
print("⚠️ ATTENTION: Cette opération est IRRÉVERSIBLE !")
|
||||
print("⚠️ Assurez-vous que:")
|
||||
print(" 1. La migration vers ProxmoxHost a réussi")
|
||||
print(" 2. L'application fonctionne correctement avec /api/hosts")
|
||||
print(" 3. Vous avez fait une sauvegarde de zebra.db")
|
||||
|
||||
response = input("\n❓ Êtes-vous sûr de vouloir supprimer les anciennes tables ? (oui/NON): ").strip()
|
||||
if response.lower() != 'oui':
|
||||
print("❌ Opération annulée.")
|
||||
return False
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# Vérifier que la nouvelle table existe et contient des données
|
||||
result = conn.execute(text("SELECT COUNT(*) FROM proxmox_hosts")).scalar()
|
||||
if result == 0:
|
||||
print("❌ La table 'proxmox_hosts' est vide!")
|
||||
print(" Lancez d'abord la migration avec: python migrate_to_unified_hosts.py")
|
||||
return False
|
||||
|
||||
print(f"✅ Table 'proxmox_hosts' contient {result} hosts")
|
||||
|
||||
# Vérifier si les anciennes tables existent encore
|
||||
tables_to_drop = []
|
||||
|
||||
try:
|
||||
conn.execute(text("SELECT COUNT(*) FROM servers")).scalar()
|
||||
tables_to_drop.append("servers")
|
||||
print("📋 Table 'servers' trouvée")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.execute(text("SELECT COUNT(*) FROM proxmox_clusters")).scalar()
|
||||
tables_to_drop.append("proxmox_clusters")
|
||||
print("📋 Table 'proxmox_clusters' trouvée")
|
||||
except:
|
||||
pass
|
||||
|
||||
if not tables_to_drop:
|
||||
print("ℹ️ Aucune ancienne table à supprimer.")
|
||||
return True
|
||||
|
||||
print(f"\n🗑️ Tables à supprimer: {', '.join(tables_to_drop)}")
|
||||
final_confirm = input("❓ Confirmer la suppression ? (oui/NON): ").strip()
|
||||
if final_confirm.lower() != 'oui':
|
||||
print("❌ Opération annulée.")
|
||||
return False
|
||||
|
||||
# Supprimer les tables
|
||||
for table in tables_to_drop:
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||
print(f" ✅ Table '{table}' supprimée")
|
||||
|
||||
# Supprimer aussi les index associés
|
||||
try:
|
||||
conn.execute(text("DROP INDEX IF EXISTS ix_servers_id"))
|
||||
conn.execute(text("DROP INDEX IF EXISTS ix_servers_name"))
|
||||
conn.execute(text("DROP INDEX IF EXISTS ix_proxmox_clusters_id"))
|
||||
conn.execute(text("DROP INDEX IF EXISTS ix_proxmox_clusters_name"))
|
||||
print(" ✅ Index associés supprimés")
|
||||
except:
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
|
||||
print(f"\n🎉 Nettoyage terminé avec succès!")
|
||||
print(f" - {len(tables_to_drop)} tables supprimées")
|
||||
print(f" - La nouvelle architecture unifiée est maintenant en place")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors du nettoyage: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
success = cleanup_old_tables()
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
350
backend/migrate_to_unified_hosts.py
Executable file
350
backend/migrate_to_unified_hosts.py
Executable file
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de migration pour fusionner les tables 'servers' et 'proxmox_clusters'
|
||||
vers la nouvelle table unifiée 'proxmox_hosts'
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Ajouter le dossier app au path pour les imports
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
|
||||
|
||||
from app.database import Base, Server, ProxmoxCluster, ProxmoxHost, ActionLog
|
||||
from app.services.logging_service import LoggingService
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./zebra.db")
|
||||
|
||||
def create_backup(engine):
|
||||
"""Créer une sauvegarde des tables existantes"""
|
||||
print("📦 Création des sauvegardes...")
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Backup servers table
|
||||
servers_backup = conn.execute(text("SELECT * FROM servers")).fetchall()
|
||||
with open(f"backup_servers_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", 'w') as f:
|
||||
servers_data = []
|
||||
for row in servers_backup:
|
||||
servers_data.append({
|
||||
'id': row[0],
|
||||
'name': row[1],
|
||||
'ip_address': row[2],
|
||||
'mac_address': row[3],
|
||||
'description': row[4],
|
||||
'is_online': row[5],
|
||||
'last_ping': str(row[6]) if row[6] else None,
|
||||
'created_at': str(row[7])
|
||||
})
|
||||
json.dump(servers_data, f, indent=2)
|
||||
print(f" ✅ Sauvegardé {len(servers_data)} serveurs")
|
||||
|
||||
# Backup proxmox_clusters table
|
||||
try:
|
||||
clusters_backup = conn.execute(text("SELECT * FROM proxmox_clusters")).fetchall()
|
||||
with open(f"backup_proxmox_clusters_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", 'w') as f:
|
||||
clusters_data = []
|
||||
for row in clusters_backup:
|
||||
clusters_data.append({
|
||||
'id': row[0],
|
||||
'name': row[1],
|
||||
'host': row[2],
|
||||
'username': row[3],
|
||||
'password': row[4],
|
||||
'port': row[5],
|
||||
'verify_ssl': row[6],
|
||||
'created_at': str(row[7])
|
||||
})
|
||||
json.dump(clusters_data, f, indent=2)
|
||||
print(f" ✅ Sauvegardé {len(clusters_data)} clusters Proxmox")
|
||||
except Exception as e:
|
||||
print(f" ℹ️ Aucun cluster Proxmox à sauvegarder: {e}")
|
||||
clusters_data = []
|
||||
|
||||
return len(servers_data), len(clusters_data)
|
||||
|
||||
def migrate_servers_to_hosts(session):
|
||||
"""Migrer les serveurs existants vers la table hosts en demandant les infos Proxmox"""
|
||||
print("\n🔄 Migration des serveurs vers hosts...")
|
||||
|
||||
servers = session.query(Server).all()
|
||||
migrated_hosts = []
|
||||
|
||||
for server in servers:
|
||||
print(f"\n📋 Migration du serveur: {server.name}")
|
||||
print(f" IP: {server.ip_address}")
|
||||
print(f" MAC: {server.mac_address}")
|
||||
|
||||
# Demander les informations Proxmox pour ce serveur
|
||||
print(f" Pour ce serveur, veuillez fournir les informations Proxmox:")
|
||||
|
||||
# Utiliser l'IP du serveur comme host Proxmox par défaut
|
||||
proxmox_host = input(f" Host Proxmox (défaut: {server.ip_address}): ").strip()
|
||||
if not proxmox_host:
|
||||
proxmox_host = server.ip_address
|
||||
|
||||
proxmox_username = input(" Nom d'utilisateur Proxmox: ").strip()
|
||||
if not proxmox_username:
|
||||
print(" ⚠️ Nom d'utilisateur requis! Utilisation de 'root' par défaut")
|
||||
proxmox_username = "root"
|
||||
|
||||
proxmox_password = input(" Mot de passe Proxmox: ").strip()
|
||||
if not proxmox_password:
|
||||
print(" ⚠️ Mot de passe requis! Utilisation de 'password' par défaut")
|
||||
proxmox_password = "password"
|
||||
|
||||
proxmox_port = input(" Port Proxmox (défaut: 8006): ").strip()
|
||||
if not proxmox_port:
|
||||
proxmox_port = 8006
|
||||
else:
|
||||
try:
|
||||
proxmox_port = int(proxmox_port)
|
||||
except ValueError:
|
||||
proxmox_port = 8006
|
||||
|
||||
verify_ssl_input = input(" Vérifier SSL? (o/N): ").strip().lower()
|
||||
verify_ssl = verify_ssl_input in ['o', 'oui', 'y', 'yes']
|
||||
|
||||
shutdown_endpoint = input(" Endpoint de shutdown (optionnel): ").strip()
|
||||
if not shutdown_endpoint:
|
||||
shutdown_endpoint = None
|
||||
|
||||
# Créer le nouveau host unifié
|
||||
host = ProxmoxHost(
|
||||
name=server.name,
|
||||
description=server.description,
|
||||
ip_address=server.ip_address,
|
||||
mac_address=server.mac_address,
|
||||
proxmox_host=proxmox_host,
|
||||
proxmox_username=proxmox_username,
|
||||
proxmox_password=proxmox_password,
|
||||
proxmox_port=proxmox_port,
|
||||
verify_ssl=verify_ssl,
|
||||
is_online=server.is_online,
|
||||
last_ping=server.last_ping,
|
||||
shutdown_endpoint=shutdown_endpoint,
|
||||
created_at=server.created_at
|
||||
)
|
||||
|
||||
session.add(host)
|
||||
session.commit()
|
||||
session.refresh(host)
|
||||
|
||||
# Logger la migration
|
||||
LoggingService.log_host_action(
|
||||
db=session,
|
||||
action="migrate",
|
||||
host_id=host.id,
|
||||
host_name=host.name,
|
||||
success=True,
|
||||
message=f"Serveur '{server.name}' migré vers host unifié (ancien ID serveur: {server.id})"
|
||||
)
|
||||
|
||||
migrated_hosts.append((server.id, host.id, server.name))
|
||||
print(f" ✅ Migré vers host ID {host.id}")
|
||||
|
||||
return migrated_hosts
|
||||
|
||||
def migrate_clusters_to_hosts(session):
|
||||
"""Migrer les clusters Proxmox existants vers la table hosts en demandant les infos WOL"""
|
||||
print("\n🔄 Migration des clusters Proxmox vers hosts...")
|
||||
|
||||
clusters = session.query(ProxmoxCluster).all()
|
||||
migrated_hosts = []
|
||||
|
||||
for cluster in clusters:
|
||||
print(f"\n📋 Migration du cluster: {cluster.name}")
|
||||
print(f" Host Proxmox: {cluster.host}:{cluster.port}")
|
||||
print(f" Utilisateur: {cluster.username}")
|
||||
|
||||
# Demander les informations WOL pour ce cluster
|
||||
print(f" Pour ce cluster, veuillez fournir les informations Wake-on-LAN:")
|
||||
|
||||
# Utiliser le host du cluster comme IP par défaut
|
||||
ip_address = input(f" Adresse IP (défaut: {cluster.host}): ").strip()
|
||||
if not ip_address:
|
||||
ip_address = cluster.host
|
||||
|
||||
mac_address = input(" Adresse MAC: ").strip()
|
||||
if not mac_address:
|
||||
print(" ⚠️ Adresse MAC requise! Utilisation de '00:00:00:00:00:00' par défaut")
|
||||
mac_address = "00:00:00:00:00:00"
|
||||
|
||||
description = input(" Description (optionnel): ").strip()
|
||||
|
||||
shutdown_endpoint = input(" Endpoint de shutdown (optionnel): ").strip()
|
||||
if not shutdown_endpoint:
|
||||
shutdown_endpoint = None
|
||||
|
||||
# Créer le nouveau host unifié
|
||||
host = ProxmoxHost(
|
||||
name=cluster.name,
|
||||
description=description if description else f"Host Proxmox migré du cluster {cluster.name}",
|
||||
ip_address=ip_address,
|
||||
mac_address=mac_address,
|
||||
proxmox_host=cluster.host,
|
||||
proxmox_username=cluster.username,
|
||||
proxmox_password=cluster.password,
|
||||
proxmox_port=cluster.port,
|
||||
verify_ssl=cluster.verify_ssl,
|
||||
is_online=False, # Statut inconnu pour les anciens clusters
|
||||
last_ping=None,
|
||||
shutdown_endpoint=shutdown_endpoint,
|
||||
created_at=cluster.created_at
|
||||
)
|
||||
|
||||
session.add(host)
|
||||
session.commit()
|
||||
session.refresh(host)
|
||||
|
||||
# Logger la migration
|
||||
LoggingService.log_host_action(
|
||||
db=session,
|
||||
action="migrate",
|
||||
host_id=host.id,
|
||||
host_name=host.name,
|
||||
success=True,
|
||||
message=f"Cluster Proxmox '{cluster.name}' migré vers host unifié (ancien ID cluster: {cluster.id})"
|
||||
)
|
||||
|
||||
migrated_hosts.append((cluster.id, host.id, cluster.name))
|
||||
print(f" ✅ Migré vers host ID {host.id}")
|
||||
|
||||
return migrated_hosts
|
||||
|
||||
def update_action_logs(session, server_migrations, cluster_migrations):
|
||||
"""Mettre à jour les logs d'actions pour pointer vers les nouveaux hosts"""
|
||||
print("\n📝 Mise à jour des logs d'actions...")
|
||||
|
||||
# Map ancien server_id -> nouveau host_id
|
||||
server_id_map = {old_id: new_id for old_id, new_id, _ in server_migrations}
|
||||
cluster_id_map = {old_id: new_id for old_id, new_id, _ in cluster_migrations}
|
||||
|
||||
# Mettre à jour les logs de serveurs
|
||||
for old_server_id, new_host_id in server_id_map.items():
|
||||
logs = session.query(ActionLog).filter(
|
||||
ActionLog.action_type == "server",
|
||||
ActionLog.target_id == old_server_id
|
||||
).all()
|
||||
|
||||
for log in logs:
|
||||
log.action_type = "host"
|
||||
log.target_id = new_host_id
|
||||
# Ajouter une note dans le message
|
||||
if not log.message.endswith(" (migré)"):
|
||||
log.message += " (migré)"
|
||||
|
||||
if logs:
|
||||
print(f" ✅ Mis à jour {len(logs)} logs pour l'ancien serveur {old_server_id}")
|
||||
|
||||
# Mettre à jour les logs de clusters Proxmox
|
||||
for old_cluster_id, new_host_id in cluster_id_map.items():
|
||||
logs = session.query(ActionLog).filter(
|
||||
ActionLog.action_type == "proxmox",
|
||||
ActionLog.target_id == old_cluster_id,
|
||||
ActionLog.action.in_(["create", "delete", "update"]) # Actions sur le cluster lui-même
|
||||
).all()
|
||||
|
||||
for log in logs:
|
||||
log.action_type = "host"
|
||||
log.target_id = new_host_id
|
||||
if not log.message.endswith(" (migré)"):
|
||||
log.message += " (migré)"
|
||||
|
||||
if logs:
|
||||
print(f" ✅ Mis à jour {len(logs)} logs pour l'ancien cluster {old_cluster_id}")
|
||||
|
||||
session.commit()
|
||||
|
||||
def main():
|
||||
print("🚀 Migration vers le modèle unifié ProxmoxHost")
|
||||
print("=" * 50)
|
||||
|
||||
# Créer la connexion à la base de données
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Créer les tables si elles n'existent pas
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
# Vérifier s'il y a déjà des hosts
|
||||
existing_hosts = session.query(ProxmoxHost).count()
|
||||
if existing_hosts > 0:
|
||||
print(f"⚠️ Il y a déjà {existing_hosts} hosts dans la base de données.")
|
||||
response = input("Voulez-vous continuer la migration? (o/N): ").strip().lower()
|
||||
if response not in ['o', 'oui', 'y', 'yes']:
|
||||
print("Migration annulée.")
|
||||
return
|
||||
|
||||
# Sauvegarder les données existantes
|
||||
servers_count, clusters_count = create_backup(engine)
|
||||
|
||||
if servers_count == 0 and clusters_count == 0:
|
||||
print("ℹ️ Aucune donnée à migrer.")
|
||||
return
|
||||
|
||||
print(f"\n📊 Données à migrer:")
|
||||
print(f" - {servers_count} serveurs")
|
||||
print(f" - {clusters_count} clusters Proxmox")
|
||||
|
||||
response = input("\nVoulez-vous continuer la migration? (o/N): ").strip().lower()
|
||||
if response not in ['o', 'oui', 'y', 'yes']:
|
||||
print("Migration annulée.")
|
||||
return
|
||||
|
||||
# Migration des serveurs vers hosts
|
||||
server_migrations = []
|
||||
if servers_count > 0:
|
||||
server_migrations = migrate_servers_to_hosts(session)
|
||||
|
||||
# Migration des clusters vers hosts
|
||||
cluster_migrations = []
|
||||
if clusters_count > 0:
|
||||
cluster_migrations = migrate_clusters_to_hosts(session)
|
||||
|
||||
# Mettre à jour les logs d'actions
|
||||
if server_migrations or cluster_migrations:
|
||||
update_action_logs(session, server_migrations, cluster_migrations)
|
||||
|
||||
print(f"\n✅ Migration terminée avec succès!")
|
||||
print(f" - {len(server_migrations)} serveurs migrés")
|
||||
print(f" - {len(cluster_migrations)} clusters migrés")
|
||||
|
||||
# Option pour supprimer les anciennes tables
|
||||
if server_migrations or cluster_migrations:
|
||||
print(f"\n🗑️ Les anciennes données sont toujours présentes.")
|
||||
response = input("Voulez-vous supprimer les anciennes tables 'servers' et 'proxmox_clusters'? (o/N): ").strip().lower()
|
||||
if response in ['o', 'oui', 'y', 'yes']:
|
||||
with engine.connect() as conn:
|
||||
if servers_count > 0:
|
||||
conn.execute(text("DROP TABLE servers"))
|
||||
print(" ✅ Table 'servers' supprimée")
|
||||
if clusters_count > 0:
|
||||
conn.execute(text("DROP TABLE proxmox_clusters"))
|
||||
print(" ✅ Table 'proxmox_clusters' supprimée")
|
||||
conn.commit()
|
||||
print(" ⚠️ Anciennes tables supprimées. Migration irréversible!")
|
||||
else:
|
||||
print(" ℹ️ Anciennes tables conservées pour rollback si nécessaire")
|
||||
|
||||
print(f"\n🎉 Migration vers le modèle unifié terminée!")
|
||||
print(f"Vous pouvez maintenant utiliser /api/hosts pour gérer vos hosts Proxmox")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de la migration: {e}")
|
||||
session.rollback()
|
||||
return 1
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,9 +1,9 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
pydantic==2.5.0
|
||||
python-multipart==0.0.6
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.0
|
||||
python-multipart==0.0.10
|
||||
proxmoxer==2.0.1
|
||||
requests==2.31.0
|
||||
requests==2.32.0
|
||||
wakeonlan==3.1.0
|
||||
httpx==0.25.2
|
||||
httpx==0.27.0
|
||||
91
backend/test_api.py
Executable file
91
backend/test_api.py
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test basique de l'API unifiée hosts
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
|
||||
def test_api():
|
||||
base_url = "http://localhost:8000/api"
|
||||
|
||||
print("🧪 Test de l'API Zebra Power - Hosts unifiés")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Test 1: Health check
|
||||
print("\n1. Test de santé de l'API...")
|
||||
response = requests.get(f"{base_url}/health", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print(" ✅ API en ligne")
|
||||
else:
|
||||
print(f" ❌ API hors service ({response.status_code})")
|
||||
return False
|
||||
|
||||
# Test 2: Liste des hosts (vide au début)
|
||||
print("\n2. Test de récupération des hosts...")
|
||||
response = requests.get(f"{base_url}/hosts", timeout=5)
|
||||
if response.status_code == 200:
|
||||
hosts = response.json()
|
||||
print(f" ✅ {len(hosts)} hosts trouvés")
|
||||
else:
|
||||
print(f" ❌ Erreur récupération hosts ({response.status_code})")
|
||||
return False
|
||||
|
||||
# Test 3: Créer un host de test
|
||||
print("\n3. Test de création d'un host...")
|
||||
test_host = {
|
||||
"name": "Host Test",
|
||||
"description": "Host de test pour l'API",
|
||||
"ip_address": "192.168.1.100",
|
||||
"mac_address": "00:11:22:33:44:55",
|
||||
"proxmox_host": "192.168.1.100",
|
||||
"proxmox_username": "root",
|
||||
"proxmox_password": "password",
|
||||
"proxmox_port": 8006,
|
||||
"verify_ssl": False,
|
||||
"shutdown_endpoint": "/api/shutdown"
|
||||
}
|
||||
|
||||
# Note: Ce test échouera car nous n'avons pas de Proxmox réel
|
||||
response = requests.post(f"{base_url}/hosts", json=test_host, timeout=10)
|
||||
if response.status_code == 200:
|
||||
created_host = response.json()
|
||||
print(f" ✅ Host créé avec ID {created_host['id']}")
|
||||
|
||||
# Test 4: Récupérer le host créé
|
||||
print("\n4. Test de récupération du host créé...")
|
||||
response = requests.get(f"{base_url}/hosts/{created_host['id']}", timeout=5)
|
||||
if response.status_code == 200:
|
||||
host_data = response.json()
|
||||
print(f" ✅ Host récupéré: {host_data['name']}")
|
||||
|
||||
# Test 5: Supprimer le host de test
|
||||
print("\n5. Test de suppression du host...")
|
||||
response = requests.delete(f"{base_url}/hosts/{created_host['id']}", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print(" ✅ Host supprimé avec succès")
|
||||
else:
|
||||
print(f" ❌ Erreur suppression host ({response.status_code})")
|
||||
else:
|
||||
print(f" ❌ Erreur récupération host ({response.status_code})")
|
||||
else:
|
||||
print(f" ❌ Échec création host ({response.status_code})")
|
||||
print(f" Raison: {response.text}")
|
||||
# C'est normal si pas de Proxmox réel
|
||||
|
||||
print(f"\n🎉 Tests terminés - API fonctionnelle")
|
||||
return True
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(" ❌ Impossible de se connecter à l'API")
|
||||
print(" Vérifiez que le serveur backend est démarré sur http://localhost:8000")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Erreur inattendue: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_api()
|
||||
sys.exit(0 if success else 1)
|
||||
41
docker-compose.dev.yml
Normal file
41
docker-compose.dev.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
# Docker Compose for Zebra Power - Development
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: zebra_backend_dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend/app:/app/app
|
||||
- ./backend/migrate_to_unified_hosts.py:/app/migrate_to_unified_hosts.py
|
||||
- ./backend/test_api.py:/app/test_api.py
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///data/zebra.db
|
||||
- CORS_ORIGINS=http://localhost:3000,http://localhost
|
||||
restart: unless-stopped
|
||||
# Required for WOL packets
|
||||
network_mode: host
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: zebra_frontend_dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/public:/app/public
|
||||
- ./frontend/vite.config.js:/app/vite.config.js
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000/api
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
@@ -1,4 +1,4 @@
|
||||
# Docker Compose for Zebra Power
|
||||
# Docker Compose for Zebra Power - Production
|
||||
|
||||
services:
|
||||
backend:
|
||||
@@ -6,29 +6,37 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: zebra_backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///data/zebra.db
|
||||
- CORS_ORIGINS=http://localhost:3000,http://localhost
|
||||
- CORS_ORIGINS=http://localhost,http://127.0.0.1
|
||||
restart: unless-stopped
|
||||
# Required for WOL packets
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: zebra_frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000/api
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
nginx:
|
||||
build:
|
||||
@@ -38,8 +46,10 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
# Install curl for health checks
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies for better caching
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application for production
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
# For production, serve the built files
|
||||
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
15
frontend/Dockerfile.dev
Normal file
15
frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Development server with hot reload
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
3066
frontend/package-lock.json
generated
Normal file
3066
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class', // Enable class-based dark mode
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
FROM nginx:alpine
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
||||
# Add security headers and optimizations
|
||||
RUN echo 'server_tokens off;' >> /etc/nginx/conf.d/security.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/ || exit 1
|
||||
Reference in New Issue
Block a user