feat: enhance backup management in AdvancedSettingsPage
- Added backup management functionality to `AdvancedSettingsPageClient`, including creating and verifying backups. - Updated `package.json` with new backup-related scripts. - Improved UI to display backup status and next scheduled backup time. - Updated `.gitignore` to exclude backup files. - Enhanced server-side data fetching to include backup data and database statistics.
This commit is contained in:
416
services/backup.ts
Normal file
416
services/backup.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
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);
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean;
|
||||
interval: 'hourly' | 'daily' | 'weekly';
|
||||
maxBackups: number;
|
||||
backupPath: string;
|
||||
includeUploads?: boolean;
|
||||
compression?: boolean;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
type: 'manual' | 'automatic';
|
||||
status: 'success' | 'failed' | 'in_progress';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
private defaultConfig: BackupConfig = {
|
||||
enabled: true,
|
||||
interval: 'hourly',
|
||||
maxBackups: 5,
|
||||
backupPath: path.join(process.cwd(), 'backups'),
|
||||
includeUploads: true,
|
||||
compression: true,
|
||||
};
|
||||
|
||||
private config: BackupConfig;
|
||||
|
||||
constructor(config?: Partial<BackupConfig>) {
|
||||
this.config = { ...this.defaultConfig, ...config };
|
||||
// Charger la config depuis la DB de manière asynchrone
|
||||
this.loadConfigFromDB().catch(() => {
|
||||
// Ignorer les erreurs de chargement initial
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la configuration depuis la base de données
|
||||
*/
|
||||
private async loadConfigFromDB(): Promise<void> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
|
||||
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
|
||||
if (backupConfig) {
|
||||
this.config = { ...this.defaultConfig, ...backupConfig };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load backup config from DB, using defaults:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration dans la base de données
|
||||
*/
|
||||
private async saveConfigToDB(): Promise<void> {
|
||||
try {
|
||||
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
|
||||
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { id: 'default' },
|
||||
update: {
|
||||
viewPreferences: {
|
||||
...(await userPreferencesService.getViewPreferences()),
|
||||
// Cast pour contourner la restriction de type temporairement
|
||||
...(({ backupConfig: this.config } as any))
|
||||
}
|
||||
},
|
||||
create: {
|
||||
id: 'default',
|
||||
kanbanFilters: {},
|
||||
viewPreferences: { backupConfig: this.config } as any,
|
||||
columnVisibility: {},
|
||||
jiraConfig: {}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save backup config to DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde complète de la base de données
|
||||
*/
|
||||
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
|
||||
const backupId = `backup_${Date.now()}`;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `towercontrol_${timestamp}.db`;
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
|
||||
console.log(`🔄 Starting ${type} backup: ${filename}`);
|
||||
|
||||
try {
|
||||
// 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
|
||||
await this.createSQLiteBackup(backupPath);
|
||||
|
||||
// Compresser si activé
|
||||
let finalPath = backupPath;
|
||||
if (this.config.compression) {
|
||||
finalPath = await this.compressBackup(backupPath);
|
||||
await fs.unlink(backupPath); // Supprimer le fichier non compressé
|
||||
}
|
||||
|
||||
// Obtenir les stats du fichier
|
||||
const stats = await fs.stat(finalPath);
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
id: backupId,
|
||||
filename: path.basename(finalPath),
|
||||
size: stats.size,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
// Nettoyer les anciennes sauvegardes
|
||||
await this.cleanOldBackups();
|
||||
|
||||
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
|
||||
|
||||
return backupInfo;
|
||||
} catch (error) {
|
||||
console.error(`❌ Backup failed:`, error);
|
||||
|
||||
return {
|
||||
id: backupId,
|
||||
filename,
|
||||
size: 0,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||
*/
|
||||
private async createSQLiteBackup(backupPath: string): Promise<void> {
|
||||
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||
|
||||
// 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<string> {
|
||||
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
|
||||
*/
|
||||
async restoreBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||
|
||||
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
|
||||
|
||||
console.log(`🔄 Starting restore from: ${filename}`);
|
||||
|
||||
try {
|
||||
// Vérifier que le fichier de sauvegarde existe
|
||||
await fs.access(backupPath);
|
||||
|
||||
// Décompresser si nécessaire
|
||||
let sourceFile = backupPath;
|
||||
if (filename.endsWith('.gz')) {
|
||||
const tempFile = backupPath.replace('.gz', '');
|
||||
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
|
||||
|
||||
try {
|
||||
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
|
||||
console.log(`✅ Decompression successful`);
|
||||
|
||||
// Vérifier que le fichier décompressé existe
|
||||
await fs.access(tempFile);
|
||||
console.log(`✅ Decompressed file exists: ${tempFile}`);
|
||||
|
||||
sourceFile = tempFile;
|
||||
} catch (decompError) {
|
||||
console.error(`❌ Decompression failed:`, decompError);
|
||||
throw decompError;
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
|
||||
// Fermer toutes les connexions
|
||||
await prisma.$disconnect();
|
||||
|
||||
// Vérifier que le fichier source existe
|
||||
await fs.access(sourceFile);
|
||||
console.log(`✅ Source file verified: ${sourceFile}`);
|
||||
|
||||
// Remplacer la base de données
|
||||
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
|
||||
await fs.copyFile(sourceFile, dbPath);
|
||||
console.log(`✅ Database file copied successfully`);
|
||||
|
||||
// Nettoyer le fichier temporaire si décompressé
|
||||
if (sourceFile !== backupPath) {
|
||||
await fs.unlink(sourceFile);
|
||||
}
|
||||
|
||||
// Reconnecter à la base
|
||||
await prisma.$connect();
|
||||
|
||||
// Vérifier l'intégrité après restauration
|
||||
await this.verifyDatabaseHealth();
|
||||
|
||||
console.log(`✅ Database restored from: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Restore failed:`, error);
|
||||
throw new Error(`Failed to restore backup: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les sauvegardes disponibles
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
try {
|
||||
await this.ensureBackupDirectory();
|
||||
const files = await fs.readdir(this.config.backupPath);
|
||||
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
|
||||
const filePath = path.join(this.config.backupPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Extraire la date du nom de fichier
|
||||
const dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
let createdAt = stats.birthtime;
|
||||
|
||||
if (dateMatch) {
|
||||
// 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[1]
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||
createdAt = new Date(isoString);
|
||||
}
|
||||
|
||||
backups.push({
|
||||
id: file,
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
createdAt,
|
||||
type: 'automatic', // On ne peut pas déterminer le type depuis le nom
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (error) {
|
||||
console.error('Error listing backups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une sauvegarde
|
||||
*/
|
||||
async deleteBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
|
||||
try {
|
||||
await fs.unlink(backupPath);
|
||||
console.log(`✅ Backup deleted: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to delete backup ${filename}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'intégrité de la base de données
|
||||
*/
|
||||
async verifyDatabaseHealth(): Promise<void> {
|
||||
try {
|
||||
// Test de connexion simple
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// Vérification de l'intégrité SQLite
|
||||
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
|
||||
|
||||
if (result.length > 0 && result[0].integrity_check !== 'ok') {
|
||||
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
|
||||
}
|
||||
|
||||
console.log('✅ Database health check passed');
|
||||
} catch (error) {
|
||||
console.error('❌ Database health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie les anciennes sauvegardes selon la configuration
|
||||
*/
|
||||
private async cleanOldBackups(): Promise<void> {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
|
||||
if (backups.length > this.config.maxBackups) {
|
||||
const toDelete = backups.slice(this.config.maxBackups);
|
||||
|
||||
for (const backup of toDelete) {
|
||||
await this.deleteBackup(backup.filename);
|
||||
}
|
||||
|
||||
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning old backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure que le dossier de backup existe
|
||||
*/
|
||||
private async ensureBackupDirectory(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.config.backupPath);
|
||||
} catch {
|
||||
await fs.mkdir(this.config.backupPath, { recursive: true });
|
||||
console.log(`📁 Created backup directory: ${this.config.backupPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
await this.saveConfigToDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la configuration actuelle
|
||||
*/
|
||||
getConfig(): BackupConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const backupService = new BackupService();
|
||||
Reference in New Issue
Block a user