feat(backup): add enterprise backup & restore foundation

This commit is contained in:
Schubert Ferenc 2026-07-05 17:13:31 +02:00
parent 7835d3ca75
commit 6c917df515
30 changed files with 1538 additions and 61 deletions

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { filename } = await context.params;
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`);
}

View file

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function GET(request: NextRequest, context: Params) {
const { filename } = await context.params;
return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`);
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function POST(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { filename } = await context.params;
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`);
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
filename: string;
}>;
};
export async function POST(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { filename } = await context.params;
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`);
}

View file

@ -0,0 +1,12 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/backups/create");
}

View file

@ -0,0 +1,7 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/backups");
}

View file

@ -0,0 +1,415 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertTriangle, Archive, Download, RefreshCcw, RotateCcw, ShieldAlert, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import SummaryCard from "@/components/common/SummaryCard";
import { useToast } from "@/components/common/ToastProvider";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type { ApiSuccess } from "@/types/audit";
import type { BackupListResponse, BackupSummary, BackupValidationResponse } from "@/types/backup";
import type { CurrentUser } from "@/types/rbac";
const RESTORE_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO";
function getErrorMessage(error: unknown, fallback: string) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
return response?.data?.detail ?? response?.data?.message ?? fallback;
}
return fallback;
}
function formatBytes(bytes: number) {
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatDate(value: string | null) {
if (!value) {
return "Unbekannt";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export default function BackupsPage() {
const { showToast } = useToast();
const [data, setData] = useState<BackupListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<BackupSummary | null>(null);
const [deleting, setDeleting] = useState(false);
const [validationByFile, setValidationByFile] = useState<Record<string, BackupValidationResponse>>({});
const [validatingFile, setValidatingFile] = useState("");
const [restoreTarget, setRestoreTarget] = useState<BackupSummary | null>(null);
const [restoreConfirm, setRestoreConfirm] = useState("");
const [restoring, setRestoring] = useState(false);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const loadBackups = useCallback(async () => {
setError("");
try {
const response = await api.get<ApiSuccess<BackupListResponse>>("/backups");
setData(response.data.data);
} catch (err) {
setError(getErrorMessage(err, "Backups konnten nicht geladen werden."));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadBackups();
});
}, [loadBackups]);
useEffect(() => {
queueMicrotask(async () => {
try {
const response = await api.get<CurrentUser>("/me");
setCurrentUser(response.data);
} catch {
setCurrentUser(null);
}
});
}, []);
const cards = useMemo(() => {
if (!data) {
return [];
}
return [
{ label: "Backups", value: data.total_count },
{ label: "Gesamtgroesse", value: formatBytes(data.total_size_bytes) },
{ label: "Letztes Backup", value: data.latest_backup_at ? formatDate(data.latest_backup_at) : "Noch keines" },
];
}, [data]);
const canCreate = hasPermission(currentUser, "backup.create");
const canDownload = hasPermission(currentUser, "backup.download");
const canDelete = hasPermission(currentUser, "backup.delete");
const canRestore = hasPermission(currentUser, "backup.restore");
async function createBackup() {
setCreating(true);
try {
await api.post<ApiSuccess<BackupSummary>>("/backups/create");
await loadBackups();
showToast({ type: "success", title: "Backup erstellt" });
} catch (err) {
showToast({
type: "error",
title: "Backup konnte nicht erstellt werden",
description: getErrorMessage(err, "Bitte pruefe die Hermes- und Datenbank-Konfiguration."),
});
} finally {
setCreating(false);
}
}
async function validateBackup(filename: string) {
setValidatingFile(filename);
try {
const response = await api.post<ApiSuccess<BackupValidationResponse>>(`/backups/${encodeURIComponent(filename)}/validate`);
setValidationByFile((current) => ({ ...current, [filename]: response.data.data }));
showToast({
type: response.data.data.valid ? "success" : "error",
title: response.data.data.valid ? "Backup ist gueltig" : "Backup-Pruefung fehlgeschlagen",
description: response.data.data.valid ? response.data.data.message : response.data.data.issues.join(" | "),
});
} catch (err) {
showToast({
type: "error",
title: "Backup konnte nicht validiert werden",
description: getErrorMessage(err, "Bitte pruefe die Backup-Datei."),
});
} finally {
setValidatingFile("");
}
}
async function deleteBackup() {
if (!deleteTarget) {
return;
}
setDeleting(true);
try {
await api.delete(`/backups/${encodeURIComponent(deleteTarget.filename)}/delete`);
setDeleteTarget(null);
await loadBackups();
showToast({ type: "success", title: "Backup geloescht" });
} catch (err) {
showToast({
type: "error",
title: "Backup konnte nicht geloescht werden",
description: getErrorMessage(err, "Bitte versuche es erneut."),
});
} finally {
setDeleting(false);
}
}
async function restoreBackup() {
if (!restoreTarget) {
return;
}
setRestoring(true);
try {
await api.post(`/backups/${encodeURIComponent(restoreTarget.filename)}/restore`, {
confirm_text: restoreConfirm,
});
showToast({ type: "success", title: "Restore abgeschlossen" });
} catch (err) {
showToast({
type: "info",
title: "CLI-Restore erforderlich",
description: getErrorMessage(err, "Automatischer Restore ist derzeit deaktiviert."),
});
} finally {
setRestoring(false);
}
}
if (loading) {
return <div className="rounded-lg border bg-white p-8 text-slate-500">Backups werden geladen...</div>;
}
if (error || !data) {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Backups verfuegbar"}</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Backups</h1>
<p className="mt-1 text-sm text-slate-500">PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => void loadBackups()}>
<RefreshCcw />
Aktualisieren
</Button>
{canCreate && (
<Button type="button" onClick={() => void createBackup()} disabled={creating}>
<Archive />
{creating ? "Backup wird erstellt..." : "Backup erstellen"}
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<SummaryCard key={card.label} label={card.label} value={card.value} />
))}
</div>
<section className="rounded-lg border bg-white">
<div className="border-b px-6 py-4">
<h2 className="text-lg font-semibold text-slate-950">Verfuegbare Backup-Dateien</h2>
</div>
{data.items.length === 0 ? (
<div className="px-6 py-10 text-sm text-slate-500">Noch keine Backup-Dateien vorhanden.</div>
) : (
<div className="divide-y">
{data.items.map((backup) => {
const validation = validationByFile[backup.filename];
return (
<div key={backup.filename} className="px-6 py-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<p className="truncate font-semibold text-slate-950">{backup.filename}</p>
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
backup.validation_status === "valid"
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20"
: "bg-amber-50 text-amber-700 ring-1 ring-amber-600/20"
}`}>
{backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"}
</span>
</div>
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Erstellt</p>
<p>{formatDate(backup.created_at)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Groesse</p>
<p>{formatBytes(backup.size_bytes)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Version</p>
<p>{backup.app_version || "Unbekannt"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Ersteller</p>
<p>{backup.created_by_username || "System"}</p>
</div>
</div>
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Datenbank</p>
<p>{backup.database_name || "Unbekannt"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Dateien</p>
<p>{backup.file_count}</p>
</div>
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-slate-400">Storage-Pfad</p>
<p className="break-all">{backup.storage_base_path || "-"}</p>
</div>
</div>
{validation && (
<div className={`mt-4 rounded-lg border p-4 text-sm ${
validation.valid
? "border-emerald-200 bg-emerald-50 text-emerald-950"
: "border-amber-200 bg-amber-50 text-amber-950"
}`}>
<div className="flex items-start gap-3">
{validation.valid ? <RotateCcw className="mt-0.5 h-4 w-4" /> : <AlertTriangle className="mt-0.5 h-4 w-4" />}
<div className="space-y-1">
<p className="font-medium">{validation.message}</p>
<p>Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}</p>
{!validation.valid && validation.issues.length > 0 && (
<p>{validation.issues.join(" | ")}</p>
)}
{validation.requires_cli_restore && (
<p>Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.</p>
)}
</div>
</div>
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{canDownload && (
<a
href={`/api/backups/${encodeURIComponent(backup.filename)}/download`}
className={buttonVariants({ variant: "outline" })}
>
<Download />
Download
</a>
)}
{canRestore && (
<>
<Button
type="button"
variant="outline"
onClick={() => void validateBackup(backup.filename)}
disabled={validatingFile === backup.filename}
>
<ShieldAlert />
{validatingFile === backup.filename ? "Prueft..." : "Validieren"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setRestoreTarget(backup);
setRestoreConfirm("");
}}
>
<RotateCcw />
Restore vorbereiten
</Button>
</>
)}
{canDelete && (
<Button type="button" variant="destructive" onClick={() => setDeleteTarget(backup)}>
<Trash2 />
Loeschen
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</section>
<ConfirmDialog
open={deleteTarget !== null}
title="Backup loeschen"
description="Die Backup-Datei wird dauerhaft entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden."
confirmLabel="Backup loeschen"
pending={deleting}
pendingLabel="Backup wird geloescht..."
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null);
}
}}
onConfirm={() => void deleteBackup()}
>
{deleteTarget && (
<div className="space-y-1 text-sm text-slate-700">
<p className="font-medium">{deleteTarget.filename}</p>
<p>{formatBytes(deleteTarget.size_bytes)}</p>
</div>
)}
</ConfirmDialog>
<ConfirmDialog
open={restoreTarget !== null}
title="Restore vorbereiten"
description="Restore ist ein Hochrisiko-Vorgang. Bitte bestaetige den Text exakt, bevor Olympus den CLI-Restore vorbereitet."
confirmLabel="Restore starten"
pending={restoring}
pendingLabel="Restore wird vorbereitet..."
confirmDisabled={restoreConfirm !== RESTORE_CONFIRM_TEXT}
onOpenChange={(open) => {
if (!open) {
setRestoreTarget(null);
setRestoreConfirm("");
}
}}
onConfirm={() => void restoreBackup()}
>
<div className="space-y-3">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950">
<div className="flex gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.</p>
</div>
</div>
<div>
<p className="mb-2 text-sm font-medium text-slate-700">Bestaetigungstext</p>
<Input
value={restoreConfirm}
onChange={(event) => setRestoreConfirm(event.target.value)}
placeholder={RESTORE_CONFIRM_TEXT}
/>
</div>
{restoreTarget && (
<p className="text-sm text-slate-600">
Ziel-Backup: <span className="font-medium text-slate-900">{restoreTarget.filename}</span>
</p>
)}
</div>
</ConfirmDialog>
</div>
);
}

View file

@ -6,6 +6,7 @@ import {
BookOpen,
ClipboardList,
FileText,
HardDriveDownload,
LayoutDashboard,
Package,
Settings,
@ -78,6 +79,12 @@ const menu = [
name: "Dokumente",
href: "/documents",
},
{
icon: HardDriveDownload,
name: "Backups",
href: "/backups",
permission: "backup.read",
},
{
icon: Settings,
name: "Einstellungen",
@ -135,7 +142,7 @@ export default function Sidebar() {
</nav>
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
Olympus CRM v0.1
Olympus CRM v0.9.1
</div>
</aside>
);

View file

@ -18,6 +18,8 @@ type Props = {
description: string;
confirmLabel?: string;
pending?: boolean;
pendingLabel?: string;
confirmDisabled?: boolean;
children?: ReactNode;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
@ -29,6 +31,8 @@ export default function ConfirmDialog({
description,
confirmLabel = "Löschen",
pending = false,
pendingLabel = "Wird gelöscht...",
confirmDisabled = false,
children,
onOpenChange,
onConfirm,
@ -56,9 +60,9 @@ export default function ConfirmDialog({
type="button"
variant="destructive"
onClick={onConfirm}
disabled={pending}
disabled={pending || confirmDisabled}
>
{pending ? "Wird gelöscht..." : confirmLabel}
{pending ? pendingLabel : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -23,6 +23,7 @@ export const config = {
"/roles/:path*",
"/customers/:path*",
"/repairs/:path*",
"/backups/:path*",
"/settings/:path*",
],
};

View file

@ -0,0 +1,49 @@
export interface BackupManifest {
backup_id: string;
created_at: string;
app_version: string;
backup_type: string;
database_url_host_anonymized: string;
database_name: string;
storage_base_path: string;
included_sections: string[];
file_count: number;
total_size_bytes: number;
checksum_sha256: string;
created_by_user_id: number | null;
created_by_username: string;
}
export interface BackupSummary {
filename: string;
size_bytes: number;
created_at: string | null;
app_version: string;
backup_type: string;
database_name: string;
storage_base_path: string;
file_count: number;
total_size_bytes: number;
created_by_user_id: number | null;
created_by_username: string;
validation_status: string;
validation_message: string;
}
export interface BackupListResponse {
items: BackupSummary[];
total_count: number;
total_size_bytes: number;
latest_backup_at: string | null;
}
export interface BackupValidationResponse {
filename: string;
valid: boolean;
message: string;
issues: string[];
checksum_valid: boolean;
restore_supported: boolean;
requires_cli_restore: boolean;
manifest: BackupManifest | null;
}