github.com/minio/console@v1.4.1/web-app/src/screens/Console/Configurations/SiteReplication/AddReplicationSites.tsx (about) 1 // This file is part of MinIO Console Server 2 // Copyright (c) 2022 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 { useNavigate } from "react-router-dom"; 19 import { 20 BackLink, 21 Button, 22 ClustersIcon, 23 HelpBox, 24 PageLayout, 25 Box, 26 Grid, 27 ProgressBar, 28 InputLabel, 29 SectionTitle, 30 } from "mds"; 31 import useApi from "../../Common/Hooks/useApi"; 32 import { IAM_PAGES } from "../../../../common/SecureComponent/permissions"; 33 import { 34 setErrorSnackMessage, 35 setHelpName, 36 setSnackBarMessage, 37 } from "../../../../systemSlice"; 38 import { useAppDispatch } from "../../../../store"; 39 import { useSelector } from "react-redux"; 40 import { selSession } from "../../consoleSlice"; 41 import SRSiteInputRow from "./SRSiteInputRow"; 42 import { SiteInputRow } from "./Types"; 43 import PageHeaderWrapper from "../../Common/PageHeaderWrapper/PageHeaderWrapper"; 44 import HelpMenu from "../../HelpMenu"; 45 46 const isValidEndPoint = (ep: string) => { 47 let isValidEndPointUrl = false; 48 49 try { 50 new URL(ep); 51 isValidEndPointUrl = true; 52 } catch (err) { 53 isValidEndPointUrl = false; 54 } 55 if (isValidEndPointUrl) { 56 return ""; 57 } else { 58 return "Invalid Endpoint"; 59 } 60 }; 61 62 const isEmptyValue = (value: string): boolean => { 63 return value?.trim() === ""; 64 }; 65 66 const TableHeader = () => { 67 return ( 68 <React.Fragment> 69 <Box> 70 <InputLabel>Site Name</InputLabel> 71 </Box> 72 <Box> 73 <InputLabel>Endpoint {"*"}</InputLabel> 74 </Box> 75 <Box> 76 <InputLabel>Access Key {"*"}</InputLabel> 77 </Box> 78 <Box> 79 <InputLabel>Secret Key {"*"}</InputLabel> 80 </Box> 81 <Box> </Box> 82 </React.Fragment> 83 ); 84 }; 85 86 const SiteTypeHeader = ({ title }: { title: string }) => { 87 return ( 88 <Grid item xs={12}> 89 <Box 90 sx={{ 91 marginBottom: "15px", 92 fontSize: "14px", 93 fontWeight: 600, 94 }} 95 > 96 {title} 97 </Box> 98 </Grid> 99 ); 100 }; 101 102 const AddReplicationSites = () => { 103 const dispatch = useAppDispatch(); 104 const navigate = useNavigate(); 105 106 const { serverEndPoint = "" } = useSelector(selSession); 107 108 const [currentSite, setCurrentSite] = useState<SiteInputRow[]>([ 109 { 110 endpoint: serverEndPoint, 111 name: "", 112 accessKey: "", 113 secretKey: "", 114 }, 115 ]); 116 117 const [existingSites, setExistingSites] = useState<SiteInputRow[]>([]); 118 119 const setDefaultNewRows = () => { 120 const defaultNewSites = [ 121 { endpoint: "", name: "", accessKey: "", secretKey: "" }, 122 ]; 123 setExistingSites(defaultNewSites); 124 }; 125 126 const [isSiteInfoLoading, invokeSiteInfoApi] = useApi( 127 (res: any) => { 128 const { sites: siteList, name: curSiteName } = res; 129 // current site name to be the fist one. 130 const foundIdx = siteList.findIndex((el: any) => el.name === curSiteName); 131 if (foundIdx !== -1) { 132 let curSite = siteList[foundIdx]; 133 curSite = { 134 ...curSite, 135 isCurrent: true, 136 isSaved: true, 137 }; 138 139 setCurrentSite([curSite]); 140 siteList.splice(foundIdx, 1); 141 } 142 143 siteList.sort((x: any, y: any) => { 144 return x.name === curSiteName ? -1 : y.name === curSiteName ? 1 : 0; 145 }); 146 147 let existingSiteList = siteList.map((si: any) => { 148 return { 149 ...si, 150 accessKey: "", 151 secretKey: "", 152 isSaved: true, 153 }; 154 }); 155 156 if (existingSiteList.length) { 157 setExistingSites(existingSiteList); 158 } else { 159 setDefaultNewRows(); 160 } 161 }, 162 (err: any) => { 163 setDefaultNewRows(); 164 }, 165 ); 166 167 const getSites = () => { 168 invokeSiteInfoApi("GET", `api/v1/admin/site-replication`); 169 }; 170 171 useEffect(() => { 172 getSites(); 173 // eslint-disable-next-line react-hooks/exhaustive-deps 174 }, []); 175 176 useEffect(() => { 177 dispatch(setHelpName("add-replication-sites")); 178 // eslint-disable-next-line react-hooks/exhaustive-deps 179 }, []); 180 181 const existingEndPointsValidity = existingSites.reduce( 182 (acc: string[], cv, i) => { 183 const epValue = existingSites[i].endpoint; 184 const isEpValid = isValidEndPoint(epValue); 185 186 if (isEpValid === "" && epValue !== "") { 187 acc.push(isEpValid); 188 } 189 return acc; 190 }, 191 [], 192 ); 193 194 const isExistingCredsValidity = existingSites 195 .map((site) => { 196 return !isEmptyValue(site.accessKey) && !isEmptyValue(site.secretKey); 197 }) 198 .filter(Boolean); 199 200 const { accessKey: cAccessKey, secretKey: cSecretKey } = currentSite[0]; 201 202 const isCurCredsValid = 203 !isEmptyValue(cAccessKey) && !isEmptyValue(cSecretKey); 204 const peerEndpointsValid = 205 existingEndPointsValidity.length === existingSites.length; 206 const peerCredsValid = 207 isExistingCredsValidity.length === existingSites.length; 208 209 let isAllFieldsValid = 210 isCurCredsValid && peerEndpointsValid && peerCredsValid; 211 212 const [isAdding, invokeSiteAddApi] = useApi( 213 (res: any) => { 214 if (res.success) { 215 dispatch(setSnackBarMessage(res.status)); 216 resetForm(); 217 getSites(); 218 navigate(IAM_PAGES.SITE_REPLICATION); 219 } else { 220 dispatch( 221 setErrorSnackMessage({ 222 errorMessage: "Error", 223 detailedError: res.status, 224 }), 225 ); 226 } 227 }, 228 (err: any) => { 229 dispatch(setErrorSnackMessage(err)); 230 }, 231 ); 232 233 const resetForm = () => { 234 setDefaultNewRows(); 235 setCurrentSite((prevItems) => { 236 return prevItems.map((item, ix) => ({ 237 ...item, 238 accessKey: "", 239 secretKey: "", 240 name: "", 241 })); 242 }); 243 }; 244 245 const addSiteReplication = () => { 246 const curSite: any[] = currentSite?.map((es, idx) => { 247 return { 248 accessKey: es.accessKey, 249 secretKey: es.secretKey, 250 name: es.name, 251 endpoint: es.endpoint.trim(), 252 }; 253 }); 254 255 const newOrExistingSitesToAdd = existingSites.reduce( 256 (acc: any, ns, idx) => { 257 if (ns.endpoint) { 258 acc.push({ 259 accessKey: ns.accessKey, 260 secretKey: ns.secretKey, 261 name: ns.name || `dr-site-${idx}`, 262 endpoint: ns.endpoint.trim(), 263 }); 264 } 265 return acc; 266 }, 267 [], 268 ); 269 270 const sitesToAdd = curSite.concat(newOrExistingSitesToAdd); 271 272 invokeSiteAddApi("POST", `api/v1/admin/site-replication`, sitesToAdd); 273 }; 274 275 const renderCurrentSite = () => { 276 return ( 277 <Box 278 sx={{ 279 marginTop: "15px", 280 }} 281 > 282 <SiteTypeHeader title={"This Site"} /> 283 <Box 284 withBorders 285 sx={{ 286 display: "grid", 287 gridTemplateColumns: ".8fr 1.2fr .8fr .8fr .2fr", 288 padding: "15px", 289 gap: "10px", 290 maxHeight: "430px", 291 overflowY: "auto", 292 }} 293 > 294 <TableHeader /> 295 296 {currentSite.map((cs, index) => { 297 const accessKeyError = isEmptyValue(cs.accessKey) 298 ? "AccessKey is required" 299 : ""; 300 const secretKeyError = isEmptyValue(cs.secretKey) 301 ? "SecretKey is required" 302 : ""; 303 return ( 304 <SRSiteInputRow 305 key={`current-${index}`} 306 rowData={cs} 307 rowId={index} 308 fieldErrors={{ 309 accessKey: accessKeyError, 310 secretKey: secretKeyError, 311 }} 312 onFieldChange={(e, fieldName, index) => { 313 const filedValue = e.target.value; 314 if (fieldName !== "") { 315 setCurrentSite((prevItems) => { 316 return prevItems.map((item, ix) => 317 ix === index 318 ? { ...item, [fieldName]: filedValue } 319 : item, 320 ); 321 }); 322 } 323 }} 324 showRowActions={false} 325 /> 326 ); 327 })} 328 </Box> 329 </Box> 330 ); 331 }; 332 333 const renderPeerSites = () => { 334 return ( 335 <Box 336 sx={{ 337 marginTop: "25px", 338 }} 339 > 340 <SiteTypeHeader title={"Peer Sites"} /> 341 <Box 342 withBorders 343 sx={{ 344 display: "grid", 345 gridTemplateColumns: ".8fr 1.2fr .8fr .8fr .2fr", 346 padding: "15px", 347 gap: "10px", 348 maxHeight: "430px", 349 overflowY: "auto", 350 }} 351 > 352 <TableHeader /> 353 354 {existingSites.map((ps, index) => { 355 const endPointError = isValidEndPoint(ps.endpoint); 356 357 const accessKeyError = isEmptyValue(ps.accessKey) 358 ? "AccessKey is required" 359 : ""; 360 const secretKeyError = isEmptyValue(ps.secretKey) 361 ? "SecretKey is required" 362 : ""; 363 364 return ( 365 <SRSiteInputRow 366 key={`exiting-${index}`} 367 rowData={ps} 368 rowId={index} 369 fieldErrors={{ 370 endpoint: endPointError, 371 accessKey: accessKeyError, 372 secretKey: secretKeyError, 373 }} 374 onFieldChange={(e, fieldName, index) => { 375 const filedValue = e.target.value; 376 setExistingSites((prevItems) => { 377 return prevItems.map((item, ix) => 378 ix === index 379 ? { ...item, [fieldName]: filedValue } 380 : item, 381 ); 382 }); 383 }} 384 canAdd={true} 385 canRemove={index > 0 && !ps.isSaved} 386 onAddClick={() => { 387 const newRows = [...existingSites]; 388 //add at the next index 389 newRows.splice(index + 1, 0, { 390 name: "", 391 endpoint: "", 392 accessKey: "", 393 secretKey: "", 394 }); 395 396 setExistingSites(newRows); 397 }} 398 onRemoveClick={(index) => { 399 setExistingSites( 400 existingSites.filter((_, idx) => idx !== index), 401 ); 402 }} 403 /> 404 ); 405 })} 406 </Box> 407 </Box> 408 ); 409 }; 410 411 return ( 412 <Fragment> 413 <PageHeaderWrapper 414 label={ 415 <BackLink 416 label={"Add Replication Site"} 417 onClick={() => navigate(IAM_PAGES.SITE_REPLICATION)} 418 /> 419 } 420 actions={<HelpMenu />} 421 /> 422 <PageLayout> 423 <Box 424 sx={{ 425 display: "grid", 426 padding: "25px", 427 gap: "25px", 428 gridTemplateColumns: "1fr", 429 border: "1px solid #eaeaea", 430 }} 431 > 432 <Box> 433 <SectionTitle separator icon={<ClustersIcon />}> 434 Add Sites for Replication 435 </SectionTitle> 436 437 {isSiteInfoLoading || isAdding ? <ProgressBar /> : null} 438 439 <Box 440 sx={{ 441 fontSize: "14px", 442 fontStyle: "italic", 443 marginTop: "10px", 444 marginBottom: "10px", 445 }} 446 > 447 Note: AccessKey and SecretKey values for every site is required 448 while adding or editing peer sites 449 </Box> 450 <form 451 noValidate 452 autoComplete="off" 453 onSubmit={(e: React.FormEvent<HTMLFormElement>) => { 454 e.preventDefault(); 455 return addSiteReplication(); 456 }} 457 > 458 {renderCurrentSite()} 459 460 {renderPeerSites()} 461 462 <Grid item xs={12}> 463 <Box 464 sx={{ 465 display: "flex", 466 alignItems: "center", 467 justifyContent: "flex-end", 468 marginTop: "20px", 469 gap: "15px", 470 }} 471 > 472 <Button 473 id={"clear"} 474 type="button" 475 variant="regular" 476 disabled={isAdding} 477 onClick={resetForm} 478 label={"Clear"} 479 /> 480 481 <Button 482 id={"save"} 483 type="submit" 484 variant="callAction" 485 disabled={isAdding || !isAllFieldsValid} 486 label={"Save"} 487 /> 488 </Box> 489 </Grid> 490 </form> 491 </Box> 492 493 <HelpBox 494 title={""} 495 iconComponent={null} 496 help={ 497 <Fragment> 498 <Box 499 sx={{ 500 marginTop: "-25px", 501 fontSize: "16px", 502 fontWeight: 600, 503 display: "flex", 504 alignItems: "center", 505 justifyContent: "flex-start", 506 padding: "2px", 507 }} 508 > 509 <Box 510 sx={{ 511 backgroundColor: "#07193E", 512 height: "15px", 513 width: "15px", 514 display: "flex", 515 alignItems: "center", 516 justifyContent: "center", 517 borderRadius: "50%", 518 marginRight: "18px", 519 padding: "3px", 520 paddingLeft: "2px", 521 "& .min-icon": { 522 height: "11px", 523 width: "11px", 524 fill: "#ffffff", 525 }, 526 }} 527 > 528 <ClustersIcon /> 529 </Box> 530 About Site Replication 531 </Box> 532 <Box 533 sx={{ 534 display: "flex", 535 flexFlow: "column", 536 fontSize: "14px", 537 flex: "2", 538 "& li": { 539 fontSize: "14px", 540 display: "flex", 541 marginTop: "15px", 542 marginBottom: "15px", 543 width: "100%", 544 545 "&.step-text": { 546 fontWeight: 400, 547 }, 548 }, 549 }} 550 > 551 <Box> 552 The following changes are replicated to all other sites 553 </Box> 554 <ul> 555 <li>Creation and deletion of buckets and objects</li> 556 <li> 557 Creation and deletion of all IAM users, groups, policies 558 and their mappings to users or groups 559 </li> 560 <li>Creation of STS credentials</li> 561 <li> 562 Creation and deletion of service accounts (except those 563 owned by the root user) 564 </li> 565 <li> 566 <Box 567 style={{ 568 display: "flex", 569 flexFlow: "column", 570 571 justifyContent: "flex-start", 572 }} 573 > 574 <div 575 style={{ 576 paddingTop: "1px", 577 }} 578 > 579 Changes to Bucket features such as 580 </div> 581 <ul> 582 <li>Bucket Policies</li> 583 <li>Bucket Tags</li> 584 <li>Bucket Object-Lock configurations</li> 585 <li>Bucket Encryption configuration</li> 586 </ul> 587 </Box> 588 </li> 589 590 <li> 591 <Box 592 style={{ 593 display: "flex", 594 flexFlow: "column", 595 596 justifyContent: "flex-start", 597 }} 598 > 599 <div 600 style={{ 601 paddingTop: "1px", 602 }} 603 > 604 The following Bucket features will NOT be replicated 605 </div> 606 607 <ul> 608 <li>Bucket notification configuration</li> 609 <li>Bucket lifecycle (ILM) configuration</li> 610 </ul> 611 </Box> 612 </li> 613 </ul> 614 </Box> 615 </Fragment> 616 } 617 /> 618 </Box> 619 </PageLayout> 620 </Fragment> 621 ); 622 }; 623 624 export default AddReplicationSites;