diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 77b1ea8..6d2103e 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -11,6 +11,19 @@ const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, '../../data/crowd // Ensure data directory exists fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); +// ── Pending restore (déclenché par POST /api/admin/exports/:filename/restore) ─ +// Si un fichier .pending-restore existe, on l'applique avant d'ouvrir la DB. +const PENDING_RESTORE = DB_PATH + '.pending-restore'; +if (fs.existsSync(PENDING_RESTORE)) { + try { + fs.copyFileSync(PENDING_RESTORE, DB_PATH); + fs.unlinkSync(PENDING_RESTORE); + console.log('[restore] Base de données restaurée avec succès.'); + } catch (e) { + console.error('[restore] Échec de la restauration :', e.message); + } +} + const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); diff --git a/backend/src/jobs/autoExport.js b/backend/src/jobs/autoExport.js new file mode 100644 index 0000000..48f3902 --- /dev/null +++ b/backend/src/jobs/autoExport.js @@ -0,0 +1,125 @@ +/** + * autoExport.js — Job planifié à 3h00 du matin. + * Génère un export complet (DB + assets) et le conserve dans DATA_DIR/exports/. + * Résultat tracé dans job_logs (job_name = 'auto_export'). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import db from '../db/index.js'; +import { createZip } from '../utils/zip.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); +const exportsDir = path.join(dataDir, 'exports'); +const MAX_EXPORTS = 10; +const JOB_NAME = 'auto_export'; + +function writeLog({ status, nbChanges = 0, details = null, errorMsg = null }) { + try { + db.prepare(` + INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg) + VALUES (?, ?, ?, ?, ?) + `).run(JOB_NAME, status, nbChanges, details, errorMsg); + } catch (e) { + console.error('[autoExport] Impossible d\'écrire dans job_logs :', e.message); + } +} + +function purgeOldExports() { + try { + fs.mkdirSync(exportsDir, { recursive: true }); + const files = fs.readdirSync(exportsDir) + .filter(f => f.endsWith('.zip')) + .map(f => ({ f, mtime: fs.statSync(path.join(exportsDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + for (const { f } of files.slice(MAX_EXPORTS)) { + fs.unlinkSync(path.join(exportsDir, f)); + } + } catch (e) { + console.error('[autoExport] Erreur purge :', e.message); + } +} + +export async function runAutoExport() { + const tmpDb = path.join(os.tmpdir(), `cl-auto-export-${Date.now()}.db`); + const startedAt = Date.now(); + console.log('[autoExport] Démarrage de l\'export automatique…'); + + try { + await db.backup(tmpDb); + const dbData = fs.readFileSync(tmpDb); + + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + const filename = `crowdlending-export-${ts}.zip`; + + const entries = []; + entries.push({ + name: 'manifest.json', + data: JSON.stringify({ + version: '1.0', app: 'crowdlending', + exported_at: now.toISOString(), + type: 'full-export', + source: 'auto_export_job', + }, null, 2), + }); + entries.push({ name: 'crowdlending.db', data: dbData }); + + let assetCount = 0; + for (const subdir of ['logos', 'icons']) { + const dir = path.join(dataDir, subdir); + if (!fs.existsSync(dir)) continue; + for (const f of fs.readdirSync(dir)) { + const fpath = path.join(dir, f); + if (fs.statSync(fpath).isFile()) { + entries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fpath) }); + assetCount++; + } + } + } + + const zipBuf = createZip(entries); + fs.mkdirSync(exportsDir, { recursive: true }); + fs.writeFileSync(path.join(exportsDir, filename), zipBuf); + purgeOldExports(); + + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Durée : ${elapsed}s`; + writeLog({ status: 'ok', nbChanges: 1, details }); + console.log(`[autoExport] Export terminé en ${elapsed}s → ${filename}`); + } catch (e) { + console.error('[autoExport] Erreur :', e.message); + writeLog({ status: 'error', nbChanges: 0, errorMsg: e.message }); + } finally { + if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb); + } +} + +/** Planifie runAutoExport() tous les jours à 3h00 locale (sans dérive). */ +export function startAutoExportJob() { + function msUntil3am() { + const now = new Date(); + const next = new Date(now); + next.setHours(3, 0, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next.getTime() - now.getTime(); + } + + function scheduleDailyRun() { + const delay = msUntil3am(); + setTimeout(async () => { + await runAutoExport(); + scheduleDailyRun(); + }, delay); + } + + scheduleDailyRun(); + console.log(`[autoExport] Job planifié — prochaine exécution dans ${Math.round(msUntil3am() / 60000)} min (3h00)`); +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index bee233e..8883975 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,10 +1,25 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { z } from 'zod'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { checkStatutsRetard } from '../jobs/autoStatut.js'; +import { runAutoExport } from '../jobs/autoExport.js'; import { audit } from '../utils/audit.js'; +import multer from 'multer'; +import { createZip, readZip } from '../utils/zip.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); +const dbPath = process.env.DB_PATH + ? path.resolve(process.env.DB_PATH) + : path.resolve(__dirname, '../../data/crowdlending.db'); // ── Helpers similarité de noms ──────────────────────────────────────────── */ @@ -36,9 +51,10 @@ function similarity(a, b) { const SIMILARITY_THRESHOLD = 0.80; -// Registre des jobs disponibles (nom → fonction synchrone) +// Registre des jobs disponibles (nom → fonction) const JOBS = { auto_statut_retard: checkStatutsRetard, + auto_export: runAutoExport, }; const router = Router(); @@ -302,18 +318,18 @@ router.post('/plateformes-orphelines/:id/lier', (req, res, next) => { /* -- Execution manuelle d'un job ----------------------------------------- */ -router.post('/jobs/:name/run', (req, res, next) => { +router.post('/jobs/:name/run', async (req, res, next) => { try { const { name } = req.params; const fn = JOBS[name]; if (!fn) throw new HttpError(404, `Job inconnu : ${name}`); - const nbChanges = fn(); - const lastLog = db.prepare( + await fn(); + const lastLog = db.prepare( 'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1' ).get(name); - res.json({ ok: true, nb_changes: nbChanges, log: lastLog }); + res.json({ ok: true, nb_changes: lastLog?.nb_changes ?? 0, log: lastLog }); } catch (e) { next(e); } }); @@ -408,4 +424,249 @@ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => { } catch (e) { next(e); } }); +/* ── Export complet prod → dev ────────────────────────────────────────────── */ + +const exportsDir = path.join(dataDir, 'exports'); +const MAX_EXPORTS = 10; + +/** Retourne la liste des exports triée du plus récent au plus ancien */ +function listExportFiles() { + fs.mkdirSync(exportsDir, { recursive: true }); + return fs.readdirSync(exportsDir) + .filter(f => f.endsWith('.zip')) + .map(f => { + const stat = fs.statSync(path.join(exportsDir, f)); + return { filename: f, size: stat.size, created_at: stat.mtime.toISOString() }; + }) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); +} + +/** Supprime les exports les plus anciens au-delà de MAX_EXPORTS */ +function purgeOldExports() { + const files = listExportFiles(); + for (const f of files.slice(MAX_EXPORTS)) { + fs.unlinkSync(path.join(exportsDir, f.filename)); + } +} + +/** + * GET /api/admin/export-full + * Génère un export ZIP, le sauvegarde sur disque, et le retourne au navigateur. + */ +router.get('/export-full', async (req, res, next) => { + const tmpDb = path.join(os.tmpdir(), `cl-backup-${Date.now()}.db`); + try { + await db.backup(tmpDb); + const dbData = fs.readFileSync(tmpDb); + + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + const filename = `crowdlending-export-${ts}.zip`; + + const entries = []; + entries.push({ + name: 'manifest.json', + data: JSON.stringify({ + version: '1.0', + app: 'crowdlending', + exported_at: now.toISOString(), + type: 'full-export', + }, null, 2), + }); + entries.push({ name: 'crowdlending.db', data: dbData }); + + for (const subdir of ['logos', 'icons']) { + const dir = path.join(dataDir, subdir); + if (!fs.existsSync(dir)) continue; + for (const f of fs.readdirSync(dir)) { + const fpath = path.join(dir, f); + if (fs.statSync(fpath).isFile()) { + entries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fpath) }); + } + } + } + + const zipBuf = createZip(entries); + + // Sauvegarde sur disque + purge + fs.mkdirSync(exportsDir, { recursive: true }); + fs.writeFileSync(path.join(exportsDir, filename), zipBuf); + purgeOldExports(); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(zipBuf); + } catch (e) { + next(e); + } finally { + if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb); + } +}); + +/** GET /api/admin/exports — liste des exports stockés sur le serveur */ +router.get('/exports', (req, res, next) => { + try { + res.json(listExportFiles()); + } catch (e) { next(e); } +}); + +const exportUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 200 * 1024 * 1024 }, // 200 Mo max + fileFilter: (_req, file, cb) => { + if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true); + else cb(new HttpError(400, 'Fichier ZIP attendu')); + }, +}); + +/** POST /api/admin/exports/upload — import d'un export depuis le client */ +router.post('/exports/upload', exportUpload.single('file'), (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'Aucun fichier reçu'); + + // Validation : le ZIP doit contenir un manifest.json avec type 'full-export' + let entries; + try { entries = readZip(req.file.buffer); } + catch { throw new HttpError(400, 'Archive ZIP invalide ou corrompue'); } + + const manifestEntry = entries.find(e => e.name === 'manifest.json'); + if (!manifestEntry) throw new HttpError(400, "Archive invalide : manifest.json introuvable"); + let manifest; + try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } + catch { throw new HttpError(400, 'manifest.json illisible'); } + if (manifest.type !== 'full-export') { + throw new HttpError(400, `Type d'archive incorrect : "${manifest.type}" (attendu : "full-export")`); + } + + // Sanitisation du nom de fichier + let filename = path.basename(req.file.originalname); + if (!filename.endsWith('.zip')) filename += '.zip'; + + // Évite les collisions de nom + const dest = path.join(exportsDir, filename); + if (fs.existsSync(dest)) { + const ts = Date.now(); + filename = filename.replace(/\.zip$/, `-${ts}.zip`); + } + + fs.mkdirSync(exportsDir, { recursive: true }); + fs.writeFileSync(path.join(exportsDir, filename), req.file.buffer); + purgeOldExports(); + + res.json({ ok: true, filename, exports: listExportFiles() }); + } catch (e) { next(e); } +}); + +/** GET /api/admin/exports/:filename — téléchargement d'un export stocké */ +router.get('/exports/:filename', (req, res, next) => { + try { + const filename = path.basename(req.params.filename); // sécurité : pas de path traversal + if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide'); + const fpath = path.join(exportsDir, filename); + if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable'); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(fs.readFileSync(fpath)); + } catch (e) { next(e); } +}); + +/** DELETE /api/admin/exports/:filename — suppression d'un export */ +router.delete('/exports/:filename', (req, res, next) => { + try { + const filename = path.basename(req.params.filename); + if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide'); + const fpath = path.join(exportsDir, filename); + if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable'); + fs.unlinkSync(fpath); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +/** + * POST /api/admin/exports/:filename/restore + * Restaure l'environnement depuis un export stocké : + * 1. Sauvegarde l'état courant dans exports/ (filet de sécurité) + * 2. Copie les assets (logos, icons) immédiatement + * 3. Écrit la nouvelle DB dans {DB_PATH}.pending-restore + * 4. Répond au client, puis redémarre le processus (process.exit) + * → Docker/nodemon relance le serveur qui applique le pending-restore au démarrage + */ +router.post('/exports/:filename/restore', async (req, res, next) => { + const tmpDb = path.join(os.tmpdir(), `cl-pre-restore-${Date.now()}.db`); + try { + const filename = path.basename(req.params.filename); + if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide'); + const fpath = path.join(exportsDir, filename); + if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable'); + + // Lecture et validation du ZIP + const zipBuf = fs.readFileSync(fpath); + let entries; + try { entries = readZip(zipBuf); } + catch { throw new HttpError(400, 'Archive ZIP invalide ou corrompue'); } + const manifestEntry = entries.find(e => e.name === 'manifest.json'); + if (!manifestEntry) throw new HttpError(400, 'manifest.json introuvable dans l\'archive'); + let manifest; + try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } + catch { throw new HttpError(400, 'manifest.json illisible'); } + if (manifest.type !== 'full-export') throw new HttpError(400, 'Type d\'archive incorrect'); + + const dbEntry = entries.find(e => e.name === 'crowdlending.db'); + if (!dbEntry) throw new HttpError(400, 'crowdlending.db introuvable dans l\'archive'); + + // 1. Sauvegarde de sécurité de l'état courant + await db.backup(tmpDb); + const backupEntries = []; + backupEntries.push({ + name: 'manifest.json', + data: JSON.stringify({ + version: '1.0', app: 'crowdlending', + exported_at: new Date().toISOString(), + type: 'full-export', + note: 'pre-restore-backup', + }, null, 2), + }); + backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) }); + for (const subdir of ['logos', 'icons']) { + const dir = path.join(dataDir, subdir); + if (!fs.existsSync(dir)) continue; + for (const f of fs.readdirSync(dir)) { + const fp = path.join(dir, f); + if (fs.statSync(fp).isFile()) backupEntries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fp) }); + } + } + const pad = n => String(n).padStart(2, '0'); + const now = new Date(); + const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + fs.mkdirSync(exportsDir, { recursive: true }); + fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries)); + purgeOldExports(); + + // 2. Remplacement des assets (logos + icons) — safe à faire en live + for (const subdir of ['logos', 'icons']) { + const dir = path.join(dataDir, subdir); + fs.mkdirSync(dir, { recursive: true }); + // Vidage du dossier existant + for (const f of fs.readdirSync(dir)) fs.unlinkSync(path.join(dir, f)); + // Écriture des nouveaux fichiers + for (const entry of entries.filter(e => e.name.startsWith(`${subdir}/`) && !e.name.endsWith('/'))) { + fs.writeFileSync(path.join(dir, path.basename(entry.name)), entry.data); + } + } + + // 3. Écriture du pending-restore (appliqué par db/index.js au prochain démarrage) + fs.writeFileSync(dbPath + '.pending-restore', dbEntry.data); + + // 4. Réponse puis redémarrage + res.json({ ok: true, backup: `pre-restore-backup-${ts}.zip` }); + setTimeout(() => process.exit(0), 300); + } catch (e) { + next(e); + } finally { + if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb); + } +}); + export default router; diff --git a/backend/src/server.js b/backend/src/server.js index b6ddeb3..6708eb5 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -32,6 +32,7 @@ import iconsRouter from './routes/icons.js'; import { errorHandler } from './middleware/errorHandler.js'; import { requireAuth, requireAdmin } from './middleware/auth.js'; import { startAutoStatutJob } from './jobs/autoStatut.js'; +import { startAutoExportJob } from './jobs/autoExport.js'; import adminRouter from './routes/admin.js'; import invitationsRouter from './routes/invitations.js'; import auditLogsRouter from './routes/auditLogs.js'; @@ -134,4 +135,6 @@ app.use(errorHandler); app.listen(PORT, () => { console.log(`Crowdlending API listening on http://localhost:${PORT}`); + startAutoStatutJob(); + startAutoExportJob(); }); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 26253d0..b3118b0 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -6,6 +6,7 @@ import JobLogsSection from './admin/JobLogsSection.jsx'; import IconsSection from './admin/IconsSection.jsx'; import SmtpSection from './admin/SmtpSection.jsx'; import GeneralSection from './admin/GeneralSection.jsx'; +import ExportSection from './admin/ExportSection.jsx'; /* ── Icônes nav ───────────────────────────────────────────────── */ function IconUsers() { return ; } @@ -16,6 +17,7 @@ function IconTax() { return ; } function IconSettings() { return ; } function IconMail() { return ; } +function IconDownload() { return ; } const NAV = [ { @@ -41,6 +43,12 @@ const NAV = [ { id: 'smtp', label: 'SMTP', icon: }, ], }, + { + group: 'Maintenance', + items: [ + { id: 'export', label: 'Export complet', icon: }, + ], + }, ]; export default function Admin() { @@ -79,6 +87,7 @@ export default function Admin() { {section === 'job-logs' && } {section === 'icons' && } {section === 'smtp' && } + {section === 'export' && } ); diff --git a/frontend/src/pages/admin/ExportSection.jsx b/frontend/src/pages/admin/ExportSection.jsx new file mode 100644 index 0000000..564063e --- /dev/null +++ b/frontend/src/pages/admin/ExportSection.jsx @@ -0,0 +1,401 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { api } from '../../api.js'; + +function SectionHeader({ title, description }) { + return ( +
+

{title}

+ {description &&

{description}

} +
+ ); +} + +function MenuBtn({ onClick, icon, label, danger = false }) { + return ( + + ); +} + +function IcoDownload() { + return ; +} +function IcoTrash() { + return ; +} +function IcoRestore() { + return ; +} + +function fmtSize(bytes) { + if (bytes < 1024) return `${bytes} o`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`; + return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`; +} + +function fmtDate(iso) { + const d = new Date(iso); + return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); +} + +function fmtRunAt(iso) { + if (!iso) return '—'; + const d = new Date(iso); + return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +/** Attend que /api/health réponde, avec retry toutes les 2s pendant max 60s */ +async function pollUntilReady(maxMs = 60000) { + const base = import.meta.env.VITE_API_URL || '/api'; + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 2000)); + try { + const res = await fetch(base + '/health'); + if (res.ok) return true; + } catch { /* serveur pas encore prêt */ } + } + return false; +} + +export default function ExportSection() { + const [generating, setGenerating] = useState(false); + const [genErr, setGenErr] = useState(null); + const [exports, setExports] = useState([]); + const [loadingList, setLoadingList] = useState(true); + const [openMenu, setOpenMenu] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadErr, setUploadErr] = useState(null); + const [jobLogs, setJobLogs] = useState([]); + const [loadingLogs, setLoadingLogs] = useState(true); + const [runningJob, setRunningJob] = useState(false); + const [restoring, setRestoring] = useState(null); // filename en cours + const [restoreConfirm, setRestoreConfirm] = useState(null); // filename à confirmer + const [restarting, setRestarting] = useState(false); // écran "serveur redémarre" + const fileInputRef = useRef(null); + + const loadExports = useCallback(() => { + setLoadingList(true); + api.get('/admin/exports') + .then(setExports) + .catch(() => setExports([])) + .finally(() => setLoadingList(false)); + }, []); + + const loadJobLogs = useCallback(() => { + setLoadingLogs(true); + api.get('/admin/job-logs', { job: 'auto_export', limit: 20 }) + .then(d => setJobLogs(d.rows || [])) + .catch(() => setJobLogs([])) + .finally(() => setLoadingLogs(false)); + }, []); + + async function runJobNow() { + setRunningJob(true); + try { + await api.post('/admin/jobs/auto_export/run', {}); + loadExports(); + loadJobLogs(); + } catch (e) { + alert(e.message || "Erreur lors de l'exécution manuelle"); + } finally { + setRunningJob(false); + } + } + + useEffect(() => { loadExports(); }, [loadExports]); + useEffect(() => { loadJobLogs(); }, [loadJobLogs]); + + useEffect(() => { + if (!openMenu) return; + const close = () => setOpenMenu(null); + window.addEventListener('scroll', close, true); + return () => window.removeEventListener('scroll', close, true); + }, [openMenu]); + + async function handleGenerate() { + setGenerating(true); + setGenErr(null); + try { + const blob = await api.blob('/admin/export-full'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const d = new Date(); + const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + a.download = `crowdlending-export-${ymd}.zip`; + a.click(); + URL.revokeObjectURL(url); + loadExports(); + } catch (e) { + setGenErr(e.message || "Erreur lors de la génération de l'export"); + } finally { + setGenerating(false); + } + } + + async function handleDownload(filename) { + setOpenMenu(null); + try { + const blob = await api.blob(`/admin/exports/${encodeURIComponent(filename)}`); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + alert(e.message || 'Erreur lors du téléchargement'); + } + } + + async function handleDelete(filename) { + setOpenMenu(null); + try { + await api.del(`/admin/exports/${encodeURIComponent(filename)}`); + loadExports(); + } catch (e) { + alert(e.message || 'Erreur lors de la suppression'); + } + } + + async function handleUpload(e) { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + setUploading(true); + setUploadErr(null); + try { + const formData = new FormData(); + formData.append('file', file); + const result = await api.upload('/admin/exports/upload', formData); + setExports(result.exports); + } catch (err) { + setUploadErr(err.message || "Erreur lors de l'import"); + } finally { + setUploading(false); + } + } + + async function confirmRestore(filename) { + setOpenMenu(null); + setRestoreConfirm(filename); + } + + async function executeRestore() { + const filename = restoreConfirm; + setRestoreConfirm(null); + setRestoring(filename); + try { + await api.post(`/admin/exports/${encodeURIComponent(filename)}/restore`, {}); + // Le serveur va redémarrer — on affiche l'écran d'attente + setRestarting(true); + const ok = await pollUntilReady(90000); + if (ok) { + window.location.href = '/'; + } else { + setRestarting(false); + alert('Le serveur ne répond pas après 90 secondes. Vérifiez les logs.'); + } + } catch (e) { + setRestoring(null); + alert(e.message || 'Erreur lors de la restauration'); + } + } + + const openMenuFor = (e, filename) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + setOpenMenu({ filename, x: rect.right, y: rect.bottom }); + }; + + // ── Écran "serveur en cours de redémarrage" ──────────────────────────────── + if (restarting) { + return ( +
+ + + + +
+
Restauration en cours…
+
Le serveur redémarre. Vous serez redirigé automatiquement.
+
+
+ ); + } + + return ( +
+ + + {/* Bloc génération */} +
+
+
Nouvel export
+
+ Inclut : crowdlending.db, logos/, icons/, manifest.json +
+ {genErr &&
{genErr}
} +
+ +
+ + {/* En-tête liste */} + +
+
+ Fichiers disponibles sur le serveur + {uploadErr &&
{uploadErr}
} +
+ +
+ + {/* Liste */} + {loadingList ? ( +
Chargement…
+ ) : exports.length === 0 ? ( +
Aucun export disponible.
+ ) : ( +
+ + + + + + + + + + {exports.map((exp, i) => ( + + + + + + + ))} + +
FichierDateTaille +
{exp.filename}{fmtDate(exp.created_at)}{fmtSize(exp.size)} + +
+
+ )} + + {/* Menu ⋮ contextuel */} + {openMenu && ( + <> +
setOpenMenu(null)} /> +
+ } label="Télécharger" onClick={() => handleDownload(openMenu.filename)} /> + } label="Restaurer" onClick={() => confirmRestore(openMenu.filename)} /> +
+ } label="Supprimer" danger onClick={() => handleDelete(openMenu.filename)} /> +
+ + )} + + {/* Panel logs du job automatique */} +
+
+
+ Job automatique — 3h00 + auto_export +
+ +
+ + {loadingLogs ? ( +
Chargement…
+ ) : jobLogs.length === 0 ? ( +
+ Aucune exécution enregistrée. +
+ ) : ( +
+ + + + + + + + + + {jobLogs.map((log, i) => ( + + + + + + ))} + +
DateStatutDétails
+ {fmtRunAt(log.run_at)} + + + {log.status === 'ok' ? '✓ Succès' : '✗ Erreur'} + + + {log.error_msg || log.details || '—'} +
+
+ )} +
+ + {/* Modale de confirmation restauration */} + {restoreConfirm && ( + <> +
setRestoreConfirm(null)} /> +
+
Restaurer cet export ?
+
+ L'environnement courant sera remplacé par le contenu de : +
+
{restoreConfirm}
+
+ ⚠️ Un backup automatique de l'état actuel sera créé avant la restauration. Le serveur redémarrera automatiquement. +
+
+ + +
+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/admin/JobLogsSection.jsx b/frontend/src/pages/admin/JobLogsSection.jsx index 1f4f647..b0826df 100644 --- a/frontend/src/pages/admin/JobLogsSection.jsx +++ b/frontend/src/pages/admin/JobLogsSection.jsx @@ -4,6 +4,7 @@ import { fmt, StatusBadge } from './adminHelpers.jsx'; const KNOWN_JOBS = [ { name: 'auto_statut_retard', label: 'Passage automatique en retard' }, + { name: 'auto_export', label: 'Export automatique (3h00)' }, ]; export default function JobLogsSection() {