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

636 lines
21 KiB
TypeScript

"use client"
import * as React from "react"
import Link from "next/link"
import { useTranslation } from "react-i18next"
import {
RiFileAddLine,
RiDeleteBin5Line,
RiDownloadCloud2Line,
RiRefreshLine,
RiFolderLine,
RiFileLine,
RiArrowLeftSLine,
RiArrowRightSLine,
RiEyeLine,
} from "@remixicon/react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { SearchInput } from "@/components/search-input"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table/data-table"
import { useDataTable } from "@/hooks/use-data-table"
import { useObject } from "@/hooks/use-object"
import { useBucket } from "@/hooks/use-bucket"
import { useLocalStorage } from "@/hooks/use-local-storage"
import { usePermissions } from "@/hooks/use-permissions"
import { useMessage } from "@/lib/feedback/message"
import { exportFile } from "@/lib/export-file"
import { getContentType } from "@/lib/mime-types"
import { formatBytes } from "@/lib/functions"
import { buildBucketPath } from "@/lib/bucket-path"
import { TaskStatsButton } from "@/components/tasks/stats-button"
import { useAddDeleteKeys, useAddDeleteFolder, useTasks } from "@/contexts/task-context"
import type { ColumnDef } from "@tanstack/react-table"
import dayjs from "dayjs"
import { saveAs } from "file-saver"
import JSZip from "jszip"
interface ObjectRow {
Key: string
type: "prefix" | "object"
Size: number
LastModified: string
}
interface ObjectListProps {
bucket: string
path: string
onOpenInfo: (bucket: string, key: string) => void
onUploadClick: () => void
canUpload?: boolean
pageSize?: number
onRefresh?: () => void
refreshTrigger?: number
onPreview: (params: { key?: string; data?: Record<string, unknown> }) => void
}
export function ObjectList({
bucket,
path,
onOpenInfo,
onUploadClick,
canUpload = false,
pageSize = 25,
onRefresh,
refreshTrigger = 0,
onPreview,
}: ObjectListProps) {
const { t } = useTranslation()
const message = useMessage()
const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket)
const { getBucketVersioning } = useBucket()
const { canCapability } = usePermissions()
const addDeleteKeys = useAddDeleteKeys()
const addDeleteFolder = useAddDeleteFolder()
const tasks = useTasks()
const [searchTerm, setSearchTerm] = React.useState("")
const [showDeleted, setShowDeleted] = useLocalStorage("object-list-show-deleted", false)
const [loading, setLoading] = React.useState(false)
const [data, setData] = React.useState<ObjectRow[]>([])
const [continuationToken, setContinuationToken] = React.useState<string | undefined>()
const [tokenHistory, setTokenHistory] = React.useState<string[]>([])
const [nextToken, setNextToken] = React.useState<string | undefined>()
const [bucketVersioningEnabled, setBucketVersioningEnabled] = React.useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [deleteDialogKeys, setDeleteDialogKeys] = React.useState<string[]>([])
const [deleteAllVersions, setDeleteAllVersions] = React.useState(false)
const prefix = decodeURIComponent(path)
const canBulkDelete = canCapability("objects.bulkDelete", { bucket, prefix })
const canBulkDownload = canCapability("objects.download", { bucket, prefix })
const bucketPath = React.useCallback((p?: string | string[]) => buildBucketPath(bucket, p), [bucket])
const fetchObjects = React.useCallback(
async (tokenOverride?: string) => {
const token = tokenOverride !== undefined ? tokenOverride : continuationToken
setLoading(true)
try {
const response = await listObject(bucket, prefix || undefined, pageSize, token, {
includeDeleted: showDeleted,
})
const r = response as {
CommonPrefixes?: Array<{ Prefix?: string }>
Contents?: Array<{ Key?: string; Size?: number; LastModified?: Date }>
NextContinuationToken?: string
}
setNextToken(r.NextContinuationToken)
const prefixItems: ObjectRow[] = (r.CommonPrefixes ?? []).map((item) => ({
Key: item.Prefix ?? "",
type: "prefix" as const,
Size: 0,
LastModified: "",
}))
const objectItems: ObjectRow[] = (r.Contents ?? []).map((item) => ({
Key: item.Key ?? "",
type: "object" as const,
Size: item.Size ?? 0,
LastModified: item.LastModified ? item.LastModified.toISOString() : "",
}))
setData([...prefixItems, ...objectItems])
} finally {
setTimeout(() => setLoading(false), 200)
}
},
[bucket, prefix, pageSize, continuationToken, showDeleted, listObject],
)
const prevRefreshTriggerRef = React.useRef(refreshTrigger)
React.useEffect(() => {
const isRefresh = prevRefreshTriggerRef.current !== refreshTrigger
prevRefreshTriggerRef.current = refreshTrigger
if (isRefresh) {
setContinuationToken(undefined)
setTokenHistory([])
void fetchObjects(undefined)
} else {
void fetchObjects()
}
}, [bucket, prefix, pageSize, continuationToken, showDeleted, refreshTrigger, fetchObjects])
const prevDeleteTaskIdsRef = React.useRef<Set<string>>(new Set())
React.useEffect(() => {
const currentDeleteTasks = tasks.filter((t) => t.kind === "delete" || t.kind === "delete-folder")
const completedDeleteTasks = currentDeleteTasks.filter((t) => t.status === "completed")
const newlyCompleted = completedDeleteTasks.filter((t) => !prevDeleteTaskIdsRef.current.has(t.id))
if (newlyCompleted.length > 0) {
const anyActive = currentDeleteTasks.some((t) => ["pending", "running"].includes(t.status))
if (!anyActive) {
// No more active delete tasks, refresh the list
void fetchObjects()
}
}
prevDeleteTaskIdsRef.current = new Set(completedDeleteTasks.map((t) => t.id))
}, [tasks, fetchObjects])
React.useEffect(() => {
const loadBucketVersioningStatus = async () => {
try {
const resp = await getBucketVersioning(bucket)
setBucketVersioningEnabled((resp as { Status?: string })?.Status === "Enabled")
} catch {
setBucketVersioningEnabled(false)
}
}
loadBucketVersioningStatus()
}, [bucket, getBucketVersioning])
React.useEffect(() => {
setContinuationToken(undefined)
setTokenHistory([])
}, [showDeleted])
const displayKey = React.useCallback(
(key: string) => {
if (!prefix) return key
return key.startsWith(prefix) ? key.slice(prefix.length) : key
},
[prefix],
)
const filteredData = React.useMemo(() => {
const term = searchTerm.toLowerCase()
return data.filter((item) => {
if (term) {
const key = displayKey(item.Key).toLowerCase()
return key.includes(term)
}
return item.Key !== prefix
})
}, [data, searchTerm, prefix, displayKey])
const downloadFile = React.useCallback(
async (key: string) => {
if (!key) return
const loadingMsg = message.loading(t("Getting URL"), { duration: 0 })
try {
const url = await getSignedUrl(key)
const response = await fetch(url)
const filename = key.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"))
} finally {
loadingMsg.destroy()
}
},
[message, t, getSignedUrl],
)
const columns: ColumnDef<ObjectRow>[] = React.useMemo(
() => [
{
id: "object",
header: () => t("Object"),
accessorFn: (row) => displayKey(row.Key ?? ""),
cell: ({ row }) => {
const key = row.original.Key ?? ""
const display = displayKey(key) || "/"
if (row.original.type === "prefix") {
return (
<Link
href={bucketPath(row.original.Key)}
className="flex items-center gap-2 text-blue-500 hover:underline"
>
<RiFolderLine className="size-4" />
<span>{display}</span>
</Link>
)
}
return (
<button
type="button"
className="flex items-center gap-2 text-primary hover:underline text-left"
onClick={() => onOpenInfo(bucket, row.original.Key)}
>
<RiFileLine className="size-4" />
<span>{display}</span>
</button>
)
},
filterFn: (row, _columnId, filterValue) => {
if (!filterValue) return true
const key = displayKey(row.original.Key ?? "").toLowerCase()
return key.includes(String(filterValue).toLowerCase())
},
},
{
id: "size",
header: () => t("Size"),
accessorFn: (row) => (row.type === "object" ? row.Size : -1),
cell: ({ row }) => (row.original.type === "object" ? formatBytes(row.original.Size) : "-"),
},
{
id: "lastModified",
header: () => t("Last Modified"),
accessorFn: (row) => (row.type === "prefix" || !row.LastModified ? "" : new Date(row.LastModified).getTime()),
cell: ({ row }) =>
row.original.LastModified ? dayjs(row.original.LastModified).format("YYYY-MM-DD HH:mm:ss") : "-",
},
{
id: "actions",
header: () => t("Actions"),
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.type === "object" ? (
<>
{canCapability("objects.preview", { bucket, objectKey: row.original.Key }) ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onPreview({ key: row.original.Key })
}}
>
<RiEyeLine className="size-4" />
<span>{t("Preview")}</span>
</Button>
) : null}
{canCapability("objects.download", { bucket, objectKey: row.original.Key }) ? (
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
<RiDownloadCloud2Line className="size-4" />
<span>{t("Download")}</span>
</Button>
) : null}
</>
) : null}
{canCapability("objects.delete", { bucket, objectKey: row.original.Key }) ? (
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
],
[t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview],
)
const { table, selectedRowIds } = useDataTable<ObjectRow>({
data: filteredData,
columns,
getRowId: (row) => row.Key,
enableRowSelection: true,
manualPagination: true,
})
const checkedKeys = selectedRowIds
const computeRelativeKey = (key: string) => {
if (!key) return ""
const base = prefix || ""
return base && key.startsWith(base) ? key.slice(base.length) : key
}
const downloadMultiple = async () => {
if (!checkedKeys.length) {
message.warning(t("Please select at least one item"))
return
}
const selectedRows = data.filter((item) => checkedKeys.includes(item.Key))
if (!selectedRows.length) {
message.warning(t("No files to download"))
return
}
const collecting = message.loading(t("Collecting files"), { duration: 0 })
const allFiles: { key: string; relative: string }[] = []
try {
for (const item of selectedRows) {
if (item.type === "object") {
allFiles.push({
key: item.Key,
relative: computeRelativeKey(item.Key),
})
} else if (item.type === "prefix") {
await mapAllFiles(bucket, item.Key, (fileKey) => {
allFiles.push({
key: fileKey,
relative: computeRelativeKey(fileKey),
})
})
}
}
} catch (err) {
collecting.destroy()
message.error((err as Error)?.message ?? t("Download Failed"))
return
}
collecting.destroy()
if (!allFiles.length) {
message.warning(t("No files to download"))
return
}
const zip = new JSZip()
let finished = 0
const destroyRef: { current: (() => void) | null } = { current: null }
const updateProgress = () => {
if (destroyRef.current) destroyRef.current()
const handle = message.loading(`${t("Downloading files")} ${Math.round((finished / allFiles.length) * 100)}%`, {
duration: 0,
})
destroyRef.current = handle.destroy
}
updateProgress()
try {
await Promise.all(
allFiles.map(async ({ key, relative }) => {
const url = await getSignedUrl(key)
const response = await fetch(url)
const blob = await response.blob()
zip.file(relative || key, blob)
finished++
updateProgress()
}),
)
} catch (err) {
if (destroyRef.current) destroyRef.current()
message.error((err as Error)?.message ?? t("Download Failed"))
return
}
if (destroyRef.current) destroyRef.current()
const content = await zip.generateAsync({ type: "blob" })
saveAs(content, `${bucket ?? "download"}.zip`)
message.success(t("Download ready"))
}
const openDeleteDialog = (keys: string[]) => {
setDeleteDialogKeys(keys)
setDeleteAllVersions(false)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
const keys = [...deleteDialogKeys]
setDeleteDialogOpen(false)
if (!keys.length) return
if ((bucketVersioningEnabled && deleteAllVersions) || !bucketVersioningEnabled) {
await handleDeleteAllVersions(keys)
} else {
await handleDelete(keys)
}
}
const handleDelete = async (keys: string[]) => {
try {
const rowMap = new Map(data.map((item) => [item.Key, item]))
const objectKeys: string[] = []
const folderPrefixes: string[] = []
for (const key of keys) {
const row = rowMap.get(key)
if (row?.type === "prefix") {
folderPrefixes.push(key)
} else {
objectKeys.push(key)
}
}
if (objectKeys.length > 0) {
addDeleteKeys(objectKeys, bucket, undefined)
}
for (const prefix of folderPrefixes) {
addDeleteFolder(prefix, bucket)
}
message.success(t("Delete task created"))
table.resetRowSelection()
} catch (err) {
message.error((err as Error)?.message ?? t("Delete Failed"))
}
}
const handleDeleteAllVersions = async (keys: string[]) => {
try {
const rowMap = new Map(data.map((item) => [item.Key, item]))
const objectKeys: string[] = []
const folderPrefixes: string[] = []
for (const key of keys) {
const row = rowMap.get(key)
if (row?.type === "prefix") {
folderPrefixes.push(key)
} else {
objectKeys.push(key)
}
}
if (objectKeys.length > 0) {
addDeleteKeys(objectKeys, bucket, undefined, { forceDelete: true })
}
for (const prefix of folderPrefixes) {
addDeleteFolder(prefix, bucket, { forceDelete: true })
}
message.success(t("Delete task created"))
table.resetRowSelection()
} catch (err) {
message.error((err as Error)?.message ?? t("Delete Failed"))
}
}
const handleBatchDelete = () => {
if (!checkedKeys.length) {
message.warning(t("Please select at least one item"))
return
}
openDeleteDialog([...checkedKeys])
}
const goToNextPage = () => {
if (!nextToken) return
if (continuationToken) {
setTokenHistory((h) => [...h, continuationToken])
}
setContinuationToken(nextToken)
}
const goToPreviousPage = () => {
const prev = tokenHistory[tokenHistory.length - 1]
if (prev !== undefined) {
setTokenHistory((h) => h.slice(0, -1))
setContinuationToken(prev)
} else {
setContinuationToken(undefined)
}
}
React.useEffect(() => {
const col = table.getColumn("object")
if (col) col.setFilterValue(searchTerm || undefined)
}, [searchTerm, table])
return (
<div className="space-y-6">
<PageHeader
actions={
<>
<TaskStatsButton />
{canUpload ? (
<Button variant="outline" onClick={onUploadClick}>
<RiFileAddLine className="size-4" />
<span>
{t("Upload File")}/{t("Folder")}
</span>
</Button>
) : null}
{checkedKeys.length > 0 ? (
<>
{canBulkDelete ? (
<Button variant="outline" className="text-destructive border-destructive" onClick={handleBatchDelete}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete Selected")}</span>
</Button>
) : null}
{canBulkDownload ? (
<Button variant="outline" onClick={downloadMultiple}>
<RiDownloadCloud2Line className="size-4" />
<span>{t("Download")}</span>
</Button>
) : null}
</>
) : null}
<Button variant="outline" onClick={() => (onRefresh ?? fetchObjects)()}>
<RiRefreshLine className="size-4" />
<span>{t("Refresh")}</span>
</Button>
</>
}
>
<div className="flex flex-wrap items-center gap-4 min-w-[40vw]">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder={t("Filter From This Page")}
clearable
className="lg:max-w-sm"
/>
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<Checkbox checked={showDeleted} onCheckedChange={(v) => setShowDeleted(v === true)} />
<span>{t("Show Deleted Objects")}</span>
</label>
</div>
</PageHeader>
<DataTable
table={table}
isLoading={loading}
emptyTitle={t("No Objects")}
emptyDescription={t("Upload files or create folders to populate this bucket.")}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={tokenHistory.length === 0} onClick={goToPreviousPage}>
<RiArrowLeftSLine className="me-2 size-4 rtl:-scale-x-100" />
<span>{t("Previous Page")}</span>
</Button>
<Button variant="outline" disabled={!nextToken} onClick={goToNextPage}>
<span>{t("Next Page")}</span>
<RiArrowRightSLine className="ms-2 size-4 rtl:-scale-x-100" />
</Button>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>{t("Warning")}</AlertDialogTitle>
<AlertDialogDescription>
{t("Are you sure you want to delete the selected objects?")}
{bucketVersioningEnabled && (
<label className="mt-4 flex items-center gap-2">
<Checkbox checked={deleteAllVersions} onCheckedChange={(v) => setDeleteAllVersions(v === true)} />
<span>{t("Delete All Versions")}</span>
</label>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline" className="w-full sm:w-auto text-foreground">
{t("Cancel")}
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" className="w-full sm:w-auto text-white" onClick={handleConfirmDelete}>
{t("Confirm")}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}