feat: enhance backup management in AdvancedSettingsPage
- Added backup management functionality to `AdvancedSettingsPageClient`, including creating and verifying backups. - Updated `package.json` with new backup-related scripts. - Improved UI to display backup status and next scheduled backup time. - Updated `.gitignore` to exclude backup files. - Enhanced server-side data fetching to include backup data and database statistics.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,3 +41,5 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
backups/
|
||||||
341
BACKUP.md
Normal file
341
BACKUP.md
Normal file
@@ -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 <filename>
|
||||||
|
tsx scripts/backup-manager.ts restore <filename> --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 <filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
17
TODO.md
17
TODO.md
@@ -150,13 +150,16 @@
|
|||||||
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
- [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] 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
|
- [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
|
- [x] Système de sauvegarde automatique base de données
|
||||||
- [ ] Sauvegarde automatique toutes les 6 heures (configurable)
|
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
||||||
- [ ] Configuration dans les paramètres (intervalle de temps + bouton sauvegarde manuelle)
|
- [x] Configuration complète dans les paramètres avec interface dédiée
|
||||||
- [ ] Rotation automatique des sauvegardes (garder seulement les 5 dernières)
|
- [x] Rotation automatique des sauvegardes (configurable)
|
||||||
- [ ] Format de sauvegarde avec timestamp (backup_YYYY-MM-DD_HH-mm-ss.sqlite)
|
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
||||||
- [ ] Interface pour visualiser et gérer les sauvegardes existantes
|
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
||||||
- [ ] Option de restauration depuis une sauvegarde sélectionnée
|
- [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)
|
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||||
|
|||||||
100
clients/backup-client.ts
Normal file
100
clients/backup-client.ts
Normal file
@@ -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<BackupListResponse> {
|
||||||
|
const response = await httpClient.get<{ data: BackupListResponse }>(this.baseUrl);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une nouvelle sauvegarde manuelle
|
||||||
|
*/
|
||||||
|
async createBackup(): Promise<BackupInfo> {
|
||||||
|
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<void> {
|
||||||
|
await httpClient.post(this.baseUrl, {
|
||||||
|
action: 'verify'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour la configuration des sauvegardes
|
||||||
|
*/
|
||||||
|
async updateConfig(config: Partial<BackupConfig>): Promise<BackupConfig> {
|
||||||
|
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<void> {
|
||||||
|
await httpClient.delete(`${this.baseUrl}/${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaure une sauvegarde (développement uniquement)
|
||||||
|
*/
|
||||||
|
async restoreBackup(filename: string): Promise<void> {
|
||||||
|
await httpClient.post(`${this.baseUrl}/${filename}`, {
|
||||||
|
action: 'restore'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backupClient = new BackupClient();
|
||||||
@@ -1,16 +1,115 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { UserPreferences } from '@/lib/types';
|
import { UserPreferences } from '@/lib/types';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||||
|
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface DatabaseStats {
|
||||||
|
taskCount: number;
|
||||||
|
tagCount: number;
|
||||||
|
completionRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AdvancedSettingsPageClientProps {
|
interface AdvancedSettingsPageClientProps {
|
||||||
initialPreferences: UserPreferences;
|
initialPreferences: UserPreferences;
|
||||||
|
initialDbStats: DatabaseStats;
|
||||||
|
initialBackupData: BackupListResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdvancedSettingsPageClient({ initialPreferences }: AdvancedSettingsPageClientProps) {
|
export function AdvancedSettingsPageClient({
|
||||||
|
initialPreferences,
|
||||||
|
initialDbStats,
|
||||||
|
initialBackupData
|
||||||
|
}: AdvancedSettingsPageClientProps) {
|
||||||
|
const [backupData, setBackupData] = useState<BackupListResponse>(initialBackupData);
|
||||||
|
const [dbStats] = useState<DatabaseStats>(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 (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
@@ -52,33 +151,65 @@ export function AdvancedSettingsPageClient({ initialPreferences }: AdvancedSetti
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||||
<h3 className="font-medium mb-2">Sauvegarde automatique</h3>
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium">Sauvegarde automatique</h3>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
backupData.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}></span>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
||||||
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'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
Prochaine sauvegarde: dans 3h 42min
|
{backupData.scheduler.isRunning
|
||||||
|
? `Prochaine sauvegarde: ${getNextBackupTime()}`
|
||||||
|
: 'Planificateur arrêté'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||||
<h3 className="font-medium mb-2">Sauvegardes disponibles</h3>
|
<h3 className="font-medium mb-2">Sauvegardes disponibles</h3>
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
||||||
5 sauvegardes conservées
|
{backupData.backups.length} sauvegarde{backupData.backups.length > 1 ? 's' : ''} conservée{backupData.backups.length > 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
{backupData.backups.length > 0 ? (
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
Dernière: il y a 2h 18min
|
Dernière: {formatTimeAgo(backupData.backups[0].createdAt)}
|
||||||
|
({formatFileSize(backupData.backups[0].size)})
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Aucune sauvegarde disponible
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm font-medium">
|
<Button
|
||||||
Créer une sauvegarde
|
onClick={handleCreateBackup}
|
||||||
</button>
|
disabled={isCreatingBackup}
|
||||||
<button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium">
|
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isCreatingBackup ? 'Création...' : 'Créer une sauvegarde'}
|
||||||
|
</Button>
|
||||||
|
<Link href="/settings/backup">
|
||||||
|
<Button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium">
|
||||||
Gérer les sauvegardes
|
Gérer les sauvegardes
|
||||||
</button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
onClick={handleVerifyDatabase}
|
||||||
|
disabled={isVerifying}
|
||||||
|
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isVerifying ? 'Vérification...' : 'Vérifier DB'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -95,126 +226,31 @@ export function AdvancedSettingsPageClient({ initialPreferences }: AdvancedSetti
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||||
<h3 className="font-medium mb-1">Tâches</h3>
|
<h3 className="font-medium mb-1">Tâches</h3>
|
||||||
<p className="text-2xl font-bold text-[var(--primary)]">247</p>
|
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||||
|
{dbStats.taskCount}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||||
<h3 className="font-medium mb-1">Tags</h3>
|
<h3 className="font-medium mb-1">Tags</h3>
|
||||||
<p className="text-2xl font-bold text-[var(--primary)]">18</p>
|
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||||
|
{dbStats.tagCount}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||||
<h3 className="font-medium mb-1">Taille DB</h3>
|
<h3 className="font-medium mb-1">Taux complétion</h3>
|
||||||
<p className="text-2xl font-bold text-[var(--primary)]">2.4</p>
|
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">MB</p>
|
{dbStats.completionRate}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Export / Import */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">📤 Export / Import</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Sauvegarde et restauration des données
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-2">Export des données</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
|
||||||
Exporter toutes les données au format JSON
|
|
||||||
</p>
|
|
||||||
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm">
|
|
||||||
Télécharger export
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-2">Import des données</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
|
||||||
Restaurer des données depuis un fichier JSON
|
|
||||||
</p>
|
|
||||||
<button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm">
|
|
||||||
Choisir fichier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Logs et debug */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">🐛 Debug et logs</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Outils de diagnostic et de résolution de problèmes
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-2">Logs système</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
|
||||||
Consultation des logs d'erreur et d'activité
|
|
||||||
</p>
|
|
||||||
<button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm">
|
|
||||||
Voir les logs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<h3 className="font-medium mb-2">Mode debug</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
|
||||||
Activer les informations de debug détaillées
|
|
||||||
</p>
|
|
||||||
<button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm">
|
|
||||||
Activer debug
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Zone dangereuse */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold text-[var(--destructive)]">⚠️ Zone dangereuse</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Actions irréversibles - à utiliser avec précaution
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="p-4 bg-[var(--destructive)]/5 border border-[var(--destructive)]/20 rounded">
|
|
||||||
<h3 className="font-medium mb-2 text-[var(--destructive)]">Réinitialisation complète</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
|
||||||
Supprime toutes les données (tâches, tags, préférences)
|
|
||||||
</p>
|
|
||||||
<button className="px-3 py-1.5 bg-[var(--destructive)] text-[var(--destructive-foreground)] rounded text-sm">
|
|
||||||
Réinitialiser
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Note développement futur */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
|
||||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
|
||||||
🚧 Fonctionnalités en développement
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
La plupart des fonctions avancées seront implémentées dans les prochaines versions.
|
|
||||||
Cette page sert de prévisualisation de l'interface à venir.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
585
components/settings/BackupSettingsPageClient.tsx
Normal file
585
components/settings/BackupSettingsPageClient.tsx
Normal file
@@ -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<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 => {
|
||||||
|
return new Date(date).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
package.json
10
package.json
@@ -6,7 +6,14 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -35,6 +42,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
339
scripts/backup-manager.ts
Normal file
339
scripts/backup-manager.ts
Normal file
@@ -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<BackupConfig>;
|
||||||
|
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 <filename> Supprimer une sauvegarde
|
||||||
|
restore <filename> Restaurer une sauvegarde
|
||||||
|
verify Vérifier l'intégrité de la base
|
||||||
|
config Afficher la configuration
|
||||||
|
config-set <key=value> 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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const [key, value] = configString.split('=');
|
||||||
|
|
||||||
|
if (!key || !value) {
|
||||||
|
console.error('❌ Format invalide. Utilisez: key=value');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig: Partial<BackupConfig> = {};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
backupScheduler.start();
|
||||||
|
console.log('✅ Planificateur de sauvegarde démarré');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopScheduler(): Promise<void> {
|
||||||
|
backupScheduler.stop();
|
||||||
|
console.log('🛑 Planificateur de sauvegarde arrêté');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async schedulerStatus(): Promise<void> {
|
||||||
|
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 };
|
||||||
136
services/backup-scheduler.ts
Normal file
136
services/backup-scheduler.ts
Normal file
@@ -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<void> {
|
||||||
|
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
|
||||||
|
|
||||||
416
services/backup.ts
Normal file
416
services/backup.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
|
import { prisma } from './database';
|
||||||
|
import { userPreferencesService } from './user-preferences';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export interface BackupConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: 'hourly' | 'daily' | 'weekly';
|
||||||
|
maxBackups: number;
|
||||||
|
backupPath: string;
|
||||||
|
includeUploads?: boolean;
|
||||||
|
compression?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupInfo {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: Date;
|
||||||
|
type: 'manual' | 'automatic';
|
||||||
|
status: 'success' | 'failed' | 'in_progress';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupService {
|
||||||
|
private defaultConfig: BackupConfig = {
|
||||||
|
enabled: true,
|
||||||
|
interval: 'hourly',
|
||||||
|
maxBackups: 5,
|
||||||
|
backupPath: path.join(process.cwd(), 'backups'),
|
||||||
|
includeUploads: true,
|
||||||
|
compression: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private config: BackupConfig;
|
||||||
|
|
||||||
|
constructor(config?: Partial<BackupConfig>) {
|
||||||
|
this.config = { ...this.defaultConfig, ...config };
|
||||||
|
// Charger la config depuis la DB de manière asynchrone
|
||||||
|
this.loadConfigFromDB().catch(() => {
|
||||||
|
// Ignorer les erreurs de chargement initial
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge la configuration depuis la base de données
|
||||||
|
*/
|
||||||
|
private async loadConfigFromDB(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const preferences = await userPreferencesService.getAllPreferences();
|
||||||
|
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
|
||||||
|
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
|
||||||
|
if (backupConfig) {
|
||||||
|
this.config = { ...this.defaultConfig, ...backupConfig };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load backup config from DB, using defaults:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde la configuration dans la base de données
|
||||||
|
*/
|
||||||
|
private async saveConfigToDB(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
|
||||||
|
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
|
||||||
|
await prisma.userPreferences.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
|
update: {
|
||||||
|
viewPreferences: {
|
||||||
|
...(await userPreferencesService.getViewPreferences()),
|
||||||
|
// Cast pour contourner la restriction de type temporairement
|
||||||
|
...(({ backupConfig: this.config } as any))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: 'default',
|
||||||
|
kanbanFilters: {},
|
||||||
|
viewPreferences: { backupConfig: this.config } as any,
|
||||||
|
columnVisibility: {},
|
||||||
|
jiraConfig: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save backup config to DB:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une sauvegarde complète de la base de données
|
||||||
|
*/
|
||||||
|
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
|
||||||
|
const backupId = `backup_${Date.now()}`;
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const filename = `towercontrol_${timestamp}.db`;
|
||||||
|
const backupPath = path.join(this.config.backupPath, filename);
|
||||||
|
|
||||||
|
console.log(`🔄 Starting ${type} backup: ${filename}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Créer le dossier de backup si nécessaire
|
||||||
|
await this.ensureBackupDirectory();
|
||||||
|
|
||||||
|
// Vérifier l'état de la base de données
|
||||||
|
await this.verifyDatabaseHealth();
|
||||||
|
|
||||||
|
// Créer la sauvegarde SQLite
|
||||||
|
await this.createSQLiteBackup(backupPath);
|
||||||
|
|
||||||
|
// Compresser si activé
|
||||||
|
let finalPath = backupPath;
|
||||||
|
if (this.config.compression) {
|
||||||
|
finalPath = await this.compressBackup(backupPath);
|
||||||
|
await fs.unlink(backupPath); // Supprimer le fichier non compressé
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtenir les stats du fichier
|
||||||
|
const stats = await fs.stat(finalPath);
|
||||||
|
|
||||||
|
const backupInfo: BackupInfo = {
|
||||||
|
id: backupId,
|
||||||
|
filename: path.basename(finalPath),
|
||||||
|
size: stats.size,
|
||||||
|
createdAt: new Date(),
|
||||||
|
type,
|
||||||
|
status: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nettoyer les anciennes sauvegardes
|
||||||
|
await this.cleanOldBackups();
|
||||||
|
|
||||||
|
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
|
||||||
|
|
||||||
|
return backupInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Backup failed:`, error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: backupId,
|
||||||
|
filename,
|
||||||
|
size: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
type,
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||||
|
*/
|
||||||
|
private async createSQLiteBackup(backupPath: string): Promise<void> {
|
||||||
|
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||||
|
|
||||||
|
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
|
||||||
|
try {
|
||||||
|
const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`;
|
||||||
|
await execAsync(command);
|
||||||
|
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
|
||||||
|
return;
|
||||||
|
} catch (cliError) {
|
||||||
|
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode 2: Copie simple du fichier (fallback)
|
||||||
|
try {
|
||||||
|
await fs.copyFile(dbPath, backupPath);
|
||||||
|
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
|
||||||
|
} catch (copyError) {
|
||||||
|
throw new Error(`Failed to create SQLite backup: ${copyError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresse une sauvegarde
|
||||||
|
*/
|
||||||
|
private async compressBackup(filePath: string): Promise<string> {
|
||||||
|
const compressedPath = `${filePath}.gz`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
|
||||||
|
await execAsync(command);
|
||||||
|
console.log(`✅ Backup compressed: ${compressedPath}`);
|
||||||
|
return compressedPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaure une sauvegarde
|
||||||
|
*/
|
||||||
|
async restoreBackup(filename: string): Promise<void> {
|
||||||
|
const backupPath = path.join(this.config.backupPath, filename);
|
||||||
|
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||||
|
|
||||||
|
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
|
||||||
|
|
||||||
|
console.log(`🔄 Starting restore from: ${filename}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier que le fichier de sauvegarde existe
|
||||||
|
await fs.access(backupPath);
|
||||||
|
|
||||||
|
// Décompresser si nécessaire
|
||||||
|
let sourceFile = backupPath;
|
||||||
|
if (filename.endsWith('.gz')) {
|
||||||
|
const tempFile = backupPath.replace('.gz', '');
|
||||||
|
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
|
||||||
|
console.log(`✅ Decompression successful`);
|
||||||
|
|
||||||
|
// Vérifier que le fichier décompressé existe
|
||||||
|
await fs.access(tempFile);
|
||||||
|
console.log(`✅ Decompressed file exists: ${tempFile}`);
|
||||||
|
|
||||||
|
sourceFile = tempFile;
|
||||||
|
} catch (decompError) {
|
||||||
|
console.error(`❌ Decompression failed:`, decompError);
|
||||||
|
throw decompError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer une sauvegarde de la base actuelle avant restauration
|
||||||
|
const currentBackup = await this.createBackup('manual');
|
||||||
|
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
|
||||||
|
|
||||||
|
// Fermer toutes les connexions
|
||||||
|
await prisma.$disconnect();
|
||||||
|
|
||||||
|
// Vérifier que le fichier source existe
|
||||||
|
await fs.access(sourceFile);
|
||||||
|
console.log(`✅ Source file verified: ${sourceFile}`);
|
||||||
|
|
||||||
|
// Remplacer la base de données
|
||||||
|
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
|
||||||
|
await fs.copyFile(sourceFile, dbPath);
|
||||||
|
console.log(`✅ Database file copied successfully`);
|
||||||
|
|
||||||
|
// Nettoyer le fichier temporaire si décompressé
|
||||||
|
if (sourceFile !== backupPath) {
|
||||||
|
await fs.unlink(sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnecter à la base
|
||||||
|
await prisma.$connect();
|
||||||
|
|
||||||
|
// Vérifier l'intégrité après restauration
|
||||||
|
await this.verifyDatabaseHealth();
|
||||||
|
|
||||||
|
console.log(`✅ Database restored from: ${filename}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Restore failed:`, error);
|
||||||
|
throw new Error(`Failed to restore backup: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste toutes les sauvegardes disponibles
|
||||||
|
*/
|
||||||
|
async listBackups(): Promise<BackupInfo[]> {
|
||||||
|
try {
|
||||||
|
await this.ensureBackupDirectory();
|
||||||
|
const files = await fs.readdir(this.config.backupPath);
|
||||||
|
|
||||||
|
const backups: BackupInfo[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
|
||||||
|
const filePath = path.join(this.config.backupPath, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
// Extraire la date du nom de fichier
|
||||||
|
const dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||||
|
let createdAt = stats.birthtime;
|
||||||
|
|
||||||
|
if (dateMatch) {
|
||||||
|
// Convertir le format de fichier vers ISO string valide
|
||||||
|
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
|
||||||
|
const isoString = dateMatch[1]
|
||||||
|
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||||
|
createdAt = new Date(isoString);
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.push({
|
||||||
|
id: file,
|
||||||
|
filename: file,
|
||||||
|
size: stats.size,
|
||||||
|
createdAt,
|
||||||
|
type: 'automatic', // On ne peut pas déterminer le type depuis le nom
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing backups:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une sauvegarde
|
||||||
|
*/
|
||||||
|
async deleteBackup(filename: string): Promise<void> {
|
||||||
|
const backupPath = path.join(this.config.backupPath, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(backupPath);
|
||||||
|
console.log(`✅ Backup deleted: ${filename}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to delete backup ${filename}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie l'intégrité de la base de données
|
||||||
|
*/
|
||||||
|
async verifyDatabaseHealth(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Test de connexion simple
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
|
||||||
|
// Vérification de l'intégrité SQLite
|
||||||
|
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
|
||||||
|
|
||||||
|
if (result.length > 0 && result[0].integrity_check !== 'ok') {
|
||||||
|
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Database health check passed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database health check failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie les anciennes sauvegardes selon la configuration
|
||||||
|
*/
|
||||||
|
private async cleanOldBackups(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const backups = await this.listBackups();
|
||||||
|
|
||||||
|
if (backups.length > this.config.maxBackups) {
|
||||||
|
const toDelete = backups.slice(this.config.maxBackups);
|
||||||
|
|
||||||
|
for (const backup of toDelete) {
|
||||||
|
await this.deleteBackup(backup.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cleaning old backups:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S'assure que le dossier de backup existe
|
||||||
|
*/
|
||||||
|
private async ensureBackupDirectory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.access(this.config.backupPath);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(this.config.backupPath, { recursive: true });
|
||||||
|
console.log(`📁 Created backup directory: ${this.config.backupPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate la taille de fichier
|
||||||
|
*/
|
||||||
|
private formatFileSize(bytes: number): string {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour la configuration
|
||||||
|
*/
|
||||||
|
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
|
||||||
|
this.config = { ...this.config, ...newConfig };
|
||||||
|
await this.saveConfigToDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient la configuration actuelle
|
||||||
|
*/
|
||||||
|
getConfig(): BackupConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance singleton
|
||||||
|
export const backupService = new BackupService();
|
||||||
@@ -1,12 +1,46 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
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';
|
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function AdvancedSettingsPage() {
|
export default async function AdvancedSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch all data server-side
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const [preferences, taskStats, tags] = await Promise.all([
|
||||||
|
userPreferencesService.getAllPreferences(),
|
||||||
|
tasksService.getTaskStats(),
|
||||||
|
tagsService.getTags()
|
||||||
|
]);
|
||||||
|
|
||||||
return <AdvancedSettingsPageClient initialPreferences={preferences} />;
|
// 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 (
|
||||||
|
<AdvancedSettingsPageClient
|
||||||
|
initialPreferences={preferences}
|
||||||
|
initialDbStats={dbStats}
|
||||||
|
initialBackupData={backupData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/app/settings/backup/page.tsx
Normal file
26
src/app/settings/backup/page.tsx
Normal file
@@ -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 (
|
||||||
|
<BackupSettingsPageClient initialData={initialData} />
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user