diff --git a/.gitignore b/.gitignore index 226bd3d..79360b4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,4 @@ next-env.d.ts /src/generated/prisma /prisma/dev.db -backups/ \ No newline at end of file +/data/* diff --git a/BACKUP.md b/BACKUP.md index c0bfa50..267acb8 100644 --- a/BACKUP.md +++ b/BACKUP.md @@ -52,6 +52,19 @@ tsx scripts/backup-manager.ts config-set maxBackups=10 tsx scripts/backup-manager.ts config-set compression=true ``` +### Personnalisation du dossier de sauvegarde + +```bash +# Via variable d'environnement permanente (.env) +BACKUP_STORAGE_PATH="./custom-backups" + +# Via variable temporaire (une seule fois) +BACKUP_STORAGE_PATH="./my-backups" npm run backup:create + +# Exemple avec un chemin absolu +BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create +``` + ## Utilisation ### Interface graphique @@ -272,8 +285,34 @@ export const prisma = globalThis.__prisma || new PrismaClient({ ### Variables d'environnement ```bash -# Optionnel : personnaliser le chemin de la base -DATABASE_URL="file:./custom/path/dev.db" +# Configuration des chemins de base de données +DATABASE_URL="file:./prisma/dev.db" # Pour Prisma +BACKUP_DATABASE_PATH="./prisma/dev.db" # Base à sauvegarder (optionnel) +BACKUP_STORAGE_PATH="./backups" # Dossier des sauvegardes (optionnel) +``` + +### Docker + +En environnement Docker, tout est centralisé dans le dossier `data/` : + +```yaml +# docker-compose.yml +environment: + DATABASE_URL: "file:./data/prod.db" # Base de données Prisma + BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder + BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes +volumes: + - ./data:/app/data # Bind mount vers dossier local +``` + +**Structure des dossiers :** +``` +./data/ # Dossier local mappé +├── prod.db # Base de données production +├── dev.db # Base de données développement +└── backups/ # Sauvegardes (créé automatiquement) + ├── towercontrol_*.db.gz + └── ... ``` ## API diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..7572e73 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,201 @@ +# 🐳 Docker - TowerControl + +Guide d'utilisation de TowerControl avec Docker. + +## 🚀 Démarrage rapide + +### Production +```bash +# Démarrer le service de production +docker-compose up -d towercontrol + +# Accéder à l'application +open http://localhost:3006 +``` + +### Développement +```bash +# Démarrer le service de développement avec live reload +docker-compose --profile dev up towercontrol-dev + +# Accéder à l'application +open http://localhost:3005 +``` + +## 📋 Services disponibles + +### 🚀 `towercontrol` (Production) +- **Port** : 3006 +- **Base de données** : `./data/prod.db` +- **Sauvegardes** : `./data/backups/` +- **Mode** : Optimisé, standalone +- **Restart** : Automatique + +### 🛠️ `towercontrol-dev` (Développement) +- **Port** : 3005 +- **Base de données** : `./data/dev.db` +- **Sauvegardes** : `./data/backups/` (partagées) +- **Mode** : Live reload, debug +- **Profile** : `dev` + +## 📁 Structure des données + +``` +./data/ # Mappé vers /app/data dans les conteneurs +├── README.md # Documentation du dossier data +├── prod.db # Base SQLite production +├── dev.db # Base SQLite développement +└── backups/ # Sauvegardes automatiques + ├── towercontrol_2025-01-15T10-30-00-000Z.db.gz + └── ... +``` + +## 🔧 Configuration + +### Variables d'environnement + +| Variable | Production | Développement | Description | +|----------|------------|---------------|-------------| +| `NODE_ENV` | `production` | `development` | Mode d'exécution | +| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma | +| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup | +| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup | +| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire | + +### Ports + +- **Production** : `3006:3000` +- **Développement** : `3005:3000` + +## 📚 Commandes utiles + +### Gestion des conteneurs +```bash +# Voir les logs +docker-compose logs -f towercontrol +docker-compose logs -f towercontrol-dev + +# Arrêter les services +docker-compose down + +# Reconstruire les images +docker-compose build + +# Nettoyer tout +docker-compose down -v --rmi all +``` + +### Gestion des données +```bash +# Sauvegarder les données +docker-compose exec towercontrol npm run backup:create + +# Lister les sauvegardes +docker-compose exec towercontrol npm run backup:list + +# Accéder au shell du conteneur +docker-compose exec towercontrol sh +``` + +### Base de données +```bash +# Migrations Prisma +docker-compose exec towercontrol npx prisma migrate deploy + +# Reset de la base (dev uniquement) +docker-compose exec towercontrol-dev npx prisma migrate reset + +# Studio Prisma (dev) +docker-compose exec towercontrol-dev npx prisma studio +``` + +## 🔍 Debugging + +### Vérifier la santé +```bash +# Health check +curl http://localhost:3006/api/health +curl http://localhost:3005/api/health + +# Vérifier les variables d'env +docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)" +``` + +### Logs détaillés +```bash +# Logs avec timestamps +docker-compose logs -f -t towercontrol + +# Logs des 100 dernières lignes +docker-compose logs --tail=100 towercontrol +``` + +## 🚨 Dépannage + +### Problèmes courants + +**Port déjà utilisé** +```bash +# Trouver le processus qui utilise le port +lsof -i :3006 +kill -9 +``` + +**Base de données corrompue** +```bash +# Restaurer depuis une sauvegarde +docker-compose exec towercontrol npm run backup:restore filename.db.gz +``` + +**Permissions** +```bash +# Corriger les permissions du dossier data +sudo chown -R $USER:$USER ./data +``` + +## 📊 Monitoring + +### Espace disque +```bash +# Taille du dossier data +du -sh ./data + +# Espace libre +df -h . +``` + +### Performance +```bash +# Stats des conteneurs +docker stats + +# Utilisation mémoire +docker-compose exec towercontrol free -h +``` + +## 🔒 Production + +### Recommandations +- Utiliser un reverse proxy (nginx, traefik) +- Configurer HTTPS +- Sauvegarder régulièrement `./data/` +- Monitorer l'espace disque +- Logs centralisés + +### Exemple nginx +```nginx +server { + listen 80; + server_name towercontrol.example.com; + + location / { + proxy_pass http://localhost:3006; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +--- + +📚 **Voir aussi** : [BACKUP.md](./BACKUP.md) | [data/README.md](./data/README.md) diff --git a/Dockerfile b/Dockerfile index e65d144..029d319 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,8 +35,8 @@ RUN npm run build # Production image, copy all the files and run next FROM base AS runner -# Set timezone to Europe/Paris -RUN apk add --no-cache tzdata +# Set timezone to Europe/Paris and install sqlite3 for backups +RUN apk add --no-cache tzdata sqlite RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone WORKDIR /app @@ -64,8 +64,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma -# Create data directory for SQLite -RUN mkdir -p /app/data && chown nextjs:nodejs /app/data +# Create data directory for SQLite and backups +RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data # Set all ENV vars before switching user ENV PORT=3000 diff --git a/dev.db b/dev.db deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose.yml b/docker-compose.yml index 528354c..aff9397 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,27 @@ -version: '3.8' - services: towercontrol: build: context: . dockerfile: Dockerfile + target: runner ports: - "3006:3000" environment: - - NODE_ENV=production - - DATABASE_URL=file:/app/data/prod.db - - TZ=Europe/Paris + NODE_ENV: production + DATABASE_URL: "file:../data/prod.db" # Prisma + BACKUP_DATABASE_PATH: "./data/prod.db" # Base de données à sauvegarder + BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes + TZ: Europe/Paris volumes: - # Volume persistant pour la base SQLite - - sqlite_data:/app/data - # Monter ta DB locale (décommente pour utiliser tes données locales) - - ./prisma/dev.db:/app/data/prod.db - - ./backups:/app/backups + - ./data:/app/data # Dossier local data/ vers /app/data restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"] + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] interval: 30s timeout: 10s retries: 3 start_period: 40s - # Service de développement (optionnel) towercontrol-dev: build: context: . @@ -34,20 +30,29 @@ services: ports: - "3005:3000" environment: - - NODE_ENV=development - - DATABASE_URL=file:/app/data/dev.db + NODE_ENV: development + DATABASE_URL: "file:../data/dev.db" # Prisma + BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder + BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes + TZ: Europe/Paris volumes: - - .:/app - - /app/node_modules + - .:/app # code en live + - /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur - /app/.next - - sqlite_data_dev:/app/data - command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev" + - ./data:/app/data # Dossier local data/ vers /app/data + command: > + sh -c "npm install && + npx prisma generate && + npx prisma migrate deploy && + npm run dev" profiles: - dev -volumes: - sqlite_data: - driver: local - sqlite_data_dev: - driver: local - +# 📁 Structure des données : +# ./data/ -> /app/data (bind mount) +# ├── prod.db -> Base de données production +# ├── dev.db -> Base de données développement +# └── backups/ -> Sauvegardes automatiques +# +# 🔧 Configuration via .env.docker +# 📚 Documentation : ./data/README.md \ No newline at end of file diff --git a/env.example b/env.example index 3860ffe..a09cbce 100644 --- a/env.example +++ b/env.example @@ -1,5 +1,13 @@ # Base de données (requis) -DATABASE_URL="file:./dev.db" +DATABASE_URL="file:../data/dev.db" + +# Chemin de la base de données pour les backups (optionnel) +# Si non défini, utilise DATABASE_URL ou le chemin par défaut +BACKUP_DATABASE_PATH="./data/dev.db" + +# Dossier de stockage des sauvegardes (optionnel) +# Par défaut: ./backups en local, ./data/backups en production +BACKUP_STORAGE_PATH="./backups" # Intégration Jira (optionnel) JIRA_BASE_URL="" # https://votre-domaine.atlassian.net diff --git a/services/backup.ts b/services/backup.ts index 2e8cfbc..795545f 100644 --- a/services/backup.ts +++ b/services/backup.ts @@ -31,11 +31,23 @@ export class BackupService { enabled: true, interval: 'hourly', maxBackups: 5, - backupPath: path.join(process.cwd(), 'backups'), + backupPath: this.getDefaultBackupPath(), includeUploads: true, compression: true, }; + private getDefaultBackupPath(): string { + // 1. Variable d'environnement explicite + if (process.env.BACKUP_STORAGE_PATH) { + return path.resolve(process.cwd(), process.env.BACKUP_STORAGE_PATH); + } + + // 2. Chemin par défaut selon l'environnement + return process.env.NODE_ENV === 'production' + ? path.join(process.cwd(), 'data', 'backups') // Docker: /app/data/backups + : path.join(process.cwd(), 'backups'); // Local: ./backups + } + private config: BackupConfig; constructor(config?: Partial) { @@ -106,10 +118,7 @@ export class BackupService { // Créer le dossier de backup si nécessaire await this.ensureBackupDirectory(); - // Vérifier l'état de la base de données - await this.verifyDatabaseHealth(); - - // Créer la sauvegarde SQLite + // Créer la sauvegarde SQLite (sans vérification de santé pour éviter les conflits) await this.createSQLiteBackup(backupPath); // Compresser si activé @@ -156,7 +165,26 @@ export class BackupService { * Crée une sauvegarde SQLite en utilisant la commande .backup */ private async createSQLiteBackup(backupPath: string): Promise { - const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db'); + // Résoudre le chemin de la base de données + let dbPath: string; + if (process.env.BACKUP_DATABASE_PATH) { + // Utiliser la variable spécifique aux backups + dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH); + } else if (process.env.DATABASE_URL) { + // Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini + dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', '')); + } else { + // Chemin par défaut vers prisma/dev.db + dbPath = path.resolve(process.cwd(), 'prisma', 'dev.db'); + } + + // Vérifier que le fichier source existe + try { + await fs.stat(dbPath); + } catch (error) { + console.error(`❌ Source database not found: ${dbPath}`, error); + throw new Error(`Source database not found: ${dbPath}`); + } // Méthode 1: Utiliser sqlite3 CLI (plus fiable) try { @@ -199,7 +227,19 @@ export class BackupService { */ async restoreBackup(filename: string): Promise { const backupPath = path.join(this.config.backupPath, filename); - const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db'); + + // Résoudre le chemin de la base de données + let dbPath: string; + if (process.env.BACKUP_DATABASE_PATH) { + // Utiliser la variable spécifique aux backups + dbPath = path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH); + } else if (process.env.DATABASE_URL) { + // Fallback sur DATABASE_URL si BACKUP_DATABASE_PATH n'est pas défini + dbPath = path.resolve(process.env.DATABASE_URL.replace('file:', '')); + } else { + // Chemin par défaut vers prisma/dev.db + dbPath = path.resolve(process.cwd(), 'prisma', 'dev.db'); + } console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`); diff --git a/src/app/api/backups/[filename]/route.ts b/src/app/api/backups/[filename]/route.ts new file mode 100644 index 0000000..bf8721b --- /dev/null +++ b/src/app/api/backups/[filename]/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { backupService } from '@/services/backup'; + +interface RouteParams { + params: Promise<{ + filename: string; + }>; +} + +export async function DELETE( + request: NextRequest, + { params }: RouteParams +) { + try { + const { filename } = await params; + + // Vérification de sécurité - s'assurer que c'est bien un fichier de backup + if (!filename.startsWith('towercontrol_') || + (!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) { + return NextResponse.json( + { success: false, error: 'Invalid backup filename' }, + { status: 400 } + ); + } + + await backupService.deleteBackup(filename); + + return NextResponse.json({ + success: true, + message: `Backup ${filename} deleted successfully` + }); + } catch (error) { + console.error('Error deleting backup:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete backup' + }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: RouteParams +) { + try { + const { filename } = await params; + const body = await request.json(); + const { action } = body; + + if (action === 'restore') { + // Vérification de sécurité + if (!filename.startsWith('towercontrol_') || + (!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) { + return NextResponse.json( + { success: false, error: 'Invalid backup filename' }, + { status: 400 } + ); + } + + // Protection environnement de production + if (process.env.NODE_ENV === 'production') { + return NextResponse.json( + { success: false, error: 'Restore not allowed in production via API' }, + { status: 403 } + ); + } + + await backupService.restoreBackup(filename); + + return NextResponse.json({ + success: true, + message: `Database restored from ${filename}` + }); + } + + return NextResponse.json( + { success: false, error: 'Invalid action' }, + { status: 400 } + ); + } catch (error) { + console.error('Error in backup operation:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Operation failed' + }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/backups/route.ts b/src/app/api/backups/route.ts new file mode 100644 index 0000000..54ee423 --- /dev/null +++ b/src/app/api/backups/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { backupService } from '@/services/backup'; +import { backupScheduler } from '@/services/backup-scheduler'; + +export async function GET() { + try { + console.log('🔄 API GET /api/backups called'); + + // Test de la configuration d'abord + const config = backupService.getConfig(); + console.log('✅ Config loaded:', config); + + // Test du scheduler + const schedulerStatus = backupScheduler.getStatus(); + console.log('✅ Scheduler status:', schedulerStatus); + + // Test de la liste des backups + const backups = await backupService.listBackups(); + console.log('✅ Backups loaded:', backups.length); + + const response = { + success: true, + data: { + backups, + scheduler: schedulerStatus, + config, + } + }; + + console.log('✅ API response ready'); + return NextResponse.json(response); + } catch (error) { + console.error('❌ Error fetching backups:', error); + console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown'); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch backups', + details: error instanceof Error ? error.stack : undefined + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { action, ...params } = body; + + switch (action) { + case 'create': + const backup = await backupService.createBackup('manual'); + return NextResponse.json({ success: true, data: backup }); + + case 'verify': + await backupService.verifyDatabaseHealth(); + return NextResponse.json({ + success: true, + message: 'Database health check passed' + }); + + case 'config': + await backupService.updateConfig(params.config); + // Redémarrer le scheduler si la config a changé + if (params.config.enabled !== undefined || params.config.interval !== undefined) { + backupScheduler.restart(); + } + return NextResponse.json({ + success: true, + message: 'Configuration updated', + data: backupService.getConfig() + }); + + case 'scheduler': + if (params.enabled) { + backupScheduler.start(); + } else { + backupScheduler.stop(); + } + return NextResponse.json({ + success: true, + data: backupScheduler.getStatus() + }); + + default: + return NextResponse.json( + { success: false, error: 'Invalid action' }, + { status: 400 } + ); + } + } catch (error) { + console.error('Error in backup operation:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +}