Backup-Operations-Log + EBUSY-Fix beim Restore
Backup-Seite zeigt zwei neue Log-Panels: links Backup-Erstellung, rechts Backup-Wiederherstellung. Jeder Eintrag mit ✓/✗-Status, Summary, Timestamp + User. Klick öffnet Modal mit vollständigem Verlauf – alle console.log/error/warn/info-Zeilen werden während der Operation in einen Puffer mitgefangen und im fullLog-Feld persistiert. Auto-Refresh alle 5s. Persistenz: neue Tabelle BackupLog mit Migration 20260519100000_backup_log (CREATE TABLE IF NOT EXISTS für Re-Deploys auf DBs mit Vorab-db-push). fullLog auf 1 MB gecappt. Endpoints (settings:update): - GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50 - GET /api/settings/backup-logs/:id EBUSY-Fix: Der neue Log-Verlauf hat sofort einen alten Bug sichtbar gemacht. backup.service.restoreBackup rief deleteDirectory(UPLOADS_DIR) auf, dessen finales rmdirSync auf /app/uploads ein EBUSY warf – das Verzeichnis ist im Container ein Bind-Mount und lässt sich nicht aushängen. Fix: neuer Helper emptyDirectory() löscht nur die Inhalte, das Verzeichnis bleibt stehen. Live-verifiziert: 4867 Datensätze + 1 Datei in 13.2s wiederhergestellt; Log-Modal zeigt den vollständigen Verlauf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
-- BackupLog: persistierte Historie aller Backup-/Restore-Vorgänge mit
|
||||||
|
-- Status + Volltext-Log. UI zeigt in zwei Listen (je CREATE und RESTORE).
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS damit Re-Deploys auf bestehende DBs nicht crashen, falls
|
||||||
|
-- jemand vorher manuell `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `BackupLog` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`operation` ENUM('CREATE', 'RESTORE') NOT NULL,
|
||||||
|
`backupName` VARCHAR(191) NULL,
|
||||||
|
`success` BOOLEAN NOT NULL,
|
||||||
|
`durationMs` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`summary` TEXT NOT NULL,
|
||||||
|
`fullLog` LONGTEXT NOT NULL,
|
||||||
|
`userId` INTEGER NULL,
|
||||||
|
`userEmail` VARCHAR(191) NULL,
|
||||||
|
`ipAddress` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `BackupLog_operation_createdAt_idx`(`operation`, `createdAt`),
|
||||||
|
INDEX `BackupLog_createdAt_idx`(`createdAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -1146,6 +1146,33 @@ enum SecuritySeverity {
|
|||||||
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BackupOperation {
|
||||||
|
CREATE
|
||||||
|
RESTORE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistiertes Log für Backup-Vorgänge.
|
||||||
|
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
|
||||||
|
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
|
||||||
|
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
|
||||||
|
// geschrieben.
|
||||||
|
model BackupLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
operation BackupOperation
|
||||||
|
backupName String?
|
||||||
|
success Boolean
|
||||||
|
durationMs Int @default(0)
|
||||||
|
summary String @db.Text
|
||||||
|
fullLog String @db.LongText
|
||||||
|
userId Int?
|
||||||
|
userEmail String?
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([operation, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model SecurityEvent {
|
model SecurityEvent {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
type SecurityEventType
|
type SecurityEventType
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as backupService from '../services/backup.service.js';
|
import * as backupService from '../services/backup.service.js';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
|
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
|
||||||
@@ -11,6 +12,83 @@ function isValidBackupName(name: string): boolean {
|
|||||||
}
|
}
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
|
||||||
|
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
|
||||||
|
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
|
||||||
|
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
|
||||||
|
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
|
||||||
|
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
|
||||||
|
// process-globale Patch-Variante.
|
||||||
|
function startLogCapture(): { lines: string[]; restore: () => void } {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const orig = {
|
||||||
|
log: console.log,
|
||||||
|
info: console.info,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
};
|
||||||
|
function fmt(args: unknown[]): string {
|
||||||
|
return args
|
||||||
|
.map((a) => {
|
||||||
|
if (a instanceof Error) return a.stack || a.message;
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(a);
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
|
||||||
|
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
|
||||||
|
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
|
||||||
|
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
restore: () => {
|
||||||
|
console.log = orig.log;
|
||||||
|
console.info = orig.info;
|
||||||
|
console.warn = orig.warn;
|
||||||
|
console.error = orig.error;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordBackupLog(opts: {
|
||||||
|
req: Request;
|
||||||
|
operation: 'CREATE' | 'RESTORE';
|
||||||
|
backupName: string | null;
|
||||||
|
success: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
summary: string;
|
||||||
|
fullLog: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const user = (opts.req as any).user;
|
||||||
|
await prisma.backupLog.create({
|
||||||
|
data: {
|
||||||
|
operation: opts.operation,
|
||||||
|
backupName: opts.backupName,
|
||||||
|
success: opts.success,
|
||||||
|
durationMs: opts.durationMs,
|
||||||
|
summary: opts.summary.slice(0, 2000),
|
||||||
|
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
|
||||||
|
fullLog: opts.fullLog.slice(0, 1_000_000),
|
||||||
|
userId: user?.userId ?? null,
|
||||||
|
userEmail: user?.email ?? null,
|
||||||
|
ipAddress:
|
||||||
|
(opts.req as any).socket?.remoteAddress ||
|
||||||
|
(opts.req.headers?.['x-forwarded-for'] as string) ||
|
||||||
|
null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste aller Backups abrufen
|
* Liste aller Backups abrufen
|
||||||
* GET /api/settings/backups
|
* GET /api/settings/backups
|
||||||
@@ -29,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
|
|||||||
* POST /api/settings/backup
|
* POST /api/settings/backup
|
||||||
*/
|
*/
|
||||||
export async function createBackup(req: Request, res: Response) {
|
export async function createBackup(req: Request, res: Response) {
|
||||||
|
const start = Date.now();
|
||||||
|
const capture = startLogCapture();
|
||||||
try {
|
try {
|
||||||
const result = await backupService.createBackup();
|
const result = await backupService.createBackup();
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
capture.restore();
|
||||||
|
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'CREATE', backupName: result.backupName ?? null,
|
||||||
|
success: true, durationMs, summary,
|
||||||
|
fullLog: capture.lines.join('\n') || summary,
|
||||||
|
});
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Backup',
|
req, action: 'CREATE', resourceType: 'Backup',
|
||||||
label: `Backup ${result.backupName} erstellt`,
|
label: `Backup ${result.backupName} erstellt`,
|
||||||
});
|
});
|
||||||
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
||||||
} else {
|
} else {
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'CREATE', backupName: null,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'CREATE', backupName: null,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Fehler: ${error?.message || 'unbekannt'}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
|
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,16 +177,26 @@ function makeRestoreErrorReadable(raw: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreBackup(req: Request, res: Response) {
|
export async function restoreBackup(req: Request, res: Response) {
|
||||||
try {
|
const start = Date.now();
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name || !isValidBackupName(name)) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capture = startLogCapture();
|
||||||
|
try {
|
||||||
const result = await backupService.restoreBackup(name);
|
const result = await backupService.restoreBackup(name);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
capture.restore();
|
||||||
|
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'RESTORE', backupName: name,
|
||||||
|
success: true, durationMs, summary,
|
||||||
|
fullLog: capture.lines.join('\n') || summary,
|
||||||
|
});
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'Backup',
|
req, action: 'UPDATE', resourceType: 'Backup',
|
||||||
label: `Backup ${name} wiederhergestellt`,
|
label: `Backup ${name} wiederhergestellt`,
|
||||||
@@ -97,18 +210,33 @@ export async function restoreBackup(req: Request, res: Response) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
|
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'RESTORE', backupName: name,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
|
||||||
|
});
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Wiederherstellung fehlgeschlagen',
|
error: 'Wiederherstellung fehlgeschlagen',
|
||||||
details: makeRestoreErrorReadable(result.error),
|
details: makeRestoreErrorReadable(result.error),
|
||||||
hint: 'Vollständiger Stack-Trace im Server-Log: docker logs opencrm-app | tail -200',
|
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[restore] Exception bei Backup ${req.params.name}:`, error?.stack || error);
|
const durationMs = Date.now() - start;
|
||||||
|
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'RESTORE', backupName: name,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
|
||||||
|
});
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Fehler bei der Wiederherstellung',
|
error: 'Fehler bei der Wiederherstellung',
|
||||||
details: makeRestoreErrorReadable(error),
|
details: makeRestoreErrorReadable(error),
|
||||||
hint: 'Vollständiger Stack-Trace im Server-Log: docker logs opencrm-app | tail -200',
|
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,3 +371,61 @@ export async function factoryReset(req: Request, res: Response) {
|
|||||||
console.error('factoryReset error:', error);
|
console.error('factoryReset error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste der Backup-Logs (CREATE oder RESTORE)
|
||||||
|
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
|
||||||
|
* Liefert die Übersichtsdaten OHNE den großen fullLog.
|
||||||
|
*/
|
||||||
|
export async function listBackupLogs(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const op = String(req.query.operation || '').toUpperCase();
|
||||||
|
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (op === 'CREATE' || op === 'RESTORE') {
|
||||||
|
where.operation = op;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await prisma.backupLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
operation: true,
|
||||||
|
backupName: true,
|
||||||
|
success: true,
|
||||||
|
durationMs: true,
|
||||||
|
summary: true,
|
||||||
|
userEmail: true,
|
||||||
|
ipAddress: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: logs });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail eines Backup-Logs inkl. fullLog
|
||||||
|
* GET /api/settings/backup-logs/:id
|
||||||
|
*/
|
||||||
|
export async function getBackupLogDetail(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige ID' });
|
||||||
|
}
|
||||||
|
const log = await prisma.backupLog.findUnique({ where: { id } });
|
||||||
|
if (!log) {
|
||||||
|
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
|
||||||
|
}
|
||||||
|
res.json({ data: log });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ router.post(
|
|||||||
backupController.factoryReset
|
backupController.factoryReset
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Backup-Operations-Log: Liste (ohne fullLog) + Detail
|
||||||
|
router.get(
|
||||||
|
'/backup-logs',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings:update'),
|
||||||
|
backupController.listBackupLogs
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/backup-logs/:id',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings:update'),
|
||||||
|
backupController.getBackupLogDetail
|
||||||
|
);
|
||||||
|
|
||||||
// Rate-Limit-Verwaltung (Admin)
|
// Rate-Limit-Verwaltung (Admin)
|
||||||
router.get(
|
router.get(
|
||||||
'/rate-limits/active',
|
'/rate-limits/active',
|
||||||
|
|||||||
@@ -138,6 +138,24 @@ function deleteDirectory(dirPath: string): void {
|
|||||||
fs.rmdirSync(dirPath);
|
fs.rmdirSync(dirPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wie deleteDirectory, ABER das Ziel-Verzeichnis selbst bleibt stehen –
|
||||||
|
// nur die Inhalte verschwinden. Notwendig für Docker-Bind-Mounts wie
|
||||||
|
// `/app/uploads`: dort wirft `rmdir` ein EBUSY, weil das Volume vom Host
|
||||||
|
// gemountet ist und sich nicht aushängen lässt.
|
||||||
|
function emptyDirectory(dirPath: string): void {
|
||||||
|
if (!fs.existsSync(dirPath)) return;
|
||||||
|
const items = fs.readdirSync(dirPath);
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dirPath, item);
|
||||||
|
const stats = fs.lstatSync(itemPath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
deleteDirectory(itemPath);
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(itemPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste aller verfügbaren Backups
|
* Liste aller verfügbaren Backups
|
||||||
*/
|
*/
|
||||||
@@ -926,10 +944,10 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
let restoredFiles = 0;
|
let restoredFiles = 0;
|
||||||
const uploadsBackupDir = path.join(backupDir, 'uploads');
|
const uploadsBackupDir = path.join(backupDir, 'uploads');
|
||||||
if (fs.existsSync(uploadsBackupDir)) {
|
if (fs.existsSync(uploadsBackupDir)) {
|
||||||
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben)
|
// Inhalte leeren, das Verzeichnis selbst NICHT löschen –
|
||||||
if (fs.existsSync(UPLOADS_DIR)) {
|
// UPLOADS_DIR ist im Container ein Bind-Mount auf den Host und
|
||||||
deleteDirectory(UPLOADS_DIR);
|
// `rmdir` darauf liefert EBUSY (siehe emptyDirectory()).
|
||||||
}
|
emptyDirectory(UPLOADS_DIR);
|
||||||
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
|
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore**
|
||||||
|
- Zwei neue Log-Panels auf der DB-Backup-Seite: links
|
||||||
|
"Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder
|
||||||
|
Eintrag zeigt ✓/✗-Status, Summary, Timestamp und User. Klick
|
||||||
|
öffnet ein Modal mit dem vollständigen Verlauf (alle
|
||||||
|
`console.log/error/warn/info`-Zeilen werden während der
|
||||||
|
Operation in einen Puffer mitgefangen).
|
||||||
|
- Persistiert in neuer Tabelle `BackupLog`
|
||||||
|
(Migration `20260519100000_backup_log` mit `IF NOT EXISTS`).
|
||||||
|
Limit 1 MB pro `fullLog`, Auto-Refresh alle 5s.
|
||||||
|
- Endpoints (settings:update):
|
||||||
|
`GET /api/settings/backup-logs?operation=CREATE|RESTORE`,
|
||||||
|
`GET /api/settings/backup-logs/:id`.
|
||||||
|
- **Bonus**: Das neue Log hat sofort einen alten Bug aufgedeckt –
|
||||||
|
`EBUSY: rmdir '/app/uploads'` beim Restore. Ursache: das
|
||||||
|
Backup-Service rief `deleteDirectory(UPLOADS_DIR)` mit dem
|
||||||
|
finalen `rmdirSync`, aber `/app/uploads` ist ein Bind-Mount,
|
||||||
|
den Linux nicht aushängen lässt. Fix: neuer Helper
|
||||||
|
`emptyDirectory()` löscht nur die Inhalte, das Verzeichnis
|
||||||
|
selbst bleibt stehen.
|
||||||
|
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||||||
|
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
||||||
|
|
||||||
- [x] **🐛 DSGVO-Rolle: Menüpunkte in den Einstellungen unsichtbar**
|
- [x] **🐛 DSGVO-Rolle: Menüpunkte in den Einstellungen unsichtbar**
|
||||||
- Symptom: User mit ausschließlich DSGVO-Rolle sah keinerlei
|
- Symptom: User mit ausschließlich DSGVO-Rolle sah keinerlei
|
||||||
Karten unter Einstellungen → System (DSGVO-Dashboard,
|
Karten unter Einstellungen → System (DSGVO-Dashboard,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, Bomb } from 'lucide-react';
|
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, Bomb, CheckCircle2, XCircle, ScrollText, X } from 'lucide-react';
|
||||||
import { backupApi, BackupInfo, getAccessToken } from '../../services/api';
|
import { backupApi, BackupInfo, backupLogApi, BackupLogEntry, BackupLogDetail, getAccessToken } from '../../services/api';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
@@ -25,10 +25,33 @@ export default function DatabaseBackup() {
|
|||||||
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
|
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
|
||||||
const [factoryResetConfirmText, setFactoryResetConfirmText] = useState('');
|
const [factoryResetConfirmText, setFactoryResetConfirmText] = useState('');
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const [showLogId, setShowLogId] = useState<number | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
// Logs für Backup-Erstellung und -Wiederherstellung
|
||||||
|
const { data: createLogsData } = useQuery({
|
||||||
|
queryKey: ['backup-logs', 'CREATE'],
|
||||||
|
queryFn: () => backupLogApi.list('CREATE'),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const { data: restoreLogsData } = useQuery({
|
||||||
|
queryKey: ['backup-logs', 'RESTORE'],
|
||||||
|
queryFn: () => backupLogApi.list('RESTORE'),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const createLogs: BackupLogEntry[] = createLogsData?.data || [];
|
||||||
|
const restoreLogs: BackupLogEntry[] = restoreLogsData?.data || [];
|
||||||
|
|
||||||
|
// Detail-Log (für das Modal)
|
||||||
|
const { data: logDetailData, isLoading: logDetailLoading } = useQuery({
|
||||||
|
queryKey: ['backup-log-detail', showLogId],
|
||||||
|
queryFn: () => backupLogApi.get(showLogId!),
|
||||||
|
enabled: showLogId !== null,
|
||||||
|
});
|
||||||
|
const logDetail: BackupLogDetail | undefined = logDetailData?.data;
|
||||||
|
|
||||||
// Backups laden
|
// Backups laden
|
||||||
const { data: backupsData, isLoading } = useQuery({
|
const { data: backupsData, isLoading } = useQuery({
|
||||||
queryKey: ['backups'],
|
queryKey: ['backups'],
|
||||||
@@ -42,6 +65,10 @@ export default function DatabaseBackup() {
|
|||||||
mutationFn: () => backupApi.create(),
|
mutationFn: () => backupApi.create(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['backup-logs', 'CREATE'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['backup-logs', 'CREATE'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +77,7 @@ export default function DatabaseBackup() {
|
|||||||
mutationFn: (name: string) => backupApi.restore(name),
|
mutationFn: (name: string) => backupApi.restore(name),
|
||||||
onSuccess: (response: any) => {
|
onSuccess: (response: any) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['backup-logs', 'RESTORE'] });
|
||||||
setShowRestoreConfirm(null);
|
setShowRestoreConfirm(null);
|
||||||
// Backend liefert message: "X Datensätze und Y Dateien wiederhergestellt"
|
// Backend liefert message: "X Datensätze und Y Dateien wiederhergestellt"
|
||||||
const msg = response?.message || 'Backup erfolgreich wiederhergestellt.';
|
const msg = response?.message || 'Backup erfolgreich wiederhergestellt.';
|
||||||
@@ -58,6 +86,7 @@ export default function DatabaseBackup() {
|
|||||||
// Bei Fehler bleibt das Dialog absichtlich offen, damit der User
|
// Bei Fehler bleibt das Dialog absichtlich offen, damit der User
|
||||||
// die Detail-Message sehen + ggf. erneut versuchen kann.
|
// die Detail-Message sehen + ggf. erneut versuchen kann.
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['backup-logs', 'RESTORE'] });
|
||||||
const e = extractError(err);
|
const e = extractError(err);
|
||||||
const msg = e.details ? `${e.headline}\n${e.details}` : e.headline;
|
const msg = e.details ? `${e.headline}\n${e.details}` : e.headline;
|
||||||
toast.error(msg, { duration: 10000 });
|
toast.error(msg, { duration: 10000 });
|
||||||
@@ -432,6 +461,101 @@ export default function DatabaseBackup() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Operations-Logs: zwei Spalten (CREATE | RESTORE) */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<LogPanel
|
||||||
|
title="Backup-Erstellung"
|
||||||
|
emptyText="Noch keine Backup-Erstellung protokolliert."
|
||||||
|
entries={createLogs}
|
||||||
|
onSelect={setShowLogId}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
<LogPanel
|
||||||
|
title="Backup-Wiederherstellung"
|
||||||
|
emptyText="Noch keine Wiederherstellung protokolliert."
|
||||||
|
entries={restoreLogs}
|
||||||
|
onSelect={setShowLogId}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log-Detail-Modal */}
|
||||||
|
{showLogId !== null && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<ScrollText className="w-5 h-5 text-gray-600" />
|
||||||
|
Log-Detail
|
||||||
|
{logDetail && (
|
||||||
|
<span className="text-sm font-normal text-gray-500">
|
||||||
|
#{logDetail.id} · {logDetail.operation === 'CREATE' ? 'Backup-Erstellung' : 'Wiederherstellung'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogId(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-700 p-1"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 overflow-auto flex-1">
|
||||||
|
{logDetailLoading && (
|
||||||
|
<div className="text-gray-500 text-sm flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Lade Log-Daten...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{logDetail && (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">Zeitpunkt</div>
|
||||||
|
<div>{formatDate(logDetail.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">Status</div>
|
||||||
|
<div className={logDetail.success ? 'text-green-700' : 'text-red-700'}>
|
||||||
|
{logDetail.success ? 'Erfolgreich' : 'Fehlgeschlagen'}
|
||||||
|
{' · '}
|
||||||
|
{(logDetail.durationMs / 1000).toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{logDetail.backupName && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">Backup</div>
|
||||||
|
<div className="font-mono text-xs">{logDetail.backupName}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{logDetail.userEmail && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">Ausgelöst von</div>
|
||||||
|
<div>{logDetail.userEmail}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Zusammenfassung</div>
|
||||||
|
<div className="text-sm bg-gray-50 border border-gray-200 rounded p-2 mb-4 whitespace-pre-wrap break-words">
|
||||||
|
{logDetail.summary}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Vollständiges Log</div>
|
||||||
|
<pre className="text-xs bg-gray-900 text-gray-100 rounded p-3 overflow-auto whitespace-pre-wrap break-words font-mono">
|
||||||
|
{logDetail.fullLog || '(leer)'}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-gray-200 flex justify-end">
|
||||||
|
<Button variant="secondary" onClick={() => setShowLogId(null)}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Werkseinstellungen */}
|
{/* Werkseinstellungen */}
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mt-8">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mt-8">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
@@ -521,3 +645,52 @@ export default function DatabaseBackup() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LogPanelProps {
|
||||||
|
title: string;
|
||||||
|
emptyText: string;
|
||||||
|
entries: BackupLogEntry[];
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
formatDate: (iso: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogPanel({ title, emptyText, entries, onSelect, formatDate }: LogPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<ScrollText className="w-4 h-4" />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-500">{entries.length} Einträge</span>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-sm text-gray-500">{emptyText}</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-200 max-h-80 overflow-auto">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.id}
|
||||||
|
onClick={() => onSelect(e.id)}
|
||||||
|
className="px-4 py-2.5 hover:bg-gray-50 cursor-pointer flex items-start gap-3"
|
||||||
|
>
|
||||||
|
{e.success ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-gray-900 truncate">{e.summary}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||||
|
<span>{formatDate(e.createdAt)}</span>
|
||||||
|
{e.backupName && <span className="font-mono">{e.backupName}</span>}
|
||||||
|
{e.userEmail && <span>{e.userEmail}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1012,6 +1012,33 @@ export const backupApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface BackupLogEntry {
|
||||||
|
id: number;
|
||||||
|
operation: 'CREATE' | 'RESTORE';
|
||||||
|
backupName: string | null;
|
||||||
|
success: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
summary: string;
|
||||||
|
userEmail: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface BackupLogDetail extends BackupLogEntry {
|
||||||
|
fullLog: string;
|
||||||
|
}
|
||||||
|
export const backupLogApi = {
|
||||||
|
list: async (operation: 'CREATE' | 'RESTORE') => {
|
||||||
|
const res = await api.get<ApiResponse<BackupLogEntry[]>>('/settings/backup-logs', {
|
||||||
|
params: { operation, limit: 50 },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
get: async (id: number) => {
|
||||||
|
const res = await api.get<ApiResponse<BackupLogDetail>>(`/settings/backup-logs/${id}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Rate-Limit-Verwaltung (Admin)
|
// Rate-Limit-Verwaltung (Admin)
|
||||||
export interface ActiveRateLimit {
|
export interface ActiveRateLimit {
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user