From 5ab5232474178e19fb8674380c40d11ee2938260 Mon Sep 17 00:00:00 2001 From: Alex <33497058+bexsoft@users.noreply.github.com> Date: Fri, 18 Mar 2022 13:07:34 -0700 Subject: [PATCH] Delete Non-current versions (#1735) - Delete Non-current API - Delete non current modal implementation Signed-off-by: Benjamin Perez --- portal-ui/src/icons/DeleteNonCurrentIcon.tsx | 37 ++++++ portal-ui/src/icons/index.ts | 1 + .../Objects/ListObjects/DeleteNonCurrent.tsx | 118 ++++++++++++++++++ .../ObjectDetails/VersionsNavigator.tsx | 36 +++++- .../screens/Console/Common/IconsScreen.tsx | 6 + restapi/embedded_spec.go | 10 ++ .../user_api/delete_object_parameters.go | 32 +++++ .../user_api/delete_object_urlbuilder.go | 17 ++- restapi/user_objects.go | 43 ++++++- restapi/user_objects_test.go | 86 ++++++++++--- swagger-console.yml | 4 + 11 files changed, 366 insertions(+), 24 deletions(-) create mode 100644 portal-ui/src/icons/DeleteNonCurrentIcon.tsx create mode 100644 portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/DeleteNonCurrent.tsx diff --git a/portal-ui/src/icons/DeleteNonCurrentIcon.tsx b/portal-ui/src/icons/DeleteNonCurrentIcon.tsx new file mode 100644 index 000000000..ceaad31fe --- /dev/null +++ b/portal-ui/src/icons/DeleteNonCurrentIcon.tsx @@ -0,0 +1,37 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import * as React from "react"; +import { SVGProps } from "react"; + +const DeleteNonCurrentIcon = (props: SVGProps) => ( + + + + +); + +export default DeleteNonCurrentIcon; diff --git a/portal-ui/src/icons/index.ts b/portal-ui/src/icons/index.ts index 9466931df..2ee8b3fc5 100644 --- a/portal-ui/src/icons/index.ts +++ b/portal-ui/src/icons/index.ts @@ -180,3 +180,4 @@ export { default as ArrowRightLink } from "./ArrowRightLink"; export { default as LicenseDocIcon } from "./LicenseDocIcon"; export { default as SelectAllIcon } from "./SelectAllIcon"; export { default as BackIcon } from "./BackIcon"; +export { default as DeleteNonCurrentIcon } from "./DeleteNonCurrentIcon"; diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/DeleteNonCurrent.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/DeleteNonCurrent.tsx new file mode 100644 index 000000000..45832b4f3 --- /dev/null +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/DeleteNonCurrent.tsx @@ -0,0 +1,118 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { useState, useEffect } from "react"; +import { connect } from "react-redux"; +import { DialogContentText } from "@mui/material"; +import Grid from "@mui/material/Grid"; +import { setErrorSnackMessage } from "../../../../../../actions"; +import { ErrorResponseHandler } from "../../../../../../common/types"; +import { decodeFileName } from "../../../../../../common/utils"; +import { ConfirmDeleteIcon } from "../../../../../../icons"; +import ConfirmDialog from "../../../../Common/ModalWrapper/ConfirmDialog"; +import api from "../../../../../../common/api"; +import InputBoxWrapper from "../../../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; + +interface IDeleteNonCurrentProps { + closeDeleteModalAndRefresh: (refresh: boolean) => void; + deleteOpen: boolean; + selectedObject: string; + selectedBucket: string; + setErrorSnackMessage: typeof setErrorSnackMessage; +} + +const DeleteNonCurrentVersions = ({ + closeDeleteModalAndRefresh, + deleteOpen, + selectedBucket, + selectedObject, + setErrorSnackMessage, +}: IDeleteNonCurrentProps) => { + const [deleteLoading, setDeleteLoading] = useState(false); + const [typeConfirm, setTypeConfirm] = useState(""); + + useEffect(() => { + if (deleteLoading) { + api + .invoke( + "DELETE", + `/api/v1/buckets/${selectedBucket}/objects?path=${selectedObject}&non_current_versions=true` + ) + .then(() => { + closeDeleteModalAndRefresh(true); + }) + .catch((error: ErrorResponseHandler) => { + setErrorSnackMessage(error); + setDeleteLoading(false); + }); + } + }, [ + deleteLoading, + closeDeleteModalAndRefresh, + setErrorSnackMessage, + selectedObject, + selectedBucket, + ]); + + if (!selectedObject) { + return null; + } + const onConfirmDelete = () => { + setDeleteLoading(true); + }; + + return ( + } + isLoading={deleteLoading} + onConfirm={onConfirmDelete} + onClose={() => closeDeleteModalAndRefresh(false)} + confirmButtonProps={{ + disabled: typeConfirm !== "YES, PROCEED" || deleteLoading, + }} + confirmationContent={ + + Are you sure you want to delete all the non-current versions for:{" "} + {decodeFileName(selectedObject)}?
+
+ To continue please type YES, PROCEED in the box. + + ) => { + setTypeConfirm(event.target.value); + }} + label="" + value={typeConfirm} + /> + +
+ } + /> + ); +}; + +const mapDispatchToProps = { + setErrorSnackMessage, +}; + +const connector = connect(null, mapDispatchToProps); + +export default connector(DeleteNonCurrentVersions); diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/VersionsNavigator.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/VersionsNavigator.tsx index 86f25a001..38f2b2ff2 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/VersionsNavigator.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/VersionsNavigator.tsx @@ -55,11 +55,13 @@ import { } from "../../../../ObjectBrowser/actions"; import { AppState } from "../../../../../../store"; -import { VersionsIcon } from "../../../../../../icons"; +import { DeleteNonCurrentIcon, VersionsIcon } from "../../../../../../icons"; import VirtualizedList from "../../../../Common/VirtualizedList/VirtualizedList"; import FileVersionItem from "./FileVersionItem"; import SelectWrapper from "../../../../Common/FormComponents/SelectWrapper/SelectWrapper"; import PreviewFileModal from "../Preview/PreviewFileModal"; +import RBIconButton from "../../../BucketDetails/SummaryItems/RBIconButton"; +import DeleteNonCurrent from "../ListObjects/DeleteNonCurrent"; const styles = (theme: Theme) => createStyles({ @@ -160,6 +162,8 @@ const VersionsNavigator = ({ const [restoreVersion, setRestoreVersion] = useState(""); const [sortValue, setSortValue] = useState("date"); const [previewOpen, setPreviewOpen] = useState(false); + const [deleteNonCurrentOpen, setDeleteNonCurrentOpen] = + useState(false); // calculate object name to display let objectNameArray: string[] = []; @@ -283,6 +287,16 @@ const VersionsNavigator = ({ } }; + const closeDeleteNonCurrent = (reloadAfterDelete: boolean) => { + setDeleteNonCurrentOpen(false); + + if (reloadAfterDelete) { + setLoadingVersions(true); + setSelectedVersion(""); + setLoadingObjectInfo(true); + } + }; + const totalSpace = versions.reduce((acc: number, currValue: IFileInfo) => { if (currValue.size) { return acc + parseInt(currValue.size); @@ -376,6 +390,14 @@ const VersionsNavigator = ({ }} /> )} + {deleteNonCurrentOpen && ( + + )} {!actualInfo && ( @@ -417,6 +439,18 @@ const VersionsNavigator = ({ } actions={ + { + setDeleteNonCurrentOpen(true); + }} + text={""} + icon={} + color="secondary" + style={{ marginRight: 15 }} + disabled={versions.length <= 1} + /> Sort by { DeleteIcon + + +
+ DeleteNonCurrentIcon +
+
diff --git a/restapi/embedded_spec.go b/restapi/embedded_spec.go index 5c7228d6d..b977bcf64 100644 --- a/restapi/embedded_spec.go +++ b/restapi/embedded_spec.go @@ -1315,6 +1315,11 @@ func init() { "type": "boolean", "name": "all_versions", "in": "query" + }, + { + "type": "boolean", + "name": "non_current_versions", + "in": "query" } ], "responses": { @@ -7799,6 +7804,11 @@ func init() { "type": "boolean", "name": "all_versions", "in": "query" + }, + { + "type": "boolean", + "name": "non_current_versions", + "in": "query" } ], "responses": { diff --git a/restapi/operations/user_api/delete_object_parameters.go b/restapi/operations/user_api/delete_object_parameters.go index dcd721e88..1d44382ce 100644 --- a/restapi/operations/user_api/delete_object_parameters.go +++ b/restapi/operations/user_api/delete_object_parameters.go @@ -59,6 +59,10 @@ type DeleteObjectParams struct { In: path */ BucketName string + /* + In: query + */ + NonCurrentVersions *bool /* Required: true In: query @@ -95,6 +99,11 @@ func (o *DeleteObjectParams) BindRequest(r *http.Request, route *middleware.Matc res = append(res, err) } + qNonCurrentVersions, qhkNonCurrentVersions, _ := qs.GetOK("non_current_versions") + if err := o.bindNonCurrentVersions(qNonCurrentVersions, qhkNonCurrentVersions, route.Formats); err != nil { + res = append(res, err) + } + qPath, qhkPath, _ := qs.GetOK("path") if err := o.bindPath(qPath, qhkPath, route.Formats); err != nil { res = append(res, err) @@ -152,6 +161,29 @@ func (o *DeleteObjectParams) bindBucketName(rawData []string, hasKey bool, forma return nil } +// bindNonCurrentVersions binds and validates parameter NonCurrentVersions from query. +func (o *DeleteObjectParams) bindNonCurrentVersions(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + + value, err := swag.ConvertBool(raw) + if err != nil { + return errors.InvalidType("non_current_versions", "query", "bool", raw) + } + o.NonCurrentVersions = &value + + return nil +} + // bindPath binds and validates parameter Path from query. func (o *DeleteObjectParams) bindPath(rawData []string, hasKey bool, formats strfmt.Registry) error { if !hasKey { diff --git a/restapi/operations/user_api/delete_object_urlbuilder.go b/restapi/operations/user_api/delete_object_urlbuilder.go index 65364f33f..da50ecb20 100644 --- a/restapi/operations/user_api/delete_object_urlbuilder.go +++ b/restapi/operations/user_api/delete_object_urlbuilder.go @@ -35,10 +35,11 @@ import ( type DeleteObjectURL struct { BucketName string - AllVersions *bool - Path string - Recursive *bool - VersionID *string + AllVersions *bool + NonCurrentVersions *bool + Path string + Recursive *bool + VersionID *string _basePath string // avoid unkeyed usage @@ -89,6 +90,14 @@ func (o *DeleteObjectURL) Build() (*url.URL, error) { qs.Set("all_versions", allVersionsQ) } + var nonCurrentVersionsQ string + if o.NonCurrentVersions != nil { + nonCurrentVersionsQ = swag.FormatBool(*o.NonCurrentVersions) + } + if nonCurrentVersionsQ != "" { + qs.Set("non_current_versions", nonCurrentVersionsQ) + } + pathQ := o.Path if pathQ != "" { qs.Set("path", pathQ) diff --git a/restapi/user_objects.go b/restapi/user_objects.go index 55a5f2c44..684df5be4 100644 --- a/restapi/user_objects.go +++ b/restapi/user_objects.go @@ -570,6 +570,7 @@ func getDeleteObjectResponse(session *models.Principal, params user_api.DeleteOb var rec bool var version string var allVersions bool + var nonCurrentVersions bool if params.Recursive != nil { rec = *params.Recursive } @@ -579,7 +580,16 @@ func getDeleteObjectResponse(session *models.Principal, params user_api.DeleteOb if params.AllVersions != nil { allVersions = *params.AllVersions } - err = deleteObjects(ctx, mcClient, params.BucketName, prefix, version, rec, allVersions) + if params.NonCurrentVersions != nil { + nonCurrentVersions = *params.NonCurrentVersions + } + + if allVersions && nonCurrentVersions { + err := errors.New("cannot set delete all versions and delete non-current versions flags at the same time") + return prepareError(err) + } + + err = deleteObjects(ctx, mcClient, params.BucketName, prefix, version, rec, allVersions, nonCurrentVersions) if err != nil { return prepareError(err) } @@ -606,7 +616,7 @@ func getDeleteMultiplePathsResponse(session *models.Principal, params user_api.D // create a mc S3Client interface implementation // defining the client to be used mcClient := mcClient{client: s3Client} - err = deleteObjects(ctx, mcClient, params.BucketName, params.Files[i].Path, version, params.Files[i].Recursive, allVersions) + err = deleteObjects(ctx, mcClient, params.BucketName, params.Files[i].Path, version, params.Files[i].Recursive, allVersions, false) if err != nil { return prepareError(err) } @@ -615,7 +625,15 @@ func getDeleteMultiplePathsResponse(session *models.Principal, params user_api.D } // deleteObjects deletes either a single object or multiple objects based on recursive flag -func deleteObjects(ctx context.Context, client MCClient, bucket string, path string, versionID string, recursive bool, allVersions bool) error { +func deleteObjects(ctx context.Context, client MCClient, bucket string, path string, versionID string, recursive bool, allVersions bool, nonCurrentVersionsOnly bool) error { + // Delete All non-Current versions only. + if nonCurrentVersionsOnly { + if err := deleteNonCurrentVersions(ctx, client, bucket, path); err != nil { + return err + } + return nil + } + if allVersions { if err := deleteMultipleObjects(ctx, client, recursive, true); err != nil { return err @@ -718,6 +736,25 @@ func deleteSingleObject(ctx context.Context, client MCClient, bucket, object str return nil } +func deleteNonCurrentVersions(ctx context.Context, client MCClient, bucket, path string) error { + // Get current object versions + for lsObj := range client.list(ctx, mc.ListOptions{WithDeleteMarkers: true, WithOlderVersions: true, Recursive: true}) { + if lsObj.Err != nil { + return errors.New(lsObj.Err.String()) + } + + if !lsObj.IsLatest { + err := deleteSingleObject(ctx, client, bucket, path, lsObj.VersionID) + + if err != nil { + return err + } + } + } + + return nil +} + func getUploadObjectResponse(session *models.Principal, params user_api.PostBucketsBucketNameObjectsUploadParams) *models.Error { ctx := context.Background() mClient, err := newMinioClient(session) diff --git a/restapi/user_objects_test.go b/restapi/user_objects_test.go index 67ff2d165..9f7d0057c 100644 --- a/restapi/user_objects_test.go +++ b/restapi/user_objects_test.go @@ -583,6 +583,7 @@ func Test_deleteObjects(t *testing.T) { path string versionID string recursive bool + nonCurrent bool listFunc func(ctx context.Context, opts mc.ListOptions) <-chan *mc.ClientContent removeFunc func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult } @@ -594,9 +595,10 @@ func Test_deleteObjects(t *testing.T) { { test: "Remove single object", args: args{ - path: "obj.txt", - versionID: "", - recursive: false, + path: "obj.txt", + versionID: "", + recursive: false, + nonCurrent: false, removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { resultCh := make(chan mc.RemoveResult, 1) resultCh <- mc.RemoveResult{Err: nil} @@ -609,9 +611,10 @@ func Test_deleteObjects(t *testing.T) { { test: "Error on Remove single object", args: args{ - path: "obj.txt", - versionID: "", - recursive: false, + path: "obj.txt", + versionID: "", + recursive: false, + nonCurrent: false, removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { resultCh := make(chan mc.RemoveResult, 1) resultCh <- mc.RemoveResult{Err: probe.NewError(errors.New("probe error"))} @@ -624,9 +627,10 @@ func Test_deleteObjects(t *testing.T) { { test: "Remove multiple objects", args: args{ - path: "path/", - versionID: "", - recursive: true, + path: "path/", + versionID: "", + recursive: true, + nonCurrent: false, removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { resultCh := make(chan mc.RemoveResult, 1) resultCh <- mc.RemoveResult{Err: nil} @@ -647,9 +651,10 @@ func Test_deleteObjects(t *testing.T) { // while deleting multiple objects test: "Error on Remove multiple objects 1", args: args{ - path: "path/", - versionID: "", - recursive: true, + path: "path/", + versionID: "", + recursive: true, + nonCurrent: false, removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { resultCh := make(chan mc.RemoveResult, 1) resultCh <- mc.RemoveResult{Err: nil} @@ -670,9 +675,58 @@ func Test_deleteObjects(t *testing.T) { // while deleting multiple objects test: "Error on Remove multiple objects 2", args: args{ - path: "path/", - versionID: "", - recursive: true, + path: "path/", + versionID: "", + recursive: true, + nonCurrent: false, + removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { + resultCh := make(chan mc.RemoveResult, 1) + resultCh <- mc.RemoveResult{Err: probe.NewError(errors.New("probe error"))} + close(resultCh) + return resultCh + }, + listFunc: func(ctx context.Context, opts mc.ListOptions) <-chan *mc.ClientContent { + ch := make(chan *mc.ClientContent, 1) + ch <- &mc.ClientContent{} + close(ch) + return ch + }, + }, + wantError: errors.New("probe error"), + }, + { + // Description handle error when error happens on remove function + // while deleting multiple objects + test: "Remove non current objects", + args: args{ + path: "path/", + versionID: "", + recursive: true, + nonCurrent: true, + removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { + resultCh := make(chan mc.RemoveResult, 1) + resultCh <- mc.RemoveResult{Err: nil} + close(resultCh) + return resultCh + }, + listFunc: func(ctx context.Context, opts mc.ListOptions) <-chan *mc.ClientContent { + ch := make(chan *mc.ClientContent, 1) + ch <- &mc.ClientContent{} + close(ch) + return ch + }, + }, + wantError: nil, + }, + { + // Description handle error when error happens on remove function + // while deleting multiple objects + test: "Error deleting non current objects", + args: args{ + path: "path/", + versionID: "", + recursive: true, + nonCurrent: true, removeFunc: func(ctx context.Context, isIncomplete, isRemoveBucket, isBypass bool, contentCh <-chan *mc.ClientContent) <-chan mc.RemoveResult { resultCh := make(chan mc.RemoveResult, 1) resultCh <- mc.RemoveResult{Err: probe.NewError(errors.New("probe error"))} @@ -695,7 +749,7 @@ func Test_deleteObjects(t *testing.T) { t.Run(tt.test, func(t *testing.T) { mcListMock = tt.args.listFunc mcRemoveMock = tt.args.removeFunc - err := deleteObjects(ctx, s3Client1, tt.args.bucket, tt.args.path, tt.args.versionID, tt.args.recursive, false) + err := deleteObjects(ctx, s3Client1, tt.args.bucket, tt.args.path, tt.args.versionID, tt.args.recursive, false, tt.args.nonCurrent) if err == nil && tt.wantError != nil { t.Errorf("deleteObjects() error: %v, wantErr: %v", err, tt.wantError) } else if err != nil && tt.wantError == nil { diff --git a/swagger-console.yml b/swagger-console.yml index 845b93849..d764eb191 100644 --- a/swagger-console.yml +++ b/swagger-console.yml @@ -341,6 +341,10 @@ paths: in: query required: false type: boolean + - name: non_current_versions + in: query + required: false + type: boolean responses: 200: description: A successful response.