From fbeb36c04a825ccc577d5356113ddcfee6949628 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Wed, 13 Aug 2025 20:55:27 +0000 Subject: [PATCH] Add system for managing and scheduling data backups Introduce a new backup management feature with automated scheduling, manual backups, and a dedicated interface for viewing backup status and details. This includes integrating `node-cron` for scheduling, updating the database schema to store backup information, and creating new routes and components for the backup manager. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 7c468936-2a88-4686-ac0a-84921a45222d Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/7c468936-2a88-4686-ac0a-84921a45222d/9xfbVBU --- client/src/components/Layout.tsx | 2 +- client/src/components/RouterProduction.tsx | 2 + client/src/components/Sidebar.tsx | 6 ++ client/src/pages/BackupManager.tsx | 30 +++++- package-lock.json | 10 ++ package.json | 1 + server/backupService.ts | 114 +++++++++++---------- server/routes.ts | 7 +- shared/schema.ts | 17 +++ 9 files changed, 131 insertions(+), 58 deletions(-) diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index aac637b..5713485 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -138,7 +138,7 @@ export default function Layout({ children }: LayoutProps) {
{store.name}
diff --git a/client/src/components/RouterProduction.tsx b/client/src/components/RouterProduction.tsx index f0b60d3..bb5f6e8 100644 --- a/client/src/components/RouterProduction.tsx +++ b/client/src/components/RouterProduction.tsx @@ -17,6 +17,7 @@ import DatabaseDebug from "@/pages/DatabaseDebug"; import CustomerOrders from "@/pages/CustomerOrders"; import DlcPage from "@/pages/DlcPage"; +import BackupManager from "@/pages/BackupManager"; import Tasks from "@/pages/Tasks"; import TasksSimplified from "@/pages/TasksSimplified"; import Layout from "@/components/Layout"; @@ -76,6 +77,7 @@ function RouterProduction() { + diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index fac67f0..0675126 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -146,6 +146,12 @@ export default function Sidebar() { icon: UserCog, roles: ["admin"] }, + { + path: "/backup", + label: "Sauvegardes", + icon: Database, + roles: ["admin"] + }, { path: "/nocodb-config", label: "Configuration NocoDB", diff --git a/client/src/pages/BackupManager.tsx b/client/src/pages/BackupManager.tsx index 2fc31e1..d80fdb1 100644 --- a/client/src/pages/BackupManager.tsx +++ b/client/src/pages/BackupManager.tsx @@ -24,9 +24,13 @@ import { fr } from "date-fns/locale"; interface BackupFile { id: string; filename: string; + description: string | null; size: number; createdAt: string; - type: 'manual' | 'automatic'; + createdBy: string; + tablesCount: number; + status: string; + backupType: string; } export default function BackupManager() { @@ -118,6 +122,19 @@ export default function BackupManager() { : Manuelle; }; + const getStatusBadge = (status: string) => { + switch (status) { + case 'completed': + return Terminé; + case 'creating': + return En cours; + case 'failed': + return Échec; + default: + return {status}; + } + }; + if (!canManageBackups) { return (
@@ -254,7 +271,7 @@ export default function BackupManager() { >
- {getTypeIcon(backup.type)} + {getTypeIcon(backup.backupType)}
@@ -262,12 +279,17 @@ export default function BackupManager() {
{backup.filename}
-
+
+ {backup.description || 'Sauvegarde de base de données'} +
+
{format(new Date(backup.createdAt), 'dd/MM/yyyy à HH:mm', { locale: fr })} {formatFileSize(backup.size)} - {getTypeBadge(backup.type)} + {backup.tablesCount} tables + {getTypeBadge(backup.backupType)} + {getStatusBadge(backup.status)}
diff --git a/package-lock.json b/package-lock.json index 8eb97a2..23a42c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "memorystore": "^1.6.7", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "node-cron": "^4.2.1", "openid-client": "^6.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", @@ -6919,6 +6920,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", diff --git a/package.json b/package.json index 715a45a..b3be968 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "memorystore": "^1.6.7", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "node-cron": "^4.2.1", "openid-client": "^6.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", diff --git a/server/backupService.ts b/server/backupService.ts index c1ea50d..07661f0 100644 --- a/server/backupService.ts +++ b/server/backupService.ts @@ -3,17 +3,14 @@ import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import cron from 'node-cron'; +import { nanoid } from 'nanoid'; +import { eq, desc } from "drizzle-orm"; +import { db } from "./db"; +import { databaseBackups } from "@shared/schema"; +import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema"; const execAsync = promisify(exec); -export interface BackupFile { - id: string; - filename: string; - size: number; - createdAt: Date; - type: 'manual' | 'automatic'; -} - export class BackupService { private backupDir: string; private maxBackups = 10; @@ -48,12 +45,25 @@ export class BackupService { }; } - async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise { + async createBackup(type: 'manual' | 'automatic' = 'manual', createdBy: string = 'system'): Promise { try { + const id = nanoid(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `backup_${type}_${timestamp}.sql`; const filepath = path.join(this.backupDir, filename); + // Create database record first with "creating" status + const [backupRecord] = await db.insert(databaseBackups).values({ + id, + filename, + description: `${type === 'manual' ? 'Manuel' : 'Automatique'} backup du ${new Date().toLocaleDateString('fr-FR')}`, + size: 0, + createdBy, + tablesCount: 0, + status: 'creating', + backupType: type, + }).returning(); + const dbConfig = this.getDatabaseConfig(); // Set environment variable for password to avoid prompt @@ -69,56 +79,41 @@ export class BackupService { await execAsync(command, { env }); - // Get file stats + // Get file stats and count tables const stats = fs.statSync(filepath); + const sqlContent = fs.readFileSync(filepath, 'utf8'); + const tablesCount = (sqlContent.match(/CREATE TABLE/g) || []).length; - const backupFile: BackupFile = { - id: timestamp, - filename, - size: stats.size, - createdAt: new Date(), - type, - }; + // Update database record with completion details + const [updatedBackup] = await db.update(databaseBackups) + .set({ + size: stats.size, + tablesCount, + status: 'completed', + }) + .where(eq(databaseBackups.id, id)) + .returning(); - console.log(`✅ ${type} backup created: ${filename} (${this.formatFileSize(stats.size)})`); + console.log(`✅ ${type} backup created: ${filename} (${this.formatFileSize(stats.size)}, ${tablesCount} tables)`); // Clean old backups await this.cleanOldBackups(); - return backupFile; + return updatedBackup; } catch (error) { console.error('❌ Backup failed:', error); throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } - async getBackupList(): Promise { + async getBackupList(): Promise { try { - const files = fs.readdirSync(this.backupDir); - const backupFiles: BackupFile[] = []; + const backups = await db.select() + .from(databaseBackups) + .orderBy(desc(databaseBackups.createdAt)) + .limit(this.maxBackups); - for (const filename of files) { - if (filename.endsWith('.sql')) { - const filepath = path.join(this.backupDir, filename); - const stats = fs.statSync(filepath); - - // Extract type and timestamp from filename - const parts = filename.replace('.sql', '').split('_'); - const type = parts[1] as 'manual' | 'automatic'; - const timestamp = parts.slice(2).join('_'); - - backupFiles.push({ - id: timestamp, - filename, - size: stats.size, - createdAt: stats.birthtime, - type, - }); - } - } - - // Sort by creation date (newest first) - return backupFiles.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return backups; } catch (error) { console.error('❌ Failed to list backups:', error); return []; @@ -127,13 +122,26 @@ export class BackupService { async deleteBackup(filename: string): Promise { try { - const filepath = path.join(this.backupDir, filename); - - if (!fs.existsSync(filepath)) { - throw new Error('Backup file not found'); + // Find backup record + const [backup] = await db.select() + .from(databaseBackups) + .where(eq(databaseBackups.filename, filename)) + .limit(1); + + if (!backup) { + throw new Error('Backup record not found'); } - fs.unlinkSync(filepath); + // Delete physical file + const filepath = path.join(this.backupDir, filename); + if (fs.existsSync(filepath)) { + fs.unlinkSync(filepath); + } + + // Delete database record + await db.delete(databaseBackups) + .where(eq(databaseBackups.filename, filename)); + console.log(`🗑️ Backup deleted: ${filename}`); } catch (error) { console.error('❌ Failed to delete backup:', error); @@ -153,10 +161,12 @@ export class BackupService { private async cleanOldBackups(): Promise { try { - const backups = await this.getBackupList(); + const allBackups = await db.select() + .from(databaseBackups) + .orderBy(desc(databaseBackups.createdAt)); - if (backups.length > this.maxBackups) { - const toDelete = backups.slice(this.maxBackups); + if (allBackups.length > this.maxBackups) { + const toDelete = allBackups.slice(this.maxBackups); for (const backup of toDelete) { await this.deleteBackup(backup.filename); diff --git a/server/routes.ts b/server/routes.ts index 4c8c169..7345faf 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2352,7 +2352,12 @@ RÉSUMÉ DU SCAN app.post('/api/backups', isAuthenticated, requireAdmin, async (req: any, res) => { try { - const backup = await backupService.createBackup('manual'); + const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const backup = await backupService.createBackup('manual', user.id); res.json(backup); } catch (error) { console.error("Error creating backup:", error); diff --git a/shared/schema.ts b/shared/schema.ts index 93855d7..89102b0 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -11,6 +11,7 @@ import { date, decimal, primaryKey, + bigint, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { createInsertSchema } from "drizzle-zod"; @@ -512,8 +513,24 @@ export const insertInvoiceVerificationCacheSchema = createInsertSchema(invoiceVe updatedAt: true, }); +export const databaseBackups = pgTable("database_backups", { + id: varchar("id", { length: 255 }).primaryKey(), + filename: varchar("filename", { length: 255 }).notNull(), + description: text("description"), + size: bigint("size", { mode: "number" }).default(0), + createdAt: timestamp("created_at").defaultNow(), + createdBy: varchar("created_by", { length: 255 }).notNull(), + tablesCount: integer("tables_count").default(0), + status: varchar("status", { length: 50 }).default("creating"), + backupType: varchar("backup_type", { length: 10 }).default("manual"), +}); + +export const insertDatabaseBackupSchema = createInsertSchema(databaseBackups); + export type InsertNocodbConfig = z.infer; export type InsertInvoiceVerificationCache = z.infer; +export type DatabaseBackup = typeof databaseBackups.$inferSelect; +export type InsertDatabaseBackup = z.infer; // Types export type User = typeof users.$inferSelect;