From f2b18e45272bbf1906295f4ef2978b624c684203 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 25 Sep 2025 22:28:17 +0200 Subject: [PATCH] feat: implement backup management features - Added `createBackupAction`, `verifyDatabaseAction`, and `refreshBackupStatsAction` for handling backup operations. - Introduced `getBackupStats` method in `BackupClient` to retrieve daily backup statistics. - Updated API route to support fetching backup stats. - Integrated backup stats into the `BackupSettingsPage` and visualized them with `BackupTimelineChart`. - Enhanced `BackupSettingsPageClient` to manage backup stats and actions more effectively. --- src/actions/backup.ts | 60 +++++ src/app/api/backups/route.ts | 10 + src/app/settings/backup/page.tsx | 2 + src/clients/backup-client.ts | 18 ++ src/components/backup/BackupTimelineChart.tsx | 211 ++++++++++++++++++ .../settings/BackupSettingsPageClient.tsx | 176 +++++++++------ src/lib/backup-utils.ts | 2 +- src/services/data-management/backup.ts | 59 ++++- 8 files changed, 471 insertions(+), 67 deletions(-) create mode 100644 src/actions/backup.ts create mode 100644 src/components/backup/BackupTimelineChart.tsx diff --git a/src/actions/backup.ts b/src/actions/backup.ts new file mode 100644 index 0000000..415505d --- /dev/null +++ b/src/actions/backup.ts @@ -0,0 +1,60 @@ +'use server'; + +import { backupService } from '@/services/data-management/backup'; +import { revalidatePath } from 'next/cache'; + +export async function createBackupAction(force: boolean = false) { + try { + const result = await backupService.createBackup('manual', force); + + // Invalider le cache de la page pour forcer le rechargement des données SSR + revalidatePath('/settings/backup'); + + if (result === null) { + return { + success: true, + skipped: true, + message: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.' + }; + } + + return { + success: true, + data: result, + message: `Sauvegarde créée : ${result.filename}` + }; + } catch (error) { + console.error('Failed to create backup:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur lors de la création de la sauvegarde' + }; + } +} + +export async function verifyDatabaseAction() { + try { + await backupService.verifyDatabaseHealth(); + return { + success: true, + message: 'Intégrité vérifiée' + }; + } catch (error) { + console.error('Database verification failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Vérification échouée' + }; + } +} + +export async function refreshBackupStatsAction() { + try { + // Cette action sert juste à revalider le cache + revalidatePath('/settings/backup'); + return { success: true }; + } catch (error) { + console.error('Failed to refresh backup stats:', error); + return { success: false }; + } +} diff --git a/src/app/api/backups/route.ts b/src/app/api/backups/route.ts index 3fa31e8..55b86d6 100644 --- a/src/app/api/backups/route.ts +++ b/src/app/api/backups/route.ts @@ -17,6 +17,16 @@ export async function GET(request: NextRequest) { }); } + if (action === 'stats') { + const days = parseInt(searchParams.get('days') || '30'); + const stats = await backupService.getBackupStats(days); + + return NextResponse.json({ + success: true, + data: stats + }); + } + console.log('🔄 API GET /api/backups called'); // Test de la configuration d'abord diff --git a/src/app/settings/backup/page.tsx b/src/app/settings/backup/page.tsx index cb4b701..78b3069 100644 --- a/src/app/settings/backup/page.tsx +++ b/src/app/settings/backup/page.tsx @@ -10,6 +10,7 @@ export default async function BackupSettingsPage() { const backups = await backupService.listBackups(); const schedulerStatus = backupScheduler.getStatus(); const config = backupService.getConfig(); + const backupStats = await backupService.getBackupStats(30); const initialData = { backups, @@ -18,6 +19,7 @@ export default async function BackupSettingsPage() { nextBackup: schedulerStatus.nextBackup ? schedulerStatus.nextBackup.toISOString() : null, }, config, + backupStats, }; return ( diff --git a/src/clients/backup-client.ts b/src/clients/backup-client.ts index 42abed6..74196da 100644 --- a/src/clients/backup-client.ts +++ b/src/clients/backup-client.ts @@ -109,6 +109,24 @@ export class BackupClient { const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`); return response.data.logs; } + + /** + * Récupère les statistiques de sauvegarde par jour + */ + async getBackupStats(days: number = 30): Promise> { + const response = await httpClient.get<{ data: Array<{ + date: string; + manual: number; + automatic: number; + total: number; + }> }>(`${this.baseUrl}?action=stats&days=${days}`); + return response.data; + } } export const backupClient = new BackupClient(); diff --git a/src/components/backup/BackupTimelineChart.tsx b/src/components/backup/BackupTimelineChart.tsx new file mode 100644 index 0000000..d3b7d8f --- /dev/null +++ b/src/components/backup/BackupTimelineChart.tsx @@ -0,0 +1,211 @@ +'use client'; + +interface BackupStats { + date: string; + manual: number; + automatic: number; + total: number; +} + +interface BackupTimelineChartProps { + stats?: BackupStats[]; + className?: string; +} + +export function BackupTimelineChart({ stats = [], className = '' }: BackupTimelineChartProps) { + // Protection contre les stats non-array + const safeStats = Array.isArray(stats) ? stats : []; + const error = safeStats.length === 0 ? 'Aucune donnée disponible' : null; + + // Convertir les stats en map pour accès rapide + const statsMap = new Map(safeStats.map(s => [s.date, s])); + + // Générer les 30 derniers jours + const days = Array.from({ length: 30 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - (29 - i)); + // Utiliser la date locale pour éviter les décalages UTC + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return localDate.toISOString().split('T')[0]; + }); + + // Organiser en semaines (5 semaines de 6 jours + quelques jours) + const weeks = []; + for (let i = 0; i < days.length; i += 7) { + weeks.push(days.slice(i, i + 7)); + } + + + + const formatDateFull = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long' + }); + }; + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + return ( +
+

+ 💾 Activité de sauvegarde (30 derniers jours) +

+ + {/* Vue en ligne avec indicateurs clairs */} +
+ {/* En-têtes des jours */} +
+ {['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => ( +
+ {day} +
+ ))} +
+ + {/* Grille des jours avec indicateurs visuels */} +
+ {weeks.map((week, weekIndex) => ( +
+ {week.map((day) => { + const stat = statsMap.get(day) || { date: day, manual: 0, automatic: 0, total: 0 }; + const hasManual = stat.manual > 0; + const hasAuto = stat.automatic > 0; + const dayNumber = new Date(day).getDate(); + + return ( +
+
+ {/* Jour du mois */} + 0 ? 'text-white font-bold' : ''}`}> + {dayNumber} + + + {/* Fond selon le type */} + {stat.total > 0 && ( +
+ )} + + {/* Indicateurs visuels pour l'intensité */} + {stat.total > 0 && stat.total > 1 && ( +
+ {stat.total > 9 ? '9+' : stat.total} +
+ )} +
+ + {/* Tooltip détaillé */} +
+
{formatDateFull(day)}
+ {stat.total > 0 ? ( +
+ {stat.manual > 0 && ( +
+
+ Manuel: {stat.manual} +
+ )} + {stat.automatic > 0 && ( +
+
+ Auto: {stat.automatic} +
+ )} +
+ Total: {stat.total} +
+
+ ) : ( +
Aucune sauvegarde
+ )} +
+
+ ); + })} +
+ ))} +
+
+ + {/* Légende claire */} +
+

Légende

+
+
+
+
15
+ Manuel seul +
+
+
+
+
15
+ Auto seul +
+
+
+
+
15
+ Manuel + Auto +
+
+
+
+
15
+ Aucune +
+
+
+
+ 💡 Le badge orange indique le nombre total quand > 1 +
+
+ + {/* Statistiques résumées */} +
+
+
+ {safeStats.reduce((sum, s) => sum + s.manual, 0)} +
+
Manuelles
+
+
+
+ {safeStats.reduce((sum, s) => sum + s.automatic, 0)} +
+
Automatiques
+
+
+
+ {safeStats.reduce((sum, s) => sum + s.total, 0)} +
+
Total
+
+
+
+ ); +} diff --git a/src/components/settings/BackupSettingsPageClient.tsx b/src/components/settings/BackupSettingsPageClient.tsx index 31d3625..98095ed 100644 --- a/src/components/settings/BackupSettingsPageClient.tsx +++ b/src/components/settings/BackupSettingsPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useTransition } from 'react'; import { backupClient, BackupListResponse } from '@/clients/backup-client'; import { BackupConfig } from '@/services/data-management/backup'; import { Button } from '@/components/ui/Button'; @@ -8,18 +8,27 @@ 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 { formatDateForDisplay, parseDate, getToday } from '@/lib/date-utils'; +import { BackupTimelineChart } from '@/components/backup/BackupTimelineChart'; +import { createBackupAction, verifyDatabaseAction } from '@/actions/backup'; +import { parseDate, getToday } from '@/lib/date-utils'; import Link from 'next/link'; interface BackupSettingsPageClientProps { - initialData?: BackupListResponse; + initialData?: BackupListResponse & { + backupStats?: Array<{ + date: string; + manual: number; + automatic: number; + total: number; + }>; + }; } export default function BackupSettingsPageClient({ initialData }: BackupSettingsPageClientProps) { const [data, setData] = useState(initialData || null); + const [backupStats, setBackupStats] = useState(initialData?.backupStats || []); const [isLoading, setIsLoading] = useState(!initialData); - const [isCreatingBackup, setIsCreatingBackup] = useState(false); - const [isVerifying, setIsVerifying] = useState(false); + const [isPending, startTransition] = useTransition(); const [showRestoreConfirm, setShowRestoreConfirm] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [config, setConfig] = useState(initialData?.config || null); @@ -56,10 +65,18 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings const loadData = async () => { try { console.log('🔄 Loading backup data...'); - const response = await backupClient.listBackups(); + const [response, newBackupStats] = await Promise.all([ + backupClient.listBackups(), + backupClient.getBackupStats(30) + ]); console.log('✅ Backup data loaded:', response); + console.log('✅ Backup stats loaded:', newBackupStats); + setData(response); + setBackupStats(newBackupStats); setConfig(response.config); + + console.log('✅ States updated - backups count:', response.backups.length, 'stats count:', newBackupStats.length); } catch (error) { console.error('❌ Failed to load backup data:', error); // Afficher l'erreur spécifique à l'utilisateur @@ -71,59 +88,79 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings } }; - const handleCreateBackup = async (force: boolean = false) => { - setIsCreatingBackup(true); - try { - const result = await backupClient.createBackup(force); - - if (result === null) { + const handleCreateBackup = (force: boolean = false) => { + startTransition(async () => { + try { + console.log('🔄 Creating backup...'); + const result = await createBackupAction(force); + console.log('✅ Backup action result:', result); + + if (!result.success) { + setMessage('backup', { + type: 'error', + text: result.error || 'Erreur lors de la création de la sauvegarde' + }); + return; + } + + if (result.skipped) { + setMessage('backup', { + type: 'success', + text: result.message || 'Sauvegarde sautée : aucun changement détecté.' + }); + } else { + setMessage('backup', { + type: 'success', + text: result.message || 'Sauvegarde créée avec succès' + }); + } + + // Petit délai pour être sûr que la sauvegarde est bien créée + await new Promise(resolve => setTimeout(resolve, 500)); + + // Recharger les données manuellement pour être sûr + console.log('🔄 Reloading data after backup...'); + await loadData(); + console.log('✅ Data reloaded manually'); + } catch (error) { + console.error('❌ Error in handleCreateBackup:', error); 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}` + type: 'error', + text: 'Erreur lors de la création de la sauvegarde' }); } + }); + }; + + const handleVerifyDatabase = () => { + startTransition(async () => { + setMessage('verify', null); + const result = await verifyDatabaseAction(); - 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); - } + if (result.success) { + setMessage('verify', {type: 'success', text: result.message || 'Intégrité vérifiée'}); + } else { + setMessage('verify', {type: 'error', text: result.error || 'Vérification échouée'}); + } + }); }; - 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 handleDeleteBackup = (filename: string) => { + startTransition(async () => { + try { + console.log('🔄 Deleting backup:', filename); + await backupClient.deleteBackup(filename); + setShowDeleteConfirm(null); + setMessage('restore', {type: 'success', text: `Sauvegarde ${filename} supprimée`}); + + console.log('🔄 Reloading data after deletion...'); + await loadData(); + console.log('✅ Data reloaded after deletion'); + } catch (error) { + console.error('Failed to delete backup:', error); + setMessage('restore', {type: 'error', text: 'Suppression échouée'}); + } + }); }; const handleRestoreBackup = async (filename: string) => { @@ -192,10 +229,16 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings return `${size.toFixed(1)} ${units[unitIndex]}`; }; - const formatDate = (date: string | Date): string => { - // Format cohérent serveur/client pour éviter les erreurs d'hydratation + + const formatDateWithTime = (date: string | Date): string => { const d = typeof date === 'string' ? parseDate(date) : date; - return formatDateForDisplay(d, 'DISPLAY_MEDIUM'); + return d.toLocaleDateString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); }; if (isLoading) { @@ -391,17 +434,17 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
@@ -413,10 +456,10 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
@@ -433,6 +476,13 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings
+ + {/* Graphique timeline des sauvegardes */} + + + + +
{/* Colonne latérale: Statut et historique */} @@ -520,7 +570,7 @@ export default function BackupSettingsPageClient({ initialData }: BackupSettings

- {formatDate(backup.createdAt)} + {formatDateWithTime(backup.createdAt)}

diff --git a/src/lib/backup-utils.ts b/src/lib/backup-utils.ts index 3dfd762..c1fb19a 100644 --- a/src/lib/backup-utils.ts +++ b/src/lib/backup-utils.ts @@ -170,7 +170,7 @@ export class BackupUtils { * Génère un nom de fichier de backup */ static generateBackupFilename(type: 'manual' | 'automatic'): string { - const timestamp = getToday().toISOString().replace(/[:.]/g, '-'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); return `towercontrol_${type}_${timestamp}.db`; } diff --git a/src/services/data-management/backup.ts b/src/services/data-management/backup.ts index 13a2f0e..1f87238 100644 --- a/src/services/data-management/backup.ts +++ b/src/services/data-management/backup.ts @@ -281,7 +281,7 @@ export class BackupService { id: backupId, filename: path.basename(finalPath), size: stats.size, - createdAt: getToday(), + createdAt: new Date(), // Utiliser l'heure actuelle au lieu de getToday() type, status: 'success', databaseHash, @@ -290,7 +290,7 @@ export class BackupService { // Sauvegarder les métadonnées du backup await this.saveBackupMetadata(path.basename(finalPath), { databaseHash, - createdAt: getToday(), + createdAt: new Date(), // Utiliser l'heure actuelle type, }); @@ -314,7 +314,7 @@ export class BackupService { id: backupId, filename, size: 0, - createdAt: getToday(), + createdAt: new Date(), // Utiliser l'heure actuelle même en cas d'erreur type, status: 'failed', error: errorMessage, @@ -565,6 +565,59 @@ export class BackupService { return []; } } + + /** + * Récupère les statistiques de sauvegarde par jour pour les N derniers jours + */ + async getBackupStats(days: number = 30): Promise> { + try { + const backups = await this.listBackups(); + const now = new Date(); + const stats: { [date: string]: { manual: number; automatic: number; total: number } } = {}; + + // Initialiser les stats pour chaque jour + for (let i = 0; i < days; i++) { + const date = new Date(now); + date.setDate(date.getDate() - i); + // Utiliser la date locale pour éviter les décalages UTC + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + const dateStr = localDate.toISOString().split('T')[0]; // Format YYYY-MM-DD + stats[dateStr] = { manual: 0, automatic: 0, total: 0 }; + } + + // Compter les sauvegardes par jour et par type + backups.forEach(backup => { + // Utiliser la date locale pour éviter les décalages UTC + const backupDate = new Date(backup.createdAt.getTime() - backup.createdAt.getTimezoneOffset() * 60000) + .toISOString().split('T')[0]; + + if (stats[backupDate]) { + if (backup.type === 'manual') { + stats[backupDate].manual++; + } else { + stats[backupDate].automatic++; + } + stats[backupDate].total++; + } + }); + + // Convertir en tableau et trier par date + return Object.entries(stats) + .map(([date, counts]) => ({ + date, + ...counts + })) + .sort((a, b) => a.date.localeCompare(b.date)); + } catch (error) { + console.error('Error getting backup stats:', error); + return []; + } + } } // Instance singleton