diff --git a/portal-ui/src/common/SecureComponent/permissions.ts b/portal-ui/src/common/SecureComponent/permissions.ts index 7e2107637..4697e9202 100644 --- a/portal-ui/src/common/SecureComponent/permissions.ts +++ b/portal-ui/src/common/SecureComponent/permissions.ts @@ -161,6 +161,7 @@ export const IAM_PAGES = { TIERS: "/settings/tiers", TIERS_ADD: "/settings/tiers/add", TIERS_ADD_SERVICE: "/settings/tiers/add/:service", + SITE_REPLICATION: "/settings/site-replication", /* Operator */ TENANTS: "/tenants", @@ -379,6 +380,10 @@ export const IAM_PAGES_PERMISSIONS = { IAM_SCOPES.ADMIN_SERVER_INFO, IAM_SCOPES.ADMIN_CONFIG_UPDATE, ], + [IAM_PAGES.SITE_REPLICATION]:[ + IAM_SCOPES.ADMIN_SERVER_INFO, + IAM_SCOPES.ADMIN_CONFIG_UPDATE, + ] }; export const S3_ALL_RESOURCES = "arn:aws:s3:::*"; diff --git a/portal-ui/src/screens/Console/Configurations/SiteReplication/AddReplicationSitesModal.tsx b/portal-ui/src/screens/Console/Configurations/SiteReplication/AddReplicationSitesModal.tsx new file mode 100644 index 000000000..36dabd513 --- /dev/null +++ b/portal-ui/src/screens/Console/Configurations/SiteReplication/AddReplicationSitesModal.tsx @@ -0,0 +1,419 @@ +// 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, { Fragment, useState } from "react"; +import { AddIcon, RecoverIcon, RemoveIcon } from "../../../../icons"; +import { ReplicationSite } from "./SiteReplication"; +import ModalWrapper from "../../Common/ModalWrapper/ModalWrapper"; +import InputBoxWrapper from "../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import Grid from "@mui/material/Grid"; +import { Box, Button, LinearProgress } from "@mui/material"; +import RBIconButton from "../../Buckets/BucketDetails/SummaryItems/RBIconButton"; +import useApi from "../../Common/Hooks/useApi"; +import { connect } from "react-redux"; +import { setErrorSnackMessage, setSnackBarMessage } from "../../../../actions"; +import { ErrorResponseHandler } from "../../../../common/types"; + +type SiteInputRow = { + name: string; + endpoint: string; +}; + +const isValidEndPoint = (ep: string) => { + let isValidEndPointUrl = false; + + try { + new URL(ep); + isValidEndPointUrl = true; + } catch (err) { + isValidEndPointUrl = false; + } + if (isValidEndPointUrl || ep === "") { + return ""; + } else { + return "Invalid Endpoint"; + } +}; +const AddReplicationSitesModal = ({ + existingSites = [], + onClose, + setErrorSnackMessage, + setSnackBarMessage, +}: { + existingSites: ReplicationSite[]; + onClose: () => void; + setErrorSnackMessage: (err: ErrorResponseHandler) => void; + setSnackBarMessage: (msg: string) => void; +}) => { + const defaultNewSites = existingSites?.length + ? [{ endpoint: "", name: "" }] + : [ + { endpoint: "", name: "" }, + { endpoint: "", name: "" }, + ]; + + const [accessKey, setAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [siteConfig, setSiteConfig] = useState(defaultNewSites); + + const isAllEndpointsValid = + siteConfig.reduce((acc: string[], cv, i) => { + const epValue = siteConfig[i].endpoint; + const isEpValid = isValidEndPoint(epValue); + + if (isEpValid === "" && epValue !== "") { + acc.push(isEpValid); + } + return acc; + }, []).length === siteConfig.length; + + const [isAdding, invokeSiteAddApi] = useApi( + (res: any) => { + if (res.success) { + setSnackBarMessage(res.status); + onClose(); + } else { + setErrorSnackMessage({ + errorMessage: "Error", + detailedError: res.status, + }); + } + }, + (err: any) => { + setErrorSnackMessage(err); + } + ); + + const resetForm = () => { + setAccessKey(""); + setSecretKey(""); + setSiteConfig(defaultNewSites); + }; + + const addSiteReplication = () => { + const existingSitesToAdd = existingSites?.map((es, idx) => { + return { + accessKey: accessKey, + secretKey: secretKey, + name: es.name, + endpoint: es.endpoint, + }; + }); + + const newSitesToAdd = siteConfig.reduce((acc: any, ns, idx) => { + if (ns.endpoint) { + acc.push({ + accessKey: accessKey, + secretKey: secretKey, + name: ns.name || `dr-site-${idx}`, + endpoint: ns.endpoint, + }); + } + return acc; + }, []); + + invokeSiteAddApi("POST", `api/v1/admin/site-replication`, [ + ...(existingSitesToAdd || []), + ...(newSitesToAdd || []), + ]); + }; + + return ( + } + data-test-id={"add-site-replication-modal"} + > + {isAdding ? : null} +
) => { + e.preventDefault(); + return addSiteReplication(); + }} + > + + + Note:{" "} + + Access Key and Secret Key should be same on all sites. + + + + + ) => { + setAccessKey(event.target.value); + }} + label="Access Key" + required={true} + value={accessKey} + error={accessKey === "" ? "Access Key is required." : ""} + data-test-id={"add-site-rep-acc-key"} + /> + + + ) => { + setSecretKey(event.target.value); + }} + error={secretKey === "" ? "Secret Key is required." : ""} + label="Secret Key" + value={secretKey} + data-test-id={"add-site-rep-sec-key"} + /> + + + + + Peer Sites + + + + + + Site Name + + Endpoint {"*"} + + {existingSites?.map((si, index) => { + return ( + + + {}} + /> + + + {}} + /> + + + {" "} + + + ); + })} + + {siteConfig.map((sci, index) => { + let isDelDisabled = false; + + if (existingSites?.length && index === 0) { + isDelDisabled = true; + } else if (!existingSites?.length && index < 2) { + isDelDisabled = true; + } + + return ( + + + { + const nameTxt = e.target.value; + setSiteConfig((prevItems) => { + return prevItems.map((item, ix) => + ix === index ? { ...item, name: nameTxt } : item + ); + }); + }} + data-test-id={`add-site-rep-peer-site-${index}`} + /> + + + { + const epTxt = e.target.value; + setSiteConfig((prevItems) => { + return prevItems.map((item, ix) => + ix === index ? { ...item, endpoint: epTxt } : item + ); + }); + }} + data-test-id={`add-site-rep-peer-ep-${index}`} + /> + + + + } + onClick={(e) => { + e.preventDefault(); + const newRows = [...siteConfig]; + //add at the next index + newRows.splice(index + 1, 0, { + name: "", + endpoint: "", + }); + + setSiteConfig(newRows); + }} + /> + } + onClick={(e) => { + e.preventDefault(); + setSiteConfig( + siteConfig.filter((_, idx) => idx !== index) + ); + }} + /> + + + + ); + })} + + + + + + + + + +
+
+ ); +}; + +const connector = connect(null, { + setErrorSnackMessage, + setSnackBarMessage, +}); +export default connector(AddReplicationSitesModal); diff --git a/portal-ui/src/screens/Console/Configurations/SiteReplication/ReplicationSites.tsx b/portal-ui/src/screens/Console/Configurations/SiteReplication/ReplicationSites.tsx new file mode 100644 index 000000000..60598e94b --- /dev/null +++ b/portal-ui/src/screens/Console/Configurations/SiteReplication/ReplicationSites.tsx @@ -0,0 +1,464 @@ +// 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 } from "react"; +import ListSubheader from "@mui/material/ListSubheader"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import Collapse from "@mui/material/Collapse"; +import { Box, Button, DialogContentText, Tooltip } from "@mui/material"; +import { + MenuCollapsedIcon, + MenuExpandedIcon, +} from "../../../../icons/SidebarMenus"; +import { ReplicationSite } from "./SiteReplication"; +import RBIconButton from "../../Buckets/BucketDetails/SummaryItems/RBIconButton"; +import TrashIcon from "../../../../icons/TrashIcon"; +import { CircleIcon, ConfirmDeleteIcon, EditIcon } from "../../../../icons"; +import ConfirmDialog from "../../Common/ModalWrapper/ConfirmDialog"; +import InputBoxWrapper from "../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import Grid from "@mui/material/Grid"; +import useApi from "../../Common/Hooks/useApi"; +import { connect } from "react-redux"; +import { setErrorSnackMessage, setSnackBarMessage } from "../../../../actions"; +import { ErrorResponseHandler } from "../../../../common/types"; +import ModalWrapper from "../../Common/ModalWrapper/ModalWrapper"; +import withStyles from "@mui/styles/withStyles"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import { + formFieldStyles, + modalStyleUtils, + spacingUtils, +} from "../../Common/FormComponents/common/styleLibrary"; + +const styles = (theme: Theme) => + createStyles({ + ...modalStyleUtils, + ...formFieldStyles, + ...spacingUtils, + }); + +const ReplicationSites = ({ + sites, + onDeleteSite, + setErrorSnackMessage, + setSnackBarMessage, + onRefresh, + classes, +}: { + sites: ReplicationSite[]; + onDeleteSite: (isAll: boolean, sites: string[]) => void; + setErrorSnackMessage: (err: ErrorResponseHandler) => void; + setSnackBarMessage: (msg: string) => void; + onRefresh: () => void; + classes: any; +}) => { + const [expanded, setExpanded] = React.useState(""); + + const [deleteSiteKey, setIsDeleteSiteKey] = useState(""); + const [editSite, setEditSite] = useState(null); + const [editEndPointName, setEditEndPointName] = useState(""); + + const handleClick = (key: string) => { + setExpanded(key); + }; + + const [isEditing, invokeSiteEditApi] = useApi( + (res: any) => { + if (res.success) { + setEditSite(null); + setSnackBarMessage(res.status); + } else { + setErrorSnackMessage({ + errorMessage: "Error", + detailedError: res.status, + }); + } + onRefresh(); + }, + (err: any) => { + setErrorSnackMessage(err); + onRefresh(); + } + ); + const updatePeerSite = () => { + invokeSiteEditApi("PUT", `api/v1/admin/site-replication`, { + endpoint: editEndPointName, + name: editSite.name, + deploymentId: editSite.deploymentID, // readonly + }); + }; + + const hasExpand = false; //siteInfo.isCurrent to b + let isValidEndPointUrl = false; + + try { + new URL(editEndPointName); + isValidEndPointUrl = true; + } catch (err) { + isValidEndPointUrl = false; + } + + return ( + + + + List of Replicated Sites + + {sites.map((siteInfo, index) => { + const key = `${siteInfo.name}`; + const isExpanded = expanded === siteInfo.name; + + const handleToggle = () => { + if (!isExpanded) { + handleClick(key); + } else { + handleClick(""); + } + }; + return ( + + + + + {siteInfo.name} + + + {siteInfo.isCurrent ? ( + + + + + + ) : null} + + + {siteInfo.endpoint} + + + + + + + } + onClick={(e) => { + e.preventDefault(); + setIsDeleteSiteKey(key); + }} + /> + } + onClick={(e) => { + e.preventDefault(); + setEditSite(siteInfo); + }} + /> + + {hasExpand ? ( + + {isExpanded ? ( + + ) : ( + + )} + + ) : ( + + )} + + {isExpanded ? ( + + + Replication status + + + + Status info + + + ) : null} + + {deleteSiteKey === key ? ( + } + isLoading={false} + onConfirm={() => { + onDeleteSite(false, [key]); + }} + onClose={() => { + setIsDeleteSiteKey(""); + }} + confirmationContent={ + + Are you sure you want to remove the replication site:{" "} + {key}.? + + } + /> + ) : null} + + {editSite?.name === key ? ( + } + onClose={() => { + setEditSite(null); + }} + > + + + + Site: {" "} + {editSite.name} + + + Current Endpoint: {" "} + {editSite.endpoint} + + + + + New Endpoint: + + ) => { + setEditEndPointName(event.target.value); + }} + label="" + value={editEndPointName} + /> + + + + + + + + + ) : null} + + ); + })} + + + ); +}; + +const connector = connect(null, { + setErrorSnackMessage, + setSnackBarMessage, +}); +export default connector(withStyles(styles)(ReplicationSites)); diff --git a/portal-ui/src/screens/Console/Configurations/SiteReplication/SiteReplication.tsx b/portal-ui/src/screens/Console/Configurations/SiteReplication/SiteReplication.tsx new file mode 100644 index 000000000..8c517f5ee --- /dev/null +++ b/portal-ui/src/screens/Console/Configurations/SiteReplication/SiteReplication.tsx @@ -0,0 +1,240 @@ +// 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, { Fragment, useEffect, useState } from "react"; +import PageHeader from "../../Common/PageHeader/PageHeader"; +import PageLayout from "../../Common/Layout/PageLayout"; +import { Box, DialogContentText } from "@mui/material"; +import useApi from "../../Common/Hooks/useApi"; +import ReplicationSites from "./ReplicationSites"; +import TrashIcon from "../../../../icons/TrashIcon"; +import RBIconButton from "../../Buckets/BucketDetails/SummaryItems/RBIconButton"; +import Loader from "../../Common/Loader/Loader"; +import { AddIcon, ConfirmDeleteIcon, RecoverIcon } from "../../../../icons"; +import AddReplicationSitesModal from "./AddReplicationSitesModal"; +import { connect } from "react-redux"; +import { setErrorSnackMessage, setSnackBarMessage } from "../../../../actions"; +import { ErrorResponseHandler } from "../../../../common/types"; +import HelpBox from "../../../../common/HelpBox"; +import ConfirmDialog from "../../Common/ModalWrapper/ConfirmDialog"; + +export type ReplicationSite = { + deploymentID: string; + endpoint: string; + name: string; + isCurrent?: boolean; +}; + +const SiteReplication = ({ + setSnackBarMessage, +}: { + setSnackBarMessage: (msg: string) => void; +}) => { + const [sites, setSites] = useState([]); + + const [isAddOpen, setIsAddOpen] = useState(false); + + const [deleteAll, setIsDeleteAll] = useState(false); + const [isSiteInfoLoading, invokeSiteInfoApi] = useApi( + (res: any) => { + const { sites: siteList, name: curSiteName } = res; + // current site name to be the fist one. + const foundIdx = siteList.findIndex((el: any) => el.name === curSiteName); + if (foundIdx !== -1) { + let curSite = siteList[foundIdx]; + curSite = { + ...curSite, + isCurrent: true, + }; + siteList.splice(foundIdx, 1, curSite); + } + + siteList.sort((x: any, y: any) => { + return x.name === curSiteName ? -1 : y.name === curSiteName ? 1 : 0; + }); + setSites(siteList); + }, + (err: any) => { + setSites([]); + } + ); + + const getSites = () => { + invokeSiteInfoApi("GET", `api/v1/admin/site-replication`); + }; + + const [isRemoving, invokeSiteRemoveApi] = useApi( + (res: any) => { + setIsDeleteAll(false); + setSnackBarMessage(`Successfully deleted.`); + getSites(); + }, + (err: ErrorResponseHandler) => { + setErrorSnackMessage(err); + } + ); + + const removeSites = (isAll: boolean = false, delSites: string[] = []) => { + invokeSiteRemoveApi("DELETE", `api/v1/admin/site-replication`, { + all: isAll, + sites: delSites, + }); + }; + + useEffect(() => { + getSites(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const hasSites = sites?.length; + + return ( + + + + + {hasSites ? ( + + } + onClick={() => { + setIsDeleteAll(true); + }} + /> + + ) : null} + } + onClick={() => { + setIsAddOpen(true); + }} + /> + + {hasSites ? ( + + ) : null} + {isSiteInfoLoading ? ( + + + + ) : null} + {!hasSites && !isSiteInfoLoading ? ( + + Site Replication is not configured. + + ) : null} + + {isAddOpen ? ( + { + setIsAddOpen(false); + getSites(); + }} + /> + ) : null} + + } + help={ + + This feature allows multiple independent MinIO sites (or clusters) + that are using the same external IDentity Provider (IDP) to be + configured as replicas. In this situation the set of replica sites + are referred to as peer sites or just sites. +
+
+ You can learn more at our{" "} + + documentation + + . +
+ } + /> + + {deleteAll ? ( + } + isLoading={false} + onConfirm={() => { + const siteNames = sites.map((s: any) => s.name); + removeSites(true, siteNames); + }} + onClose={() => { + setIsDeleteAll(false); + }} + confirmationContent={ + + Are you sure you want to remove all the replication sites?. + + } + /> + ) : null} +
+
+ ); +}; + +const connector = connect(null, { + setErrorSnackMessage, + setSnackBarMessage, +}); + +export default connector(SiteReplication); diff --git a/portal-ui/src/screens/Console/Console.tsx b/portal-ui/src/screens/Console/Console.tsx index fe0b13a9b..ec11f1b83 100644 --- a/portal-ui/src/screens/Console/Console.tsx +++ b/portal-ui/src/screens/Console/Console.tsx @@ -118,7 +118,7 @@ const ConfigurationOptions = React.lazy( const AddPool = React.lazy( () => import("./Tenants/TenantDetails/Pools/AddPool/AddPool") ); - +const SiteReplication = React.lazy(() => import("./Configurations/SiteReplication/SiteReplication")); const styles = (theme: Theme) => createStyles({ root: { @@ -368,6 +368,10 @@ const Console = ({ component: ListTiersConfiguration, path: IAM_PAGES.TIERS, }, + { + component: SiteReplication, + path: IAM_PAGES.SITE_REPLICATION, + }, { component: Account, path: IAM_PAGES.ACCOUNT, diff --git a/portal-ui/src/screens/Console/valid-routes.ts b/portal-ui/src/screens/Console/valid-routes.ts index dd4c8abef..3dab762f0 100644 --- a/portal-ui/src/screens/Console/valid-routes.ts +++ b/portal-ui/src/screens/Console/valid-routes.ts @@ -49,6 +49,7 @@ import { DocumentationIcon, LambdaIcon, LicenseIcon, + RecoverIcon, TenantsOutlineIcon, TiersIcon, } from "../../icons"; @@ -245,6 +246,13 @@ export const validRoutes = ( icon: TiersIcon, id: "tiers", }, + { + component: NavLink, + to: IAM_PAGES.SITE_REPLICATION, + name: "Site Replication", + icon: RecoverIcon, + id: "sitereplication", + }, ], }, { diff --git a/portal-ui/tests/permissions-2/site-replication.ts b/portal-ui/tests/permissions-2/site-replication.ts new file mode 100644 index 000000000..359455918 --- /dev/null +++ b/portal-ui/tests/permissions-2/site-replication.ts @@ -0,0 +1,54 @@ +// 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 roles from "../utils/roles"; +import { settingsElement } from "../utils/elements-menu"; +import { IAM_PAGES } from "../../src/common/SecureComponent/permissions"; +import { Selector } from "testcafe"; + +let testDomainUrl = "http://localhost:9090"; +const screenUrl = `${testDomainUrl}${IAM_PAGES.SITE_REPLICATION}`; +const siteReplicationEl = Selector("span").withText("Site Replication"); +export const addSitesBtn = Selector("button").withText("Add Sites"); + +/* Begin Local Testing config block */ +// For local Testing Create users and assign policies then update here. +// Command to invoke the test locally: testcafe chrome tests/permissions/site-replication.ts +/* End Local Testing config block */ + +fixture("Site Replication for user with Admin permissions") + .page(testDomainUrl) + .beforeEach(async (t) => { + await t.useRole(roles.settings); + }); + +test("Site replication sidebar item exists", async (t) => { + await t + .expect(settingsElement.exists) + .ok() + .click(settingsElement) + .expect(siteReplicationEl.exists) + .ok(); +}); + +test("Add Sites button exists", async (t) => { + const addSitesBtnExists = addSitesBtn.exists; + await t.navigateTo(screenUrl).expect(addSitesBtnExists).ok(); +}); + +test("Add Sites button is clickable", async (t) => { + await t.navigateTo(screenUrl).click(addSitesBtn); +});