From 11ca80a929950da9d188271f4f8b5f69e5d08f06 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Mon, 3 Nov 2025 22:03:55 +0100 Subject: [PATCH] feat: backup details snapshots timeline & file explorer --- apps/client/app/components/file-tree.tsx | 2 +- .../backups/components/schedule-summary.tsx | 98 ++------------ .../components/snapshot-file-browser.tsx | 96 ++++++++++++++ .../backups/components/snapshot-timeline.tsx | 84 ++++++++++++ .../modules/backups/routes/backup-details.tsx | 125 ++++++++++-------- 5 files changed, 259 insertions(+), 146 deletions(-) create mode 100644 apps/client/app/modules/backups/components/snapshot-file-browser.tsx create mode 100644 apps/client/app/modules/backups/components/snapshot-timeline.tsx diff --git a/apps/client/app/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx index 6c50538..b09d876 100644 --- a/apps/client/app/components/file-tree.tsx +++ b/apps/client/app/components/file-tree.tsx @@ -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"; diff --git a/apps/client/app/modules/backups/components/schedule-summary.tsx b/apps/client/app/modules/backups/components/schedule-summary.tsx index d0899e9..45b24f4 100644 --- a/apps/client/app/modules/backups/components/schedule-summary.tsx +++ b/apps/client/app/modules/backups/components/schedule-summary.tsx @@ -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) => {
Backup schedule - Automated backup configuration for volume {volume.name} + Automated backup configuration for volume{" "} + {schedule.volume.name}
@@ -102,7 +78,6 @@ export const ScheduleSummary = (props: Props) => { variant="outline" size="sm" onClick={() => setShowDeleteConfirm(true)} - disabled={isDeleting} className="text-destructive hover:text-destructive" > @@ -117,7 +92,7 @@ export const ScheduleSummary = (props: Props) => {

Repository

-

{repository.name}

+

{schedule.repository.name}

Last backup

@@ -148,8 +123,8 @@ export const ScheduleSummary = (props: Props) => { Delete backup schedule? - Are you sure you want to delete this backup schedule for {volume.name}? This action - cannot be undone. Existing snapshots will not be deleted. + Are you sure you want to delete this backup schedule for {schedule.volume.name}? This + action cannot be undone. Existing snapshots will not be deleted.
@@ -163,59 +138,6 @@ export const ScheduleSummary = (props: Props) => {
- - - -
-
- Snapshots - - Backup snapshots for this volume. Total: {snapshots?.snapshots.length} - -
-
-
- {loadingSnapshots && !snapshots ? ( - -

Loading snapshots...

-
- ) : !snapshots ? ( - -
-
-
-
-
- -
-
-
-

No snapshots yet

-

- Snapshots are point-in-time backups of your data. The next scheduled backup will create the first - snapshot. -

-
- - ) : ( - <> - -
- {`Showing ${snapshots.snapshots.length} of ${snapshots.snapshots.length}`} - - Total size:  - - sum + s.size, 0)} - base={1024} - maximumFractionDigits={1} - /> - - -
- - )} -
); }; diff --git a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx new file mode 100644 index 0000000..cac9816 --- /dev/null +++ b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx @@ -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>(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 ( + + +
+
+
+
+
+ +
+
+
+

No snapshots

+

+ Snapshots are point-in-time backups of your data. The first snapshot will appear here after the next + scheduled backup. +

+
+ + + ); + } + + return ( +
+ + + File Browser + {`Viewing snapshot from ${new Date(selectedSnapshot?.time ?? 0).toLocaleString()}`} + + + {filesLoading && ( +
+

Loading files...

+
+ )} + + {filesData?.files.length === 0 && ( +
+ +

No files in this snapshot

+
+ )} + + {filesData?.files.length && ( +
+ +
+ )} +
+
+
+ ); +}; diff --git a/apps/client/app/modules/backups/components/snapshot-timeline.tsx b/apps/client/app/modules/backups/components/snapshot-timeline.tsx new file mode 100644 index 0000000..1b9104f --- /dev/null +++ b/apps/client/app/modules/backups/components/snapshot-timeline.tsx @@ -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 ( +
+
+

No snapshots available

+
+
+ ); + } + + return ( + +
+
+
+
+ {sortedSnapshots.map((snapshot, index) => { + const date = new Date(snapshot.time); + const isSelected = snapshotId === snapshot.short_id; + const isLatest = index === sortedSnapshots.length - 1; + + return ( + + ); + })} +
+
+
+ +
+ {sortedSnapshots.length} snapshots + + {new Date(sortedSnapshots[0].time).toLocaleDateString()} -{" "} + {new Date(sortedSnapshots[sortedSnapshots.length - 1].time).toLocaleDateString()} + +
+
+
+ ); +}; diff --git a/apps/client/app/modules/backups/routes/backup-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx index 6b442e2..0ecb887 100644 --- a/apps/client/app/modules/backups/routes/backup-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -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(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 ( -
- - -

Loading...

-
-
-
- ); - } - - if (!schedule) { - return ( - - -

Not found

- -
-
- ); - } - - if (!isEditMode) { - return ( - + +
+
); } return ( -
- -
- - -
+
+ + + + +
); }