feat: backup details snapshots timeline & file explorer

This commit is contained in:
Nicolas Meienberger
2025-11-03 22:03:55 +01:00
parent f2643436b0
commit 11ca80a929
5 changed files with 259 additions and 146 deletions

View File

@@ -15,7 +15,7 @@ import { Checkbox } from "~/components/ui/checkbox";
const NODE_PADDING_LEFT = 12;
interface FileEntry {
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";

View File

@@ -1,10 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Database, Pencil, Play, Trash2 } from "lucide-react";
import { Pencil, Play, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
import { ByteSize } from "~/components/bytes-size";
import { OnOff } from "~/components/onoff";
import { SnapshotsTable } from "~/components/snapshots-table";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
@@ -16,41 +12,20 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import type { BackupSchedule, Repository, Volume } from "~/lib/types";
import type { BackupSchedule } from "~/lib/types";
type Props = {
volume: Volume;
schedule: BackupSchedule;
repository: Repository;
handleToggleEnabled: (enabled: boolean) => void;
handleRunBackupNow: () => void;
handleDeleteSchedule: () => void;
setIsEditMode: (isEdit: boolean) => void;
isDeleting?: boolean;
};
export const ScheduleSummary = (props: Props) => {
const {
volume,
schedule,
repository,
handleToggleEnabled,
handleRunBackupNow,
handleDeleteSchedule,
setIsEditMode,
isDeleting,
} = props;
const { schedule, handleToggleEnabled, handleRunBackupNow, handleDeleteSchedule, setIsEditMode } = props;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
...listSnapshotsOptions({
path: { name: repository.name },
query: { volumeId: volume.id.toString() },
}),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const summary = useMemo(() => {
const scheduleLabel = schedule ? schedule.cronExpression : "-";
@@ -66,12 +41,12 @@ export const ScheduleSummary = (props: Props) => {
}
return {
vol: volume.name,
vol: schedule.volume.name,
scheduleLabel,
repositoryLabel: schedule.repositoryId || "No repository selected",
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
};
}, [schedule, volume.name]);
}, [schedule, schedule.volume.name]);
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
@@ -85,7 +60,8 @@ export const ScheduleSummary = (props: Props) => {
<div>
<CardTitle>Backup schedule</CardTitle>
<CardDescription>
Automated backup configuration for volume <strong className="text-strong-accent">{volume.name}</strong>
Automated backup configuration for volume{" "}
<strong className="text-strong-accent">{schedule.volume.name}</strong>
</CardDescription>
</div>
<div className="flex items-center gap-2">
@@ -102,7 +78,6 @@ export const ScheduleSummary = (props: Props) => {
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -117,7 +92,7 @@ export const ScheduleSummary = (props: Props) => {
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Repository</p>
<p className="font-medium">{repository.name}</p>
<p className="font-medium">{schedule.repository.name}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
@@ -148,8 +123,8 @@ export const ScheduleSummary = (props: Props) => {
<AlertDialogHeader>
<AlertDialogTitle>Delete backup schedule?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this backup schedule for <strong>{volume.name}</strong>? This action
cannot be undone. Existing snapshots will not be deleted.
Are you sure you want to delete this backup schedule for <strong>{schedule.volume.name}</strong>? This
action cannot be undone. Existing snapshots will not be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
@@ -163,59 +138,6 @@ export const ScheduleSummary = (props: Props) => {
</div>
</AlertDialogContent>
</AlertDialog>
<Card className="p-0 gap-0">
<CardHeader className="p-4 bg-card-header">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-4 justify-between">
<div className="flex-1">
<CardTitle>Snapshots</CardTitle>
<CardDescription className="mt-1">
Backup snapshots for this volume. Total: {snapshots?.snapshots.length}
</CardDescription>
</div>
</div>
</CardHeader>
{loadingSnapshots && !snapshots ? (
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading snapshots...</p>
</CardContent>
) : !snapshots ? (
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3">
<h3 className="text-2xl font-semibold text-foreground">No snapshots yet</h3>
<p className="text-muted-foreground text-sm">
Snapshots are point-in-time backups of your data. The next scheduled backup will create the first
snapshot.
</p>
</div>
</CardContent>
) : (
<>
<SnapshotsTable snapshots={snapshots.snapshots} repositoryName={repository.name} />
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>{`Showing ${snapshots.snapshots.length} of ${snapshots.snapshots.length}`}</span>
<span>
Total size:&nbsp;
<span className="text-strong-accent font-medium">
<ByteSize
bytes={snapshots.snapshots.reduce((sum, s) => sum + s.size, 0)}
base={1024}
maximumFractionDigits={1}
/>
</span>
</span>
</div>
</>
)}
</Card>
</div>
);
};

View File

@@ -0,0 +1,96 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileIcon, Folder } from "lucide-react";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree, type FileEntry } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
interface Props {
snapshots: ListSnapshotsResponse;
repositoryName: string;
snapshotId: string;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshots, repositoryName, snapshotId } = props;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set([""]));
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path: "/" },
}),
});
const handleFolderExpand = (folderPath: string) => {
const newFolders = new Set(expandedFolders);
newFolders.add(folderPath);
setExpandedFolders(newFolders);
};
const selectedSnapshot = useMemo(() => {
return snapshots.find((s) => s.short_id === snapshotId);
}, [snapshotId, snapshots]);
if (snapshots.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Folder className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3">
<h3 className="text-2xl font-semibold text-foreground">No snapshots</h3>
<p className="text-muted-foreground text-sm">
Snapshots are point-in-time backups of your data. The first snapshot will appear here after the next
scheduled backup.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(selectedSnapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{filesData?.files.length === 0 && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{filesData?.files.length && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={filesData?.files as FileEntry[]}
onFolderExpand={handleFolderExpand}
expandedFolders={expandedFolders}
className="px-2 py-2"
/>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { useMemo } from "react";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { cn } from "~/lib/utils";
import { Card } from "~/components/ui/card";
import { ByteSize } from "~/components/bytes-size";
interface Props {
snapshots: ListSnapshotsResponse;
snapshotId: string;
onSnapshotSelect: (snapshotId: string) => void;
}
export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, onSnapshotSelect } = props;
const sortedSnapshots = useMemo(() => {
return [...snapshots].sort((a, b) => a.time - b.time);
}, [snapshots]);
if (snapshots.length === 0) {
return (
<div className="w-full bg-card border-t border-border py-4 px-4">
<div className="flex items-center justify-center h-24">
<p className="text-muted-foreground">No snapshots available</p>
</div>
</div>
);
}
return (
<Card className="p-0 pt-2">
<div className="w-full bg-card">
<div className="relative flex items-center">
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2">
{sortedSnapshots.map((snapshot, index) => {
const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id;
const isLatest = index === sortedSnapshots.length - 1;
return (
<button
type="button"
key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)}
className={cn(
"shrink-0 flex flex-col items-center gap-2 p-3 rounded-lg transition-all",
"border-2 cursor-pointer",
{
"border-primary bg-primary/10 shadow-md": isSelected,
"border-border hover:border-accent hover:bg-accent/5": !isSelected,
},
)}
>
<div className="text-xs font-semibold text-foreground">
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
<div className="text-xs text-muted-foreground">
{date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} />
</div>
{isLatest && (
<div className="text-xs font-semibold text-primary px-2 py-0.5 bg-primary/20 rounded">Latest</div>
)}
</button>
);
})}
</div>
</div>
</div>
<div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between">
<span>{sortedSnapshots.length} snapshots</span>
<span>
{new Date(sortedSnapshots[0].time).toLocaleDateString()} -{" "}
{new Date(sortedSnapshots[sortedSnapshots.length - 1].time).toLocaleDateString()}
</span>
</div>
</div>
</Card>
);
};

View File

@@ -1,33 +1,58 @@
import { useId, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Link, useParams, useNavigate } from "react-router";
import { redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import {
upsertBackupScheduleMutation,
getBackupScheduleOptions,
runBackupNowMutation,
deleteBackupScheduleMutation,
listSnapshotsOptions,
} from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import { ScheduleSummary } from "../components/schedule-summary";
import { getBackupSchedule, listSnapshots } from "~/api-client";
import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
export default function ScheduleDetailsPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
const { data: schedule, isLoading: loadingSchedule } = useQuery({
...getBackupScheduleOptions({
path: { scheduleId: id || "" },
}),
if (!data) return redirect("/backups");
const snapshots = await listSnapshots({
path: { name: data.repository.name },
query: { volumeId: data.volumeId.toString() },
});
console.log("Schedule Details:", schedule);
if (snapshots.data) return { snapshots: snapshots.data, schedule: data };
return { snapshots: [], schedule: data };
};
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>(loaderData.snapshots.at(-1)?.short_id ?? "");
const { data: schedule } = useQuery({
...getBackupScheduleOptions({
path: { scheduleId: params.id },
}),
initialData: loaderData.schedule,
});
const { data: snapshots } = useQuery({
...listSnapshotsOptions({
path: { name: schedule.repository.name },
query: { volumeId: schedule.volumeId.toString() },
}),
initialData: loaderData.snapshots,
});
const upsertSchedule = useMutation({
...upsertBackupScheduleMutation(),
@@ -125,57 +150,43 @@ export default function ScheduleDetailsPage() {
deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } });
};
if (loadingSchedule && !schedule) {
if (isEditMode) {
return (
<div className="container mx-auto p-4 sm:p-8">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
);
}
if (!schedule) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Not found</p>
<Button className="mt-4">
<Link to="/backups">Back to backups</Link>
<div>
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={upsertSchedule.isPending}>
Update schedule
</Button>
</CardContent>
</Card>
);
}
if (!isEditMode) {
return (
<ScheduleSummary
handleToggleEnabled={handleToggleEnabled}
handleRunBackupNow={handleRunBackupNow}
handleDeleteSchedule={handleDeleteSchedule}
repository={schedule.repository}
setIsEditMode={setIsEditMode}
schedule={schedule}
volume={schedule.volume}
isDeleting={deleteSchedule.isPending}
/>
<Button variant="outline" onClick={() => setIsEditMode(false)}>
Cancel
</Button>
</div>
</div>
);
}
return (
<div>
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={upsertSchedule.isPending}>
Update schedule
</Button>
<Button variant="outline" onClick={() => setIsEditMode(false)}>
Cancel
</Button>
</div>
<div className="flex flex-col gap-6">
<ScheduleSummary
handleToggleEnabled={handleToggleEnabled}
handleRunBackupNow={handleRunBackupNow}
handleDeleteSchedule={handleDeleteSchedule}
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
<SnapshotTimeline
snapshots={snapshots}
snapshotId={selectedSnapshotId}
onSnapshotSelect={setSelectedSnapshotId}
/>
<SnapshotFileBrowser
snapshots={snapshots}
repositoryName={schedule.repository.name}
snapshotId={selectedSnapshotId}
/>
</div>
);
}