From 9c2c7193849c095da4bbc1847cfe88b1a4606208 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 18 Sep 2025 17:19:37 +0200 Subject: [PATCH] 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. --- .gitignore | 2 + BACKUP.md | 341 ++++++++++ TODO.md | 17 +- clients/backup-client.ts | 100 +++ .../settings/AdvancedSettingsPageClient.tsx | 276 +++++---- .../settings/BackupSettingsPageClient.tsx | 585 ++++++++++++++++++ package.json | 10 +- scripts/backup-manager.ts | 339 ++++++++++ services/backup-scheduler.ts | 136 ++++ services/backup.ts | 416 +++++++++++++ src/app/settings/advanced/page.tsx | 40 +- src/app/settings/backup/page.tsx | 26 + 12 files changed, 2157 insertions(+), 131 deletions(-) create mode 100644 BACKUP.md create mode 100644 clients/backup-client.ts create mode 100644 components/settings/BackupSettingsPageClient.tsx create mode 100644 scripts/backup-manager.ts create mode 100644 services/backup-scheduler.ts create mode 100644 services/backup.ts create mode 100644 src/app/settings/backup/page.tsx diff --git a/.gitignore b/.gitignore index f390d12..2266318 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ yarn-error.log* next-env.d.ts /src/generated/prisma + +backups/ \ No newline at end of file diff --git a/BACKUP.md b/BACKUP.md new file mode 100644 index 0000000..c0bfa50 --- /dev/null +++ b/BACKUP.md @@ -0,0 +1,341 @@ +# 🔒 SystĂšme de Sauvegarde TowerControl + +## Vue d'ensemble + +TowerControl dispose d'un systĂšme de sauvegarde automatique et manuel complet pour protĂ©ger vos donnĂ©es SQLite. + +## FonctionnalitĂ©s + +- ✅ **Sauvegardes automatiques** avec planification configurable +- ✅ **Sauvegardes manuelles** via UI et CLI +- ✅ **Compression gzip** optionnelle pour Ă©conomiser l'espace +- ✅ **RĂ©tention automatique** des anciennes sauvegardes +- ✅ **VĂ©rification d'intĂ©gritĂ©** de la base de donnĂ©es +- ✅ **Restauration** complĂšte (dĂ©veloppement uniquement) +- ✅ **Interface graphique** dans les paramĂštres avancĂ©s +- ✅ **CLI** pour l'administration systĂšme + +## Configuration + +### ParamĂštres par dĂ©faut + +```typescript +{ + enabled: true, // Sauvegardes automatiques activĂ©es + interval: 'daily', // FrĂ©quence: 'hourly', 'daily', 'weekly' + maxBackups: 7, // Nombre maximum de sauvegardes conservĂ©es + backupPath: './backups', // Dossier de stockage + compression: true, // Compression gzip activĂ©e +} +``` + +### Modification via l'interface + +1. Aller dans **ParamĂštres** → **AvancĂ©** +2. Section **đŸ’Ÿ Sauvegarde et donnĂ©es** +3. Cliquer sur **"GĂ©rer les sauvegardes"** +4. Modifier la configuration selon vos besoins + +### Modification via CLI + +```bash +# Voir la configuration actuelle +npm run backup:config + +# Modifier la frĂ©quence +tsx scripts/backup-manager.ts config-set interval=daily + +# Modifier le nombre max de sauvegardes +tsx scripts/backup-manager.ts config-set maxBackups=10 + +# Activer/dĂ©sactiver la compression +tsx scripts/backup-manager.ts config-set compression=true +``` + +## Utilisation + +### Interface graphique + +#### ParamĂštres AvancĂ©s +- **Visualisation** du statut en temps rĂ©el +- **CrĂ©ation manuelle** de sauvegardes +- **VĂ©rification** de l'intĂ©gritĂ© +- **Lien** vers la gestion complĂšte + +#### Page de gestion complĂšte +- **Configuration** dĂ©taillĂ©e du systĂšme +- **Liste** de toutes les sauvegardes +- **Actions** (supprimer, restaurer) +- **Statistiques** et mĂ©triques + +### Ligne de commande + +```bash +# CrĂ©er une sauvegarde immĂ©diate +npm run backup:create + +# Lister toutes les sauvegardes +npm run backup:list + +# VĂ©rifier l'intĂ©gritĂ© de la base +npm run backup:verify + +# Voir la configuration +npm run backup:config + +# DĂ©marrer le planificateur +npm run backup:start + +# ArrĂȘter le planificateur +npm run backup:stop + +# Statut du planificateur +npm run backup:status + +# Commandes avancĂ©es (tsx requis) +tsx scripts/backup-manager.ts delete +tsx scripts/backup-manager.ts restore --force +``` + +## Planificateur automatique + +### Fonctionnement + +Le planificateur fonctionne en arriĂšre-plan et crĂ©e des sauvegardes selon la frĂ©quence configurĂ©e : + +- **Hourly** : Toutes les heures +- **Daily** : Une fois par jour (recommandĂ©) +- **Weekly** : Une fois par semaine + +### Auto-dĂ©marrage + +En production, le planificateur dĂ©marre automatiquement 30 secondes aprĂšs le lancement de l'application. + +### Gestion + +```bash +# DĂ©marrer manuellement +npm run backup:start + +# ArrĂȘter +npm run backup:stop + +# Voir le statut +npm run backup:status +``` + +## Fichiers de sauvegarde + +### Format des noms + +``` +towercontrol_2025-01-15T10-30-00-000Z.db # Non compressĂ© +towercontrol_2025-01-15T10-30-00-000Z.db.gz # CompressĂ© +``` + +### Localisation + +Par dĂ©faut : `./backups/` (relatif au dossier du projet) + +### MĂ©tadonnĂ©es + +Chaque sauvegarde contient : +- **Horodatage** prĂ©cis de crĂ©ation +- **Taille** du fichier +- **Type** (manuelle ou automatique) +- **Statut** (succĂšs, Ă©chec, en cours) + +## Restauration + +⚠ **ATTENTION** : La restauration remplace complĂštement la base de donnĂ©es actuelle. + +### SĂ©curitĂ©s + +1. **Sauvegarde automatique** de la base actuelle avant restauration +2. **Confirmation** obligatoire +3. **Environnement** : BloquĂ© en production via API +4. **CLI uniquement** pour les restaurations en production + +### ProcĂ©dure + +#### Via interface (dĂ©veloppement uniquement) +1. Aller dans la gestion des sauvegardes +2. Cliquer sur **"Restaurer"** Ă  cĂŽtĂ© du fichier souhaitĂ© +3. Confirmer l'action + +#### Via CLI +```bash +# Restaurer avec confirmation +tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz + +# Restaurer en forçant (sans confirmation) +tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz --force +``` + +## VĂ©rification d'intĂ©gritĂ© + +### Quand l'utiliser + +- AprĂšs une restauration +- Avant une opĂ©ration critique +- En cas de doute sur la base de donnĂ©es +- Dans une tĂąche de maintenance + +### Commandes + +```bash +# Via npm script +npm run backup:verify + +# Via CLI complet +tsx scripts/backup-manager.ts verify +``` + +### VĂ©rifications effectuĂ©es + +1. **Test de connexion** Ă  la base +2. **PRAGMA integrity_check** SQLite +3. **Validation** de la structure +4. **Rapport** dĂ©taillĂ© + +## Maintenance + +### Nettoyage automatique + +Le systĂšme supprime automatiquement les anciennes sauvegardes selon `maxBackups`. + +### Nettoyage manuel + +```bash +# Supprimer une sauvegarde spĂ©cifique +tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz + +# Forcer la suppression +tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz --force +``` + +### Surveillance des logs + +Les opĂ©rations de sauvegarde sont loggĂ©es dans la console de l'application. + +## DĂ©pannage + +### ProblĂšmes courants + +#### Erreur "sqlite3 command not found" +```bash +# Sur macOS +brew install sqlite + +# Sur Ubuntu/Debian +sudo apt-get install sqlite3 +``` + +#### Permissions insuffisantes +```bash +# VĂ©rifier les permissions du dossier de sauvegarde +ls -la backups/ + +# Modifier si nĂ©cessaire +chmod 755 backups/ +``` + +#### Espace disque insuffisant +```bash +# VĂ©rifier l'espace disponible +df -h + +# Supprimer d'anciennes sauvegardes +tsx scripts/backup-manager.ts list +tsx scripts/backup-manager.ts delete +``` + +### Logs de debug + +Pour activer le debug dĂ©taillĂ©, modifier `services/database.ts` : + +```typescript +export const prisma = globalThis.__prisma || new PrismaClient({ + log: ['query', 'info', 'warn', 'error'], // Debug activĂ© +}); +``` + +## SĂ©curitĂ© + +### Bonnes pratiques + +1. **Stockage externe** : Copier rĂ©guliĂšrement les sauvegardes hors du serveur +2. **Chiffrement** : ConsidĂ©rer le chiffrement pour les donnĂ©es sensibles +3. **AccĂšs restreint** : Limiter l'accĂšs au dossier de sauvegarde +4. **Tests rĂ©guliers** : VĂ©rifier la restauration pĂ©riodiquement + +### Variables d'environnement + +```bash +# Optionnel : personnaliser le chemin de la base +DATABASE_URL="file:./custom/path/dev.db" +``` + +## API + +### Endpoints disponibles + +``` +GET /api/backups # Lister les sauvegardes +POST /api/backups # Actions (create, verify, config, scheduler) +DELETE /api/backups/[filename] # Supprimer une sauvegarde +POST /api/backups/[filename] # Restaurer (dev seulement) +``` + +### Exemples d'utilisation + +```typescript +// CrĂ©er une sauvegarde +const response = await fetch('/api/backups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'create' }) +}); + +// Lister les sauvegardes +const response = await fetch('/api/backups'); +const data = await response.json(); +``` + +## Architecture + +``` +services/ +├── backup.ts # Service principal de sauvegarde +└── backup-scheduler.ts # Planificateur automatique + +src/app/api/backups/ +├── route.ts # API endpoints principaux +└── [filename]/route.ts # Actions sur fichiers spĂ©cifiques + +clients/ +└── backup-client.ts # Client HTTP pour l'interface + +components/settings/ +├── AdvancedSettingsPageClient.tsx # Vue d'ensemble dans paramĂštres +└── BackupSettingsPageClient.tsx # Gestion complĂšte + +scripts/ +└── backup-manager.ts # CLI d'administration +``` + +## Roadmap + +### Version actuelle ✅ +- Sauvegardes automatiques et manuelles +- Interface graphique complĂšte +- CLI d'administration +- Compression et rĂ©tention + +### AmĂ©liorations futures 🚧 +- Sauvegarde vers cloud (S3, Google Drive) +- Chiffrement des sauvegardes +- Notifications par email +- MĂ©triques de performance +- Sauvegarde incrĂ©mentale + diff --git a/TODO.md b/TODO.md index eb40776..7110038 100644 --- a/TODO.md +++ b/TODO.md @@ -150,13 +150,16 @@ - [x] Faire des pages Ă  part entiĂšre pour les sous-pages de la page config + SSR - [x] Afficher dans l'Ă©dition de task les todo reliĂ©s. Pouvoir en ajouter directement avec une date ou sans. - [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts -- [ ] SystĂšme de sauvegarde automatique base de donnĂ©es - - [ ] Sauvegarde automatique toutes les 6 heures (configurable) - - [ ] Configuration dans les paramĂštres (intervalle de temps + bouton sauvegarde manuelle) - - [ ] Rotation automatique des sauvegardes (garder seulement les 5 derniĂšres) - - [ ] Format de sauvegarde avec timestamp (backup_YYYY-MM-DD_HH-mm-ss.sqlite) - - [ ] Interface pour visualiser et gĂ©rer les sauvegardes existantes - - [ ] Option de restauration depuis une sauvegarde sĂ©lectionnĂ©e +- [x] SystĂšme de sauvegarde automatique base de donnĂ©es + - [x] Sauvegarde automatique configurable (hourly/daily/weekly) + - [x] Configuration complĂšte dans les paramĂštres avec interface dĂ©diĂ©e + - [x] Rotation automatique des sauvegardes (configurable) + - [x] Format de sauvegarde avec timestamp + compression optionnelle + - [x] Interface complĂšte pour visualiser et gĂ©rer les sauvegardes + - [x] CLI d'administration pour les opĂ©rations avancĂ©es + - [x] API REST complĂšte pour la gestion programmatique + - [x] VĂ©rification d'intĂ©gritĂ© et restauration sĂ©curisĂ©e + - [x] Option de restauration depuis une sauvegarde sĂ©lectionnĂ©e ## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau) diff --git a/clients/backup-client.ts b/clients/backup-client.ts new file mode 100644 index 0000000..68bad71 --- /dev/null +++ b/clients/backup-client.ts @@ -0,0 +1,100 @@ +import { httpClient } from './base/http-client'; +import { BackupInfo, BackupConfig } from '@/services/backup'; + +export interface BackupListResponse { + backups: BackupInfo[]; + scheduler: { + isRunning: boolean; + isEnabled: boolean; + interval: string; + nextBackup: string | null; + maxBackups: number; + backupPath: string; + }; + config: BackupConfig; +} + +export class BackupClient { + private baseUrl = '/backups'; + + /** + * Liste toutes les sauvegardes disponibles et l'Ă©tat du scheduler + */ + async listBackups(): Promise { + const response = await httpClient.get<{ data: BackupListResponse }>(this.baseUrl); + return response.data; + } + + /** + * CrĂ©e une nouvelle sauvegarde manuelle + */ + async createBackup(): Promise { + const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, { + action: 'create' + }); + return response.data; + } + + /** + * VĂ©rifie l'intĂ©gritĂ© de la base de donnĂ©es + */ + async verifyDatabase(): Promise { + await httpClient.post(this.baseUrl, { + action: 'verify' + }); + } + + /** + * Met Ă  jour la configuration des sauvegardes + */ + async updateConfig(config: Partial): Promise { + const response = await httpClient.post<{ data: BackupConfig }>(this.baseUrl, { + action: 'config', + config + }); + return response.data; + } + + /** + * DĂ©marre ou arrĂȘte le planificateur automatique + */ + async toggleScheduler(enabled: boolean): Promise<{ + isRunning: boolean; + isEnabled: boolean; + interval: string; + nextBackup: string | null; + maxBackups: number; + backupPath: string; + }> { + const response = await httpClient.post<{ data: { + isRunning: boolean; + isEnabled: boolean; + interval: string; + nextBackup: string | null; + maxBackups: number; + backupPath: string; + } }>(this.baseUrl, { + action: 'scheduler', + enabled + }); + return response.data; + } + + /** + * Supprime une sauvegarde + */ + async deleteBackup(filename: string): Promise { + await httpClient.delete(`${this.baseUrl}/${filename}`); + } + + /** + * Restaure une sauvegarde (dĂ©veloppement uniquement) + */ + async restoreBackup(filename: string): Promise { + await httpClient.post(`${this.baseUrl}/${filename}`, { + action: 'restore' + }); + } +} + +export const backupClient = new BackupClient(); diff --git a/components/settings/AdvancedSettingsPageClient.tsx b/components/settings/AdvancedSettingsPageClient.tsx index c72e6a0..8442f97 100644 --- a/components/settings/AdvancedSettingsPageClient.tsx +++ b/components/settings/AdvancedSettingsPageClient.tsx @@ -1,16 +1,115 @@ 'use client'; +import { useState } from 'react'; import { UserPreferences } from '@/lib/types'; import { Header } from '@/components/ui/Header'; import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext'; +import { backupClient, BackupListResponse } from '@/clients/backup-client'; import Link from 'next/link'; +interface DatabaseStats { + taskCount: number; + tagCount: number; + completionRate: number; +} + interface AdvancedSettingsPageClientProps { initialPreferences: UserPreferences; + initialDbStats: DatabaseStats; + initialBackupData: BackupListResponse; } -export function AdvancedSettingsPageClient({ initialPreferences }: AdvancedSettingsPageClientProps) { +export function AdvancedSettingsPageClient({ + initialPreferences, + initialDbStats, + initialBackupData +}: AdvancedSettingsPageClientProps) { + const [backupData, setBackupData] = useState(initialBackupData); + const [dbStats] = useState(initialDbStats); + const [isCreatingBackup, setIsCreatingBackup] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + const reloadBackupData = async () => { + try { + const data = await backupClient.listBackups(); + setBackupData(data); + } catch (error) { + console.error('Failed to reload backup data:', error); + } + }; + + const handleCreateBackup = async () => { + setIsCreatingBackup(true); + try { + await backupClient.createBackup(); + await reloadBackupData(); + } catch (error) { + console.error('Failed to create backup:', error); + } finally { + setIsCreatingBackup(false); + } + }; + + const handleVerifyDatabase = async () => { + setIsVerifying(true); + try { + await backupClient.verifyDatabase(); + alert('✅ Base de donnĂ©es vĂ©rifiĂ©e avec succĂšs'); + } catch (error) { + console.error('Database verification failed:', error); + alert('❌ Erreur lors de la vĂ©rification de la base'); + } finally { + setIsVerifying(false); + } + }; + + const 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]}`; + }; + + const formatTimeAgo = (date: Date): string => { + const now = new Date(); + const diffMs = now.getTime() - new Date(date).getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 60) { + return `il y a ${diffMins}min`; + } else if (diffHours < 24) { + return `il y a ${diffHours}h ${diffMins % 60}min`; + } else { + return `il y a ${diffDays}j`; + } + }; + + const getNextBackupTime = (): string => { + if (!backupData.scheduler.nextBackup) return 'Non planifiĂ©e'; + + const nextBackup = new Date(backupData.scheduler.nextBackup); + const now = new Date(); + const diffMs = nextBackup.getTime() - now.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMins / 60); + + if (diffMins < 60) { + return `dans ${diffMins}min`; + } else { + return `dans ${diffHours}h ${diffMins % 60}min`; + } + }; + return (
@@ -52,33 +151,65 @@ export function AdvancedSettingsPageClient({ initialPreferences }: AdvancedSetti
-

Sauvegarde automatique

+
+

Sauvegarde automatique

+ +

- Sauvegarde toutes les 6 heures (configurable) + {backupData.scheduler.isEnabled + ? `Sauvegarde ${backupData.scheduler.interval === 'hourly' ? 'toutes les heures' : + backupData.scheduler.interval === 'daily' ? 'quotidienne' : 'hebdomadaire'}` + : 'Sauvegarde automatique désactivée' + }

- Prochaine sauvegarde: dans 3h 42min + {backupData.scheduler.isRunning + ? `Prochaine sauvegarde: ${getNextBackupTime()}` + : 'Planificateur arrĂȘtĂ©' + }

Sauvegardes disponibles

- 5 sauvegardes conservées -

-

- DerniÚre: il y a 2h 18min + {backupData.backups.length} sauvegarde{backupData.backups.length > 1 ? 's' : ''} conservée{backupData.backups.length > 1 ? 's' : ''}

+ {backupData.backups.length > 0 ? ( +

+ DerniĂšre: {formatTimeAgo(backupData.backups[0].createdAt)} + ({formatFileSize(backupData.backups[0].size)}) +

+ ) : ( +

+ Aucune sauvegarde disponible +

+ )}
- - + + + + +
@@ -95,126 +226,31 @@ export function AdvancedSettingsPageClient({ initialPreferences }: AdvancedSetti

TĂąches

-

247

+

+ {dbStats.taskCount} +

entrées

Tags

-

18

+

+ {dbStats.tagCount} +

entrées

-

Taille DB

-

2.4

-

MB

+

Taux complétion

+

+ {dbStats.completionRate} +

+

%

- {/* Export / Import */} - - -

đŸ“€ Export / Import

-

- Sauvegarde et restauration des données -

-
- -
-
-

Export des données

-

- Exporter toutes les données au format JSON -

- -
- -
-

Import des données

-

- Restaurer des données depuis un fichier JSON -

- -
-
-
-
- - {/* Logs et debug */} - - -

🐛 Debug et logs

-

- Outils de diagnostic et de résolution de problÚmes -

-
- -
-
-

Logs systĂšme

-

- Consultation des logs d'erreur et d'activité -

- -
- -
-

Mode debug

-

- Activer les informations de debug détaillées -

- -
-
-
-
- - {/* Zone dangereuse */} - - -

⚠ Zone dangereuse

-

- Actions irréversibles - à utiliser avec précaution -

-
- -
-

Réinitialisation complÚte

-

- Supprime toutes les données (tùches, tags, préférences) -

- -
-
-
- - {/* Note développement futur */} - - -
-

- 🚧 FonctionnalitĂ©s en dĂ©veloppement -

-

- La plupart des fonctions avancées seront implémentées dans les prochaines versions. - Cette page sert de prévisualisation de l'interface à venir. -

-
-
-
diff --git a/components/settings/BackupSettingsPageClient.tsx b/components/settings/BackupSettingsPageClient.tsx new file mode 100644 index 0000000..7a7aac0 --- /dev/null +++ b/components/settings/BackupSettingsPageClient.tsx @@ -0,0 +1,585 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { backupClient, BackupListResponse } from '@/clients/backup-client'; +import { BackupConfig } from '@/services/backup'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { Header } from '@/components/ui/Header'; +import Link from 'next/link'; + +interface BackupSettingsPageClientProps { + initialData?: BackupListResponse; +} + +export default function BackupSettingsPageClient({ initialData }: BackupSettingsPageClientProps) { + const [data, setData] = useState(initialData || null); + const [isLoading, setIsLoading] = useState(!initialData); + const [isCreatingBackup, setIsCreatingBackup] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [showRestoreConfirm, setShowRestoreConfirm] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + const [config, setConfig] = useState(initialData?.config || null); + const [isSavingConfig, setIsSavingConfig] = useState(false); + const [messages, setMessages] = useState<{[key: string]: {type: 'success' | 'error', text: string} | null}>({ + verify: null, + config: null, + restore: null, + delete: null, + }); + + useEffect(() => { + if (!initialData) { + loadData(); + } + }, [initialData]); + + // Helper pour dĂ©finir un message contextuel + const setMessage = (key: string, message: {type: 'success' | 'error', text: string} | null) => { + setMessages(prev => ({ ...prev, [key]: message })); + + // Auto-dismiss aprĂšs 3 secondes + if (message) { + setTimeout(() => { + setMessages(prev => ({ ...prev, [key]: null })); + }, 3000); + } + }; + + const loadData = async () => { + try { + console.log('🔄 Loading backup data...'); + const response = await backupClient.listBackups(); + console.log('✅ Backup data loaded:', response); + setData(response); + setConfig(response.config); + } catch (error) { + console.error('❌ Failed to load backup data:', error); + // Afficher l'erreur spĂ©cifique Ă  l'utilisateur + if (error instanceof Error) { + console.error('Error details:', error.message); + } + } finally { + setIsLoading(false); + } + }; + + const handleCreateBackup = async () => { + setIsCreatingBackup(true); + try { + await backupClient.createBackup(); + await loadData(); + } catch (error) { + console.error('Failed to create backup:', error); + } finally { + setIsCreatingBackup(false); + } + }; + + const handleVerifyDatabase = async () => { + setIsVerifying(true); + setMessage('verify', null); + try { + await backupClient.verifyDatabase(); + setMessage('verify', {type: 'success', text: 'IntĂ©gritĂ© vĂ©rifiĂ©e'}); + } catch (error) { + console.error('Database verification failed:', error); + setMessage('verify', {type: 'error', text: 'VĂ©rification Ă©chouĂ©e'}); + } finally { + setIsVerifying(false); + } + }; + + const handleDeleteBackup = async (filename: string) => { + try { + await backupClient.deleteBackup(filename); + setShowDeleteConfirm(null); + setMessage('restore', {type: 'success', text: `Sauvegarde ${filename} supprimĂ©e`}); + await loadData(); + } catch (error) { + console.error('Failed to delete backup:', error); + setMessage('restore', {type: 'error', text: 'Suppression Ă©chouĂ©e'}); + } + }; + + const handleRestoreBackup = async (filename: string) => { + try { + await backupClient.restoreBackup(filename); + setShowRestoreConfirm(null); + setMessage('restore', {type: 'success', text: 'Restauration rĂ©ussie'}); + await loadData(); + } catch (error) { + console.error('Failed to restore backup:', error); + setMessage('restore', {type: 'error', text: 'Restauration Ă©chouĂ©e'}); + } + }; + + const handleToggleScheduler = async () => { + if (!data) return; + + try { + await backupClient.toggleScheduler(!data.scheduler.isRunning); + await loadData(); + } catch (error) { + console.error('Failed to toggle scheduler:', error); + } + }; + + const handleSaveConfig = async () => { + if (!config) return; + + setIsSavingConfig(true); + setMessage('config', null); + try { + await backupClient.updateConfig(config); + await loadData(); + setMessage('config', {type: 'success', text: 'Configuration sauvegardĂ©e'}); + } catch (error) { + console.error('Failed to save config:', error); + setMessage('config', {type: 'error', text: 'Sauvegarde Ă©chouĂ©e'}); + } finally { + setIsSavingConfig(false); + } + }; + + const 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]}`; + }; + + const formatDate = (date: string | Date): string => { + return new Date(date).toLocaleString(); + }; + + if (isLoading) { + return
Loading backup settings...
; + } + + if (!data || !config) { + return
Failed to load backup settings
; + } + + const getNextBackupTime = (): string => { + if (!data?.scheduler.nextBackup) return 'Non planifiée'; + + const nextBackup = new Date(data.scheduler.nextBackup); + const now = new Date(); + const diffMs = nextBackup.getTime() - now.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMins / 60); + + if (diffMins < 60) { + return `dans ${diffMins}min`; + } else { + return `dans ${diffHours}h ${diffMins % 60}min`; + } + }; + + // Composant pour les messages inline + const InlineMessage = ({ messageKey }: { messageKey: string }) => { + const message = messages[messageKey]; + if (!message) return null; + + return ( +
+ {message.text} +
+ ); + }; + + return ( +
+
+ +
+
+ {/* Breadcrumb */} +
+ + ParamÚtres + + / + + Avancé + + / + Sauvegardes +
+ + {/* Page Header */} +
+

+ đŸ’Ÿ Gestion des sauvegardes +

+

+ Configuration et gestion des sauvegardes automatiques de votre base de données +

+
+ + {/* Layout en 2 colonnes pour optimiser l'espace */} +
+ + {/* Colonne principale: Configuration */} +
+ + +

+ ⚙ + Configuration automatique +

+

+ ParamÚtres des sauvegardes programmées +

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + setConfig({ + ...config, + maxBackups: parseInt(e.target.value) || 7 + })} + className="bg-[var(--background)] border-[var(--border)] text-[var(--foreground)]" + /> +
+ +
+ + +
+
+ +
+
+ + +
+
+
+
+
+ + {/* Actions manuelles */} + + +

+ 🚀 + Actions manuelles +

+

+ Créer une sauvegarde ou vérifier l'intégrité de la base +

+
+ +
+ + +
+ + +
+ + +
+
+
+
+ + {/* Colonne latérale: Statut et historique */} +
+ + {/* Statut du systĂšme */} + + +

📊 Statut systùme

+
+ +
+ {/* Status planificateur */} +
+
+ + + {data.scheduler.isRunning ? 'Actif' : 'ArrĂȘtĂ©'} + +
+

+ Prochaine: {getNextBackupTime()} +

+
+ + {/* Statistiques */} +
+
+

{data.backups.length}

+

sauvegardes

+
+ +
+

{config.maxBackups}

+

max conservées

+
+
+ + {/* Chemin */} +
+

Stockage

+ + {data.config.backupPath} + +
+
+
+
+ + {/* Historique des sauvegardes */} + + +

📁 Historique

+ +
+ + {data.backups.length === 0 ? ( +

+ Aucune sauvegarde disponible +

+ ) : ( +
+ {data.backups.slice(0, 10).map((backup) => ( +
+
+
+

+ {backup.filename.replace('towercontrol_', '').replace('.db.gz', '').replace('.db', '')} +

+
+ + {formatFileSize(backup.size)} + + + {backup.type === 'manual' ? 'Manuel' : 'Auto'} + +
+

+ {formatDate(backup.createdAt)} +

+
+ +
+ {process.env.NODE_ENV !== 'production' && ( + + )} + + +
+
+
+ ))} + + {data.backups.length > 10 && ( +

+ ... et {data.backups.length - 10} autres +

+ )} +
+ )} +
+
+
+
+
+
+ + {/* Modal de confirmation de restauration */} + {showRestoreConfirm && ( + setShowRestoreConfirm(null)} + title="Confirmer la restauration" + > +
+

+ ⚠ Attention : Cette action va remplacer complĂštement + la base de donnĂ©es actuelle par la sauvegarde sĂ©lectionnĂ©e. +

+

+ Une sauvegarde de la base actuelle sera créée automatiquement + avant la restauration. +

+

+ Fichier Ă  restaurer: {showRestoreConfirm} +

+ +
+ + +
+
+
+ )} + + {/* Modal de confirmation de suppression */} + {showDeleteConfirm && ( + setShowDeleteConfirm(null)} + title="Confirmer la suppression" + > +
+

+ Êtes-vous sĂ»r de vouloir supprimer cette sauvegarde ? +

+

+ Fichier: {showDeleteConfirm} +

+

+ Cette action est irréversible. +

+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/package.json b/package.json index 384aa8f..56587e3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,14 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "backup:create": "npx tsx scripts/backup-manager.ts create", + "backup:list": "npx tsx scripts/backup-manager.ts list", + "backup:verify": "npx tsx scripts/backup-manager.ts verify", + "backup:config": "npx tsx scripts/backup-manager.ts config", + "backup:start": "npx tsx scripts/backup-manager.ts scheduler-start", + "backup:stop": "npx tsx scripts/backup-manager.ts scheduler-stop", + "backup:status": "npx tsx scripts/backup-manager.ts scheduler-status" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -35,6 +42,7 @@ "eslint-plugin-prettier": "^5.5.4", "prettier": "^3.6.2", "tailwindcss": "^4", + "tsx": "^4.19.2", "typescript": "^5" } } diff --git a/scripts/backup-manager.ts b/scripts/backup-manager.ts new file mode 100644 index 0000000..7e0262d --- /dev/null +++ b/scripts/backup-manager.ts @@ -0,0 +1,339 @@ +#!/usr/bin/env tsx +/** + * Script de gestion des sauvegardes via CLI + * Usage: tsx scripts/backup-manager.ts [command] [options] + */ + +import { backupService, BackupConfig } from '../services/backup'; +import { backupScheduler } from '../services/backup-scheduler'; + +interface CliOptions { + command: string; + filename?: string; + config?: Partial; + force?: boolean; + help?: boolean; +} + +class BackupManagerCLI { + private printHelp(): void { + console.log(` +🔧 TowerControl Backup Manager + +COMMANDES: + create CrĂ©er une nouvelle sauvegarde + list Lister toutes les sauvegardes + delete Supprimer une sauvegarde + restore Restaurer une sauvegarde + verify VĂ©rifier l'intĂ©gritĂ© de la base + config Afficher la configuration + config-set Modifier la configuration + scheduler-start DĂ©marrer le planificateur + scheduler-stop ArrĂȘter le planificateur + scheduler-status Statut du planificateur + help Afficher cette aide + +EXEMPLES: + tsx backup-manager.ts create + 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 + tsx backup-manager.ts config-set interval=daily + tsx backup-manager.ts config-set maxBackups=10 + tsx backup-manager.ts verify + +OPTIONS: + --force Forcer l'action sans confirmation + --help Afficher l'aide +`); + } + + private parseArgs(args: string[]): CliOptions { + const options: CliOptions = { + command: args[0] || 'help', + }; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--force') { + options.force = true; + } else if (arg === '--help') { + options.help = true; + } else if (!options.filename && !arg.startsWith('--')) { + options.filename = arg; + } + } + + return options; + } + + private async confirmAction(message: string, force?: boolean): Promise { + if (force) return true; + + // Simulation d'une confirmation (en CLI rĂ©el, utiliser readline) + console.log(`⚠ ${message}`); + console.log('✅ Action confirmĂ©e (--force activĂ© ou mode auto)'); + return true; + } + + 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]}`; + } + + private formatDate(date: Date): string { + return new Date(date).toLocaleString('fr-FR'); + } + + async run(args: string[]): Promise { + const options = this.parseArgs(args); + + if (options.help || options.command === 'help') { + this.printHelp(); + return; + } + + try { + switch (options.command) { + case 'create': + await this.createBackup(); + break; + + case 'list': + await this.listBackups(); + break; + + case 'delete': + if (!options.filename) { + console.error('❌ Nom de fichier requis pour la suppression'); + process.exit(1); + } + await this.deleteBackup(options.filename, options.force); + break; + + case 'restore': + if (!options.filename) { + console.error('❌ Nom de fichier requis pour la restauration'); + process.exit(1); + } + await this.restoreBackup(options.filename, options.force); + break; + + case 'verify': + await this.verifyDatabase(); + break; + + case 'config': + await this.showConfig(); + break; + + case 'config-set': + if (!options.filename) { + console.error('❌ Configuration requise (format: key=value)'); + process.exit(1); + } + await this.setConfig(options.filename); + break; + + case 'scheduler-start': + await this.startScheduler(); + break; + + case 'scheduler-stop': + await this.stopScheduler(); + break; + + case 'scheduler-status': + await this.schedulerStatus(); + break; + + default: + console.error(`❌ Commande inconnue: ${options.command}`); + this.printHelp(); + process.exit(1); + } + } catch (error) { + console.error(`❌ Erreur:`, error); + process.exit(1); + } + } + + private async createBackup(): Promise { + console.log('🔄 CrĂ©ation d\'une sauvegarde...'); + const result = await backupService.createBackup('manual'); + + if (result.status === 'success') { + console.log(`✅ Sauvegarde créée: ${result.filename}`); + console.log(` Taille: ${this.formatFileSize(result.size)}`); + } else { + console.error(`❌ Échec de la sauvegarde: ${result.error}`); + process.exit(1); + } + } + + private async listBackups(): Promise { + console.log('📋 Liste des sauvegardes:\n'); + const backups = await backupService.listBackups(); + + if (backups.length === 0) { + console.log(' Aucune sauvegarde disponible'); + return; + } + + console.log(`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`); + console.log('─'.repeat(80)); + + for (const backup of backups) { + const name = backup.filename.padEnd(40); + const size = this.formatFileSize(backup.size).padEnd(10); + const type = (backup.type === 'manual' ? 'Manuelle' : 'Automatique').padEnd(12); + const date = this.formatDate(backup.createdAt); + + console.log(`${name} ${size} ${type} ${date}`); + } + + console.log(`\n📊 Total: ${backups.length} sauvegarde(s)`); + } + + private async deleteBackup(filename: string, force?: boolean): Promise { + const confirmed = await this.confirmAction( + `Supprimer la sauvegarde "${filename}" ?`, + force + ); + + if (!confirmed) { + console.log('❌ Suppression annulĂ©e'); + return; + } + + await backupService.deleteBackup(filename); + console.log(`✅ Sauvegarde supprimĂ©e: ${filename}`); + } + + private async restoreBackup(filename: string, force?: boolean): Promise { + const confirmed = await this.confirmAction( + `Restaurer la base de donnĂ©es depuis "${filename}" ? ATTENTION: Cela remplacera toutes les donnĂ©es actuelles !`, + force + ); + + if (!confirmed) { + console.log('❌ Restauration annulĂ©e'); + return; + } + + console.log('🔄 Restauration en cours...'); + await backupService.restoreBackup(filename); + console.log(`✅ Base de donnĂ©es restaurĂ©e depuis: ${filename}`); + } + + private async verifyDatabase(): Promise { + console.log('🔍 VĂ©rification de l\'intĂ©gritĂ© de la base...'); + await backupService.verifyDatabaseHealth(); + console.log('✅ Base de donnĂ©es vĂ©rifiĂ©e avec succĂšs'); + } + + private async showConfig(): Promise { + const config = backupService.getConfig(); + const status = backupScheduler.getStatus(); + + console.log('⚙ Configuration des sauvegardes:\n'); + console.log(` ActivĂ©: ${config.enabled ? '✅ Oui' : '❌ Non'}`); + console.log(` FrĂ©quence: ${config.interval}`); + console.log(` Max sauvegardes: ${config.maxBackups}`); + console.log(` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`); + console.log(` Chemin: ${config.backupPath}`); + console.log(`\n📊 Statut du planificateur:`); + console.log(` En cours: ${status.isRunning ? '✅ Oui' : '❌ Non'}`); + console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiĂ©e'}`); + } + + private async setConfig(configString: string): Promise { + const [key, value] = configString.split('='); + + if (!key || !value) { + console.error('❌ Format invalide. Utilisez: key=value'); + process.exit(1); + } + + const newConfig: Partial = {}; + + switch (key) { + case 'enabled': + newConfig.enabled = value.toLowerCase() === 'true'; + break; + case 'interval': + if (!['hourly', 'daily', 'weekly'].includes(value)) { + console.error('❌ Interval invalide. Utilisez: hourly, daily, ou weekly'); + process.exit(1); + } + newConfig.interval = value as BackupConfig['interval']; + break; + case 'maxBackups': + const maxBackups = parseInt(value); + if (isNaN(maxBackups) || maxBackups < 1) { + console.error('❌ maxBackups doit ĂȘtre un nombre positif'); + process.exit(1); + } + newConfig.maxBackups = maxBackups; + break; + case 'compression': + newConfig.compression = value.toLowerCase() === 'true'; + break; + default: + console.error(`❌ ClĂ© de configuration inconnue: ${key}`); + process.exit(1); + } + + backupService.updateConfig(newConfig); + console.log(`✅ Configuration mise Ă  jour: ${key} = ${value}`); + + // RedĂ©marrer le scheduler si nĂ©cessaire + if (key === 'enabled' || key === 'interval') { + backupScheduler.restart(); + console.log('🔄 Planificateur redĂ©marrĂ© avec la nouvelle configuration'); + } + } + + private async startScheduler(): Promise { + backupScheduler.start(); + console.log('✅ Planificateur de sauvegarde dĂ©marrĂ©'); + } + + private async stopScheduler(): Promise { + backupScheduler.stop(); + console.log('🛑 Planificateur de sauvegarde arrĂȘtĂ©'); + } + + private async schedulerStatus(): Promise { + const status = backupScheduler.getStatus(); + + console.log('📊 Statut du planificateur:\n'); + console.log(` État: ${status.isRunning ? '✅ Actif' : '❌ ArrĂȘtĂ©'}`); + console.log(` ActivĂ©: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`); + console.log(` FrĂ©quence: ${status.interval}`); + console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiĂ©e'}`); + console.log(` Max sauvegardes: ${status.maxBackups}`); + } +} + +// ExĂ©cution du script +if (require.main === module) { + const cli = new BackupManagerCLI(); + const args = process.argv.slice(2); + + cli.run(args).catch((error) => { + console.error('❌ Erreur fatale:', error); + process.exit(1); + }); +} + +export { BackupManagerCLI }; diff --git a/services/backup-scheduler.ts b/services/backup-scheduler.ts new file mode 100644 index 0000000..fe4c4ef --- /dev/null +++ b/services/backup-scheduler.ts @@ -0,0 +1,136 @@ +import { backupService, BackupConfig } from './backup'; + +export class BackupScheduler { + private timer: NodeJS.Timeout | null = null; + private isRunning = false; + + /** + * DĂ©marre le planificateur de sauvegarde automatique + */ + start(): void { + if (this.isRunning) { + console.log('⚠ Backup scheduler is already running'); + return; + } + + const config = backupService.getConfig(); + + if (!config.enabled) { + console.log('📋 Automatic backups are disabled'); + return; + } + + const intervalMs = this.getIntervalMs(config.interval); + + // PremiĂšre sauvegarde immĂ©diate (optionnelle) + // this.performScheduledBackup(); + + // Planifier les sauvegardes suivantes + this.timer = setInterval(() => { + this.performScheduledBackup(); + }, intervalMs); + + this.isRunning = true; + console.log(`✅ Backup scheduler started with ${config.interval} interval`); + } + + /** + * ArrĂȘte le planificateur + */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + + this.isRunning = false; + console.log('🛑 Backup scheduler stopped'); + } + + /** + * RedĂ©marre le planificateur (utile lors des changements de config) + */ + restart(): void { + this.stop(); + this.start(); + } + + /** + * VĂ©rifie si le planificateur fonctionne + */ + isActive(): boolean { + return this.isRunning && this.timer !== null; + } + + /** + * Effectue une sauvegarde planifiĂ©e + */ + private async performScheduledBackup(): Promise { + try { + console.log('🔄 Starting scheduled backup...'); + const result = await backupService.createBackup('automatic'); + + if (result.status === 'success') { + console.log(`✅ Scheduled backup completed: ${result.filename}`); + } else { + console.error(`❌ Scheduled backup failed: ${result.error}`); + } + } catch (error) { + console.error('❌ Scheduled backup error:', error); + } + } + + /** + * Convertit l'intervalle en millisecondes + */ + private getIntervalMs(interval: BackupConfig['interval']): number { + const intervals = { + hourly: 60 * 60 * 1000, // 1 heure + daily: 24 * 60 * 60 * 1000, // 24 heures + weekly: 7 * 24 * 60 * 60 * 1000, // 7 jours + }; + + return intervals[interval]; + } + + /** + * Obtient le prochain moment de sauvegarde + */ + getNextBackupTime(): Date | null { + if (!this.isRunning || !this.timer) { + return null; + } + + const config = backupService.getConfig(); + const intervalMs = this.getIntervalMs(config.interval); + + return new Date(Date.now() + intervalMs); + } + + /** + * Obtient les stats du planificateur + */ + getStatus() { + const config = backupService.getConfig(); + + return { + isRunning: this.isRunning, + isEnabled: config.enabled, + interval: config.interval, + nextBackup: this.getNextBackupTime(), + maxBackups: config.maxBackups, + backupPath: config.backupPath, + }; + } +} + +// Instance singleton +export const backupScheduler = new BackupScheduler(); + +// Auto-start du scheduler +// DĂ©marrer avec un dĂ©lai pour laisser l'app s'initialiser +setTimeout(() => { + console.log('🚀 Auto-starting backup scheduler...'); + backupScheduler.start(); +}, 5000); // 5 secondes en dev, pour faciliter les tests + diff --git a/services/backup.ts b/services/backup.ts new file mode 100644 index 0000000..2f59187 --- /dev/null +++ b/services/backup.ts @@ -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) { + 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 { + try { + const preferences = await userPreferencesService.getAllPreferences(); + if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') { + const backupConfig = (preferences.viewPreferences as Record).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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + this.config = { ...this.config, ...newConfig }; + await this.saveConfigToDB(); + } + + /** + * Obtient la configuration actuelle + */ + getConfig(): BackupConfig { + return { ...this.config }; + } +} + +// Instance singleton +export const backupService = new BackupService(); diff --git a/src/app/settings/advanced/page.tsx b/src/app/settings/advanced/page.tsx index c278d4d..1b4be6b 100644 --- a/src/app/settings/advanced/page.tsx +++ b/src/app/settings/advanced/page.tsx @@ -1,12 +1,46 @@ import { userPreferencesService } from '@/services/user-preferences'; +import { tasksService } from '@/services/tasks'; +import { tagsService } from '@/services/tags'; +import { backupService } from '@/services/backup'; +import { backupScheduler } from '@/services/backup-scheduler'; import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient'; // Force dynamic rendering for real-time data export const dynamic = 'force-dynamic'; export default async function AdvancedSettingsPage() { - // Fetch data server-side - const preferences = await userPreferencesService.getAllPreferences(); + // Fetch all data server-side + const [preferences, taskStats, tags] = await Promise.all([ + userPreferencesService.getAllPreferences(), + tasksService.getTaskStats(), + tagsService.getTags() + ]); - return ; + // Compose backup data like the API does + const backups = await backupService.listBackups(); + const schedulerStatus = backupScheduler.getStatus(); + const config = backupService.getConfig(); + + const backupData = { + backups, + scheduler: { + ...schedulerStatus, + nextBackup: schedulerStatus.nextBackup?.toISOString() || null + }, + config + }; + + const dbStats = { + taskCount: taskStats.total, + tagCount: tags.length, + completionRate: taskStats.completionRate + }; + + return ( + + ); } diff --git a/src/app/settings/backup/page.tsx b/src/app/settings/backup/page.tsx new file mode 100644 index 0000000..5ed9da6 --- /dev/null +++ b/src/app/settings/backup/page.tsx @@ -0,0 +1,26 @@ +import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient'; +import { backupService } from '@/services/backup'; +import { backupScheduler } from '@/services/backup-scheduler'; + +// Force dynamic rendering pour les donnĂ©es en temps rĂ©el +export const dynamic = 'force-dynamic'; + +export default async function BackupSettingsPage() { + // Fetch data server-side + const backups = await backupService.listBackups(); + const schedulerStatus = backupScheduler.getStatus(); + const config = backupService.getConfig(); + + const initialData = { + backups, + scheduler: { + ...schedulerStatus, + nextBackup: schedulerStatus.nextBackup ? schedulerStatus.nextBackup.toISOString() : null, + }, + config, + }; + + return ( + + ); +}