mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: backup details snapshots timeline & file explorer
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user