refact: use only server

This commit is contained in:
2025-08-27 13:28:50 +02:00
parent cf8a37f183
commit 2934224787
39 changed files with 6843 additions and 1223 deletions

85
.gitignore vendored Normal file
View 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
View File

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

@@ -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.

View File

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

View File

@@ -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}

View File

@@ -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"}

View File

@@ -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"

View File

@@ -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():

View File

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

View File

@@ -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
)

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

View File

@@ -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
View 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())

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

View File

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

View File

@@ -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:

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

View File

@@ -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({

View File

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

View File

@@ -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;
}
}

View File

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

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class', // Enable class-based dark mode
theme: {
extend: {},
},

View File

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

View File

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