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
+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>