From f5277c5fb4687d8d778469ff1b818dc687e58db2 Mon Sep 17 00:00:00 2001 From: Divyansh Kamboj Date: Fri, 11 Oct 2024 11:18:18 +0530 Subject: [PATCH] Add Delete and Empty Bucket modals to S3 Browser - Add Delete Bucket modal for bucket removal - Add Empty Bucket modal for clearing bucket contents - Implement success/failure alerts for operations - Handle S3 API responses Signed-off-by: Divyansh Kamboj --- locales/en/plugin__odf-console.json | 17 + .../bucket-overview/BucketOverview.tsx | 206 ++++++++++-- .../buckets-list-page/bucketListTable.tsx | 79 ++++- .../buckets-list-page/bucketsListPage.tsx | 17 + packages/odf/constants/s3-browser.ts | 1 + .../DeleteBucketModal.tsx | 212 ++++++++++++ .../EmptyBucketModal.tsx | 309 ++++++++++++++++++ .../lazy-delete-and-empty-bucket.ts | 8 + packages/shared/src/s3/commands.ts | 10 + packages/shared/src/s3/types.ts | 12 + 10 files changed, 829 insertions(+), 42 deletions(-) create mode 100644 packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx create mode 100644 packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx create mode 100644 packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 7a119742d..b282c5061 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -1394,6 +1394,23 @@ "Organize objects within a bucket by creating virtual folders for easier management and navigation of objects.": "Organize objects within a bucket by creating virtual folders for easier management and navigation of objects.", "Folders structure and group objects logically by using prefixes in object keys, without enforcing any physical hierarchy.": "Folders structure and group objects logically by using prefixes in object keys, without enforcing any physical hierarchy.", "Folder name": "Folder name", + "Delete bucket permanently?": "Delete bucket permanently?", + "The bucket is being deleted. This may take a while.": "The bucket is being deleted. This may take a while.", + "Cannot delete this bucket: it is not empty": "Cannot delete this bucket: it is not empty", + "Bucket must be empty before it can be deleted. Use the <2>Empty bucket configuration to erase all the contents i.e. <5>objects of the bucket and try again.": "Bucket must be empty before it can be deleted. Use the <2>Empty bucket configuration to erase all the contents i.e. <5>objects of the bucket and try again.", + "Deleting a bucket cannot be undone.": "Deleting a bucket cannot be undone.", + "Bucket names are unique. If you delete a bucket, another S3 user can use the name.": "Bucket names are unique. If you delete a bucket, another S3 user can use the name.", + "Bucket name input": "Bucket name input", + "<0>To confirm deletion, type <1>{{bucketName}}:": "<0>To confirm deletion, type <1>{{bucketName}}:", + "Empty bucket permanently?": "Empty bucket permanently?", + "Emptying the bucket will permanentaly delete all objects. This action cannot be undone.": "Emptying the bucket will permanentaly delete all objects. This action cannot be undone.", + "Any objects added during this process may also be deleted. To prevent adding new objects during the emptying process, consider updating the bucket policy (through CLI).": "Any objects added during this process may also be deleted. To prevent adding new objects during the emptying process, consider updating the bucket policy (through CLI).", + "The bucket is being emptied. This may take a while to complete.": "The bucket is being emptied. This may take a while to complete.", + "Cannot empty bucket": "Cannot empty bucket", + "Check potential reasons": "Check potential reasons", + "Bucket emptying was not completed. Check for conflicts or permissions issues that are blocking this operation.": "Bucket emptying was not completed. Check for conflicts or permissions issues that are blocking this operation.", + "Successfully emptied bucket ": "Successfully emptied bucket ", + "Your bucket is now empty. If you want to delete this bucket, click Delete bucket": "Your bucket is now empty. If you want to delete this bucket, click Delete bucket", "<0>To confirm deletion, type <1>{{delete}} in the text input field.": "<0>To confirm deletion, type <1>{{delete}} in the text input field.", "Object name": "Object name", "Delete object?": "Delete object?", diff --git a/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx b/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx index 29eeb5984..206fb21b7 100644 --- a/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx +++ b/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx @@ -1,8 +1,17 @@ import * as React from 'react'; import { BucketDetails } from '@odf/core/components/s3-browser/bucket-details/BucketDetails'; +import { + EmptyBucketAlerts, + EmptyBucketResponse, +} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal'; +import { + LazyEmptyBucketModal, + LazyDeleteBucketModal, +} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket'; import PageHeading from '@odf/shared/heading/page-heading'; import { useRefresh } from '@odf/shared/hooks'; import { ModalKeys, defaultModalMap } from '@odf/shared/modals/types'; +import { S3Commands } from '@odf/shared/s3'; import { BlueSyncIcon } from '@odf/shared/status'; import { K8sResourceKind } from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; @@ -26,7 +35,7 @@ import { ActionsColumn, IAction } from '@patternfly/react-table'; import { PREFIX, BUCKETS_BASE_ROUTE } from '../../../constants'; import { NooBaaObjectBucketModel } from '../../../models'; import { getBreadcrumbs } from '../../../utils'; -import { NoobaaS3Provider } from '../noobaa-context'; +import { NoobaaS3Context, NoobaaS3Provider } from '../noobaa-context'; import { CustomActionsToggle } from '../objects-list'; import { ObjectListWithSidebar } from '../objects-list/ObjectListWithSidebar'; import { PageTitle } from './PageTitle'; @@ -47,16 +56,39 @@ const getBucketActionsItems = ( navigate: NavigateFunction, bucketName: string, isCreatedByOBC: boolean, - noobaaObjectBucket: K8sResourceKind + noobaaS3: S3Commands, + noobaaObjectBucket: K8sResourceKind, + refreshTokens: () => void, + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + > ): IAction[] => [ - // ToDo: add empty/delete bucket actions { title: t('Empty bucket'), - onClick: () => undefined, + onClick: () => + launcher(LazyEmptyBucketModal, { + isOpen: true, + extraProps: { + bucketName, + noobaaS3, + refreshTokens, + setEmptyBucketResponse, + }, + }), }, { title: t('Delete bucket'), - onClick: () => undefined, + onClick: () => + launcher(LazyDeleteBucketModal, { + isOpen: true, + extraProps: { + bucketName, + noobaaS3, + launcher, + refreshTokens, + setEmptyBucketResponse, + }, + }), }, ...(isCreatedByOBC ? [ @@ -90,6 +122,53 @@ const getBucketActionsItems = ( : []), ]; +const createBucketActions = ( + t: TFunction, + fresh: boolean, + triggerRefresh: () => void, + foldersPath: string | null, + launcher: ReturnType, + navigate: NavigateFunction, + bucketName: string, + isCreatedByOBC: boolean, + noobaaS3: S3Commands, + noobaaObjectBucket: K8sResourceKind, + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + > +) => { + return ( + <> + + {!foldersPath && ( + + )} + + ); +}; + const BucketOverview: React.FC<{}> = () => { const { t } = useCustomTranslation(); const [fresh, triggerRefresh] = useRefresh(); @@ -100,6 +179,12 @@ const BucketOverview: React.FC<{}> = () => { const { bucketName } = useParams(); const [searchParams] = useSearchParams(); + const [emptyBucketResponse, setEmptyBucketResponse] = + React.useState({ + response: null, + bucketName: '', + }); + // if non-empty means we are inside particular folder(s) of a bucket, else just inside a bucket (top-level) const foldersPath = searchParams.get(PREFIX); @@ -151,38 +236,84 @@ const BucketOverview: React.FC<{}> = () => { : []), ]; - const actions = () => { - return ( - <> - - {!foldersPath && ( - - )} - + const renderActions = (noobaaS3: S3Commands) => () => + createBucketActions( + t, + fresh, + triggerRefresh, + foldersPath, + launcher, + navigate, + bucketName, + isCreatedByOBC, + noobaaS3, + noobaaObjectBucket, + setEmptyBucketResponse ); - }; return ( + + + ); +}; + +type NavPage = { + href: string; + name: string; + component: React.ComponentType; +}; + +type BucketOverviewContentProps = { + breadcrumbs: { name: string; path: string }[]; + foldersPath: string | null; + currentFolder: string; + isCreatedByOBC: boolean; + fresh: boolean; + triggerRefresh: () => void; + noobaaObjectBucket: K8sResourceKind; + navPages: NavPage[]; + bucketName: string; + actions: (noobaaS3: S3Commands) => () => JSX.Element; + launcher: LaunchModal; + emptyBucketResponse: EmptyBucketResponse; + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + >; +}; + +const BucketOverviewContent: React.FC = ({ + breadcrumbs, + foldersPath, + currentFolder, + isCreatedByOBC, + fresh, + triggerRefresh, + noobaaObjectBucket, + navPages, + bucketName, + actions, + emptyBucketResponse, + setEmptyBucketResponse, +}) => { + const { noobaaS3 } = React.useContext(NoobaaS3Context); + + return ( + <> = () => { noobaaObjectBucket={noobaaObjectBucket} /> } - actions={actions} + actions={actions(noobaaS3)} className="pf-v5-u-mt-md" /> + = () => { } as any } /> - + ); }; diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx index ebdb9f6b4..46f0d786c 100644 --- a/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx +++ b/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx @@ -3,16 +3,24 @@ import { BUCKET_BOOKMARKS_USER_SETTINGS_KEY, BUCKETS_BASE_ROUTE, } from '@odf/core/constants'; +import { EmptyBucketResponse } from '@odf/core/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal'; +import { + LazyDeleteBucketModal, + LazyEmptyBucketModal, +} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket'; import { BucketCrFormat } from '@odf/core/types'; import { Timestamp } from '@odf/shared/details-page/timestamp'; import { EmptyPage } from '@odf/shared/empty-state-page'; import { useUserSettingsLocalStorage } from '@odf/shared/hooks/useUserSettingsLocalStorage'; +import { S3Commands } from '@odf/shared/s3'; import { ComposableTable, RowComponentType, } from '@odf/shared/table/composable-table'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { sortRows } from '@odf/shared/utils'; +import { useModal } from '@openshift-console/dynamic-plugin-sdk'; +import { LaunchModal } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; import { TFunction } from 'react-i18next'; import { Link } from 'react-router-dom-v5-compat'; import { Bullseye, Label } from '@patternfly/react-core'; @@ -24,9 +32,18 @@ import { Td, Tr, } from '@patternfly/react-table'; +import { NoobaaS3Context } from '../noobaa-context'; -const getRowActions = (t: TFunction): IAction[] => [ - // ToDo: add empty/delete bucket action +const getRowActions = ( + t: TFunction, + launcher: LaunchModal, + bucketName: string, + noobaaS3: S3Commands, + refreshTokens: () => void, + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + > +): IAction[] => [ { title: ( <> @@ -36,11 +53,30 @@ const getRowActions = (t: TFunction): IAction[] => [

), - onClick: () => undefined, + onClick: () => + launcher(LazyEmptyBucketModal, { + isOpen: true, + extraProps: { + bucketName, + noobaaS3, + refreshTokens, + setEmptyBucketResponse, + }, + }), }, { title: t('Delete bucket'), - onClick: () => undefined, + onClick: () => + launcher(LazyDeleteBucketModal, { + isOpen: true, + extraProps: { + bucketName, + noobaaS3, + launcher, + refreshTokens, + setEmptyBucketResponse, + }, + }), }, ]; @@ -122,7 +158,15 @@ const BucketsTableRow: React.FC> = ({ apiResponse: { owner }, metadata: { name, creationTimestamp }, } = bucket; - const { favorites, setFavorites }: RowExtraPropsType = extraProps; + const { + favorites, + setFavorites, + triggerRefresh, + setEmptyBucketResponse, + launcher, + }: RowExtraPropsType = extraProps; + + const { noobaaS3 } = React.useContext(NoobaaS3Context); const onSetFavorite = (key, active) => { setFavorites((oldFavorites) => [ @@ -156,7 +200,16 @@ const BucketsTableRow: React.FC> = ({ {owner} - + ); @@ -167,6 +220,7 @@ export const BucketsListTable: React.FC = ({ filteredBuckets, loaded, error, + setEmptyBucketResponse, }) => { const { t } = useCustomTranslation(); const [favorites, setFavorites] = useUserSettingsLocalStorage( @@ -174,6 +228,8 @@ export const BucketsListTable: React.FC = ({ true, [] ); + const launcher = useModal(); + return ( = ({ loadError={error} isFavorites={true} variant={TableVariant.compact} - extraProps={{ favorites, setFavorites }} + extraProps={{ favorites, setFavorites, setEmptyBucketResponse, launcher }} /> ); }; @@ -196,9 +252,18 @@ type BucketsListTableProps = { filteredBuckets: BucketCrFormat[]; loaded: boolean; error: any; + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + >; + triggerRefresh: () => void; }; type RowExtraPropsType = { favorites: string[]; setFavorites: React.Dispatch>; + triggerRefresh: () => void; + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + >; + launcher: LaunchModal; }; diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx index 46753b9ea..f410475c4 100644 --- a/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx +++ b/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx @@ -1,4 +1,8 @@ import * as React from 'react'; +import { + EmptyBucketAlerts, + EmptyBucketResponse, +} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal'; import { useRefresh } from '@odf/shared/hooks'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { getValidFilteredData } from '@odf/shared/utils'; @@ -31,12 +35,23 @@ const BucketsListPageBody: React.FC = ({ }) => { const { t } = useCustomTranslation(); const [fresh, triggerRefresh] = useRefresh(); + const [emptyBucketResponse, setEmptyBucketResponse] = + React.useState({ + response: null, + bucketName: '', + }); + const [buckets, loaded, loadError] = bucketInfo; const [allBuckets, filteredBuckets, onFilterChange] = useListPageFilter(buckets); return ( + @@ -71,6 +86,8 @@ const BucketsListPageBody: React.FC = ({ filteredBuckets={filteredBuckets} loaded={loaded} error={loadError} + setEmptyBucketResponse={setEmptyBucketResponse} + triggerRefresh={triggerRefresh} /> )} diff --git a/packages/odf/constants/s3-browser.ts b/packages/odf/constants/s3-browser.ts index f147ca80a..e210961b3 100644 --- a/packages/odf/constants/s3-browser.ts +++ b/packages/odf/constants/s3-browser.ts @@ -23,6 +23,7 @@ export const BUCKET_TAGGING_CACHE_KEY_SUFFIX = 'BUCKET_TAGGING_CACHE_KEY'; export const BUCKET_VERSIONING_CACHE_KEY_SUFFIX = 'BUCKET_VERSIONING_CACHE_KEY'; export const LIST_BUCKET = 'LIST_BUCKET_CACHE_KEY'; export const LIST_OBJECTS = 'LIST_OBJECTS_CACHE_KEY'; +export const LIST_VERSIONED_OBJECTS = 'LIST_VERSIONED_OBJECTS'; export const OBJECT_CACHE_KEY_SUFFIX = 'OBJECT_CACHE_KEY'; export const OBJECT_TAGGING_CACHE_KEY_SUFFIX = 'OBJECT_TAGGING_CACHE_KEY'; diff --git a/packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx b/packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx new file mode 100644 index 000000000..1bc434ce8 --- /dev/null +++ b/packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx @@ -0,0 +1,212 @@ +import * as React from 'react'; +import { + BUCKET_BOOKMARKS_USER_SETTINGS_KEY, + LIST_VERSIONED_OBJECTS, +} from '@odf/core/constants'; +import { useUserSettingsLocalStorage } from '@odf/shared'; +import { CommonModalProps } from '@odf/shared/modals'; +import { S3Commands } from '@odf/shared/s3'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { LaunchModal } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; +import { Trans } from 'react-i18next'; +import useSWR from 'swr'; +import { + Modal, + Button, + Text, + TextInput, + ModalVariant, + ValidatedOptions, + TextInputTypes, + TextVariants, + TextContent, + FormGroup, + Alert, + Spinner, + AlertVariant, + AlertActionLink, + ButtonVariant, +} from '@patternfly/react-core'; +import { EmptyBucketResponse, getTextInputLabel } from './EmptyBucketModal'; +import { LazyEmptyBucketModal } from './lazy-delete-and-empty-bucket'; + +type DeleteBucketModalProps = { + bucketName: string; + noobaaS3: S3Commands; + launcher: LaunchModal; + refreshTokens?: () => void; + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + >; +}; + +const DeleteBucketModal: React.FC> = ({ + closeModal, + isOpen, + extraProps: { + bucketName, + noobaaS3, + launcher, + refreshTokens, + setEmptyBucketResponse, + }, +}) => { + const { t } = useCustomTranslation(); + const [inputValue, setInputValue] = React.useState(''); + const [inProgress, setInProgress] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(null); + + const { + data, + error, + isLoading: isChecking, + } = useSWR(`${bucketName}-${LIST_VERSIONED_OBJECTS}`, () => + noobaaS3.listObjectVersions({ Bucket: bucketName, MaxKeys: 1 }) + ); + const hasObjects = + data?.Versions?.length > 0 || data?.DeleteMarkers?.length > 0; + + const [_favorites, setFavorites] = useUserSettingsLocalStorage( + BUCKET_BOOKMARKS_USER_SETTINGS_KEY, + true, + [] + ); + + const onDelete = async (event) => { + event.preventDefault(); + setInProgress(true); + + try { + await noobaaS3.deleteBucket({ + Bucket: bucketName, + }); + + setInProgress(false); + closeModal(); + setFavorites((oldFavorites) => + oldFavorites.filter((bucket) => bucket !== bucketName) + ); + refreshTokens?.(); + } catch (err) { + setDeleteError(err); + setInProgress(false); + } + }; + + return ( + + {inProgress && ( + + + {t('The bucket is being deleted. This may take a while.')} + + + )} + + + + , + ]} + > + {isChecking ? ( + + ) : ( + hasObjects && ( + + launcher(LazyEmptyBucketModal, { + isOpen: true, + extraProps: { + bucketName, + noobaaS3, + refreshTokens, + setEmptyBucketResponse, + }, + }) + } + > + {t('Empty bucket')} + + } + > +

+ + Bucket must be empty before it can be deleted. Use the{' '} + Empty bucket configuration to erase all the contents i.e.{' '} + objects of the bucket and try again. + +

+
+ ) + )} + + + {t('Deleting a bucket cannot be undone.')} + + + {t( + 'Bucket names are unique. If you delete a bucket, another S3 user can use the name.' + )} + + + + + setInputValue(value)} + aria-label={t('Bucket name input')} + validated={ + inputValue === bucketName + ? ValidatedOptions.success + : ValidatedOptions.default + } + placeholder={bucketName} + /> + + {(error || deleteError) && ( + + {error?.message || deleteError?.message} + + )} +
+ ); +}; + +export default DeleteBucketModal; diff --git a/packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx b/packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx new file mode 100644 index 000000000..562f17892 --- /dev/null +++ b/packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx @@ -0,0 +1,309 @@ +import * as React from 'react'; +import { DeleteObjectsCommandOutput } from '@aws-sdk/client-s3'; +import { NoobaaS3Context } from '@odf/core/components/s3-browser/noobaa-context'; +import { CommonModalProps } from '@odf/shared/modals'; +import { S3Commands } from '@odf/shared/s3'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { useModal } from '@openshift-console/dynamic-plugin-sdk'; +import { TFunction } from 'i18next'; +import { Trans, useTranslation } from 'react-i18next'; +import { + Modal, + Button, + Text, + TextInput, + ModalVariant, + ValidatedOptions, + TextInputTypes, + TextVariants, + TextContent, + Alert, + AlertVariant, + AlertActionCloseButton, + AlertActionLink, + FormGroup, +} from '@patternfly/react-core'; +import { LazyDeleteObjectsSummary } from '../delete-objects/LazyDeleteModals'; +import { LazyDeleteBucketModal } from './lazy-delete-and-empty-bucket'; + +type EmptyBucketModalProps = { + bucketName: string; + noobaaS3: S3Commands; + refreshTokens?: () => void; + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + >; +}; + +export const getTextInputLabel = (t: TFunction, bucketName: string) => ( + + + To confirm deletion, type {{ bucketName }}: + + +); + +const EmptyBucketModal: React.FC> = ({ + closeModal, + isOpen, + extraProps: { bucketName, noobaaS3, refreshTokens, setEmptyBucketResponse }, +}) => { + const { t } = useCustomTranslation(); + const [inputValue, setInputValue] = React.useState(''); + const [inProgress, setInProgress] = React.useState(false); + + const onEmpty = async (event) => { + event.preventDefault(); + setInProgress(true); + let deleteResponse: DeleteObjectsCommandOutput; + + try { + let isTruncated = true; + let keyMarker: string; + let versionIdMarker: string; + + while (isTruncated) { + const deleteObjectKeys = []; + const searchParams = { + Bucket: bucketName, + KeyMarker: keyMarker, + VersionIdMarker: versionIdMarker, + }; + + // eslint-disable-next-line no-await-in-loop + const objects = await noobaaS3.listObjectVersions(searchParams); + + if (objects?.Versions) { + deleteObjectKeys.push( + ...objects.Versions.map((object) => ({ + Key: object.Key, + VersionId: object.VersionId, + })) + ); + } + if (objects?.DeleteMarkers) { + deleteObjectKeys.push( + ...objects.DeleteMarkers.map((marker) => ({ + Key: marker.Key, + VersionId: marker.VersionId, + })) + ); + } + + // eslint-disable-next-line no-await-in-loop + deleteResponse = await noobaaS3.deleteObjects({ + Bucket: bucketName, + Delete: { Objects: deleteObjectKeys }, + }); + + if (deleteResponse.Errors?.length > 0) { + throw new Error(`${deleteResponse.Errors.length} objects failed`); + } + + isTruncated = objects.IsTruncated; + keyMarker = objects.NextKeyMarker; + versionIdMarker = objects.NextVersionIdMarker; + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('Error while emptying bucket ', err); + } finally { + setInProgress(false); + closeModal(); + setEmptyBucketResponse({ + response: deleteResponse, + bucketName: bucketName, + }); + refreshTokens?.(); + } + }; + + return ( + + + {t( + 'Emptying the bucket will permanentaly delete all objects. This action cannot be undone.' + )} + + + {t( + 'Any objects added during this process may also be deleted. To prevent adding new objects during the emptying process, consider updating the bucket policy (through CLI).' + )} + + + } + actions={[ +
+ {inProgress && ( + + + {t( + 'The bucket is being emptied. This may take a while to complete.' + )} + + + )} + + + +
, + ]} + > + + setInputValue(value)} + aria-label={t('Bucket name input')} + validated={ + inputValue === bucketName + ? ValidatedOptions.success + : ValidatedOptions.default + } + placeholder={bucketName} + /> + +
+ ); +}; + +export type EmptyBucketResponse = { + response: DeleteObjectsCommandOutput; + bucketName: string; +}; + +type EmptyBucketAlertProps = { + emptyBucketResponse: EmptyBucketResponse; + setEmptyBucketResponse: React.Dispatch< + React.SetStateAction + >; + triggerRefresh: () => void; +}; + +export const EmptyBucketAlerts: React.FC = ({ + emptyBucketResponse, + setEmptyBucketResponse, + triggerRefresh, +}) => { + const { noobaaS3 } = React.useContext(NoobaaS3Context); + const { t } = useTranslation(); + const launcher = useModal(); + + if (emptyBucketResponse.response === null) return null; + + const hasErrors = + emptyBucketResponse.response?.Errors && + emptyBucketResponse.response.Errors.length > 0; + + if (hasErrors) { + return ( + { + setEmptyBucketResponse({ response: null, bucketName: '' }); + }} + /> + } + actionLinks={ + + launcher(LazyDeleteObjectsSummary, { + isOpen: true, + extraProps: { + foldersPath: '', + errorResponse: emptyBucketResponse.response.Errors, + selectedObjects: [], + }, + }) + } + > + {t('Check potential reasons')} + + } + > +

+ {t( + 'Bucket emptying was not completed. Check for conflicts or permissions issues that are blocking this operation.' + )} +

+
+ ); + } + + return ( + { + setEmptyBucketResponse({ response: null, bucketName: '' }); + }} + /> + } + actionLinks={ + <> + + launcher(LazyDeleteBucketModal, { + isOpen: true, + extraProps: { + bucketName: emptyBucketResponse.bucketName, + noobaaS3, + launcher, + triggerRefresh, + setEmptyBucketResponse, + }, + }) + } + > + {t('Delete bucket')} + + + setEmptyBucketResponse({ response: null, bucketName: '' }) + } + > + {t('Dismiss')} + + + } + > +

+ {t( + 'Your bucket is now empty. If you want to delete this bucket, click Delete bucket' + )} +

+
+ ); +}; + +export default EmptyBucketModal; diff --git a/packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts b/packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts new file mode 100644 index 000000000..2a5ba5469 --- /dev/null +++ b/packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export const LazyEmptyBucketModal = React.lazy( + () => import('./EmptyBucketModal') +); +export const LazyDeleteBucketModal = React.lazy( + () => import('./DeleteBucketModal') +); diff --git a/packages/shared/src/s3/commands.ts b/packages/shared/src/s3/commands.ts index f02b637ae..49fc4cc61 100644 --- a/packages/shared/src/s3/commands.ts +++ b/packages/shared/src/s3/commands.ts @@ -12,6 +12,8 @@ import { GetBucketTaggingCommand, GetBucketAclCommand, GetBucketPolicyCommand, + ListObjectVersionsCommand, + DeleteBucketCommand, } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; @@ -19,11 +21,13 @@ import { CreateBucket, ListBuckets, ListObjectsV2, + ListObjectVersions, PutBucketTags, GetSignedUrl, GetObject, GetObjectTagging, DeleteObjects, + DeleteBucket, GetBucketEncryption, GetBucketVersioning, GetBucketTagging, @@ -49,6 +53,9 @@ export class S3Commands extends S3Client { createBucket: CreateBucket = (input) => this.send(new CreateBucketCommand(input)); + deleteBucket: DeleteBucket = (input) => + this.send(new DeleteBucketCommand(input)); + getBucketAcl: GetBucketAcl = (input) => this.send(new GetBucketAclCommand(input)); @@ -74,6 +81,9 @@ export class S3Commands extends S3Client { listObjects: ListObjectsV2 = (input) => this.send(new ListObjectsV2Command(input)); + listObjectVersions: ListObjectVersions = (input) => + this.send(new ListObjectVersionsCommand(input)); + getSignedUrl: GetSignedUrl = (input, expiresIn) => getSignedUrl(this, new GetObjectCommand(input), { expiresIn }); diff --git a/packages/shared/src/s3/types.ts b/packages/shared/src/s3/types.ts index 2e4b1fe52..e1d6285c8 100644 --- a/packages/shared/src/s3/types.ts +++ b/packages/shared/src/s3/types.ts @@ -23,6 +23,10 @@ import { GetObjectCommandOutput, GetObjectTaggingCommandInput, GetObjectTaggingCommandOutput, + ListObjectVersionsCommandInput, + ListObjectVersionsCommandOutput, + DeleteBucketCommandInput, + DeleteBucketCommandOutput, } from '@aws-sdk/client-s3'; // Bucket command types @@ -30,6 +34,10 @@ export type CreateBucket = ( input?: CreateBucketCommandInput ) => Promise; +export type DeleteBucket = ( + input: DeleteBucketCommandInput +) => Promise; + export type GetBucketAcl = ( input?: GetBucketAclCommandInput ) => Promise; @@ -84,6 +92,10 @@ export type ListObjectsV2 = ( input: ListObjectsV2CommandInput ) => Promise; +export type ListObjectVersions = ( + input: ListObjectVersionsCommandInput +) => Promise; + // Bucket Policy type BucketPolicyCondition = Record;