From c529a6d127cf650176fd666322275ad887fb27b6 Mon Sep 17 00:00:00 2001 From: Alex <33497058+bexsoft@users.noreply.github.com> Date: Tue, 30 Nov 2021 12:06:01 -0600 Subject: [PATCH] Added Object Manager feature for Uploads & downloads (#1265) Signed-off-by: Benjamin Perez --- portal-ui/src/icons/ObjectManagerIcon.tsx | 47 +++ portal-ui/src/icons/index.ts | 1 + .../Objects/ListObjects/ListObjects.tsx | 307 +++++++----------- .../ListBuckets/Objects/ListObjects/utils.tsx | 133 ++++++++ .../Objects/ObjectDetails/ObjectDetails.tsx | 46 ++- .../Buckets/ListBuckets/Objects/utils.ts | 48 ++- .../screens/Console/Common/IconsScreen.tsx | 6 + .../Common/ObjectManager/ObjectHandled.tsx | 164 ++++++++++ .../Common/ObjectManager/ObjectManager.tsx | 136 ++++++++ .../Console/Common/PageHeader/PageHeader.tsx | 58 +++- .../ProgressBarWrapper/ProgressBarWrapper.tsx | 34 +- portal-ui/src/screens/Console/Console.tsx | 7 + .../screens/Console/ObjectBrowser/actions.ts | 90 ++++- .../screens/Console/ObjectBrowser/reducers.ts | 117 +++++++ 14 files changed, 972 insertions(+), 222 deletions(-) create mode 100644 portal-ui/src/icons/ObjectManagerIcon.tsx create mode 100644 portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/utils.tsx create mode 100644 portal-ui/src/screens/Console/Common/ObjectManager/ObjectHandled.tsx create mode 100644 portal-ui/src/screens/Console/Common/ObjectManager/ObjectManager.tsx diff --git a/portal-ui/src/icons/ObjectManagerIcon.tsx b/portal-ui/src/icons/ObjectManagerIcon.tsx new file mode 100644 index 000000000..0f81ba6b4 --- /dev/null +++ b/portal-ui/src/icons/ObjectManagerIcon.tsx @@ -0,0 +1,47 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 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 ObjectManagerIcon = (props: SVGProps) => { + return ( + + + + + + + + ); +}; + +export default ObjectManagerIcon; diff --git a/portal-ui/src/icons/index.ts b/portal-ui/src/icons/index.ts index a41c034c9..84bedf31c 100644 --- a/portal-ui/src/icons/index.ts +++ b/portal-ui/src/icons/index.ts @@ -77,6 +77,7 @@ export { default as CircleIcon } from "./CircleIcon"; export { default as PreviewIcon } from "./PreviewIcon"; export { default as LockIcon } from "./LockIcon"; export { default as VersionIcon } from "./VersionIcon"; +export { default as ObjectManagerIcon } from "./ObjectManagerIcon"; export { default as FileLockIcon } from "./FileLockIcon"; export { default as FileXlsIcon } from "./FileXlsIcon"; diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx index 3a0a0d4a6..7fe43153b 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx @@ -64,8 +64,6 @@ import { ErrorResponseHandler } from "../../../../../../common/types"; import ScreenTitle from "../../../../Common/ScreenTitle/ScreenTitle"; import AddFolderIcon from "../../../../../../icons/AddFolderIcon"; import HistoryIcon from "../../../../../../icons/HistoryIcon"; -import ObjectBrowserIcon from "../../../../../../icons/ObjectBrowserIcon"; -import ObjectBrowserFolderIcon from "../../../../../../icons/ObjectBrowserFolderIcon"; import FolderIcon from "../../../../../../icons/FolderIcon"; import RefreshIcon from "../../../../../../icons/RefreshIcon"; import UploadIcon from "../../../../../../icons/UploadIcon"; @@ -73,24 +71,7 @@ import { setBucketDetailsLoad, setBucketInfo } from "../../../actions"; import { AppState } from "../../../../../../store"; import PageLayout from "../../../../Common/Layout/PageLayout"; import BoxIconButton from "../../../../Common/BoxIconButton/BoxIconButton"; -import { - DeleteIcon, - FileBookIcon, - FileCodeIcon, - FileConfigIcon, - FileDbIcon, - FileFontIcon, - FileImageIcon, - FileLockIcon, - FileMissingIcon, - FileMusicIcon, - FilePdfIcon, - FilePptIcon, - FileTxtIcon, - FileVideoIcon, - FileXlsIcon, - FileZipIcon, -} from "../../../../../../icons"; +import { DeleteIcon } from "../../../../../../icons"; import { IAM_SCOPES } from "../../../../../../common/SecureComponent/permissions"; import SecureComponent, { hasPermission, @@ -98,6 +79,12 @@ import SecureComponent, { import SearchBox from "../../../../Common/SearchBox"; import withSuspense from "../../../../Common/Components/withSuspense"; +import { + setNewObject, + updateProgress, + completeObject, +} from "../../../../ObjectBrowser/actions"; +import { displayName } from "./utils"; const CreateFolderModal = withSuspense( React.lazy(() => import("./CreateFolderModal")) @@ -233,6 +220,9 @@ interface IListObjectsProps { setBucketInfo: typeof setBucketInfo; bucketInfo: BucketInfo | null; setBucketDetailsLoad: typeof setBucketDetailsLoad; + setNewObject: typeof setNewObject; + updateProgress: typeof updateProgress; + completeObject: typeof completeObject; } function useInterval(callback: any, delay: number) { @@ -277,6 +267,9 @@ const ListObjects = ({ loadingBucket, setBucketInfo, bucketInfo, + setNewObject, + updateProgress, + completeObject, }: IListObjectsProps) => { const [records, setRecords] = useState([]); const [loading, setLoading] = useState(true); @@ -616,62 +609,86 @@ const ListObjects = ({ return; } e.preventDefault(); + let files = e.target.files; - let uploadUrl = `api/v1/buckets/${bucketName}/objects/upload`; - if (encodedPath !== "") { - uploadUrl = `${uploadUrl}?prefix=${encodedPath}`; - } - let xhr = new XMLHttpRequest(); - const areMultipleFiles = files.length > 1; - const errorMessage = `An error occurred while uploading the file${ - areMultipleFiles ? "s" : "" - }.`; - const okMessage = `Object${ - areMultipleFiles ? "s" : `` - } uploaded successfully.`; - xhr.open("POST", uploadUrl, true); + if (files.length > 0) { + let uploadUrl = `api/v1/buckets/${bucketName}/objects/upload`; - xhr.withCredentials = false; - xhr.onload = function (event) { - if ( - xhr.status === 401 || - xhr.status === 403 || - xhr.status === 400 || - xhr.status === 500 - ) { - setSnackBarMessage(errorMessage); + if (encodedPath !== "") { + uploadUrl = `${uploadUrl}?prefix=${encodedPath}`; } - if (xhr.status === 200) { - setSnackBarMessage(okMessage); + + for (let file of files) { + const fileName = file.name; + const blobFile = new Blob([file], { type: file.type }); + + const identity = btoa( + `${bucketName}-${encodedPath}-${new Date().getTime()}-${Math.random()}` + ); + + setNewObject({ + bucketName, + done: false, + instanceID: identity, + percentage: 0, + prefix: `${decodeFileName(encodedPath)}${fileName}`, + type: "upload", + waitingForFile: false, + }); + + let xhr = new XMLHttpRequest(); + const areMultipleFiles = files.length > 1; + const errorMessage = `An error occurred while uploading the file${ + areMultipleFiles ? "s" : "" + }.`; + const okMessage = `Object${ + areMultipleFiles ? "s" : `` + } uploaded successfully.`; + + xhr.open("POST", uploadUrl, true); + + xhr.withCredentials = false; + xhr.onload = function (event) { + if ( + xhr.status === 401 || + xhr.status === 403 || + xhr.status === 400 || + xhr.status === 500 + ) { + setSnackBarMessage(errorMessage); + } + if (xhr.status === 200) { + completeObject(identity); + setSnackBarMessage(okMessage); + } + }; + + xhr.upload.addEventListener("error", (event) => { + setSnackBarMessage(errorMessage); + }); + + xhr.upload.addEventListener("progress", (event) => { + const progress = Math.floor((event.loaded * 100) / event.total); + + updateProgress(identity, progress); + }); + + xhr.onerror = () => { + setSnackBarMessage(errorMessage); + }; + xhr.onloadend = () => { + setLoading(true); + setLoadingProgress(100); + }; + + const formData = new FormData(); + formData.append(file.size, blobFile, fileName); + + xhr.send(formData); } - }; - - xhr.upload.addEventListener("error", (event) => { - setSnackBarMessage(errorMessage); - }); - - xhr.upload.addEventListener("progress", (event) => { - setLoadingProgress(Math.floor((event.loaded * 100) / event.total)); - }); - - xhr.onerror = () => { - setSnackBarMessage(errorMessage); - }; - xhr.onloadend = () => { - setLoading(true); - setLoadingProgress(100); - }; - - const formData = new FormData(); - - for (let file of files) { - const fileName = file.name; - const blobFile = new Blob([file], { type: file.type }); - formData.append(file.size, blobFile, fileName); } - xhr.send(formData); e.target.value = null; }; @@ -699,14 +716,32 @@ const ListObjects = ({ }; const downloadObject = (object: BucketObject) => { - if (object.size > 104857600) { - // If file is bigger than 100MB we show a notification - setSnackBarMessage( - "Download process started, it may take a few moments to complete" - ); - } + const identityDownload = btoa( + `${bucketName}-${object.name}-${new Date().getTime()}-${Math.random()}` + ); - download(bucketName, encodeFileName(object.name), object.version_id); + setNewObject({ + bucketName, + done: false, + instanceID: identityDownload, + percentage: 0, + prefix: object.name, + type: "download", + waitingForFile: true, + }); + + download( + bucketName, + encodeFileName(object.name), + object.version_id, + object.size, + (progress) => { + updateProgress(identityDownload, progress); + }, + () => { + completeObject(identityDownload); + } + ); }; const openPath = (idElement: string) => { @@ -783,113 +818,6 @@ const ListObjects = ({ }); } - const displayName = (element: string) => { - let elementString = element; - let icon = ; - // Element is a folder - if (element.endsWith("/")) { - icon = ; - elementString = element.substr(0, element.length - 1); - } - - interface IExtToIcon { - icon: any; - extensions: string[]; - } - - const extensionToIcon: IExtToIcon[] = [ - { - icon: , - extensions: ["mp4", "mov", "avi", "mpeg", "mpg"], - }, - { - icon: , - extensions: ["mp3", "m4a", "aac"], - }, - { - icon: , - extensions: ["pdf"], - }, - { - icon: , - extensions: ["ppt", "pptx"], - }, - { - icon: , - extensions: ["xls", "xlsx"], - }, - { - icon: , - extensions: ["cer", "crt", "pem"], - }, - { - icon: , - extensions: [ - "html", - "xml", - "css", - "py", - "go", - "php", - "cpp", - "h", - "java", - ], - }, - { - icon: , - extensions: ["cfg", "yaml"], - }, - { - icon: , - extensions: ["sql"], - }, - { - icon: , - extensions: ["ttf", "otf"], - }, - { - icon: , - extensions: ["txt"], - }, - { - icon: , - extensions: ["zip", "rar", "tar", "gz"], - }, - { - icon: , - extensions: ["epub", "mobi", "azw", "azw3"], - }, - { - icon: , - extensions: ["jpeg", "jpg", "gif", "tiff", "png", "heic", "dng"], - }, - ]; - const lowercaseElement = element.toLowerCase(); - for (const etc of extensionToIcon) { - for (const ext of etc.extensions) { - if (lowercaseElement.endsWith(`.${ext}`)) { - icon = etc.icon; - } - } - } - - if (!element.endsWith("/") && element.indexOf(".") < 0) { - icon = ; - } - - const splitItem = elementString.split("/"); - - return ( -
- {icon} - - {splitItem[splitItem.length - 1]} - -
- ); - }; - const filteredRecords = records.filter((b: BucketObject) => { if (filterObjects === "") { return true; @@ -938,11 +866,15 @@ const ListObjects = ({ setLoading(true); }; + const renderName = (element: string) => { + return displayName(element, classes); + }; + const listModeColumns = [ { label: "Name", elementKey: "name", - renderFunction: displayName, + renderFunction: renderName, enableSort: true, }, { @@ -967,7 +899,7 @@ const ListObjects = ({ { label: "Name", elementKey: "name", - renderFunction: displayName, + renderFunction: renderName, enableSort: true, }, { @@ -1118,7 +1050,7 @@ const ListObjects = ({ uploadObject(e)} id="file-input" style={{ display: "none" }} @@ -1244,6 +1176,9 @@ const mapDispatchToProps = { resetRewind, setBucketDetailsLoad, setBucketInfo, + setNewObject, + updateProgress, + completeObject, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/utils.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/utils.tsx new file mode 100644 index 000000000..5e79075e0 --- /dev/null +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/utils.tsx @@ -0,0 +1,133 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 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 from "react"; +import { + FileBookIcon, + FileCodeIcon, + FileConfigIcon, + FileDbIcon, + FileFontIcon, + FileImageIcon, + FileLockIcon, + FileMissingIcon, + FileMusicIcon, + FilePdfIcon, + FilePptIcon, + FileTxtIcon, + FileVideoIcon, + FileXlsIcon, + FileZipIcon, +} from "../../../../../../icons"; +import ObjectBrowserIcon from "../../../../../../icons/ObjectBrowserIcon"; +import ObjectBrowserFolderIcon from "../../../../../../icons/ObjectBrowserFolderIcon"; + +export const displayName = (element: string, classes: any) => { + let elementString = element; + let icon = ; + // Element is a folder + if (element.endsWith("/")) { + icon = ; + elementString = element.substr(0, element.length - 1); + } + + interface IExtToIcon { + icon: any; + extensions: string[]; + } + + const extensionToIcon: IExtToIcon[] = [ + { + icon: , + extensions: ["mp4", "mov", "avi", "mpeg", "mpg"], + }, + { + icon: , + extensions: ["mp3", "m4a", "aac"], + }, + { + icon: , + extensions: ["pdf"], + }, + { + icon: , + extensions: ["ppt", "pptx"], + }, + { + icon: , + extensions: ["xls", "xlsx"], + }, + { + icon: , + extensions: ["cer", "crt", "pem"], + }, + { + icon: , + extensions: ["html", "xml", "css", "py", "go", "php", "cpp", "h", "java"], + }, + { + icon: , + extensions: ["cfg", "yaml"], + }, + { + icon: , + extensions: ["sql"], + }, + { + icon: , + extensions: ["ttf", "otf"], + }, + { + icon: , + extensions: ["txt"], + }, + { + icon: , + extensions: ["zip", "rar", "tar", "gz"], + }, + { + icon: , + extensions: ["epub", "mobi", "azw", "azw3"], + }, + { + icon: , + extensions: ["jpeg", "jpg", "gif", "tiff", "png", "heic", "dng"], + }, + ]; + const lowercaseElement = element.toLowerCase(); + for (const etc of extensionToIcon) { + for (const ext of etc.extensions) { + if (lowercaseElement.endsWith(`.${ext}`)) { + icon = etc.icon; + } + } + } + + if (!element.endsWith("/") && element.indexOf(".") < 0) { + icon = ; + } + + const splitItem = elementString.split("/"); + + return ( +
+ {icon} + + {splitItem[splitItem.length - 1]} + +
+ ); +}; diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ObjectDetails.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ObjectDetails.tsx index e0e44956d..3624ff8c9 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ObjectDetails.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ObjectDetails.tsx @@ -81,6 +81,11 @@ import VerticalTabs from "../../../../Common/VerticalTabs/VerticalTabs"; import BoxIconButton from "../../../../Common/BoxIconButton/BoxIconButton"; import { RecoverIcon } from "../../../../../../icons"; import SecureComponent from "../../../../../../common/SecureComponent/SecureComponent"; +import { + setNewObject, + updateProgress, + completeObject, +} from "../../../../ObjectBrowser/actions"; const styles = (theme: Theme) => createStyles({ @@ -225,6 +230,9 @@ interface IObjectDetailsProps { distributedSetup: boolean; setErrorSnackMessage: typeof setErrorSnackMessage; setSnackBarMessage: typeof setSnackBarMessage; + setNewObject: typeof setNewObject; + updateProgress: typeof updateProgress; + completeObject: typeof completeObject; } const emptyFile: IFileInfo = { @@ -249,6 +257,9 @@ const ObjectDetails = ({ bucketToRewind, setErrorSnackMessage, setSnackBarMessage, + setNewObject, + updateProgress, + completeObject, }: IObjectDetailsProps) => { const [loadObjectData, setLoadObjectData] = useState(true); const [shareFileModalOpen, setShareFileModalOpen] = useState(false); @@ -367,14 +378,32 @@ const ObjectDetails = ({ }; const downloadObject = (object: IFileInfo) => { - if (object.size && parseInt(object.size) > 104857600) { - // If file is bigger than 100MB we show a notification - setSnackBarMessage( - "Download process started, it may take a few moments to complete" - ); - } + const identityDownload = btoa( + `${bucketName}-${object.name}-${new Date().getTime()}-${Math.random()}` + ); - download(bucketName, internalPaths, object.version_id); + setNewObject({ + bucketName, + done: false, + instanceID: identityDownload, + percentage: 0, + prefix: object.name, + type: "download", + waitingForFile: true, + }); + + download( + bucketName, + internalPaths, + object.version_id, + parseInt(object.size || "0"), + (progress) => { + updateProgress(identityDownload, progress); + }, + () => { + completeObject(identityDownload); + } + ); }; const tableActions: ItemActions[] = [ @@ -951,6 +980,9 @@ const mapStateToProps = ({ objectBrowser, system }: AppState) => ({ const mapDispatchToProps = { setErrorSnackMessage, setSnackBarMessage, + setNewObject, + updateProgress, + completeObject, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts index de4ba33b5..7e78e68b8 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts @@ -19,7 +19,10 @@ import { BucketObject, RewindObject } from "./ListObjects/types"; export const download = ( bucketName: string, objectPath: string, - versionID: any + versionID: any, + fileSize: number, + progressCallback: (progress: number) => void, + completeCallback: () => void ) => { const anchor = document.createElement("a"); document.body.appendChild(anchor); @@ -27,7 +30,46 @@ export const download = ( if (versionID) { path = path.concat(`&version_id=${versionID}`); } - window.location.href = path; + + var req = new XMLHttpRequest(); + + req.open("GET", path, true); + req.addEventListener( + "progress", + function (evt) { + var percentComplete = Math.round((evt.loaded / fileSize) * 100); + + if (progressCallback) { + progressCallback(percentComplete); + } + }, + false + ); + + req.responseType = "blob"; + req.onreadystatechange = () => { + if (req.readyState === 4 && req.status === 200) { + const rspHeader = req.getResponseHeader("Content-Disposition"); + + let filename = "download"; + + if (rspHeader) { + filename = rspHeader.split('"')[1]; + } + + if (completeCallback) { + completeCallback(); + } + + var link = document.createElement("a"); + link.href = window.URL.createObjectURL(req.response); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }; + req.send(); }; // Review file extension by name & returns the type of preview browser that can be used @@ -50,7 +92,7 @@ export const extensionPreview = ( "png", "heic", ]; - const textExtensions = ["pdf", "txt"]; + const textExtensions = ["pdf", "txt", "json"]; const audioExtensions = ["wav", "mp3", "alac", "aiff", "dsd", "pcm"]; const videoExtensions = [ "mp4", diff --git a/portal-ui/src/screens/Console/Common/IconsScreen.tsx b/portal-ui/src/screens/Console/Common/IconsScreen.tsx index 18a51fa0f..29215e977 100644 --- a/portal-ui/src/screens/Console/Common/IconsScreen.tsx +++ b/portal-ui/src/screens/Console/Common/IconsScreen.tsx @@ -117,6 +117,7 @@ import { VersionIcon, WarpIcon, WatchIcon, + ObjectManagerIcon, } from "../../../icons"; import WarnIcon from "../../../icons/WarnIcon"; @@ -717,6 +718,11 @@ const IconsScreen = ({ classes }: IIconsScreenSimple) => { WarnIcon + + +
+ ObjectManagerIcon +
); }; diff --git a/portal-ui/src/screens/Console/Common/ObjectManager/ObjectHandled.tsx b/portal-ui/src/screens/Console/Common/ObjectManager/ObjectHandled.tsx new file mode 100644 index 000000000..9c756608d --- /dev/null +++ b/portal-ui/src/screens/Console/Common/ObjectManager/ObjectHandled.tsx @@ -0,0 +1,164 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 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, { Fragment } from "react"; +import { Theme } from "@mui/material/styles"; +import { Tooltip } from "@mui/material"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { IFileItem } from "../../ObjectBrowser/reducers"; +import ProgressBarWrapper from "../ProgressBarWrapper/ProgressBarWrapper"; +import { DownloadStatIcon, UploadStatIcon } from "../../../../icons"; + +interface IObjectHandled { + classes: any; + objectToDisplay: IFileItem; + deleteFromList: (instanceID: string) => void; +} + +const styles = (theme: Theme) => + createStyles({ + container: { + borderBottom: "#E2E2E2 1px solid", + padding: "15px 5px", + margin: "0 15px", + position: "relative", + "& .showOnHover": { + opacity: 0, + transitionDuration: "0.2s", + }, + "&.inProgress": { + "& .hideOnProgress": { + visibility: "hidden", + }, + }, + "&:hover": { + "& .showOnHover": { + opacity: 1, + }, + }, + }, + headItem: { + color: "#868686", + fontSize: 12, + width: "100%", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + overflow: "hidden", + }, + progressContainer: { + marginTop: 5, + }, + objectDetails: { + display: "flex", + alignItems: "center", + }, + iconContainer: { + paddingTop: 5, + marginRight: 5, + "& svg": { + width: 20, + height: 20, + }, + }, + closeIcon: { + "&::before": { + width: 1, + height: 12, + content: "' '", + position: "absolute", + transform: "rotate(45deg)", + borderLeft: "#9c9c9c 2px solid", + }, + "&::after": { + width: 1, + height: 12, + content: "' '", + position: "absolute", + transform: "rotate(-45deg)", + borderLeft: "#9c9c9c 2px solid", + }, + }, + closeButton: { + backgroundColor: "transparent", + border: 0, + right: 0, + position: "absolute", + }, + fileName: { + width: 230, + }, + }); + +const ObjectHandled = ({ + classes, + objectToDisplay, + deleteFromList, +}: IObjectHandled) => { + const prefix = `/${objectToDisplay.prefix}`; + return ( + +
+
+ +
+
+
+ {objectToDisplay.type === "download" ? ( + + ) : ( + + )} +
+
+
+ Bucket: + {objectToDisplay.bucketName} +
+ +
{prefix}
+
+
+
+
+ {objectToDisplay.waitingForFile ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default withStyles(styles)(ObjectHandled); diff --git a/portal-ui/src/screens/Console/Common/ObjectManager/ObjectManager.tsx b/portal-ui/src/screens/Console/Common/ObjectManager/ObjectManager.tsx new file mode 100644 index 000000000..5c2f8dc3c --- /dev/null +++ b/portal-ui/src/screens/Console/Common/ObjectManager/ObjectManager.tsx @@ -0,0 +1,136 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 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, { Fragment } from "react"; +import { Theme } from "@mui/material/styles"; +import { connect } from "react-redux"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { Tooltip, IconButton } from "@mui/material"; +import { AppState } from "../../../../store"; +import { IFileItem } from "../../ObjectBrowser/reducers"; +import { deleteFromList, cleanList } from "../../ObjectBrowser/actions"; +import { TrashIcon } from "../../../../icons"; +import ObjectHandled from "./ObjectHandled"; + +interface IObjectManager { + objects: IFileItem[]; + classes: any; + managerOpen: boolean; + deleteFromList: typeof deleteFromList; + cleanList: typeof cleanList; +} + +const styles = (theme: Theme) => + createStyles({ + downloadContainer: { + border: "#EAEDEE 1px solid", + boxShadow: "rgba(0, 0, 0, 0.08) 0 3px 10px", + backgroundColor: "#fff", + position: "absolute", + right: 0, + top: 80, + width: 300, + overflowY: "hidden", + overflowX: "hidden", + borderRadius: 3, + zIndex: 1000, + padding: 0, + height: 0, + transitionDuration: "0.3s", + visibility: "hidden", + "&.open": { + visibility: "visible", + minHeight: 400, + }, + }, + title: { + fontSize: 14, + fontWeight: "bold", + textAlign: "center", + marginBottom: 5, + paddingBottom: 12, + borderBottom: "#E2E2E2 1px solid", + margin: "15px 15px 5px 15px", + }, + actionsContainer: { + overflowY: "auto", + overflowX: "hidden", + minHeight: 250, + maxHeight: 335, + width: "100%", + display: "flex", + flexDirection: "column", + }, + cleanIcon: { + position: "absolute", + right: 14, + top: 12, + }, + cleanButton: { + "& svg": { + width: 20, + }, + }, + }); + +const ObjectManager = ({ + objects, + classes, + managerOpen, + deleteFromList, + cleanList, +}: IObjectManager) => { + return ( + +
+
+ + + + + +
+
Object Manager
+
+ {objects.map((object, key) => ( + + ))} +
+
+
+ ); +}; + +const mapState = (state: AppState) => ({ + objects: state.objectBrowser.objectManager.objectsToManage, + managerOpen: state.objectBrowser.objectManager.managerOpen, +}); + +const connector = connect(mapState, { deleteFromList, cleanList }); + +export default withStyles(styles)(connector(ObjectManager)); diff --git a/portal-ui/src/screens/Console/Common/PageHeader/PageHeader.tsx b/portal-ui/src/screens/Console/Common/PageHeader/PageHeader.tsx index 24de1191b..9793908de 100644 --- a/portal-ui/src/screens/Console/Common/PageHeader/PageHeader.tsx +++ b/portal-ui/src/screens/Console/Common/PageHeader/PageHeader.tsx @@ -1,14 +1,28 @@ import React from "react"; -import Grid from "@mui/material/Grid"; import { Theme } from "@mui/material/styles"; +import { connect } from "react-redux"; +import { Box } from "@mui/material"; +import Grid from "@mui/material/Grid"; import createStyles from "@mui/styles/createStyles"; import withStyles from "@mui/styles/withStyles"; import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; import { AppState } from "../../../../store"; -import { connect } from "react-redux"; import OperatorLogo from "../../../../icons/OperatorLogo"; import ConsoleLogo from "../../../../icons/ConsoleLogo"; -import { Box } from "@mui/material"; +import { IFileItem } from "../../ObjectBrowser/reducers"; +import { toggleList } from "../../ObjectBrowser/actions"; +import { ObjectManagerIcon } from "../../../../icons"; + +interface IPageHeader { + classes: any; + sidebarOpen?: boolean; + operatorMode?: boolean; + label: any; + actions?: any; + managerObjects?: IFileItem[]; + toggleList: typeof toggleList; +} const styles = (theme: Theme) => createStyles({ @@ -45,20 +59,14 @@ const styles = (theme: Theme) => }, }); -interface IPageHeader { - classes: any; - sidebarOpen?: boolean; - operatorMode?: boolean; - label: any; - actions?: any; -} - const PageHeader = ({ classes, label, actions, sidebarOpen, operatorMode, + managerObjects, + toggleList, }: IPageHeader) => { return ( - {actions && ( - - {actions} - - )} + + {actions && actions} + {managerObjects && managerObjects.length > 0 && ( + { + toggleList(); + }} + size="large" + > + + + )} + ); }; @@ -94,8 +113,13 @@ const PageHeader = ({ const mapState = (state: AppState) => ({ sidebarOpen: state.system.sidebarOpen, operatorMode: state.system.operatorMode, + managerObjects: state.objectBrowser.objectManager.objectsToManage, }); -const connector = connect(mapState, null); +const mapDispatchToProps = { + toggleList, +}; + +const connector = connect(mapState, mapDispatchToProps); export default connector(withStyles(styles)(PageHeader)); diff --git a/portal-ui/src/screens/Console/Common/ProgressBarWrapper/ProgressBarWrapper.tsx b/portal-ui/src/screens/Console/Common/ProgressBarWrapper/ProgressBarWrapper.tsx index 94e8f9457..f6d30f284 100644 --- a/portal-ui/src/screens/Console/Common/ProgressBarWrapper/ProgressBarWrapper.tsx +++ b/portal-ui/src/screens/Console/Common/ProgressBarWrapper/ProgressBarWrapper.tsx @@ -18,12 +18,15 @@ import React from "react"; import { styled } from "@mui/material/styles"; import LinearProgress, { linearProgressClasses, + LinearProgressProps, } from "@mui/material/LinearProgress"; +import Box from "@mui/material/Box"; interface IProgressBarWrapper { value: number; ready: boolean; indeterminate?: boolean; + withLabel?: boolean; } const BorderLinearProgress = styled(LinearProgress)(() => ({ @@ -37,18 +40,35 @@ const BorderLinearProgress = styled(LinearProgress)(() => ({ }, })); +function LinearProgressWithLabel(props: LinearProgressProps) { + return ( + + + + + + {`${Math.round(props.value || 0)}%`} + + + ); +} + const ProgressBarWrapper = ({ value, ready, indeterminate, + withLabel, }: IProgressBarWrapper) => { - return ( - - ); + const propsComponent: LinearProgressProps = { + variant: indeterminate && !ready ? "indeterminate" : "determinate", + value: ready ? 100 : value, + color: ready ? "success" : "primary", + }; + if (withLabel) { + return ; + } + + return ; }; export default ProgressBarWrapper; diff --git a/portal-ui/src/screens/Console/Console.tsx b/portal-ui/src/screens/Console/Console.tsx index 54ada76f1..8f2002301 100644 --- a/portal-ui/src/screens/Console/Console.tsx +++ b/portal-ui/src/screens/Console/Console.tsx @@ -86,6 +86,10 @@ const IconsScreen = React.lazy(() => import("./Common/IconsScreen")); const Speedtest = React.lazy(() => import("./Speedtest/Speedtest")); +const ObjectManager = React.lazy( + () => import("./Common/ObjectManager/ObjectManager") +); + const drawerWidth = 245; const Buckets = React.lazy(() => import("./Buckets/Buckets")); @@ -539,6 +543,9 @@ const Console = ({ }} /> + Loading...}> + + {allowedRoutes.map((route: any) => ( diff --git a/portal-ui/src/screens/Console/ObjectBrowser/actions.ts b/portal-ui/src/screens/Console/ObjectBrowser/actions.ts index 39514caec..38ca131dd 100644 --- a/portal-ui/src/screens/Console/ObjectBrowser/actions.ts +++ b/portal-ui/src/screens/Console/ObjectBrowser/actions.ts @@ -14,11 +14,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { IFileItem } from "./reducers"; + export const REWIND_SET_ENABLE = "REWIND/SET_ENABLE"; export const REWIND_RESET_REWIND = "REWIND/RESET_REWIND"; - export const REWIND_FILE_MODE_ENABLED = "BUCKET_BROWSER/FILE_MODE_ENABLED"; +export const OBJECT_MANAGER_NEW_OBJECT = "OBJECT_MANAGER/NEW_OBJECT"; +export const OBJECT_MANAGER_UPDATE_PROGRESS_OBJECT = + "OBJECT_MANAGER/UPDATE_PROGRESS_OBJECT"; +export const OBJECT_MANAGER_COMPLETE_OBJECT = "OBJECT_MANAGER/COMPLETE_OBJECT"; +export const OBJECT_MANAGER_DELETE_FROM_OBJECT_LIST = + "OBJECT_MANAGER/DELETE_FROM_OBJECT_LIST"; +export const OBJECT_MANAGER_CLEAN_LIST = "OBJECT_MANAGER/CLEAN_LIST"; +export const OBJECT_MANAGER_TOGGLE_LIST = "OBJECT_MANAGER/OPEN_LIST"; + interface RewindSetEnabled { type: typeof REWIND_SET_ENABLE; bucket: string; @@ -35,10 +45,45 @@ interface FileModeEnabled { status: boolean; } +interface OMNewObject { + type: typeof OBJECT_MANAGER_NEW_OBJECT; + newObject: IFileItem; +} + +interface OMUpdateProgress { + type: typeof OBJECT_MANAGER_UPDATE_PROGRESS_OBJECT; + instanceID: string; + progress: number; +} + +interface OMCompleteObject { + type: typeof OBJECT_MANAGER_COMPLETE_OBJECT; + instanceID: string; +} + +interface OMDeleteFromList { + type: typeof OBJECT_MANAGER_DELETE_FROM_OBJECT_LIST; + instanceID: string; +} + +interface OMCleanList { + type: typeof OBJECT_MANAGER_CLEAN_LIST; +} + +interface OMToggleList { + type: typeof OBJECT_MANAGER_TOGGLE_LIST; +} + export type ObjectBrowserActionTypes = | RewindSetEnabled | RewindReset - | FileModeEnabled; + | FileModeEnabled + | OMNewObject + | OMUpdateProgress + | OMCompleteObject + | OMDeleteFromList + | OMCleanList + | OMToggleList; export const setRewindEnable = ( state: boolean, @@ -65,3 +110,44 @@ export const setFileModeEnabled = (status: boolean) => { status, }; }; + +export const setNewObject = (newObject: IFileItem) => { + return { + type: OBJECT_MANAGER_NEW_OBJECT, + newObject, + }; +}; + +export const updateProgress = (instanceID: string, progress: number) => { + return { + type: OBJECT_MANAGER_UPDATE_PROGRESS_OBJECT, + instanceID, + progress, + }; +}; + +export const completeObject = (instanceID: string) => { + return { + type: OBJECT_MANAGER_COMPLETE_OBJECT, + instanceID, + }; +}; + +export const deleteFromList = (instanceID: string) => { + return { + type: OBJECT_MANAGER_DELETE_FROM_OBJECT_LIST, + instanceID, + }; +}; + +export const cleanList = () => { + return { + type: OBJECT_MANAGER_CLEAN_LIST, + }; +}; + +export const toggleList = () => { + return { + type: OBJECT_MANAGER_TOGGLE_LIST, + }; +}; diff --git a/portal-ui/src/screens/Console/ObjectBrowser/reducers.ts b/portal-ui/src/screens/Console/ObjectBrowser/reducers.ts index fe0556898..6509eb575 100644 --- a/portal-ui/src/screens/Console/ObjectBrowser/reducers.ts +++ b/portal-ui/src/screens/Console/ObjectBrowser/reducers.ts @@ -18,6 +18,12 @@ import { REWIND_RESET_REWIND, REWIND_FILE_MODE_ENABLED, ObjectBrowserActionTypes, + OBJECT_MANAGER_NEW_OBJECT, + OBJECT_MANAGER_UPDATE_PROGRESS_OBJECT, + OBJECT_MANAGER_COMPLETE_OBJECT, + OBJECT_MANAGER_DELETE_FROM_OBJECT_LIST, + OBJECT_MANAGER_CLEAN_LIST, + OBJECT_MANAGER_TOGGLE_LIST, } from "./actions"; export interface Route { @@ -35,12 +41,28 @@ export interface RewindItem { export interface ObjectBrowserState { fileMode: boolean; rewind: RewindItem; + objectManager: ObjectManager; } export interface ObjectBrowserReducer { objectBrowser: ObjectBrowserState; } +export interface ObjectManager { + objectsToManage: IFileItem[]; + managerOpen: boolean; +} + +export interface IFileItem { + type: "download" | "upload"; + instanceID: string; + bucketName: string; + prefix: string; + percentage: number; + done: boolean; + waitingForFile: boolean; +} + const defaultRewind = { rewindEnabled: false, bucketToRewind: "", @@ -52,6 +74,10 @@ const initialState: ObjectBrowserState = { rewind: { ...defaultRewind, }, + objectManager: { + objectsToManage: [], + managerOpen: false, + }, }; export function objectBrowserReducer( @@ -76,6 +102,97 @@ export function objectBrowserReducer( return { ...state, rewind: resetItem }; case REWIND_FILE_MODE_ENABLED: return { ...state, fileMode: action.status }; + case OBJECT_MANAGER_NEW_OBJECT: + const cloneObjects = [...state.objectManager.objectsToManage]; + + cloneObjects.push(action.newObject); + + return { + ...state, + objectManager: { + objectsToManage: cloneObjects, + managerOpen: true, + }, + }; + case OBJECT_MANAGER_UPDATE_PROGRESS_OBJECT: + const copyManager = [...state.objectManager.objectsToManage]; + + const itemUpdate = state.objectManager.objectsToManage.findIndex( + (item) => item.instanceID === action.instanceID + ); + + if (itemUpdate === -1) { + return { ...state }; + } + + copyManager[itemUpdate].percentage = action.progress; + copyManager[itemUpdate].waitingForFile = false; + + return { + ...state, + objectManager: { + objectsToManage: copyManager, + managerOpen: state.objectManager.managerOpen, + }, + }; + case OBJECT_MANAGER_COMPLETE_OBJECT: + const copyObject = [...state.objectManager.objectsToManage]; + + const objectToComplete = state.objectManager.objectsToManage.findIndex( + (item) => item.instanceID === action.instanceID + ); + + if (objectToComplete === -1) { + return { ...state }; + } + + copyObject[objectToComplete].percentage = 100; + copyObject[objectToComplete].waitingForFile = false; + copyObject[objectToComplete].done = true; + + return { + ...state, + objectManager: { + objectsToManage: copyObject, + managerOpen: state.objectManager.managerOpen, + }, + }; + case OBJECT_MANAGER_DELETE_FROM_OBJECT_LIST: + const notObject = state.objectManager.objectsToManage.filter( + (element) => element.instanceID !== action.instanceID + ); + + return { + ...state, + objectManager: { + objectsToManage: notObject, + managerOpen: + notObject.length === 0 ? false : state.objectManager.managerOpen, + }, + }; + case OBJECT_MANAGER_CLEAN_LIST: + const nonCompletedList = state.objectManager.objectsToManage.filter( + (item) => !item.done + ); + + return { + ...state, + objectManager: { + objectsToManage: nonCompletedList, + managerOpen: + nonCompletedList.length === 0 + ? false + : state.objectManager.managerOpen, + }, + }; + case OBJECT_MANAGER_TOGGLE_LIST: + return { + ...state, + objectManager: { + ...state.objectManager, + managerOpen: !state.objectManager.managerOpen, + }, + }; default: return state; }