Files
towercontrol/components/settings/BackupSettingsPageClient.tsx
Julien Froidefond 9a33d1ee48 fix: improve date formatting and backup path handling
- Updated `formatTimeAgo` in `AdvancedSettingsPageClient` to use a fixed format for hydration consistency.
- Refined `formatDate` in `BackupSettingsPageClient` for consistent server/client formatting.
- Refactored `BackupService` to use `getCurrentBackupPath` for all backup path references, ensuring up-to-date paths and avoiding caching issues.
- Added `getCurrentBackupPath` method to dynamically retrieve the current backup path based on environment variables.
2025-09-20 16:12:01 +02:00

596 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<BackupListResponse | null>(initialData || null);
const [isLoading, setIsLoading] = useState(!initialData);
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [showRestoreConfirm, setShowRestoreConfirm] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [config, setConfig] = useState<BackupConfig | null>(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 => {
// Format cohérent serveur/client pour éviter les erreurs d'hydratation
const d = new Date(date);
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
};
if (isLoading) {
return <div className="p-6">Loading backup settings...</div>;
}
if (!data || !config) {
return <div className="p-6">Failed to load backup settings</div>;
}
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 (
<div className={`text-xs mt-2 px-2 py-1 rounded transition-all inline-block ${
message.type === 'success'
? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/20'
: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/20'
}`}>
{message.text}
</div>
);
};
return (
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Gestion des sauvegardes"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<div className="mb-4 text-sm">
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
Paramètres
</Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
<Link href="/settings/advanced" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
Avancé
</Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">Sauvegardes</span>
</div>
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
💾 Gestion des sauvegardes
</h1>
<p className="text-[var(--muted-foreground)]">
Configuration et gestion des sauvegardes automatiques de votre base de données
</p>
</div>
{/* Layout en 2 colonnes pour optimiser l'espace */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Colonne principale: Configuration */}
<div className="xl:col-span-2 space-y-6">
<Card>
<CardHeader>
<h2 className="text-xl font-semibold flex items-center gap-2">
<span className="text-blue-600"></span>
Configuration automatique
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Paramètres des sauvegardes programmées
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--foreground)]">
Sauvegardes automatiques
</label>
<select
value={config.enabled ? 'enabled' : 'disabled'}
onChange={(e) => setConfig({
...config,
enabled: e.target.value === 'enabled'
})}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
>
<option value="enabled">Activées</option>
<option value="disabled">Désactivées</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--foreground)]">
Fréquence
</label>
<select
value={config.interval}
onChange={(e) => setConfig({
...config,
interval: e.target.value as BackupConfig['interval']
})}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
>
<option value="hourly">Toutes les heures</option>
<option value="daily">Quotidienne</option>
<option value="weekly">Hebdomadaire</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--foreground)]">
Rétention (nombre max)
</label>
<Input
type="number"
min="1"
max="50"
value={config.maxBackups}
onChange={(e) => setConfig({
...config,
maxBackups: parseInt(e.target.value) || 7
})}
className="bg-[var(--background)] border-[var(--border)] text-[var(--foreground)]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--foreground)]">
Compression
</label>
<select
value={config.compression ? 'enabled' : 'disabled'}
onChange={(e) => setConfig({
...config,
compression: e.target.value === 'enabled'
})}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
>
<option value="enabled">Activée (gzip)</option>
<option value="disabled">Désactivée</option>
</select>
</div>
</div>
<div className="pt-4 border-t border-[var(--border)]">
<div className="flex items-center gap-3">
<Button
onClick={handleSaveConfig}
disabled={isSavingConfig}
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
>
{isSavingConfig ? 'Sauvegarde...' : 'Sauvegarder'}
</Button>
<InlineMessage messageKey="config" />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions manuelles */}
<Card>
<CardHeader>
<h2 className="text-xl font-semibold flex items-center gap-2">
<span className="text-green-600">🚀</span>
Actions manuelles
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Créer une sauvegarde ou vérifier l&apos;intégrité de la base
</p>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Button
onClick={handleCreateBackup}
disabled={isCreatingBackup}
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
>
{isCreatingBackup ? 'Création...' : 'Créer une sauvegarde'}
</Button>
<div>
<Button
onClick={handleVerifyDatabase}
disabled={isVerifying}
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
>
{isVerifying ? 'Vérification...' : 'Vérifier l\'intégrité'}
</Button>
<InlineMessage messageKey="verify" />
</div>
<Button
onClick={handleToggleScheduler}
className={data.scheduler.isRunning
? 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]'
: 'bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]'
}
>
{data.scheduler.isRunning ? 'Arrêter le planificateur' : 'Démarrer le planificateur'}
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Colonne latérale: Statut et historique */}
<div className="space-y-6">
{/* Statut du système */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📊 Statut système</h3>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Status planificateur */}
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-1">
<span className={`h-2 w-2 rounded-full ${
data.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
}`}></span>
<span className="text-sm font-medium">
{data.scheduler.isRunning ? 'Actif' : 'Arrêté'}
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
Prochaine: {getNextBackupTime()}
</p>
</div>
{/* Statistiques */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<p className="text-lg font-bold text-[var(--primary)]">{data.backups.length}</p>
<p className="text-xs text-[var(--muted-foreground)]">sauvegardes</p>
</div>
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<p className="text-lg font-bold text-[var(--primary)]">{config.maxBackups}</p>
<p className="text-xs text-[var(--muted-foreground)]">max conservées</p>
</div>
</div>
{/* Chemin */}
<div>
<p className="text-xs font-medium text-[var(--muted-foreground)] mb-1">Stockage</p>
<code className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded border block truncate">
{data.config.backupPath}
</code>
</div>
</div>
</CardContent>
</Card>
{/* Historique des sauvegardes */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📁 Historique</h3>
<InlineMessage messageKey="restore" />
</CardHeader>
<CardContent>
{data.backups.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] text-center py-4">
Aucune sauvegarde disponible
</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{data.backups.slice(0, 10).map((backup) => (
<div
key={backup.id}
className="p-3 bg-[var(--card)] rounded border border-[var(--border)]"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[var(--foreground)] truncate">
{backup.filename.replace('towercontrol_', '').replace('.db.gz', '').replace('.db', '')}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-[var(--muted-foreground)]">
{formatFileSize(backup.size)}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
backup.type === 'manual'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}>
{backup.type === 'manual' ? 'Manuel' : 'Auto'}
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{formatDate(backup.createdAt)}
</p>
</div>
<div className="flex flex-col gap-1">
{process.env.NODE_ENV !== 'production' && (
<Button
onClick={() => setShowRestoreConfirm(backup.filename)}
className="bg-orange-500 hover:bg-orange-600 text-white text-xs px-2 py-1 h-auto"
>
</Button>
)}
<Button
onClick={() => setShowDeleteConfirm(backup.filename)}
className="bg-red-500 hover:bg-red-600 text-white text-xs px-2 py-1 h-auto"
>
</Button>
</div>
</div>
</div>
))}
{data.backups.length > 10 && (
<p className="text-xs text-[var(--muted-foreground)] text-center pt-2">
... et {data.backups.length - 10} autres
</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{/* Modal de confirmation de restauration */}
{showRestoreConfirm && (
<Modal
isOpen={true}
onClose={() => setShowRestoreConfirm(null)}
title="Confirmer la restauration"
>
<div className="space-y-4">
<p>
<strong>Attention :</strong> Cette action va remplacer complètement
la base de données actuelle par la sauvegarde sélectionnée.
</p>
<p>
Une sauvegarde de la base actuelle sera créée automatiquement
avant la restauration.
</p>
<p className="font-medium">
Fichier à restaurer: <code>{showRestoreConfirm}</code>
</p>
<div className="flex justify-end space-x-3">
<Button
onClick={() => setShowRestoreConfirm(null)}
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]"
>
Annuler
</Button>
<Button
onClick={() => handleRestoreBackup(showRestoreConfirm)}
className="bg-[var(--warning)] hover:bg-[var(--warning)]/90 text-[var(--warning-foreground)]"
>
Confirmer la restauration
</Button>
</div>
</div>
</Modal>
)}
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && (
<Modal
isOpen={true}
onClose={() => setShowDeleteConfirm(null)}
title="Confirmer la suppression"
>
<div className="space-y-4">
<p>
Êtes-vous sûr de vouloir supprimer cette sauvegarde ?
</p>
<p className="font-medium">
Fichier: <code>{showDeleteConfirm}</code>
</p>
<p className="text-red-600 text-sm">
Cette action est irréversible.
</p>
<div className="flex justify-end space-x-3">
<Button
onClick={() => setShowDeleteConfirm(null)}
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]"
>
Annuler
</Button>
<Button
onClick={() => handleDeleteBackup(showDeleteConfirm)}
className="bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]"
>
Supprimer
</Button>
</div>
</div>
</Modal>
)}
</div>
);
}