mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(repositories): healthchecks and doctor command
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
getSnapshotDetails,
|
||||
listSnapshotFiles,
|
||||
restoreSnapshot,
|
||||
doctorRepository,
|
||||
listBackupSchedules,
|
||||
createBackupSchedule,
|
||||
deleteBackupSchedule,
|
||||
@@ -76,6 +77,8 @@ import type {
|
||||
ListSnapshotFilesData,
|
||||
RestoreSnapshotData,
|
||||
RestoreSnapshotResponse,
|
||||
DoctorRepositoryData,
|
||||
DoctorRepositoryResponse,
|
||||
ListBackupSchedulesData,
|
||||
CreateBackupScheduleData,
|
||||
CreateBackupScheduleResponse,
|
||||
@@ -844,6 +847,46 @@ export const restoreSnapshotMutation = (
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const doctorRepositoryQueryKey = (options: Options<DoctorRepositoryData>) =>
|
||||
createQueryKey("doctorRepository", options);
|
||||
|
||||
/**
|
||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||
*/
|
||||
export const doctorRepositoryOptions = (options: Options<DoctorRepositoryData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await doctorRepository({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: doctorRepositoryQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||
*/
|
||||
export const doctorRepositoryMutation = (
|
||||
options?: Partial<Options<DoctorRepositoryData>>,
|
||||
): UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> => {
|
||||
const mutationOptions: UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await doctorRepository({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||
createQueryKey("listBackupSchedules", options);
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ import type {
|
||||
ListSnapshotFilesResponses,
|
||||
RestoreSnapshotData,
|
||||
RestoreSnapshotResponses,
|
||||
DoctorRepositoryData,
|
||||
DoctorRepositoryResponses,
|
||||
ListBackupSchedulesData,
|
||||
ListBackupSchedulesResponses,
|
||||
CreateBackupScheduleData,
|
||||
@@ -408,6 +410,18 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||
*/
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DoctorRepositoryData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/repositories/{name}/doctor",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
|
||||
@@ -156,6 +156,7 @@ export type ListVolumesResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -166,6 +167,7 @@ export type ListVolumesResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -201,6 +203,7 @@ export type CreateVolumeData = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -211,6 +214,7 @@ export type CreateVolumeData = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -244,6 +248,7 @@ export type CreateVolumeResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -254,6 +259,7 @@ export type CreateVolumeResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -289,6 +295,7 @@ export type TestConnectionData = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -299,6 +306,7 @@ export type TestConnectionData = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -385,6 +393,7 @@ export type GetVolumeResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -395,6 +404,7 @@ export type GetVolumeResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -432,6 +442,7 @@ export type UpdateVolumeData = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -442,6 +453,7 @@ export type UpdateVolumeData = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -483,6 +495,7 @@ export type UpdateVolumeResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -493,6 +506,7 @@ export type UpdateVolumeResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -904,6 +918,33 @@ export type RestoreSnapshotResponses = {
|
||||
|
||||
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
|
||||
|
||||
export type DoctorRepositoryData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/api/v1/repositories/{name}/doctor";
|
||||
};
|
||||
|
||||
export type DoctorRepositoryResponses = {
|
||||
/**
|
||||
* Doctor operation completed
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
steps: Array<{
|
||||
step: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
|
||||
|
||||
export type ListBackupSchedulesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
@@ -972,6 +1013,7 @@ export type ListBackupSchedulesResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -982,6 +1024,7 @@ export type ListBackupSchedulesResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -1153,6 +1196,7 @@ export type GetBackupScheduleResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -1163,6 +1207,7 @@ export type GetBackupScheduleResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
@@ -1315,6 +1360,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
@@ -1325,6 +1371,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||
port?: number;
|
||||
domain?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
| {
|
||||
backend: "webdav";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
const { theme = "dark" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
|
||||
@@ -8,11 +8,12 @@ interface Props {
|
||||
snapshots: ListSnapshotsResponse;
|
||||
snapshotId?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onSnapshotSelect: (snapshotId: string) => void;
|
||||
}
|
||||
|
||||
export const SnapshotTimeline = (props: Props) => {
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect } = props;
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshotId && snapshots.length > 0) {
|
||||
@@ -20,6 +21,16 @@ export const SnapshotTimeline = (props: Props) => {
|
||||
}
|
||||
}, [snapshotId, snapshots, onSnapshotSelect]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24 p-4 text-center">
|
||||
<p className="text-destructive">Error loading snapshots: {error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -40,7 +40,11 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
const { data: snapshots, isLoading } = useQuery({
|
||||
const {
|
||||
data: snapshots,
|
||||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({
|
||||
path: { name: schedule.repository.name },
|
||||
query: { backupId: schedule.id.toString() },
|
||||
@@ -174,6 +178,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
loading={isLoading}
|
||||
snapshots={snapshots ?? []}
|
||||
snapshotId={selectedSnapshot?.short_id}
|
||||
error={failureReason?.message}
|
||||
onSnapshotSelect={setSelectedSnapshotId}
|
||||
/>
|
||||
{selectedSnapshot && (
|
||||
|
||||
@@ -1,62 +1,177 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Repository } from "~/lib/types";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||
|
||||
const doctorMutation = useMutation({
|
||||
...doctorRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setShowDoctorResults(true);
|
||||
|
||||
if (data.success) {
|
||||
toast.success("Repository doctor completed successfully");
|
||||
} else {
|
||||
toast.warning("Doctor completed with some issues", {
|
||||
description: "Check the details for more information",
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to run doctor", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDoctor = () => {
|
||||
doctorMutation.mutate({ path: { name: repository.name } });
|
||||
};
|
||||
|
||||
const getStepLabel = (step: string) => {
|
||||
switch (step) {
|
||||
case "unlock":
|
||||
return "Unlock Repository";
|
||||
case "check":
|
||||
return "Check Repository";
|
||||
case "repair_index":
|
||||
return "Repair Index";
|
||||
case "recheck":
|
||||
return "Re-check Repository";
|
||||
default:
|
||||
return step;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||
<p className="mt-1 text-sm">{repository.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||
<p className="mt-1 text-sm">{repository.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repository.lastError && (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||
<p className="mt-1 text-sm">{repository.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||
<p className="mt-1 text-sm">{repository.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||
{repository.lastError && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||
<Button onClick={handleDoctor} disabled={doctorMutation.isPending} variant={"outline"} size="sm">
|
||||
{doctorMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running Doctor...
|
||||
</>
|
||||
) : (
|
||||
"Run Doctor"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{doctorMutation.data?.message || "Repository doctor operation completed"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{doctorMutation.data && (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{doctorMutation.data.steps.map((step) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className={cn("border rounded-md p-3", {
|
||||
"bg-green-500/10 border-green-500/20": step.success,
|
||||
"bg-yellow-500/10 border-yellow-500/20": !step.success,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
|
||||
<span
|
||||
className={cn("text-xs px-2 py-1 rounded", {
|
||||
"bg-green-500/20 text-green-500": step.success,
|
||||
"bg-yellow-500/20 text-yellow-500": !step.success,
|
||||
})}
|
||||
>
|
||||
{step.success ? "Success" : "Warning"}
|
||||
</span>
|
||||
</div>
|
||||
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,16 +67,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching && !data.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading snapshots</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (failureReason) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -89,6 +79,16 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching && !data.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading snapshots</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user