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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user