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:
2026-05-19 11:53:04 +02:00
parent 95541e8ac4
commit 37df8c0c4a
8 changed files with 506 additions and 15 deletions
+175 -2
View File
@@ -1,8 +1,8 @@
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, Bomb } from 'lucide-react';
import { backupApi, BackupInfo, getAccessToken } from '../../services/api';
import { Database, Download, Upload, Trash2, RefreshCw, HardDrive, Clock, FileText, FolderOpen, Archive, AlertTriangle, Bomb, CheckCircle2, XCircle, ScrollText, X } from 'lucide-react';
import { backupApi, BackupInfo, backupLogApi, BackupLogEntry, BackupLogDetail, getAccessToken } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Button from '../../components/ui/Button';
@@ -25,10 +25,33 @@ export default function DatabaseBackup() {
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
const [factoryResetConfirmText, setFactoryResetConfirmText] = useState('');
const [uploadError, setUploadError] = useState<string | null>(null);
const [showLogId, setShowLogId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
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
const { data: backupsData, isLoading } = useQuery({
queryKey: ['backups'],
@@ -42,6 +65,10 @@ export default function DatabaseBackup() {
mutationFn: () => backupApi.create(),
onSuccess: () => {
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),
onSuccess: (response: any) => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
queryClient.invalidateQueries({ queryKey: ['backup-logs', 'RESTORE'] });
setShowRestoreConfirm(null);
// Backend liefert message: "X Datensätze und Y Dateien 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
// die Detail-Message sehen + ggf. erneut versuchen kann.
onError: (err: any) => {
queryClient.invalidateQueries({ queryKey: ['backup-logs', 'RESTORE'] });
const e = extractError(err);
const msg = e.details ? `${e.headline}\n${e.details}` : e.headline;
toast.error(msg, { duration: 10000 });
@@ -432,6 +461,101 @@ export default function DatabaseBackup() {
</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 */}
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mt-8">
<div className="flex items-start gap-4">
@@ -521,3 +645,52 @@ export default function DatabaseBackup() {
</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>
);
}
+27
View File
@@ -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)
export interface ActiveRateLimit {
ipAddress: string;