feat(backup): add enterprise backup & restore foundation
This commit is contained in:
parent
7835d3ca75
commit
6c917df515
30 changed files with 1538 additions and 61 deletions
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal 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)}`);
|
||||
}
|
||||
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal 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`);
|
||||
}
|
||||
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal 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`);
|
||||
}
|
||||
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal 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`);
|
||||
}
|
||||
12
frontend/athena/app/api/backups/create/route.ts
Normal file
12
frontend/athena/app/api/backups/create/route.ts
Normal 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");
|
||||
}
|
||||
7
frontend/athena/app/api/backups/route.ts
Normal file
7
frontend/athena/app/api/backups/route.ts
Normal 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");
|
||||
}
|
||||
415
frontend/athena/app/backups/page.tsx
Normal file
415
frontend/athena/app/backups/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue