Validate backups before restore

This commit is contained in:
2026-08-06 08:28:01 +02:00
parent 72229cf14c
commit 05c842e406
7 changed files with 347 additions and 56 deletions
+135 -3
View File
@@ -1224,6 +1224,16 @@ app.post("/api/setup/restore", async (req, res) => {
res.status(201).json(result);
});
app.post("/api/setup/restore/validate", async (req, res) => {
if (await hasAdminUser()) {
res.status(409).json({ error: "Setup wurde bereits abgeschlossen. Restore ist im Adminbereich verfügbar." });
return;
}
const sql = requireString(req.body.sql, "sql");
res.json(await validateTicketTrackerBackup(sql));
});
app.post("/api/auth/login", async (req, res) => {
const username = requireString(req.body.username, "username");
const password = requireString(req.body.password, "password");
@@ -1671,6 +1681,12 @@ const databaseExportTables = [
"ticket_day_billing_acknowledgements"
] as const;
type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
issues: string[];
};
function quoteIdentifier(value: string) {
return `"${value.replace(/"/g, '""')}"`;
}
@@ -1716,7 +1732,7 @@ async function exportTableSql(table: string) {
`
);
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`];
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`, `-- columns ${table}: ${columns.join(",")}`];
for (const row of rowsResult.rows) {
lines.push(
@@ -1733,7 +1749,7 @@ async function exportTableSql(table: string) {
return lines;
}
function managedRestoreBody(sql: string) {
function restoreSqlText(sql: string) {
const trimmed = sql.replace(/^\uFEFF/, "").trim();
if (!trimmed.includes("TicketTracker Datenbankexport")) {
@@ -1747,8 +1763,119 @@ function managedRestoreBody(sql: string) {
.trim();
}
function parseDumpColumnList(value: string) {
const quoted = [...value.matchAll(/"((?:[^"]|"")+)"/g)].map((match) => match[1].replace(/""/g, '"'));
if (quoted.length > 0) {
return quoted;
}
return value
.split(",")
.map((column) => column.trim().replace(/^"|"$/g, ""))
.filter(Boolean);
}
async function currentTableColumns() {
const result = await query<{ table_name: string; column_name: string }>(
`
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ANY($1::text[])
ORDER BY table_name, ordinal_position ASC;
`,
[[...databaseExportTables]]
);
const columns = new Map<string, string[]>();
for (const row of result.rows) {
columns.set(row.table_name, [...(columns.get(row.table_name) ?? []), row.column_name]);
}
return columns;
}
async function validateTicketTrackerBackup(sql: string): Promise<RestoreValidationResult> {
const issues: string[] = [];
let body = "";
try {
body = restoreSqlText(sql);
} catch (error) {
return {
valid: false,
checkedTables: 0,
issues: [error instanceof Error ? error.message : "Backup-Datei konnte nicht gelesen werden"]
};
}
const dumpColumns = new Map<string, Set<string>>();
for (const match of sql.matchAll(/^--\s*columns\s+([a-z_][a-z0-9_]*):\s*(.+)$/gim)) {
dumpColumns.set(match[1], new Set(parseDumpColumnList(match[2])));
}
for (const match of body.matchAll(/INSERT\s+INTO\s+"?([a-z_][a-z0-9_]*)"?\s*\(([^)]+)\)\s*VALUES/gi)) {
const table = match[1];
const columns = dumpColumns.get(table) ?? new Set<string>();
for (const column of parseDumpColumnList(match[2])) {
columns.add(column);
}
dumpColumns.set(table, columns);
}
for (const table of dumpColumns.keys()) {
if (!(databaseExportTables as readonly string[]).includes(table)) {
issues.push(`Dump enthält unbekannte Tabelle "${table}".`);
}
}
const dbColumns = await currentTableColumns();
for (const table of databaseExportTables) {
const currentColumns = dbColumns.get(table) ?? [];
const backupColumns = dumpColumns.get(table);
if (currentColumns.length === 0) {
issues.push(`Aktuelle Datenbank enthält Tabelle "${table}" nicht.`);
continue;
}
if (!backupColumns) {
issues.push(`Dump enthält keine Strukturinformationen für Tabelle "${table}". Bitte ein aktuelles TicketTracker-Backup verwenden.`);
continue;
}
const missingInDump = currentColumns.filter((column) => !backupColumns.has(column));
const extraInDump = [...backupColumns].filter((column) => !currentColumns.includes(column));
if (missingInDump.length > 0) {
issues.push(`Dump passt nicht zu Tabelle "${table}": Feld(er) fehlen: ${missingInDump.join(", ")}.`);
}
if (extraInDump.length > 0) {
issues.push(`Dump passt nicht zu Tabelle "${table}": unbekannte Feld(er): ${extraInDump.join(", ")}.`);
}
}
return {
valid: issues.length === 0,
checkedTables: databaseExportTables.length,
issues
};
}
async function restoreTicketTrackerBackup(sql: string) {
const body = managedRestoreBody(sql);
const validation = await validateTicketTrackerBackup(sql);
if (!validation.valid) {
throw badRequest(`Restore-Datei passt nicht zur aktuellen Datenbankstruktur: ${validation.issues[0]}`);
}
const body = restoreSqlText(sql);
await withTransaction(async (client) => {
await client.query(`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`);
@@ -1788,6 +1915,11 @@ app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
res.send(lines.join("\n"));
});
app.post("/api/admin/restore/database/validate", requireAdmin, async (req, res) => {
const sql = requireString(req.body.sql, "sql");
res.json(await validateTicketTrackerBackup(sql));
});
app.post("/api/admin/restore/database", requireAdmin, async (req, res) => {
const sql = requireString(req.body.sql, "sql");
const result = await restoreTicketTrackerBackup(sql);
+2 -2
View File
@@ -55,9 +55,9 @@ Admin-Zugangsdaten werden nicht per `.env` gesetzt. Nach dem ersten Start oeffne
Dort gibt es zwei Wege:
- `Admin anlegen`: festen Admin direkt erstellen und anmelden.
- `Backup einspielen`: ein TicketTracker-SQL-Backup einspielen. Danach meldest du dich mit einem Benutzer aus dem Backup an.
- `Backup einspielen`: ein TicketTracker-SQL-Backup hochladen, Struktur pruefen und danach einspielen. Danach meldest du dich mit einem Benutzer aus dem Backup an.
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen werden alle TicketTracker-Tabellen geleert; danach gelten ausschliesslich Benutzer, Passwoerter, Sessions, Settings, Organisationen, fixe Abrechnungen und Abschluesse aus dem Backup.
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen wird geprueft, ob Dump-Struktur und aktuelle Datenbankstruktur zusammenpassen. Danach werden alle TicketTracker-Tabellen geleert; anschliessend gelten ausschliesslich Benutzer, Passwoerter, Sessions, Settings, Organisationen, fixe Abrechnungen und Abschluesse aus dem Backup.
## Image bauen und in Gitea pushen
+3 -3
View File
@@ -9,9 +9,9 @@ Wenn noch kein Admin existiert, zeigt TicketTracker beim Aufruf automatisch die
Dort gibt es zwei Moeglichkeiten:
- `Admin anlegen`: festen Admin mit Benutzername, vollem Namen und Passwort erstellen. Danach ist der Admin direkt angemeldet.
- `Backup einspielen`: ein TicketTracker-SQL-Backup aus einem frueheren Systemstand einspielen. Danach meldet man sich mit einem Benutzer aus dem Backup an.
- `Backup einspielen`: ein TicketTracker-SQL-Backup aus einem frueheren Systemstand hochladen, Struktur pruefen und danach einspielen. Danach meldet man sich mit einem Benutzer aus dem Backup an.
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen werden alle TicketTracker-Tabellen geleert. Danach gilt ausschliesslich der Stand des Backups, inklusive aller Benutzer, Passwoerter, Sessions, Einstellungen, Organisationen, fixen Abrechnungen und Abschluesse.
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen wird geprueft, ob die Struktur des Dumps zur aktuellen Datenbank passt. Fehlen Felder oder enthaelt der Dump unbekannte Felder, wird eine Fehlermeldung angezeigt und der Restore kann nicht gestartet werden. Beim eigentlichen Restore werden alle TicketTracker-Tabellen geleert. Danach gilt ausschliesslich der Stand des Backups, inklusive aller Benutzer, Passwoerter, Sessions, Einstellungen, Organisationen, fixen Abrechnungen und Abschluesse.
## Grundprinzip
@@ -443,7 +443,7 @@ Der Datenbankexport im Adminbereich erzeugt eine SQL-Datei fuer einen kompletten
Der Export enthaelt alle Tabellen, die fuer den Stand des Systems notwendig sind: Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen, Ausnahmen und Monatsabschluesse. Er ist kein reiner CSV-Auszug, sondern ein Restore-Backup.
Im laufenden System kann der Admin ein SQL-Backup ebenfalls im Adminbereich wieder einspielen. Vor dem Restore erscheint ein Hinweis, weil alle aktuellen Daten ersetzt werden. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
Im laufenden System kann der Admin ein SQL-Backup ebenfalls im Adminbereich wieder einspielen. Nach dem Hochladen muss zuerst die Strukturpruefung erfolgreich durchlaufen. Erst danach wird der Restore-Button freigegeben. Vor dem Restore erscheint ein Hinweis, weil alle aktuellen Daten ersetzt werden. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
## Zammad Sync
+20
View File
@@ -32,6 +32,12 @@ type DeleteSessionResult = {
};
};
export type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
issues: string[];
};
export class ApiError extends Error {
constructor(
message: string,
@@ -107,6 +113,13 @@ export function restoreSetupBackup(sql: string) {
});
}
export function validateSetupBackup(sql: string) {
return request<RestoreValidationResult>("/api/setup/restore/validate", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export function getAdminUsers() {
return request<{ users: AdminUser[] }>("/api/admin/users");
}
@@ -164,6 +177,13 @@ export function restoreDatabaseBackup(sql: string) {
});
}
export function validateDatabaseBackup(sql: string) {
return request<RestoreValidationResult>("/api/admin/restore/database/validate", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export async function downloadMonthExport(month: string) {
const response = await fetch(`/api/export/months/${month}`, {
credentials: "same-origin"
+23 -19
View File
@@ -27,24 +27,6 @@ export const helpSections: HelpSection[] = [
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
]
},
{
id: "erstes-setup",
title: "Erstes Setup",
description: "Wenn noch kein Admin existiert, startet TicketTracker mit einer Setup-Seite.",
quickItems: [
"Admin direkt im Browser anlegen.",
"Alternativ ein TicketTracker-SQL-Backup einspielen.",
"Nach Restore gelten ausschließlich die Daten aus dem Backup."
],
details: [
"Admin-Zugangsdaten werden nicht mehr über .env gesetzt.",
"Beim ersten Start ohne Admin zeigt TicketTracker automatisch die Setup-Seite.",
"Über Admin anlegen erstellst du den festen Admin und wirst direkt angemeldet.",
"Über Backup einspielen spielst du ein restore-fähiges SQL-Backup aus TicketTracker ein.",
"Beim Restore werden zuerst alle TicketTracker-Tabellen geleert. Danach gelten Benutzer, Passwörter, Sessions, Teamspace-Werte, Settings und Abschlüsse aus dem Backup.",
"Nach einem Restore meldest du dich mit einem Benutzer aus dem Backup an."
]
},
{
id: "monatsworkflow",
title: "Monatsworkflow",
@@ -99,6 +81,26 @@ export const helpSections: HelpSection[] = [
"Monat abschließen"
]
},
{
id: "erstes-setup",
title: "Erstes Setup",
description: "Wenn noch kein Admin existiert, startet TicketTracker mit einer Setup-Seite.",
quickItems: [
"Admin direkt im Browser anlegen.",
"Alternativ ein TicketTracker-SQL-Backup prüfen und einspielen.",
"Nach Restore gelten ausschließlich die Daten aus dem Backup."
],
details: [
"Admin-Zugangsdaten werden nicht mehr über .env gesetzt.",
"Beim ersten Start ohne Admin zeigt TicketTracker automatisch die Setup-Seite.",
"Über Admin anlegen erstellst du den festen Admin und wirst direkt angemeldet.",
"Über Backup einspielen lädst du ein restore-fähiges SQL-Backup aus TicketTracker hoch.",
"Nach dem Hochladen prüft TicketTracker die Dump-Struktur gegen die aktuelle Datenbankstruktur.",
"Der Restore-Button wird erst aktiv, wenn keine Felder fehlen und keine unbekannten Felder enthalten sind.",
"Beim Restore werden zuerst alle TicketTracker-Tabellen geleert. Danach gelten Benutzer, Passwörter, Sessions, Teamspace-Werte, Settings und Abschlüsse aus dem Backup.",
"Nach einem Restore meldest du dich mit einem Benutzer aus dem Backup an."
]
},
{
id: "timer",
title: "Timer",
@@ -466,7 +468,9 @@ export const helpSections: HelpSection[] = [
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
"Der Restore im Adminbereich leert zuerst alle TicketTracker-Tabellen und spielt danach den Backup-Stand ein.",
"Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.",
"Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.",
"Beim Einspielen leert TicketTracker zuerst alle Tabellen und spielt danach den Backup-Stand ein.",
"Nach einem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.",
"Für einen Restore lässt du auf dem Zielsystem zuerst die TicketTracker-Migrationen laufen und spielst danach die SQL-Datei mit psql ein."
]
+88 -21
View File
@@ -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>
+76 -8
View File
@@ -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>