Files
rustfs-console/components/object/versions.tsx
T

316 lines
10 KiB
TypeScript

"use client"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { RiFileCopyLine, RiEyeLine, RiDownloadCloud2Line, RiDeleteBin5Line, RiLoopLeftLine } from "@remixicon/react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { DataTable } from "@/components/data-table/data-table"
import { useDataTable } from "@/hooks/use-data-table"
import { useObject } from "@/hooks/use-object"
import { usePermissions } from "@/hooks/use-permissions"
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
import { copyToClipboard } from "@/lib/clipboard"
import { exportFile } from "@/lib/export-file"
import { getContentType } from "@/lib/mime-types"
import { formatBytes, formatDateTime } from "@/lib/functions"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { useS3 } from "@/contexts/s3-context"
import type { ColumnDef } from "@tanstack/react-table"
interface VersionRow {
VersionId?: string
LastModified?: Date
Size?: number
Key?: string
IsLatest?: boolean
}
interface ObjectVersionsProps {
bucketName: string
objectKey: string
visible: boolean
onClose: () => void
onPreview: (versionId: string) => void
onRefreshParent: () => void
}
export function ObjectVersions({
bucketName,
objectKey,
visible,
onClose,
onPreview,
onRefreshParent,
}: ObjectVersionsProps) {
const { t } = useTranslation()
const message = useMessage()
const dialog = useDialog()
const { listObjectVersions, deleteObject, restoreObjectVersion } = useObject(bucketName)
const { canCapability } = usePermissions()
const client = useS3()
const [versions, setVersions] = React.useState<VersionRow[]>([])
const [loading, setLoading] = React.useState(false)
const [deletingVersionId, setDeletingVersionId] = React.useState<string | null>(null)
const fetchVersions = React.useCallback(async () => {
if (!objectKey) return
setLoading(true)
try {
const response = await listObjectVersions(objectKey)
setVersions((response as { Versions?: VersionRow[] }).Versions ?? [])
} catch {
message.error(t("Failed to fetch versions"))
setVersions([])
} finally {
setLoading(false)
}
}, [objectKey, listObjectVersions, message, t])
React.useEffect(() => {
if (visible) fetchVersions()
}, [visible, fetchVersions])
const copyVersionId = React.useCallback(
async (versionId: string) => {
if (!versionId) return
try {
await copyToClipboard(versionId)
message.success(t("Copy Success"))
} catch {
message.error(t("Copy Failed"))
}
},
[message, t],
)
const getSignedUrlWithVersion = React.useCallback(
async (key: string, versionId: string, expiresIn = 3600) => {
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key,
VersionId: versionId === "00000000-0000-0000-0000-000000000000" ? undefined : versionId,
})
return getSignedUrl(client, command, { expiresIn })
},
[bucketName, client],
)
const downloadVersion = React.useCallback(
async (row: VersionRow) => {
const versionId = row.VersionId ?? ""
try {
const url = await getSignedUrlWithVersion(objectKey, versionId)
const response = await fetch(url)
const filename = objectKey.split("/").pop() ?? ""
const headers: Record<string, string> = {
"content-type": getContentType(response.headers, filename),
filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "",
}
const blob = await response.blob()
exportFile({ headers, data: blob }, filename)
} catch (err) {
message.error((err as Error)?.message ?? t("Download Failed"))
}
},
[getSignedUrlWithVersion, objectKey, message, t],
)
const deleteVersion = React.useCallback(
async (row: VersionRow) => {
const versionId = row.VersionId
if (!versionId) return
setDeletingVersionId(versionId)
try {
await deleteObject(objectKey, versionId)
message.success(t("Delete Success"))
await fetchVersions()
onRefreshParent()
} catch (err) {
message.error((err as Error)?.message ?? t("Delete Failed"))
} finally {
setDeletingVersionId(null)
}
},
[deleteObject, objectKey, message, t, fetchVersions, onRefreshParent],
)
const restoreVersion = React.useCallback(
(row: VersionRow) => {
const versionId = row.VersionId
if (!versionId || row.IsLatest) return
dialog.warning({
title: t("Restore Version"),
content: t("This will copy the selected historical version and make it the current object version."),
positiveText: t("Restore"),
negativeText: t("Cancel"),
onPositiveClick: async () => {
try {
await restoreObjectVersion(objectKey, versionId)
message.success(t("Restore Success"))
await fetchVersions()
onRefreshParent()
} catch (err) {
message.error((err as Error)?.message ?? t("Restore Failed"))
return false
}
},
})
},
[dialog, fetchVersions, message, objectKey, onRefreshParent, restoreObjectVersion, t],
)
const versionStats = React.useMemo(
() => ({
count: versions.length,
totalSize: versions.reduce((total, row) => total + (typeof row.Size === "number" ? row.Size : 0), 0),
}),
[versions],
)
const columns: ColumnDef<VersionRow>[] = React.useMemo(
() => [
{
id: "versionId",
accessorKey: "VersionId",
enableSorting: false,
header: () => t("VersionId"),
cell: ({ row }) => {
const versionId = row.original.VersionId ?? ""
return (
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate font-mono text-sm" title={versionId}>
{versionId}
</span>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
onClick={() => copyVersionId(versionId)}
title={t("Copy")}
aria-label={t("Copy")}
>
<RiFileCopyLine className="size-3" aria-hidden />
</Button>
{row.original.IsLatest ? (
<Badge variant="secondary" className="shrink-0">
{t("Current")}
</Badge>
) : null}
</div>
)
},
meta: { minWidth: 300 },
},
{
id: "lastModified",
accessorFn: (row) => (row.LastModified ? new Date(row.LastModified).getTime() : 0),
header: () => t("LastModified"),
cell: ({ row }) => (row.original.LastModified ? formatDateTime(row.original.LastModified) : ""),
meta: { width: 200 },
},
{
id: "size",
accessorFn: (row) => row.Size ?? 0,
header: () => t("Size"),
cell: ({ row }) => (typeof row.original.Size === "number" ? formatBytes(row.original.Size) : ""),
meta: { width: 140 },
},
{
id: "actions",
enableSorting: false,
header: () => t("Action"),
cell: ({ row }) => {
const objectContext = {
bucket: bucketName,
objectKey,
}
return (
<div className="flex flex-wrap gap-2">
{canCapability("objects.version.view", objectContext) ? (
<Button variant="outline" size="sm" onClick={() => onPreview(row.original.VersionId ?? "")}>
<RiEyeLine className="size-4" aria-hidden />
{t("Preview")}
</Button>
) : null}
{!row.original.IsLatest && canCapability("objects.version.restore", objectContext) ? (
<Button variant="outline" size="sm" onClick={() => restoreVersion(row.original)}>
<RiLoopLeftLine className="size-4" aria-hidden />
{t("Restore")}
</Button>
) : null}
{canCapability("objects.download", objectContext) ? (
<Button variant="outline" size="sm" onClick={() => downloadVersion(row.original)}>
<RiDownloadCloud2Line className="size-4" aria-hidden />
{t("Download")}
</Button>
) : null}
{canCapability("objects.delete", objectContext) ? (
<Button
variant="destructive"
size="sm"
disabled={deletingVersionId === row.original.VersionId}
onClick={() => deleteVersion(row.original)}
>
{deletingVersionId === row.original.VersionId ? (
<Spinner className="size-4" aria-hidden />
) : (
<RiDeleteBin5Line className="size-4" aria-hidden />
)}
{t("Delete")}
</Button>
) : null}
</div>
)
},
meta: { minWidth: 360 },
},
],
[
t,
onPreview,
copyVersionId,
restoreVersion,
downloadVersion,
deleteVersion,
canCapability,
bucketName,
objectKey,
deletingVersionId,
],
)
const { table } = useDataTable<VersionRow>({
data: versions,
columns,
disablePagination: true,
})
return (
<Dialog open={visible} onOpenChange={(open) => !open && onClose()} disablePointerDismissal>
<DialogContent className="max-h-[80vh] w-[calc(100vw-2rem)] overflow-y-auto overflow-x-hidden sm:max-w-5xl 2xl:w-[80vw] 2xl:max-w-[80vw]">
<DialogHeader>
<DialogTitle>{t("Object Versions")}</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
<span>
{t("Versions")}: <span className="font-medium text-foreground">{versionStats.count}</span>
</span>
<span>
{t("Total")} {t("Size")}:{" "}
<span className="font-medium text-foreground">{formatBytes(versionStats.totalSize)}</span>
</span>
</div>
<DataTable table={table} isLoading={loading} emptyTitle={t("No Versions")} />
</DialogContent>
</Dialog>
)
}