Maj export
This commit is contained in:
@@ -11,6 +11,19 @@ const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, '../../data/crowd
|
|||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
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);
|
const db = new Database(DB_PATH);
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
}
|
||||||
+266
-5
@@ -1,10 +1,25 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { z } from 'zod';
|
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 db from '../db/index.js';
|
||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||||
|
import { runAutoExport } from '../jobs/autoExport.js';
|
||||||
import { audit } from '../utils/audit.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 ──────────────────────────────────────────── */
|
// ── Helpers similarité de noms ──────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -36,9 +51,10 @@ function similarity(a, b) {
|
|||||||
|
|
||||||
const SIMILARITY_THRESHOLD = 0.80;
|
const SIMILARITY_THRESHOLD = 0.80;
|
||||||
|
|
||||||
// Registre des jobs disponibles (nom → fonction synchrone)
|
// Registre des jobs disponibles (nom → fonction)
|
||||||
const JOBS = {
|
const JOBS = {
|
||||||
auto_statut_retard: checkStatutsRetard,
|
auto_statut_retard: checkStatutsRetard,
|
||||||
|
auto_export: runAutoExport,
|
||||||
};
|
};
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -302,18 +318,18 @@ router.post('/plateformes-orphelines/:id/lier', (req, res, next) => {
|
|||||||
|
|
||||||
/* -- Execution manuelle d'un job ----------------------------------------- */
|
/* -- Execution manuelle d'un job ----------------------------------------- */
|
||||||
|
|
||||||
router.post('/jobs/:name/run', (req, res, next) => {
|
router.post('/jobs/:name/run', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
const fn = JOBS[name];
|
const fn = JOBS[name];
|
||||||
if (!fn) throw new HttpError(404, `Job inconnu : ${name}`);
|
if (!fn) throw new HttpError(404, `Job inconnu : ${name}`);
|
||||||
|
|
||||||
const nbChanges = fn();
|
await fn();
|
||||||
const lastLog = db.prepare(
|
const lastLog = db.prepare(
|
||||||
'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1'
|
'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1'
|
||||||
).get(name);
|
).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); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -408,4 +424,249 @@ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import iconsRouter from './routes/icons.js';
|
|||||||
import { errorHandler } from './middleware/errorHandler.js';
|
import { errorHandler } from './middleware/errorHandler.js';
|
||||||
import { requireAuth, requireAdmin } from './middleware/auth.js';
|
import { requireAuth, requireAdmin } from './middleware/auth.js';
|
||||||
import { startAutoStatutJob } from './jobs/autoStatut.js';
|
import { startAutoStatutJob } from './jobs/autoStatut.js';
|
||||||
|
import { startAutoExportJob } from './jobs/autoExport.js';
|
||||||
import adminRouter from './routes/admin.js';
|
import adminRouter from './routes/admin.js';
|
||||||
import invitationsRouter from './routes/invitations.js';
|
import invitationsRouter from './routes/invitations.js';
|
||||||
import auditLogsRouter from './routes/auditLogs.js';
|
import auditLogsRouter from './routes/auditLogs.js';
|
||||||
@@ -134,4 +135,6 @@ app.use(errorHandler);
|
|||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Crowdlending API listening on http://localhost:${PORT}`);
|
console.log(`Crowdlending API listening on http://localhost:${PORT}`);
|
||||||
|
startAutoStatutJob();
|
||||||
|
startAutoExportJob();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import JobLogsSection from './admin/JobLogsSection.jsx';
|
|||||||
import IconsSection from './admin/IconsSection.jsx';
|
import IconsSection from './admin/IconsSection.jsx';
|
||||||
import SmtpSection from './admin/SmtpSection.jsx';
|
import SmtpSection from './admin/SmtpSection.jsx';
|
||||||
import GeneralSection from './admin/GeneralSection.jsx';
|
import GeneralSection from './admin/GeneralSection.jsx';
|
||||||
|
import ExportSection from './admin/ExportSection.jsx';
|
||||||
|
|
||||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
/* ── Icônes nav ───────────────────────────────────────────────── */
|
||||||
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||||
@@ -16,6 +17,7 @@ function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
|||||||
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
||||||
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
|
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
|
||||||
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
||||||
|
function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,12 @@ const NAV = [
|
|||||||
{ id: 'smtp', label: 'SMTP', icon: <IconMail /> },
|
{ id: 'smtp', label: 'SMTP', icon: <IconMail /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Maintenance',
|
||||||
|
items: [
|
||||||
|
{ id: 'export', label: 'Export complet', icon: <IconDownload /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
@@ -79,6 +87,7 @@ export default function Admin() {
|
|||||||
{section === 'job-logs' && <JobLogsSection />}
|
{section === 'job-logs' && <JobLogsSection />}
|
||||||
{section === 'icons' && <IconsSection />}
|
{section === 'icons' && <IconsSection />}
|
||||||
{section === 'smtp' && <SmtpSection />}
|
{section === 'smtp' && <SmtpSection />}
|
||||||
|
{section === 'export' && <ExportSection />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { api } from '../../api.js';
|
||||||
|
|
||||||
|
function SectionHeader({ title, description }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<h2 style={{ margin: '0 0 6px', fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>{title}</h2>
|
||||||
|
{description && <p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>{description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuBtn({ onClick, icon, label, danger = false }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon}{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IcoDownload() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
|
||||||
|
}
|
||||||
|
function IcoTrash() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>;
|
||||||
|
}
|
||||||
|
function IcoRestore() {
|
||||||
|
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20, padding: '80px 0', textAlign: 'center' }}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ animation: 'spin 1.2s linear infinite' }}>
|
||||||
|
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/>
|
||||||
|
</svg>
|
||||||
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 18, color: 'var(--text)', marginBottom: 8 }}>Restauration en cours…</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 14 }}>Le serveur redémarre. Vous serez redirigé automatiquement.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
title="Export complet"
|
||||||
|
description="Génère une archive ZIP de la base de données et des assets. L'export est téléchargé dans le navigateur et conservé sur le serveur (10 derniers)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bloc génération */}
|
||||||
|
<div className="card" style={{ padding: '20px 24px', marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>Nouvel export</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Inclut : <code>crowdlending.db</code>, <code>logos/</code>, <code>icons/</code>, <code>manifest.json</code>
|
||||||
|
</div>
|
||||||
|
{genErr && <div className="error" style={{ marginTop: 8 }}>{genErr}</div>}
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary" onClick={() => handleGenerate()} disabled={generating} style={{ flexShrink: 0 }}>
|
||||||
|
{generating ? 'Export en cours…' : 'Générer et télécharger'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* En-tête liste */}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".zip,application/zip" style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
|
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: 15 }}>Fichiers disponibles sur le serveur</span>
|
||||||
|
{uploadErr && <div className="error" style={{ marginTop: 4, fontSize: 13 }}>{uploadErr}</div>}
|
||||||
|
</div>
|
||||||
|
<button className="btn-secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading} style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
{uploading ? 'Import en cours…' : 'Importer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{loadingList ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 14, padding: '12px 0' }}>Chargement…</div>
|
||||||
|
) : exports.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: '20px 24px', color: 'var(--text-muted)', fontSize: 14 }}>Aucun export disponible.</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||||
|
<th style={{ padding: '10px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)' }}>Fichier</th>
|
||||||
|
<th style={{ padding: '10px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)' }}>Date</th>
|
||||||
|
<th style={{ padding: '10px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Taille</th>
|
||||||
|
<th style={{ padding: '10px 16px', width: 48 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{exports.map((exp, i) => (
|
||||||
|
<tr key={exp.filename} style={{ borderBottom: i < exports.length - 1 ? '1px solid var(--border)' : 'none', opacity: restoring === exp.filename ? 0.5 : 1 }}>
|
||||||
|
<td style={{ padding: '12px 16px', color: 'var(--text)', fontFamily: 'monospace', fontSize: 13 }}>{exp.filename}</td>
|
||||||
|
<td style={{ padding: '12px 16px', color: 'var(--text-muted)' }}>{fmtDate(exp.created_at)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', color: 'var(--text-muted)', textAlign: 'right' }}>{fmtSize(exp.size)}</td>
|
||||||
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||||
|
<button onClick={e => openMenuFor(e, exp.filename)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '4px 6px', borderRadius: 4, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Menu ⋮ contextuel */}
|
||||||
|
{openMenu && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
|
||||||
|
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, transform: 'translateX(-100%) translateY(4px)', zIndex: 300, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 180 }}>
|
||||||
|
<MenuBtn icon={<IcoDownload />} label="Télécharger" onClick={() => handleDownload(openMenu.filename)} />
|
||||||
|
<MenuBtn icon={<IcoRestore />} label="Restaurer" onClick={() => confirmRestore(openMenu.filename)} />
|
||||||
|
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
|
||||||
|
<MenuBtn icon={<IcoTrash />} label="Supprimer" danger onClick={() => handleDelete(openMenu.filename)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Panel logs du job automatique */}
|
||||||
|
<div style={{ marginTop: 32 }}>
|
||||||
|
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: 15 }}>Job automatique — 3h00</span>
|
||||||
|
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--text-muted)', background: 'var(--surface-2)', padding: '2px 8px', borderRadius: 10, border: '1px solid var(--border)' }}>auto_export</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => runJobNow()}
|
||||||
|
disabled={runningJob}
|
||||||
|
style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
{runningJob ? 'Exécution…' : 'Exécuter maintenant'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingLogs ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 14, padding: '10px 0' }}>Chargement…</div>
|
||||||
|
) : jobLogs.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: '16px 20px', color: 'var(--text-muted)', fontSize: 14 }}>
|
||||||
|
Aucune exécution enregistrée.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||||
|
<th style={{ padding: '9px 14px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)' }}>Date</th>
|
||||||
|
<th style={{ padding: '9px 14px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)' }}>Statut</th>
|
||||||
|
<th style={{ padding: '9px 14px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)' }}>Détails</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobLogs.map((log, i) => (
|
||||||
|
<tr key={log.id} style={{ borderBottom: i < jobLogs.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||||
|
<td style={{ padding: '10px 14px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||||
|
{fmtRunAt(log.run_at)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
|
||||||
|
background: log.status === 'ok' ? 'color-mix(in srgb, var(--success) 15%, transparent)' : 'color-mix(in srgb, var(--danger) 15%, transparent)',
|
||||||
|
color: log.status === 'ok' ? 'var(--success)' : 'var(--danger)',
|
||||||
|
}}>
|
||||||
|
{log.status === 'ok' ? '✓ Succès' : '✗ Erreur'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: log.error_msg ? 'var(--danger)' : 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
{log.error_msg || log.details || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modale de confirmation restauration */}
|
||||||
|
{restoreConfirm && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 399 }} onClick={() => setRestoreConfirm(null)} />
|
||||||
|
<div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', zIndex: 400, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', padding: 28, width: 460, maxWidth: '90vw' }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--text)', marginBottom: 12 }}>Restaurer cet export ?</div>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--text-muted)', marginBottom: 8, lineHeight: 1.6 }}>
|
||||||
|
L'environnement courant sera <strong style={{ color: 'var(--text)' }}>remplacé</strong> par le contenu de :
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: 13, background: 'var(--surface-2)', padding: '8px 12px', borderRadius: 6, marginBottom: 16 }}>{restoreConfirm}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 20, padding: '10px 14px', background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)', borderRadius: 6, lineHeight: 1.6 }}>
|
||||||
|
⚠️ Un backup automatique de l'état actuel sera créé avant la restauration. Le serveur redémarrera automatiquement.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn-secondary" onClick={() => setRestoreConfirm(null)}>Annuler</button>
|
||||||
|
<button className="btn-danger" onClick={() => executeRestore()} style={{ background: 'var(--danger)', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 18px', cursor: 'pointer', fontWeight: 600 }}>
|
||||||
|
Restaurer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { fmt, StatusBadge } from './adminHelpers.jsx';
|
|||||||
|
|
||||||
const KNOWN_JOBS = [
|
const KNOWN_JOBS = [
|
||||||
{ name: 'auto_statut_retard', label: 'Passage automatique en retard' },
|
{ name: 'auto_statut_retard', label: 'Passage automatique en retard' },
|
||||||
|
{ name: 'auto_export', label: 'Export automatique (3h00)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function JobLogsSection() {
|
export default function JobLogsSection() {
|
||||||
|
|||||||
Reference in New Issue
Block a user