github.com/minio/console@v1.4.1/web-app/src/screens/Console/Buckets/ListBuckets/BulkReplicationModal.tsx (about) 1 // This file is part of MinIO Console Server 2 // Copyright (c) 2021 MinIO, Inc. 3 // 4 // This program is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU Affero General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // This program is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU Affero General Public License for more details. 13 // 14 // You should have received a copy of the GNU Affero General Public License 15 // along with this program. If not, see <http://www.gnu.org/licenses/>. 16 17 import React, { Fragment, useEffect, useState } from "react"; 18 import { 19 Box, 20 CheckCircleIcon, 21 FormLayout, 22 InputBox, 23 ReadBox, 24 Select, 25 Switch, 26 Tooltip, 27 WarnIcon, 28 Wizard, 29 } from "mds"; 30 import get from "lodash/get"; 31 import ModalWrapper from "../../Common/ModalWrapper/ModalWrapper"; 32 import { getBytes, k8sScalarUnitsExcluding } from "../../../../common/utils"; 33 import InputUnitMenu from "../../Common/FormComponents/InputUnitMenu/InputUnitMenu"; 34 import { setModalErrorSnackMessage } from "../../../../systemSlice"; 35 import { useAppDispatch } from "../../../../store"; 36 import { api } from "api"; 37 import { MultiBucketResponseItem } from "api/consoleApi"; 38 import { errorToHandler } from "api/errors"; 39 import { SelectorTypes } from "../../../../common/types"; 40 41 interface IBulkReplicationModal { 42 open: boolean; 43 closeModalAndRefresh: (clearSelection: boolean) => any; 44 buckets: string[]; 45 } 46 47 const AddBulkReplicationModal = ({ 48 open, 49 closeModalAndRefresh, 50 buckets, 51 }: IBulkReplicationModal) => { 52 const dispatch = useAppDispatch(); 53 const [bucketsToAlter, setBucketsToAlter] = useState<string[]>([]); 54 const [addLoading, setAddLoading] = useState<boolean>(false); 55 const [externalLoading, setExternalLoading] = useState<boolean>(false); 56 const [accessKey, setAccessKey] = useState<string>(""); 57 const [secretKey, setSecretKey] = useState<string>(""); 58 const [targetURL, setTargetURL] = useState<string>(""); 59 const [region, setRegion] = useState<string>(""); 60 const [useTLS, setUseTLS] = useState<boolean>(true); 61 const [replicationMode, setReplicationMode] = useState<"async" | "sync">( 62 "async", 63 ); 64 const [bandwidthScalar, setBandwidthScalar] = useState<string>("100"); 65 const [bandwidthUnit, setBandwidthUnit] = useState<string>("Gi"); 66 const [healthCheck, setHealthCheck] = useState<string>("60"); 67 const [relationBuckets, setRelationBuckets] = useState<string[]>([]); 68 const [remoteBucketsOpts, setRemoteBucketOpts] = useState<string[]>([]); 69 const [responseItem, setResponseItem] = useState< 70 MultiBucketResponseItem[] | undefined 71 >([]); 72 73 const optionsForBucketsDrop: SelectorTypes[] = remoteBucketsOpts.map( 74 (remoteBucketName: string) => { 75 return { 76 label: remoteBucketName, 77 value: remoteBucketName, 78 }; 79 }, 80 ); 81 82 useEffect(() => { 83 if (relationBuckets.length === 0) { 84 const bucketsAlter: string[] = []; 85 const relationBucketsAlter: string[] = []; 86 87 buckets.forEach((item: string) => { 88 bucketsAlter.push(item); 89 relationBucketsAlter.push(""); 90 }); 91 92 setRelationBuckets(relationBucketsAlter); 93 setBucketsToAlter(bucketsAlter); 94 } 95 }, [buckets, relationBuckets.length]); 96 97 const addRecord = () => { 98 setAddLoading(true); 99 const replicate = bucketsToAlter.map((bucketName, index) => { 100 return { 101 originBucket: bucketName, 102 destinationBucket: relationBuckets[index], 103 }; 104 }); 105 106 const endURL = `${useTLS ? "https://" : "http://"}${targetURL}`; 107 const hc = parseInt(healthCheck); 108 109 const remoteBucketsInfo = { 110 accessKey: accessKey, 111 secretKey: secretKey, 112 targetURL: endURL, 113 region: region, 114 bucketsRelation: replicate, 115 syncMode: replicationMode, 116 bandwidth: 117 replicationMode === "async" 118 ? parseInt(getBytes(bandwidthScalar, bandwidthUnit, true)) 119 : 0, 120 healthCheckPeriod: hc, 121 }; 122 123 api.bucketsReplication 124 .setMultiBucketReplication(remoteBucketsInfo) 125 .then((response) => { 126 setAddLoading(false); 127 128 const states = response.data.replicationState; 129 setResponseItem(states); 130 131 const filterErrors = states?.filter( 132 (itm) => itm.errorString && itm.errorString !== "", 133 ); 134 135 if (filterErrors?.length === 0) { 136 closeModalAndRefresh(true); 137 } else { 138 setTimeout(() => { 139 removeSuccessItems(states); 140 }, 500); 141 } 142 }) 143 .catch((err) => { 144 setAddLoading(false); 145 dispatch(setModalErrorSnackMessage(errorToHandler(err.error))); 146 }); 147 }; 148 149 const retrieveRemoteBuckets = ( 150 wizardPageJump: (page: number | string) => void, 151 ) => { 152 const remoteConnectInfo = { 153 accessKey: accessKey, 154 secretKey: secretKey, 155 targetURL: targetURL, 156 useTLS, 157 }; 158 setExternalLoading(true); 159 160 api.listExternalBuckets 161 .listExternalBuckets(remoteConnectInfo) 162 .then((res) => { 163 const buckets = get(res.data, "buckets", []); 164 165 if (buckets && buckets.length > 0) { 166 const arrayReplaceBuckets = buckets.map((element: any) => { 167 return element.name; 168 }); 169 170 setRemoteBucketOpts(arrayReplaceBuckets); 171 } 172 173 wizardPageJump("++"); 174 setExternalLoading(false); 175 }) 176 .catch((err) => { 177 setExternalLoading(false); 178 dispatch(setModalErrorSnackMessage(errorToHandler(err.error))); 179 }); 180 }; 181 182 const stateOfItem = (initialBucket: string) => { 183 if (responseItem && responseItem.length > 0) { 184 const bucketResponse = responseItem.find( 185 (item) => item.originBucket === initialBucket, 186 ); 187 188 if (bucketResponse) { 189 const errString = get(bucketResponse, "errorString", ""); 190 191 if (errString) { 192 return errString; 193 } 194 195 return ""; 196 } 197 } 198 return "n/a"; 199 }; 200 201 const LogoToShow = ({ errString }: { errString: string }) => { 202 switch (errString) { 203 case "": 204 return ( 205 <Box 206 sx={{ 207 color: "#42C91A", 208 }} 209 > 210 <CheckCircleIcon /> 211 </Box> 212 ); 213 case "n/a": 214 return null; 215 default: 216 if (errString) { 217 return ( 218 <Box 219 sx={{ 220 color: "#C72C48", 221 }} 222 > 223 <Tooltip tooltip={errString} placement="top"> 224 <WarnIcon /> 225 </Tooltip> 226 </Box> 227 ); 228 } 229 } 230 return null; 231 }; 232 233 const updateItem = (indexItem: number, value: string) => { 234 const updatedList = [...relationBuckets]; 235 updatedList[indexItem] = value; 236 setRelationBuckets(updatedList); 237 }; 238 239 const itemDisplayBulk = (indexItem: number) => { 240 if (remoteBucketsOpts.length > 0) { 241 return ( 242 <Fragment> 243 <Select 244 label="" 245 id={`assign-bucket-${indexItem}`} 246 name={`assign-bucket-${indexItem}`} 247 value={relationBuckets[indexItem]} 248 onChange={(value) => { 249 updateItem(indexItem, value); 250 }} 251 options={optionsForBucketsDrop} 252 disabled={addLoading} 253 /> 254 </Fragment> 255 ); 256 } 257 return ( 258 <Fragment> 259 <InputBox 260 id={`assign-bucket-${indexItem}`} 261 name={`assign-bucket-${indexItem}`} 262 label="" 263 onChange={(event: React.ChangeEvent<HTMLInputElement>) => { 264 updateItem(indexItem, event.target.value); 265 }} 266 value={relationBuckets[indexItem]} 267 disabled={addLoading} 268 /> 269 </Fragment> 270 ); 271 }; 272 273 const removeSuccessItems = ( 274 responseItem: MultiBucketResponseItem[] | undefined, 275 ) => { 276 let newBucketsToAlter = [...bucketsToAlter]; 277 let newRelationBuckets = [...relationBuckets]; 278 279 responseItem?.forEach((successElement) => { 280 const errorString = get(successElement, "errorString", ""); 281 282 if (!errorString || errorString === "") { 283 const indexToRemove = newBucketsToAlter.indexOf( 284 successElement.originBucket || "", 285 ); 286 287 newBucketsToAlter.splice(indexToRemove, 1); 288 newRelationBuckets.splice(indexToRemove, 1); 289 } 290 }); 291 292 setBucketsToAlter(newBucketsToAlter); 293 setRelationBuckets(newRelationBuckets); 294 }; 295 296 return ( 297 <ModalWrapper 298 modalOpen={open} 299 onClose={() => { 300 closeModalAndRefresh(false); 301 }} 302 title="Set Multiple Bucket Replication" 303 > 304 <Wizard 305 loadingStep={addLoading || externalLoading} 306 wizardSteps={[ 307 { 308 label: "Remote Configuration", 309 componentRender: ( 310 <Fragment> 311 <FormLayout containerPadding={false} withBorders={false}> 312 <ReadBox 313 label="Local Buckets to replicate" 314 sx={{ maxWidth: "440px", width: "100%" }} 315 > 316 {bucketsToAlter.join(", ")} 317 </ReadBox> 318 <h4>Remote Endpoint Configuration</h4> 319 <span style={{ fontSize: 14 }}> 320 Please avoid the use of root credentials for this feature 321 <br /> 322 <br /> 323 </span> 324 <InputBox 325 id="accessKey" 326 name="accessKey" 327 onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 328 setAccessKey(e.target.value); 329 }} 330 label="Access Key" 331 value={accessKey} 332 /> 333 <InputBox 334 id="secretKey" 335 name="secretKey" 336 onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 337 setSecretKey(e.target.value); 338 }} 339 label="Secret Key" 340 value={secretKey} 341 /> 342 <InputBox 343 id="targetURL" 344 name="targetURL" 345 onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 346 setTargetURL(e.target.value); 347 }} 348 placeholder="play.min.io:9000" 349 label="Target URL" 350 value={targetURL} 351 /> 352 <Switch 353 checked={useTLS} 354 id="useTLS" 355 name="useTLS" 356 label="Use TLS" 357 onChange={(e) => { 358 setUseTLS(e.target.checked); 359 }} 360 value="yes" 361 /> 362 <InputBox 363 id="region" 364 name="region" 365 onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 366 setRegion(e.target.value); 367 }} 368 label="Region" 369 value={region} 370 /> 371 <Select 372 id="replication_mode" 373 name="replication_mode" 374 onChange={(value) => { 375 setReplicationMode(value as "sync" | "async"); 376 }} 377 label="Replication Mode" 378 value={replicationMode} 379 options={[ 380 { label: "Asynchronous", value: "async" }, 381 { label: "Synchronous", value: "sync" }, 382 ]} 383 /> 384 {replicationMode === "async" && ( 385 <InputBox 386 type="number" 387 id="bandwidth_scalar" 388 name="bandwidth_scalar" 389 onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 390 if (e.target.validity.valid) { 391 setBandwidthScalar(e.target.value as string); 392 } 393 }} 394 label="Bandwidth" 395 value={bandwidthScalar} 396 min="0" 397 pattern={"[0-9]*"} 398 overlayObject={ 399 <InputUnitMenu 400 id={"quota_unit"} 401 onUnitChange={(newValue) => { 402 setBandwidthUnit(newValue); 403 }} 404 unitSelected={bandwidthUnit} 405 unitsList={k8sScalarUnitsExcluding(["Ki"])} 406 disabled={false} 407 /> 408 } 409 /> 410 )} 411 <InputBox 412 id="healthCheck" 413 name="healthCheck" 414 onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 415 setHealthCheck(e.target.value as string); 416 }} 417 label="Health Check Duration" 418 value={healthCheck} 419 /> 420 </FormLayout> 421 </Fragment> 422 ), 423 buttons: [ 424 { 425 type: "custom", 426 label: "Next", 427 enabled: !externalLoading, 428 action: retrieveRemoteBuckets, 429 }, 430 ], 431 }, 432 { 433 label: "Bucket Assignments", 434 componentRender: ( 435 <Fragment> 436 <h3>Remote Bucket Assignments</h3> 437 <span style={{ fontSize: 14 }}> 438 Please select / type the desired remote bucket were you want 439 the local data to be replicated. 440 </span> 441 <Box 442 sx={{ 443 display: "grid", 444 gridTemplateColumns: "auto auto 45px", 445 alignItems: "center", 446 justifyContent: "stretch", 447 "& .hide": { 448 opacity: 0, 449 transitionDuration: "0.3s", 450 }, 451 }} 452 > 453 {bucketsToAlter.map((bucketName: string, index: number) => { 454 const errorItem = stateOfItem(bucketName); 455 return ( 456 <Fragment 457 key={`buckets-assignation-${index.toString()}-${bucketName}`} 458 > 459 <div className={errorItem === "" ? "hide" : ""}> 460 {bucketName} 461 </div> 462 <div className={errorItem === "" ? "hide" : ""}> 463 {itemDisplayBulk(index)} 464 </div> 465 <div className={errorItem === "" ? "hide" : ""}> 466 {responseItem && responseItem.length > 0 && ( 467 <LogoToShow errString={errorItem} /> 468 )} 469 </div> 470 </Fragment> 471 ); 472 })} 473 </Box> 474 </Fragment> 475 ), 476 buttons: [ 477 { 478 type: "back", 479 label: "Back", 480 enabled: true, 481 }, 482 { 483 type: "next", 484 label: "Create", 485 enabled: !addLoading, 486 action: addRecord, 487 }, 488 ], 489 }, 490 ]} 491 forModal 492 /> 493 </ModalWrapper> 494 ); 495 }; 496 497 export default AddBulkReplicationModal;