From 43998425e675b8f477f9ca8474a1b322723355b7 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 21 Sep 2025 07:27:23 +0200 Subject: [PATCH] feat: enhance backup functionality and logging - Updated `createBackup` method to accept a `force` parameter, allowing backups to be created even if no changes are detected. - Added user alerts in `AdvancedSettingsPageClient` and `BackupSettingsPageClient` for backup status feedback. - Implemented `getBackupLogs` method in `BackupService` to retrieve backup logs, with a new API route for accessing logs. - Enhanced UI in `BackupSettingsPageClient` to display backup logs and provide a refresh option. - Updated `BackupManagerCLI` to support forced backups via command line. --- TODO.md | 2 +- clients/backup-client.ts | 22 +- .../settings/AdvancedSettingsPageClient.tsx | 8 +- .../settings/BackupSettingsPageClient.tsx | 154 +++++++- .../settings/SettingsIndexPageClient.tsx | 15 +- lib/backup-utils.ts | 211 ++++++++++ scripts/backup-manager.ts | 18 +- services/backup-scheduler.ts | 4 +- services/backup.ts | 367 +++++++++++------- src/app/api/backups/route.ts | 27 +- 10 files changed, 649 insertions(+), 179 deletions(-) create mode 100644 lib/backup-utils.ts diff --git a/TODO.md b/TODO.md index 354fe2b..666d127 100644 --- a/TODO.md +++ b/TODO.md @@ -308,7 +308,7 @@ Endpoints complexes → API Routes conservées ## Autre Todos #2 - [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde - [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook -- [ ] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle +- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle - [ ] refacto des dates avec le utils qui pour l'instant n'est pas utilisé - [ ] split de certains gros composants. diff --git a/clients/backup-client.ts b/clients/backup-client.ts index 68bad71..205c728 100644 --- a/clients/backup-client.ts +++ b/clients/backup-client.ts @@ -28,11 +28,17 @@ export class BackupClient { /** * Crée une nouvelle sauvegarde manuelle */ - async createBackup(): Promise { - const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, { - action: 'create' + async createBackup(force: boolean = false): Promise { + const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, { + action: 'create', + force }); - return response.data; + + if (response.skipped) { + return null; // Backup was skipped + } + + return response.data!; } /** @@ -95,6 +101,14 @@ export class BackupClient { action: 'restore' }); } + + /** + * Récupère les logs de backup + */ + async getBackupLogs(maxLines: number = 100): Promise { + const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`); + return response.data.logs; + } } export const backupClient = new BackupClient(); diff --git a/components/settings/AdvancedSettingsPageClient.tsx b/components/settings/AdvancedSettingsPageClient.tsx index e034fdf..2f66f24 100644 --- a/components/settings/AdvancedSettingsPageClient.tsx +++ b/components/settings/AdvancedSettingsPageClient.tsx @@ -43,10 +43,16 @@ export function AdvancedSettingsPageClient({ const handleCreateBackup = async () => { setIsCreatingBackup(true); try { - await backupClient.createBackup(); + const result = await backupClient.createBackup(); + if (result === null) { + alert('⏭️ Sauvegarde sautée : aucun changement détecté'); + } else { + alert('✅ Sauvegarde créée avec succès'); + } await reloadBackupData(); } catch (error) { console.error('Failed to create backup:', error); + alert('❌ Erreur lors de la création de la sauvegarde'); } finally { setIsCreatingBackup(false); } diff --git a/components/settings/BackupSettingsPageClient.tsx b/components/settings/BackupSettingsPageClient.tsx index 8b083eb..1430c6a 100644 --- a/components/settings/BackupSettingsPageClient.tsx +++ b/components/settings/BackupSettingsPageClient.tsx @@ -23,11 +23,15 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [config, setConfig] = useState(initialData?.config || null); const [isSavingConfig, setIsSavingConfig] = useState(false); + const [logs, setLogs] = useState([]); + const [isLoadingLogs, setIsLoadingLogs] = useState(false); + const [showLogs, setShowLogs] = useState(false); const [messages, setMessages] = useState<{[key: string]: {type: 'success' | 'error', text: string} | null}>({ verify: null, config: null, restore: null, delete: null, + backup: null, }); useEffect(() => { @@ -66,13 +70,30 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings } }; - const handleCreateBackup = async () => { + const handleCreateBackup = async (force: boolean = false) => { setIsCreatingBackup(true); try { - await backupClient.createBackup(); + const result = await backupClient.createBackup(force); + + if (result === null) { + setMessage('backup', { + type: 'success', + text: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.' + }); + } else { + setMessage('backup', { + type: 'success', + text: `Sauvegarde créée : ${result.filename}` + }); + } + await loadData(); } catch (error) { console.error('Failed to create backup:', error); + setMessage('backup', { + type: 'error', + text: 'Erreur lors de la création de la sauvegarde' + }); } finally { setIsCreatingBackup(false); } @@ -144,6 +165,19 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings } }; + const loadLogs = async () => { + setIsLoadingLogs(true); + try { + const backupLogs = await backupClient.getBackupLogs(50); + setLogs(backupLogs); + } catch (error) { + console.error('Failed to load backup logs:', error); + setLogs([]); + } finally { + setIsLoadingLogs(false); + } + }; + const formatFileSize = (bytes: number): string => { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; @@ -360,15 +394,30 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
- +
+
+ + +
+
+ Créer : Vérifie les changements • Forcer : Crée toujours +
+ +
-
+
- -
@@ -514,6 +563,71 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
+ + {/* Section des logs */} +
+ + +
+
+

+ 📋 + Logs des sauvegardes +

+

+ Historique des opérations de sauvegarde +

+
+ +
+
+ {showLogs && ( + + {isLoadingLogs ? ( +
+

Chargement des logs...

+
+ ) : logs.length === 0 ? ( +
+

Aucun log disponible

+
+ ) : ( +
+ {logs.map((log, index) => ( +
+ {log} +
+ ))} +
+ )} + {showLogs && ( +
+ +
+ )} +
+ )} +
+
diff --git a/components/settings/SettingsIndexPageClient.tsx b/components/settings/SettingsIndexPageClient.tsx index 7e55a9e..7efdb8b 100644 --- a/components/settings/SettingsIndexPageClient.tsx +++ b/components/settings/SettingsIndexPageClient.tsx @@ -50,10 +50,17 @@ export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo setIsBackupLoading(true); try { const backup = await backupClient.createBackup(); - setMessages(prev => ({ - ...prev, - backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` } - })); + if (backup) { + setMessages(prev => ({ + ...prev, + backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` } + })); + } else { + setMessages(prev => ({ + ...prev, + backup: { type: 'success', text: 'Sauvegarde sautée: aucun changement détecté' } + })); + } // Recharger les infos système pour mettre à jour le nombre de sauvegardes loadSystemInfo(); diff --git a/lib/backup-utils.ts b/lib/backup-utils.ts new file mode 100644 index 0000000..ebc8c7a --- /dev/null +++ b/lib/backup-utils.ts @@ -0,0 +1,211 @@ +import { promises as fs } from 'fs'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { createHash } from 'crypto'; + +const execAsync = promisify(exec); + +/** + * Utilitaires pour les opérations de backup + */ +export class BackupUtils { + /** + * Calcule un hash SHA-256 d'un fichier + */ + static async calculateFileHash(filePath: string): Promise { + try { + const fileBuffer = await fs.readFile(filePath); + return createHash('sha256').update(fileBuffer).digest('hex'); + } catch (error) { + throw new Error(`Failed to calculate hash for ${filePath}: ${error}`); + } + } + + /** + * Résout le chemin de la base de données selon la configuration + */ + static resolveDatabasePath(): string { + if (process.env.BACKUP_DATABASE_PATH) { + return path.resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH); + } else if (process.env.DATABASE_URL) { + return path.resolve(process.env.DATABASE_URL.replace('file:', '')); + } else { + return path.resolve(process.cwd(), 'prisma', 'dev.db'); + } + } + + /** + * Résout le chemin de stockage des backups + */ + static resolveBackupStoragePath(): string { + if (process.env.BACKUP_STORAGE_PATH) { + return path.resolve(process.cwd(), process.env.BACKUP_STORAGE_PATH); + } + + return process.env.NODE_ENV === 'production' + ? path.join(process.cwd(), 'data', 'backups') + : path.join(process.cwd(), 'backups'); + } + + /** + * Crée une sauvegarde SQLite en utilisant la commande .backup + */ + static async createSQLiteBackup(sourcePath: string, backupPath: string): Promise { + // Vérifier que le fichier source existe + try { + await fs.stat(sourcePath); + } catch { + throw new Error(`Source database not found: ${sourcePath}`); + } + + // Méthode 1: Utiliser sqlite3 CLI (plus fiable) + try { + const command = `sqlite3 "${sourcePath}" ".backup '${backupPath}'"`; + await execAsync(command); + console.log(`✅ SQLite backup created using CLI: ${backupPath}`); + return; + } catch (cliError) { + console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError); + } + + // Méthode 2: Copie simple du fichier (fallback) + try { + await fs.copyFile(sourcePath, backupPath); + console.log(`✅ SQLite backup created using file copy: ${backupPath}`); + } catch (copyError) { + throw new Error(`Failed to create SQLite backup: ${copyError}`); + } + } + + /** + * Compresse un fichier avec gzip + */ + static async compressFile(filePath: string): Promise { + const compressedPath = `${filePath}.gz`; + + try { + const command = `gzip -c "${filePath}" > "${compressedPath}"`; + await execAsync(command); + console.log(`✅ File compressed: ${compressedPath}`); + return compressedPath; + } catch (error) { + console.warn(`⚠️ Compression failed, keeping uncompressed file:`, error); + return filePath; + } + } + + /** + * Décompresse un fichier gzip temporairement + */ + static async decompressFileTemp(compressedPath: string, tempPath: string): Promise { + try { + await execAsync(`gunzip -c "${compressedPath}" > "${tempPath}"`); + } catch (error) { + throw new Error(`Failed to decompress ${compressedPath}: ${error}`); + } + } + + /** + * Formate la taille de fichier en unités lisibles + */ + static formatFileSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; + } + + /** + * S'assure qu'un dossier existe + */ + static async ensureDirectory(dirPath: string): Promise { + try { + await fs.access(dirPath); + } catch { + await fs.mkdir(dirPath, { recursive: true }); + console.log(`📁 Created directory: ${dirPath}`); + } + } + + /** + * Parse le nom de fichier de backup pour extraire les métadonnées + */ + static parseBackupFilename(filename: string): { type: 'manual' | 'automatic'; date: Date | null } { + // Nouveau format: towercontrol_manual_2025-09-18T14-12-05-737Z.db + // Ancien format: towercontrol_2025-09-18T14-12-05-737Z.db (considéré comme automatic) + let type: 'manual' | 'automatic' = 'automatic'; + let dateMatch = filename.match(/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/); + + if (!dateMatch) { + // Format ancien sans type - considérer comme automatic + dateMatch = filename.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/); + if (dateMatch) { + dateMatch = [dateMatch[0], 'automatic', dateMatch[1]]; // Restructurer pour compatibilité + } + } else { + type = dateMatch[1] as 'manual' | 'automatic'; + } + + let date: Date | null = null; + if (dateMatch && dateMatch[2]) { + // Convertir le format de fichier vers ISO string valide + // Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z + const isoString = dateMatch[2] + .replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z'); + date = new Date(isoString); + } + + return { type, date }; + } + + /** + * Génère un nom de fichier de backup + */ + static generateBackupFilename(type: 'manual' | 'automatic'): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `towercontrol_${type}_${timestamp}.db`; + } + + /** + * Écrit une entrée dans le fichier de log + */ + static async writeLogEntry( + logPath: string, + type: 'manual' | 'automatic', + action: 'created' | 'skipped' | 'failed', + details: string, + extra?: { hash?: string; size?: number; previousHash?: string } + ): Promise { + try { + const date = new Date().toLocaleString('fr-FR'); + + let logEntry = `[${date}] ${type.toUpperCase()} BACKUP ${action.toUpperCase()}: ${details}`; + + if (extra) { + if (extra.hash) { + logEntry += ` | Hash: ${extra.hash.substring(0, 12)}...`; + } + if (extra.size) { + logEntry += ` | Size: ${BackupUtils.formatFileSize(extra.size)}`; + } + if (extra.previousHash) { + logEntry += ` | Previous: ${extra.previousHash.substring(0, 12)}...`; + } + } + + logEntry += '\n'; + + await fs.appendFile(logPath, logEntry); + } catch (error) { + console.error('Error writing to backup log:', error); + // Ne pas faire échouer l'opération si on ne peut pas logger + } + } +} diff --git a/scripts/backup-manager.ts b/scripts/backup-manager.ts index 7e0262d..e62e59d 100644 --- a/scripts/backup-manager.ts +++ b/scripts/backup-manager.ts @@ -21,7 +21,7 @@ class BackupManagerCLI { 🔧 TowerControl Backup Manager COMMANDES: - create Créer une nouvelle sauvegarde + create [--force] Créer une nouvelle sauvegarde (--force pour ignorer la détection de changements) list Lister toutes les sauvegardes delete Supprimer une sauvegarde restore Restaurer une sauvegarde @@ -35,6 +35,7 @@ COMMANDES: EXEMPLES: tsx backup-manager.ts create + tsx backup-manager.ts create --force tsx backup-manager.ts list tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz @@ -105,7 +106,7 @@ OPTIONS: try { switch (options.command) { case 'create': - await this.createBackup(); + await this.createBackup(options.force || false); break; case 'list': @@ -167,13 +168,22 @@ OPTIONS: } } - private async createBackup(): Promise { + private async createBackup(force: boolean = false): Promise { console.log('🔄 Création d\'une sauvegarde...'); - const result = await backupService.createBackup('manual'); + const result = await backupService.createBackup('manual', force); + + if (result === null) { + console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde'); + console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout'); + return; + } if (result.status === 'success') { console.log(`✅ Sauvegarde créée: ${result.filename}`); console.log(` Taille: ${this.formatFileSize(result.size)}`); + if (result.databaseHash) { + console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`); + } } else { console.error(`❌ Échec de la sauvegarde: ${result.error}`); process.exit(1); diff --git a/services/backup-scheduler.ts b/services/backup-scheduler.ts index fe4c4ef..2da03a1 100644 --- a/services/backup-scheduler.ts +++ b/services/backup-scheduler.ts @@ -70,7 +70,9 @@ export class BackupScheduler { console.log('🔄 Starting scheduled backup...'); const result = await backupService.createBackup('automatic'); - if (result.status === 'success') { + if (result === null) { + console.log('⏭️ Scheduled backup skipped: no changes detected'); + } else if (result.status === 'success') { console.log(`✅ Scheduled backup completed: ${result.filename}`); } else { console.error(`❌ Scheduled backup failed: ${result.error}`); diff --git a/services/backup.ts b/services/backup.ts index 4e81b92..b509209 100644 --- a/services/backup.ts +++ b/services/backup.ts @@ -1,11 +1,8 @@ import { promises as fs } from 'fs'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import path from 'path'; import { prisma } from './database'; import { userPreferencesService } from './user-preferences'; - -const execAsync = promisify(exec); +import { BackupUtils } from '../lib/backup-utils'; export interface BackupConfig { enabled: boolean; @@ -24,6 +21,7 @@ export interface BackupInfo { type: 'manual' | 'automatic'; status: 'success' | 'failed' | 'in_progress'; error?: string; + databaseHash?: string; } export class BackupService { @@ -39,15 +37,7 @@ export class BackupService { } 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 + return BackupUtils.resolveBackupStoragePath(); } private config: BackupConfig; @@ -106,27 +96,180 @@ export class BackupService { } /** - * Crée une sauvegarde complète de la base de données + * Calcule un hash de la base de données pour détecter les changements */ - async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise { + private async calculateDatabaseHash(): Promise { + try { + const dbPath = BackupUtils.resolveDatabasePath(); + return await BackupUtils.calculateFileHash(dbPath); + } catch (error) { + console.error('Error calculating database hash:', error); + throw new Error(`Failed to calculate database hash: ${error}`); + } + } + + /** + * Vérifie si la base de données a changé depuis le dernier backup + */ + async hasChangedSinceLastBackup(): Promise { + try { + const currentHash = await this.calculateDatabaseHash(); + const backups = await this.listBackups(); + + if (backups.length === 0) { + // Pas de backup précédent, donc il y a forcément des changements + return true; + } + + // Récupérer le hash du dernier backup + const lastBackup = backups[0]; // Les backups sont triés par date décroissante + const lastBackupHash = await this.getBackupHash(lastBackup.filename); + + if (!lastBackupHash) { + // Pas de hash disponible pour le dernier backup, considérer qu'il y a des changements + console.log('No hash available for last backup, assuming changes'); + return true; + } + + const hasChanged = currentHash !== lastBackupHash; + console.log(`Database hash comparison: current=${currentHash.substring(0, 8)}..., last=${lastBackupHash.substring(0, 8)}..., changed=${hasChanged}`); + + return hasChanged; + } catch (error) { + console.error('Error checking database changes:', error); + // En cas d'erreur, on assume qu'il y a des changements pour être sûr + return true; + } + } + + /** + * Récupère le hash d'un backup depuis ses métadonnées + */ + private async getBackupHash(filename: string): Promise { + try { + const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`); + + try { + const metadataContent = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(metadataContent); + return metadata.databaseHash || null; + } catch { + // Fichier de métadonnées n'existe pas, essayer de calculer le hash du backup + return await this.calculateBackupFileHash(filename); + } + } catch (error) { + console.error(`Error getting backup hash for ${filename}:`, error); + return null; + } + } + + /** + * Calcule le hash d'un fichier de backup existant + */ + private async calculateBackupFileHash(filename: string): Promise { + try { + const backupPath = path.join(this.getCurrentBackupPath(), filename); + + // Si le fichier est compressé, il faut le décompresser temporairement + if (filename.endsWith('.gz')) { + const tempFile = path.join(this.getCurrentBackupPath(), `temp_${Date.now()}.db`); + + try { + await BackupUtils.decompressFileTemp(backupPath, tempFile); + const hash = await BackupUtils.calculateFileHash(tempFile); + + // Nettoyer le fichier temporaire + await fs.unlink(tempFile); + + return hash; + } catch (error) { + // Nettoyer le fichier temporaire en cas d'erreur + try { + await fs.unlink(tempFile); + } catch {} + throw error; + } + } else { + // Fichier non compressé + return await BackupUtils.calculateFileHash(backupPath); + } + } catch (error) { + console.error(`Error calculating hash for backup file ${filename}:`, error); + return null; + } + } + + /** + * Sauvegarde les métadonnées d'un backup + */ + private async saveBackupMetadata(filename: string, metadata: { databaseHash: string; createdAt: Date; type: string }): Promise { + try { + const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`); + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); + } catch (error) { + console.error(`Error saving backup metadata for ${filename}:`, error); + // Ne pas faire échouer le backup si on ne peut pas sauvegarder les métadonnées + } + } + + + /** + * Écrit une entrée dans le fichier de log des backups + */ + private async logBackupAction(type: 'manual' | 'automatic', action: 'created' | 'skipped' | 'failed', details: string, extra?: { hash?: string; size?: number; previousHash?: string }): Promise { + const logPath = path.join(this.getCurrentBackupPath(), 'backup.log'); + await BackupUtils.writeLogEntry(logPath, type, action, details, extra); + } + + /** + * Crée une sauvegarde complète de la base de données + * Vérifie d'abord s'il y a eu des changements (sauf si forcé) + */ + async createBackup(type: 'manual' | 'automatic' = 'manual', forceCreate: boolean = false): Promise { const backupId = `backup_${Date.now()}`; - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const filename = `towercontrol_${type}_${timestamp}.db`; + const filename = BackupUtils.generateBackupFilename(type); const backupPath = path.join(this.getCurrentBackupPath(), filename); console.log(`🔄 Starting ${type} backup: ${filename}`); try { - // Créer le dossier de backup si nécessaire - await this.ensureBackupDirectory(); + // Vérifier les changements (sauf si forcé) + if (!forceCreate) { + const hasChanged = await this.hasChangedSinceLastBackup(); + + if (!hasChanged) { + const currentHash = await this.calculateDatabaseHash(); + const backups = await this.listBackups(); + const lastBackupHash = backups.length > 0 ? await this.getBackupHash(backups[0].filename) : null; + + const message = `No changes detected since last backup`; + console.log(`⏭️ Skipping ${type} backup: ${message}`); + await this.logBackupAction(type, 'skipped', message, { + hash: currentHash, + previousHash: lastBackupHash || undefined + }); + return null; + } + + console.log(`📝 Changes detected, proceeding with ${type} backup`); + } else { + console.log(`🔧 Forced ${type} backup, skipping change detection`); + } - // Créer la sauvegarde SQLite (sans vérification de santé pour éviter les conflits) - await this.createSQLiteBackup(backupPath); + // Calculer le hash de la base de données avant le backup + const databaseHash = await this.calculateDatabaseHash(); + + // Créer le dossier de backup si nécessaire + await BackupUtils.ensureDirectory(this.getCurrentBackupPath()); + + // Créer la sauvegarde SQLite + const dbPath = BackupUtils.resolveDatabasePath(); + await BackupUtils.createSQLiteBackup(dbPath, backupPath); // Compresser si activé let finalPath = backupPath; if (this.config.compression) { - finalPath = await this.compressBackup(backupPath); + finalPath = await BackupUtils.compressFile(backupPath); await fs.unlink(backupPath); // Supprimer le fichier non compressé } @@ -140,16 +283,31 @@ export class BackupService { createdAt: new Date(), type, status: 'success', + databaseHash, }; + // Sauvegarder les métadonnées du backup + await this.saveBackupMetadata(path.basename(finalPath), { + databaseHash, + createdAt: new Date(), + type, + }); + // Nettoyer les anciennes sauvegardes await this.cleanOldBackups(); - console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`); + const successMessage = `${backupInfo.filename} created successfully`; + console.log(`✅ Backup completed: ${successMessage}`); + await this.logBackupAction(type, 'created', successMessage, { + hash: databaseHash, + size: backupInfo.size + }); return backupInfo; } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`❌ Backup failed:`, error); + await this.logBackupAction(type, 'failed', `${filename} failed: ${errorMessage}`); return { id: backupId, @@ -158,71 +316,11 @@ export class BackupService { createdAt: new Date(), type, status: 'failed', - error: error instanceof Error ? error.message : 'Unknown error', + error: errorMessage, }; } } - /** - * Crée une sauvegarde SQLite en utilisant la commande .backup - */ - private async createSQLiteBackup(backupPath: string): Promise { - // 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 { - const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`; - await execAsync(command); - console.log(`✅ SQLite backup created using CLI: ${backupPath}`); - return; - } catch (cliError) { - console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError); - } - - // Méthode 2: Copie simple du fichier (fallback) - try { - await fs.copyFile(dbPath, backupPath); - console.log(`✅ SQLite backup created using file copy: ${backupPath}`); - } catch (copyError) { - throw new Error(`Failed to create SQLite backup: ${copyError}`); - } - } - - /** - * Compresse une sauvegarde - */ - private async compressBackup(filePath: string): Promise { - const compressedPath = `${filePath}.gz`; - - try { - const command = `gzip -c "${filePath}" > "${compressedPath}"`; - await execAsync(command); - console.log(`✅ Backup compressed: ${compressedPath}`); - return compressedPath; - } catch (error) { - console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error); - return filePath; - } - } /** * Restaure une sauvegarde @@ -258,7 +356,7 @@ export class BackupService { console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`); try { - await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`); + await BackupUtils.decompressFileTemp(backupPath, tempFile); console.log(`✅ Decompression successful`); // Vérifier que le fichier décompressé existe @@ -273,8 +371,10 @@ export class BackupService { } // Créer une sauvegarde de la base actuelle avant restauration - const currentBackup = await this.createBackup('manual'); - console.log(`✅ Current database backed up as: ${currentBackup.filename}`); + const currentBackup = await this.createBackup('manual', true); // Forcer la création + if (currentBackup) { + console.log(`✅ Current database backed up as: ${currentBackup.filename}`); + } // Fermer toutes les connexions await prisma.$disconnect(); @@ -322,41 +422,19 @@ export class BackupService { async listBackups(): Promise { try { const currentBackupPath = this.getCurrentBackupPath(); - await this.ensureBackupDirectory(); + await BackupUtils.ensureDirectory(currentBackupPath); const files = await fs.readdir(currentBackupPath); const backups: BackupInfo[] = []; - for (const file of files) { + for (const file of files) { if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) { const filePath = path.join(currentBackupPath, file); const stats = await fs.stat(filePath); - // Extraire le type et la date du nom de fichier - // Nouveau format: towercontrol_manual_2025-09-18T14-12-05-737Z.db - // Ancien format: towercontrol_2025-09-18T14-12-05-737Z.db (considéré comme automatic) - let type: 'manual' | 'automatic' = 'automatic'; - let dateMatch = file.match(/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/); - - if (!dateMatch) { - // Format ancien sans type - considérer comme automatic - dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/); - if (dateMatch) { - dateMatch = [dateMatch[0], 'automatic', dateMatch[1]]; // Restructurer pour compatibilité - } - } else { - type = dateMatch[1] as 'manual' | 'automatic'; - } - - let createdAt = stats.birthtime; - - if (dateMatch && dateMatch[2]) { - // Convertir le format de fichier vers ISO string valide - // Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z - const isoString = dateMatch[2] - .replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z'); - createdAt = new Date(isoString); - } + // Utiliser l'utilitaire pour parser le nom de fichier + const { type, date } = BackupUtils.parseBackupFilename(file); + const createdAt = date || stats.birthtime; backups.push({ id: file, @@ -381,9 +459,19 @@ export class BackupService { */ async deleteBackup(filename: string): Promise { const backupPath = path.join(this.getCurrentBackupPath(), filename); + const metadataPath = path.join(this.getCurrentBackupPath(), `${filename}.meta.json`); try { + // Supprimer le fichier de backup await fs.unlink(backupPath); + + // Supprimer le fichier de métadonnées s'il existe + try { + await fs.unlink(metadataPath); + } catch { + // Ignorer si le fichier de métadonnées n'existe pas + } + console.log(`✅ Backup deleted: ${filename}`); } catch (error) { console.error(`❌ Failed to delete backup ${filename}:`, error); @@ -434,34 +522,6 @@ export class BackupService { } } - /** - * S'assure que le dossier de backup existe - */ - private async ensureBackupDirectory(): Promise { - const currentBackupPath = this.getCurrentBackupPath(); - try { - await fs.access(currentBackupPath); - } catch { - await fs.mkdir(currentBackupPath, { recursive: true }); - console.log(`📁 Created backup directory: ${currentBackupPath}`); - } - } - - /** - * Formate la taille de fichier - */ - private formatFileSize(bytes: number): string { - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - return `${size.toFixed(1)} ${units[unitIndex]}`; - } /** * Met à jour la configuration @@ -481,6 +541,29 @@ export class BackupService { backupPath: this.getCurrentBackupPath() }; } + + /** + * Lit le fichier de log des backups + */ + async getBackupLogs(maxLines: number = 100): Promise { + try { + const logPath = path.join(this.getCurrentBackupPath(), 'backup.log'); + + try { + const logContent = await fs.readFile(logPath, 'utf-8'); + const lines = logContent.trim().split('\n').filter(line => line.length > 0); + + // Retourner les dernières lignes (les plus récentes) + return lines.slice(-maxLines).reverse(); + } catch { + // Fichier de log n'existe pas encore + return []; + } + } catch (error) { + console.error('Error reading backup logs:', error); + return []; + } + } } // Instance singleton diff --git a/src/app/api/backups/route.ts b/src/app/api/backups/route.ts index 54ee423..e18fa29 100644 --- a/src/app/api/backups/route.ts +++ b/src/app/api/backups/route.ts @@ -2,8 +2,21 @@ import { NextRequest, NextResponse } from 'next/server'; import { backupService } from '@/services/backup'; import { backupScheduler } from '@/services/backup-scheduler'; -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url); + const action = searchParams.get('action'); + + if (action === 'logs') { + const maxLines = parseInt(searchParams.get('maxLines') || '100'); + const logs = await backupService.getBackupLogs(maxLines); + + return NextResponse.json({ + success: true, + data: { logs } + }); + } + console.log('🔄 API GET /api/backups called'); // Test de la configuration d'abord @@ -51,7 +64,17 @@ export async function POST(request: NextRequest) { switch (action) { case 'create': - const backup = await backupService.createBackup('manual'); + const forceCreate = params.force === true; + const backup = await backupService.createBackup('manual', forceCreate); + + if (backup === null) { + return NextResponse.json({ + success: true, + skipped: true, + message: 'No changes detected since last backup. Use force=true to create anyway.' + }); + } + return NextResponse.json({ success: true, data: backup }); case 'verify':