415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
"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>
|
|
);
|
|
}
|