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 && (
+
+ )}
+
+ {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 (
+
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,
+ },
+ )}
+ >
+
+ {date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
+
+
+ {date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
+
+
+
+
+ {isLatest && (
+ Latest
+ )}
+
+ );
+ })}
+
+
+
+
+
+ {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 (
-
- );
- }
-
- if (!schedule) {
- return (
-
-
- Not found
-
- Back to backups
+
+
+
+
+ Update schedule
-
-
- );
- }
-
- if (!isEditMode) {
- return (
-
+ setIsEditMode(false)}>
+ Cancel
+
+
+
);
}
return (
-
-
-
-
- Update schedule
-
- setIsEditMode(false)}>
- Cancel
-
-
+
+
+
+
+
+
);
}