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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const config = {
|
|||
"/roles/:path*",
|
||||
"/customers/:path*",
|
||||
"/repairs/:path*",
|
||||
"/backups/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
49
frontend/athena/types/backup.ts
Normal file
49
frontend/athena/types/backup.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue