Validate backups before restore
This commit is contained in:
1 parent
72229cf14c
commit
05c842e406
7 files changed
+347
-56
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
||||
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
restoreDatabaseBackup,
|
||||
saveZammadSettings,
|
||||
syncZammadOrganizations,
|
||||
updateAdminUser
|
||||
updateAdminUser,
|
||||
validateDatabaseBackup,
|
||||
type RestoreValidationResult
|
||||
} from "../api";
|
||||
import type { AdminUser, AuthUser } from "../types";
|
||||
|
||||
@@ -56,6 +58,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
const [validatingRestore, setValidatingRestore] = useState(false);
|
||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
@@ -240,12 +244,57 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreSql(await file.text());
|
||||
const sql = await file.text();
|
||||
setRestoreSql(sql);
|
||||
setRestoreValidation(null);
|
||||
await validateRestore(sql);
|
||||
}
|
||||
|
||||
async function validateRestore(sql = restoreSql) {
|
||||
if (!sql.trim()) {
|
||||
setRestoreValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingRestore(true);
|
||||
|
||||
try {
|
||||
const result = await validateDatabaseBackup(sql);
|
||||
setRestoreValidation(result);
|
||||
|
||||
if (result.valid) {
|
||||
toast.success("Backup-Struktur passt", {
|
||||
description: `${result.checkedTables} Tabelle(n) geprüft.`
|
||||
});
|
||||
} else {
|
||||
toast.error("Backup-Struktur passt nicht", {
|
||||
description: result.issues[0] ?? "Unbekannter Strukturfehler"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setRestoreValidation({
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
|
||||
});
|
||||
toast.error("Backup-Prüfung fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setValidatingRestore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreDatabase(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!restoreValidation?.valid) {
|
||||
toast.error("Backup zuerst prüfen", {
|
||||
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm("Restore wirklich einspielen? Alle aktuellen TicketTracker-Daten werden durch den Stand des Backups ersetzt.")) {
|
||||
return;
|
||||
}
|
||||
@@ -276,8 +325,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
|
||||
<Card>
|
||||
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -289,7 +338,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
|
||||
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
||||
<Input
|
||||
@@ -305,25 +354,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<Input
|
||||
id="zammad-api-key"
|
||||
type="password"
|
||||
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""}
|
||||
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
|
||||
value={zammadApiKey}
|
||||
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
|
||||
/>
|
||||
{hasZammadApiKey ? <p className="text-xs text-muted-foreground">API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.</p> : null}
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={syncingOrganizations}>
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 xl:col-span-2">
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={syncingOrganizations}>
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -351,10 +401,27 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Input type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readRestoreFile(event)} />
|
||||
<Button className="w-full" type="submit" variant="destructive" disabled={restoringDatabase || !restoreSql.trim()}>
|
||||
<Upload className="size-4" />
|
||||
{restoringDatabase ? "Spielt ein..." : "SQL-Backup einspielen"}
|
||||
</Button>
|
||||
{restoreValidation ? (
|
||||
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
|
||||
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
|
||||
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{restoreValidation.valid
|
||||
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
|
||||
: restoreValidation.issues.slice(0, 4).join(" ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={restoringDatabase || !restoreValidation?.valid}>
|
||||
<Upload className="size-4" />
|
||||
{restoringDatabase ? "Spielt ein..." : "SQL-Backup einspielen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useState } from "react";
|
||||
import { DatabaseBackup, KeyRound, Upload } from "lucide-react";
|
||||
import { CheckCircle2, DatabaseBackup, KeyRound, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createSetupAdmin, restoreSetupBackup } from "../api";
|
||||
import { createSetupAdmin, restoreSetupBackup, validateSetupBackup, type RestoreValidationResult } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
type SetupPageProps = {
|
||||
@@ -22,7 +22,9 @@ export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps)
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordRepeat, setPasswordRepeat] = useState("");
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
const [creatingAdmin, setCreatingAdmin] = useState(false);
|
||||
const [validatingRestore, setValidatingRestore] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
async function createAdmin(event: FormEvent) {
|
||||
@@ -55,11 +57,57 @@ export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps)
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreSql(await file.text());
|
||||
const sql = await file.text();
|
||||
setRestoreSql(sql);
|
||||
setRestoreValidation(null);
|
||||
await validateRestore(sql);
|
||||
}
|
||||
|
||||
async function validateRestore(sql = restoreSql) {
|
||||
if (!sql.trim()) {
|
||||
setRestoreValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingRestore(true);
|
||||
|
||||
try {
|
||||
const result = await validateSetupBackup(sql);
|
||||
setRestoreValidation(result);
|
||||
|
||||
if (result.valid) {
|
||||
toast.success("Backup-Struktur passt", {
|
||||
description: `${result.checkedTables} Tabelle(n) geprüft.`
|
||||
});
|
||||
} else {
|
||||
toast.error("Backup-Struktur passt nicht", {
|
||||
description: result.issues[0] ?? "Unbekannter Strukturfehler"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setRestoreValidation({
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
|
||||
});
|
||||
toast.error("Backup-Prüfung fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setValidatingRestore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!restoreValidation?.valid) {
|
||||
toast.error("Backup zuerst prüfen", {
|
||||
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoring(true);
|
||||
|
||||
try {
|
||||
@@ -156,15 +204,35 @@ export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps)
|
||||
id="setup-restore-sql"
|
||||
className="min-h-44 font-mono text-xs"
|
||||
value={restoreSql}
|
||||
onChange={(event) => setRestoreSql(event.currentTarget.value)}
|
||||
onChange={(event) => {
|
||||
setRestoreSql(event.currentTarget.value);
|
||||
setRestoreValidation(null);
|
||||
}}
|
||||
placeholder="SQL-Backup hier einfügen oder Datei auswählen"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" variant="destructive" disabled={restoring || !restoreSql.trim()}>
|
||||
<DatabaseBackup className="size-4" />
|
||||
{restoring ? "Spielt ein..." : "Backup einspielen"}
|
||||
</Button>
|
||||
{restoreValidation ? (
|
||||
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
|
||||
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
|
||||
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{restoreValidation.valid
|
||||
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
|
||||
: restoreValidation.issues.slice(0, 4).join(" ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={restoring || !restoreValidation?.valid}>
|
||||
<DatabaseBackup className="size-4" />
|
||||
{restoring ? "Spielt ein..." : "Backup einspielen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user