Checkpoint: Ajout onglet Sauvegarde DB dans ImportSettings : endpoint POST /api/db-backup (mysqldump), GET /api/db-backup/:filename, procédures tRPC backup.list et backup.delete, composant BackupSection avec liste des sauvegardes et téléchargement.
This commit is contained in:
@@ -11,10 +11,146 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
|
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
|
||||||
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
|
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
|
||||||
ArrowDownToLine, Share2
|
ArrowDownToLine, Share2, DatabaseBackup, HardDrive, CheckCircle, Clock
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
|
||||||
|
// ============= COMPOSANT SAUVEGARDE DB =============
|
||||||
|
function BackupSection() {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const { data: backupList, refetch: refetchList } = trpc.backup.list.useQuery();
|
||||||
|
const deleteBackupMutation = trpc.backup.delete.useMutation({
|
||||||
|
onSuccess: () => { refetchList(); toast.success("Sauvegarde supprimée"); },
|
||||||
|
onError: (e) => toast.error("Erreur : " + e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerateBackup = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/db-backup", { method: "POST", credentials: "include" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ error: "Erreur inconnue" }));
|
||||||
|
toast.error("Erreur : " + (err.error || response.statusText));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Déclencher le téléchargement
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get("Content-Disposition") || "";
|
||||||
|
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
|
||||||
|
const fileName = match ? decodeURIComponent(match[1]) : `backup-${new Date().toISOString().slice(0,10)}.sql`;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("Sauvegarde générée et téléchargée");
|
||||||
|
refetchList();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("Erreur : " + e.message);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " o";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||||
|
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 dark:from-slate-950/20 dark:to-gray-950/20 border-b">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-slate-600 rounded-lg">
|
||||||
|
<DatabaseBackup className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Sauvegarde de la base de données</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Générer un dump SQL de la base de données et l'enregistrer localement dans le dossier <code className="bg-muted px-1 rounded text-xs">backups/</code>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-6">
|
||||||
|
{/* Bouton générer */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateBackup}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="gap-2 bg-slate-700 hover:bg-slate-800 text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<><Loader2 className="h-5 w-5 animate-spin" />Génération en cours...</>
|
||||||
|
) : (
|
||||||
|
<><HardDrive className="h-5 w-5" />Générer une sauvegarde</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Le dump SQL sera généré, enregistré dans <code className="bg-muted px-1 rounded text-xs">backups/</code> et téléchargé automatiquement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des sauvegardes existantes */}
|
||||||
|
{backupList && backupList.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Sauvegardes enregistrées ({backupList.length})
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{backupList.map((backup) => (
|
||||||
|
<div key={backup.name} className="flex items-center justify-between px-4 py-3 hover:bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium font-mono">{backup.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(backup.createdAt).toLocaleString("fr-FR")} — {formatSize(backup.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs"
|
||||||
|
onClick={() => { window.open(`/api/db-backup/${encodeURIComponent(backup.name)}`, "_blank"); }}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
Télécharger
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => deleteBackupMutation.mutate({ name: backup.name })}
|
||||||
|
disabled={deleteBackupMutation.isPending}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backupList && backupList.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
<HardDrive className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
Aucune sauvegarde enregistrée
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function ImportSettings() {
|
export default function ImportSettings() {
|
||||||
// Chargement différé : on ne charge les statuts de services qu'après le montage
|
// Chargement différé : on ne charge les statuts de services qu'après le montage
|
||||||
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
|
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
|
||||||
@@ -207,7 +343,7 @@ export default function ImportSettings() {
|
|||||||
|
|
||||||
{/* Onglets Import / Export */}
|
{/* Onglets Import / Export */}
|
||||||
<Tabs defaultValue="import" className="w-full">
|
<Tabs defaultValue="import" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 h-12 mb-6">
|
<TabsList className="grid w-full grid-cols-3 h-12 mb-6">
|
||||||
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
|
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
|
||||||
<ArrowDownToLine className="w-4 h-4" />
|
<ArrowDownToLine className="w-4 h-4" />
|
||||||
Paramètres d'import
|
Paramètres d'import
|
||||||
@@ -216,6 +352,10 @@ export default function ImportSettings() {
|
|||||||
<Share2 className="w-4 h-4" />
|
<Share2 className="w-4 h-4" />
|
||||||
Paramètres d'export
|
Paramètres d'export
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
|
||||||
|
<DatabaseBackup className="w-4 h-4" />
|
||||||
|
Sauvegarde DB
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ===== ONGLET IMPORT ===== */}
|
{/* ===== ONGLET IMPORT ===== */}
|
||||||
@@ -955,6 +1095,10 @@ export default function ImportSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{/* ===== ONGLET SAUVEGARDE ===== */}
|
||||||
|
<TabsContent value="backup" className="space-y-6">
|
||||||
|
<BackupSection />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import net from "net";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
|
import { exec as execCb } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
const execAsync = promisify(execCb);
|
||||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||||
import { registerOAuthRoutes } from "./oauth";
|
import { registerOAuthRoutes } from "./oauth";
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
@@ -225,6 +228,70 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
|
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
|
||||||
|
|
||||||
|
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
|
||||||
|
app.post("/api/db-backup", async (req, res) => {
|
||||||
|
// Vérifier l'auth JWT
|
||||||
|
const { verifyToken } = await import("../auth");
|
||||||
|
const token = req.cookies?.auth_token;
|
||||||
|
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
|
||||||
|
const user = verifyToken(token);
|
||||||
|
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbUrl = new URL(process.env.DATABASE_URL || "");
|
||||||
|
const host = dbUrl.hostname;
|
||||||
|
const port = dbUrl.port || "3306";
|
||||||
|
const username = dbUrl.username;
|
||||||
|
const password = dbUrl.password;
|
||||||
|
const database = dbUrl.pathname.slice(1);
|
||||||
|
|
||||||
|
// Créer le dossier backups/
|
||||||
|
const backupDir = path.resolve("backups");
|
||||||
|
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||||
|
const fileName = `backup-${database}-${timestamp}.sql`;
|
||||||
|
const filePath = path.join(backupDir, fileName);
|
||||||
|
|
||||||
|
// Construire la commande mysqldump
|
||||||
|
const sslFlag = dbUrl.searchParams.get("ssl-mode") === "DISABLED" ? "" : "--ssl-mode=REQUIRED";
|
||||||
|
const cmd = `mysqldump ${sslFlag} -h "${host}" -P ${port} -u "${username}" --password="${password}" "${database}" > "${filePath}"`;
|
||||||
|
|
||||||
|
console.log(`[Backup] Generating dump for database ${database}...`);
|
||||||
|
await execAsync(cmd);
|
||||||
|
console.log(`[Backup] Dump saved to ${filePath}`);
|
||||||
|
|
||||||
|
// Retourner le fichier en téléchargement
|
||||||
|
const encodedName = encodeURIComponent(fileName);
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||||||
|
res.setHeader("Content-Type", "application/sql");
|
||||||
|
res.sendFile(filePath, (err) => {
|
||||||
|
if (err) console.error("[Backup] Error sending file:", err);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Backup] Error:", err.message);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la génération du dump : " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Télécharger une sauvegarde existante
|
||||||
|
app.get("/api/db-backup/:filename", async (req, res) => {
|
||||||
|
const { verifyToken } = await import("../auth");
|
||||||
|
const token = req.cookies?.auth_token;
|
||||||
|
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
|
||||||
|
const user = verifyToken(token);
|
||||||
|
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
|
||||||
|
|
||||||
|
const fileName = path.basename(req.params.filename);
|
||||||
|
const filePath = path.join(path.resolve("backups"), fileName);
|
||||||
|
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
|
||||||
|
const encodedName = encodeURIComponent(fileName);
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||||||
|
res.setHeader("Content-Type", "application/sql");
|
||||||
|
res.sendFile(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/web-import/push-invoice", async (req, res) => {
|
app.post("/api/web-import/push-invoice", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { apiToken, fileName, fileBase64, mimeType } = req.body;
|
const { apiToken, fileName, fileBase64, mimeType } = req.body;
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ import {
|
|||||||
updateWebImportSourceStatus,
|
updateWebImportSourceStatus,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
||||||
|
import { exec as execCb } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import fsSync from "fs";
|
||||||
|
import pathSync from "path";
|
||||||
|
const execAsync = promisify(execCb);
|
||||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
@@ -2678,5 +2683,33 @@ export const appRouter = router({
|
|||||||
return { apiToken: source.apiToken };
|
return { apiToken: source.apiToken };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ============= BACKUP ROUTES =============
|
||||||
|
backup: router({
|
||||||
|
// Liste les sauvegardes existantes dans le dossier backups/
|
||||||
|
list: adminProcedure.query(async () => {
|
||||||
|
const backupDir = pathSync.resolve("backups");
|
||||||
|
if (!fsSync.existsSync(backupDir)) return [];
|
||||||
|
const files = fsSync.readdirSync(backupDir)
|
||||||
|
.filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz"))
|
||||||
|
.map(f => {
|
||||||
|
const stat = fsSync.statSync(pathSync.join(backupDir, f));
|
||||||
|
return { name: f, size: stat.size, createdAt: stat.mtime };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
return files;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Supprime une sauvegarde
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ name: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const backupDir = pathSync.resolve("backups");
|
||||||
|
const filePath = pathSync.join(backupDir, pathSync.basename(input.name));
|
||||||
|
if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' });
|
||||||
|
fsSync.unlinkSync(filePath);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
Reference in New Issue
Block a user