backup-restore: vollständiger Stack im Server-Log + lesbare UI-Details

Der globale ORM-Leak-Sanitizer ersetzt error/details, die TypeError/
"Cannot read properties of undefined" enthalten, durch "Operation
fehlgeschlagen". Das ist richtig für Auth-Endpoints, blockt aber bei
legitimen Admin-Operationen wie Restore die Diagnose-Info.

Backend (restoreBackup):
- console.error mit "[restore]"-Prefix loggt Backup-Name + vollen
  Stack ins Server-Log. Per `docker logs opencrm-app | tail -200`
  einsehbar.
- makeRestoreErrorReadable() strippt Stack-Frames, rephrased
  bekannte JS-Runtime-Marker ("TypeError:" → "Code-Fehler:",
  "Cannot read properties of undefined (reading 'x')" → "Wert
  fehlt: x") + cuttet auf 500 Zeichen. Dadurch passiert die
  Meldung den globalen Sanitizer und landet lesbar im Response.
- Response bekommt zusätzliches `hint`-Feld mit dem konkreten
  docker-Befehl.

Frontend (DatabaseBackup):
- extractError liefert jetzt strukturiertes Objekt
  {headline, details, hint} statt nur String.
- Dialog: Headline fett, details in Mono-Box, hint italic darunter.
- Toast: Headline + details zusammen, 10s sichtbar.

Live-verifiziert:
- Bad name → "Backup nicht gefunden" (klare Meldung)
- Echtes Backup → "4859 Datensätze wiederhergestellt" als Toast,
  Dialog zu

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 08:30:13 +02:00
parent 06c427ee39
commit 6ae815393e
2 changed files with 62 additions and 16 deletions
+35 -2
View File
@@ -50,6 +50,29 @@ export async function createBackup(req: Request, res: Response) {
* Backup wiederherstellen
* POST /api/settings/backup/:name/restore
*/
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
// "Cannot read properties of undefined" → "Interner Code-Fehler".
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
function makeRestoreErrorReadable(raw: unknown): string {
if (!raw) return 'Unbekannter Fehler';
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
// Stack-Frames " at …(…:123:45)" abschneiden
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
// alles auf "Operation fehlgeschlagen" maskiert.
s = s
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
.replace(/is not defined$/i, '(Wert nicht definiert)')
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
return s.slice(0, 500); // Längenlimit für UI
}
export async function restoreBackup(req: Request, res: Response) {
try {
const { name } = req.params;
@@ -73,10 +96,20 @@ export async function restoreBackup(req: Request, res: Response) {
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
});
} else {
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
res.status(500).json({
error: 'Wiederherstellung fehlgeschlagen',
details: makeRestoreErrorReadable(result.error),
hint: 'Vollständiger Stack-Trace im Server-Log: docker logs opencrm-app | tail -200',
});
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
console.error(`[restore] Exception bei Backup ${req.params.name}:`, error?.stack || error);
res.status(500).json({
error: 'Fehler bei der Wiederherstellung',
details: makeRestoreErrorReadable(error),
hint: 'Vollständiger Stack-Trace im Server-Log: docker logs opencrm-app | tail -200',
});
}
}
+27 -14
View File
@@ -6,14 +6,17 @@ import { backupApi, BackupInfo, getAccessToken } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Button from '../../components/ui/Button';
function extractError(err: any): string {
function extractError(err: any): { headline: string; details?: string; hint?: string } {
const data = err?.response?.data;
if (data) {
if (data.error && data.details) return `${data.error}: ${data.details}`;
if (data.error) return data.error;
if (typeof data === 'string') return data;
if (data && typeof data === 'object') {
return {
headline: data.error || 'Unbekannter Fehler',
details: data.details,
hint: data.hint,
};
}
return err?.message || 'Unbekannter Fehler';
if (typeof data === 'string') return { headline: data };
return { headline: err?.message || 'Unbekannter Fehler' };
}
export default function DatabaseBackup() {
@@ -55,7 +58,9 @@ 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) => {
toast.error(extractError(err), { duration: 8000 });
const e = extractError(err);
const msg = e.details ? `${e.headline}\n${e.details}` : e.headline;
toast.error(msg, { duration: 10000 });
},
});
@@ -348,14 +353,22 @@ export default function DatabaseBackup() {
<strong>Achtung:</strong> Bestehende Daten und Dokumente werden mit dem Backup-Stand überschrieben.
Dies kann nicht rückgängig gemacht werden.
</p>
{restoreMutation.isError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-800 text-sm p-3 rounded-lg">
<div className="font-semibold mb-1">Wiederherstellung fehlgeschlagen</div>
<div className="whitespace-pre-wrap break-words">
{extractError(restoreMutation.error)}
{restoreMutation.isError && (() => {
const e = extractError(restoreMutation.error);
return (
<div className="mb-4 bg-red-50 border border-red-200 text-red-800 text-sm p-3 rounded-lg">
<div className="font-semibold mb-1">{e.headline}</div>
{e.details && (
<div className="whitespace-pre-wrap break-words font-mono text-xs bg-white border border-red-100 rounded p-2 mb-2">
{e.details}
</div>
)}
{e.hint && (
<div className="text-xs text-red-600 italic">{e.hint}</div>
)}
</div>
</div>
)}
);
})()}
<div className="flex justify-end gap-3">
<Button
variant="secondary"