mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
This commit is contained in:
+62
-35
@@ -39,7 +39,7 @@ 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 } from "@/contexts/task-context"
|
||||
import { useAddDeleteKeys, useAddDeleteFolder, useTasks } from "@/contexts/task-context"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import dayjs from "dayjs"
|
||||
import { saveAs } from "file-saver"
|
||||
@@ -76,6 +76,8 @@ export function ObjectList({
|
||||
const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket)
|
||||
const { getBucketVersioning } = useBucket()
|
||||
const addDeleteKeys = useAddDeleteKeys()
|
||||
const addDeleteFolder = useAddDeleteFolder()
|
||||
const tasks = useTasks()
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const [showDeleted, setShowDeleted] = useLocalStorage("object-list-show-deleted", false)
|
||||
@@ -147,6 +149,25 @@ export function ObjectList({
|
||||
}
|
||||
}, [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 {
|
||||
@@ -306,28 +327,6 @@ export function ObjectList({
|
||||
return base && key.startsWith(base) ? key.slice(base.length) : key
|
||||
}
|
||||
|
||||
const collectKeysForDeletion = async (keys: string[], forceDelete = false): Promise<string[]> => {
|
||||
const rowMap = new Map(data.map((item) => [item.Key, item]))
|
||||
const collected: string[] = []
|
||||
|
||||
for (const key of keys) {
|
||||
if (!key) continue
|
||||
const row = rowMap.get(key)
|
||||
if (row?.type === "prefix") {
|
||||
collected.push(row.Key)
|
||||
if (!forceDelete) {
|
||||
await mapAllFiles(bucket, row.Key, (fileKey) => {
|
||||
if (fileKey) collected.push(fileKey)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
collected.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(collected))
|
||||
}
|
||||
|
||||
const downloadMultiple = async () => {
|
||||
if (!checkedKeys.length) {
|
||||
message.warning(t("Please select at least one item"))
|
||||
@@ -430,13 +429,27 @@ export function ObjectList({
|
||||
|
||||
const handleDelete = async (keys: string[]) => {
|
||||
try {
|
||||
const targets = await collectKeysForDeletion(keys)
|
||||
if (!targets.length) {
|
||||
message.success(t("Delete Success"))
|
||||
} else {
|
||||
addDeleteKeys(targets, bucket, undefined)
|
||||
message.success(t("Delete task created"))
|
||||
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"))
|
||||
@@ -445,13 +458,27 @@ export function ObjectList({
|
||||
|
||||
const handleDeleteAllVersions = async (keys: string[]) => {
|
||||
try {
|
||||
const targets = await collectKeysForDeletion(keys, true)
|
||||
if (!targets.length) {
|
||||
message.success(t("Delete Success"))
|
||||
} else {
|
||||
addDeleteKeys(targets, bucket, undefined, { forceDelete: true })
|
||||
message.success(t("Delete task created"))
|
||||
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"))
|
||||
|
||||
@@ -43,12 +43,14 @@ const TaskContext = React.createContext<{
|
||||
taskManager: TaskManager<AnyTask, string>
|
||||
addUploadFiles: (items: { file: File; key: string }[], bucketName: string) => void
|
||||
addDeleteKeys: (keys: string[], bucketName: string, prefix?: string, options?: { forceDelete?: boolean }) => void
|
||||
addDeleteFolder: (prefix: string, bucketName: string, options?: { forceDelete?: boolean }) => void
|
||||
isTaskPanelOpen: boolean
|
||||
setTaskPanelOpen: (open: boolean) => void
|
||||
}>({
|
||||
taskManager: emptyManager,
|
||||
addUploadFiles: () => {},
|
||||
addDeleteKeys: () => {},
|
||||
addDeleteFolder: () => {},
|
||||
isTaskPanelOpen: false,
|
||||
setTaskPanelOpen: () => {},
|
||||
})
|
||||
@@ -79,6 +81,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||
handlers: {
|
||||
upload: uploadHelpers.handler as TaskHandler<unknown, string>,
|
||||
delete: deleteHelpers.handler as TaskHandler<unknown, string>,
|
||||
"delete-folder": deleteHelpers.folderHandler as TaskHandler<unknown, string>,
|
||||
},
|
||||
maxConcurrent: 6,
|
||||
maxRetries: 3,
|
||||
@@ -117,15 +120,26 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||
[managerState],
|
||||
)
|
||||
|
||||
const addDeleteFolder = React.useCallback(
|
||||
(prefix: string, bucketName: string, options?: { forceDelete?: boolean }) => {
|
||||
if (!managerState) return
|
||||
const { manager, deleteHelpers } = managerState
|
||||
const task = deleteHelpers.createFolderDeleteTask(prefix, bucketName, options)
|
||||
manager.enqueue([task as AnyTask])
|
||||
},
|
||||
[managerState],
|
||||
)
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
taskManager,
|
||||
addUploadFiles,
|
||||
addDeleteKeys,
|
||||
addDeleteFolder,
|
||||
isTaskPanelOpen,
|
||||
setTaskPanelOpen,
|
||||
}),
|
||||
[taskManager, addUploadFiles, addDeleteKeys, isTaskPanelOpen],
|
||||
[taskManager, addUploadFiles, addDeleteKeys, addDeleteFolder, isTaskPanelOpen],
|
||||
)
|
||||
|
||||
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
|
||||
@@ -156,6 +170,11 @@ export function useAddDeleteKeys() {
|
||||
return addDeleteKeys
|
||||
}
|
||||
|
||||
export function useAddDeleteFolder() {
|
||||
const { addDeleteFolder } = React.useContext(TaskContext)
|
||||
return addDeleteFolder
|
||||
}
|
||||
|
||||
export function useTaskPanelOpen() {
|
||||
const { isTaskPanelOpen, setTaskPanelOpen } = React.useContext(TaskContext)
|
||||
return { isTaskPanelOpen, setTaskPanelOpen }
|
||||
|
||||
+125
-4
@@ -1,4 +1,4 @@
|
||||
import { DeleteObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
||||
import { DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, ListObjectVersionsCommand, S3Client } from "@aws-sdk/client-s3"
|
||||
import type { ManagedTask, TaskHandler, TaskLifecycleStatus } from "./task-manager"
|
||||
|
||||
export type DeleteStatus = "pending" | "running" | "completed" | "failed" | "canceled"
|
||||
@@ -15,6 +15,18 @@ export interface DeleteTask extends ManagedTask<DeleteStatus> {
|
||||
subInfo: string
|
||||
}
|
||||
|
||||
export interface FolderDeleteTask extends ManagedTask<DeleteStatus> {
|
||||
kind: "delete-folder"
|
||||
bucketName: string
|
||||
prefix: string
|
||||
forceDelete?: boolean
|
||||
actionLabel: string
|
||||
displayName: string
|
||||
subInfo: string
|
||||
}
|
||||
|
||||
export type AnyDeleteTask = DeleteTask | FolderDeleteTask
|
||||
|
||||
export interface DeleteTaskConfig {
|
||||
maxRetries?: number
|
||||
retryDelay?: number
|
||||
@@ -22,6 +34,7 @@ export interface DeleteTaskConfig {
|
||||
|
||||
export interface DeleteTaskHelpers {
|
||||
handler: TaskHandler<DeleteTask, DeleteStatus>
|
||||
folderHandler: TaskHandler<FolderDeleteTask, DeleteStatus>
|
||||
createTasks: (
|
||||
keys: string[],
|
||||
bucketName: string,
|
||||
@@ -34,6 +47,11 @@ export interface DeleteTaskHelpers {
|
||||
prefix?: string,
|
||||
options?: { forceDelete?: boolean },
|
||||
) => DeleteTask[]
|
||||
createFolderDeleteTask: (
|
||||
prefix: string,
|
||||
bucketName: string,
|
||||
options?: { forceDelete?: boolean },
|
||||
) => FolderDeleteTask
|
||||
}
|
||||
|
||||
const lifecycle: TaskLifecycleStatus<DeleteStatus> = {
|
||||
@@ -44,7 +62,7 @@ const lifecycle: TaskLifecycleStatus<DeleteStatus> = {
|
||||
canceled: "canceled",
|
||||
}
|
||||
|
||||
function attachForceDeleteHeader(command: DeleteObjectCommand) {
|
||||
function attachForceDeleteHeader(command: DeleteObjectCommand | DeleteObjectsCommand) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(command.middlewareStack.add as any)(
|
||||
(next: (args: unknown) => Promise<unknown>) => async (args: { request?: { headers?: Record<string, string> } }) => {
|
||||
@@ -78,7 +96,80 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
|
||||
task.progress = 100
|
||||
}
|
||||
|
||||
const shouldRetry: TaskHandler<DeleteTask, DeleteStatus>["shouldRetry"] = (task, error) => {
|
||||
const performFolder: TaskHandler<FolderDeleteTask, DeleteStatus>["perform"] = async (task) => {
|
||||
const { bucketName, prefix, forceDelete } = task
|
||||
const abortController = new AbortController()
|
||||
task.abortController = abortController
|
||||
|
||||
if (forceDelete) {
|
||||
let isTruncated = true
|
||||
let keyMarker: string | undefined
|
||||
let versionIdMarker: string | undefined
|
||||
|
||||
while (isTruncated) {
|
||||
const data = await s3Client.send(
|
||||
new ListObjectVersionsCommand({
|
||||
Bucket: bucketName,
|
||||
Prefix: prefix,
|
||||
KeyMarker: keyMarker,
|
||||
VersionIdMarker: versionIdMarker,
|
||||
}),
|
||||
{ abortSignal: abortController.signal },
|
||||
)
|
||||
|
||||
const objectsToDelete: { Key: string; VersionId?: string }[] = []
|
||||
data.Versions?.forEach((v) => {
|
||||
if (v.Key) objectsToDelete.push({ Key: v.Key, VersionId: v.VersionId })
|
||||
})
|
||||
data.DeleteMarkers?.forEach((m) => {
|
||||
if (m.Key) objectsToDelete.push({ Key: m.Key, VersionId: m.VersionId })
|
||||
})
|
||||
|
||||
if (objectsToDelete.length > 0) {
|
||||
const command = new DeleteObjectsCommand({
|
||||
Bucket: bucketName,
|
||||
Delete: { Objects: objectsToDelete, Quiet: true },
|
||||
})
|
||||
attachForceDeleteHeader(command)
|
||||
await s3Client.send(command, { abortSignal: abortController.signal })
|
||||
}
|
||||
|
||||
isTruncated = data.IsTruncated ?? false
|
||||
keyMarker = data.NextKeyMarker
|
||||
versionIdMarker = data.NextVersionIdMarker
|
||||
}
|
||||
} else {
|
||||
let isTruncated = true
|
||||
let continuationToken: string | undefined
|
||||
|
||||
while (isTruncated) {
|
||||
const data = await s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
{ abortSignal: abortController.signal },
|
||||
)
|
||||
|
||||
const objectsToDelete = (data.Contents ?? []).filter((item) => item.Key).map((item) => ({ Key: item.Key! }))
|
||||
|
||||
if (objectsToDelete.length > 0) {
|
||||
const command = new DeleteObjectsCommand({
|
||||
Bucket: bucketName,
|
||||
Delete: { Objects: objectsToDelete, Quiet: true },
|
||||
})
|
||||
await s3Client.send(command, { abortSignal: abortController.signal })
|
||||
}
|
||||
|
||||
isTruncated = data.IsTruncated ?? false
|
||||
continuationToken = data.NextContinuationToken
|
||||
}
|
||||
}
|
||||
task.progress = 100
|
||||
}
|
||||
|
||||
const shouldRetry = (task: DeleteTask | FolderDeleteTask, error: unknown) => {
|
||||
if (task.status === lifecycle.canceled) return false
|
||||
if ((task.retryCount ?? 0) >= maxRetries) return false
|
||||
const errorMessage = String((error as Error)?.message ?? "").toLowerCase()
|
||||
@@ -96,6 +187,16 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
|
||||
retryDelay,
|
||||
}
|
||||
|
||||
const folderHandler: TaskHandler<FolderDeleteTask, DeleteStatus> = {
|
||||
lifecycle,
|
||||
perform: performFolder,
|
||||
shouldRetry,
|
||||
isCanceledError: (error) =>
|
||||
(error as Error)?.name === "AbortError" || String((error as Error)?.message ?? "").includes("canceled"),
|
||||
maxRetries,
|
||||
retryDelay,
|
||||
}
|
||||
|
||||
const createTasksFromItems = (
|
||||
items: { key: string; versionId?: string; forceDelete?: boolean }[],
|
||||
bucketName: string,
|
||||
@@ -138,5 +239,25 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
|
||||
prefix,
|
||||
)
|
||||
|
||||
return { handler, createTasks, createVersionedTasks }
|
||||
const createFolderDeleteTask = (
|
||||
prefix: string,
|
||||
bucketName: string,
|
||||
options?: { forceDelete?: boolean },
|
||||
): FolderDeleteTask => ({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${prefix}`,
|
||||
kind: "delete-folder" as const,
|
||||
bucketName,
|
||||
prefix,
|
||||
forceDelete: options?.forceDelete,
|
||||
status: lifecycle.pending,
|
||||
progress: 0,
|
||||
actionLabel: "Delete Folder",
|
||||
displayName: prefix,
|
||||
subInfo: options?.forceDelete ? "Deleting all versions" : "Recursive delete",
|
||||
error: undefined,
|
||||
abortController: undefined,
|
||||
retryCount: 0,
|
||||
})
|
||||
|
||||
return { handler, folderHandler, createTasks, createVersionedTasks, createFolderDeleteTask }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user