UI site replication (#1807)
This commit is contained in:
committed by
GitHub
parent
836090a0d5
commit
ee3affd140
@@ -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:::*";
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<string>("");
|
||||
const [secretKey, setSecretKey] = useState<string>("");
|
||||
const [siteConfig, setSiteConfig] = useState<SiteInputRow[]>(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 (
|
||||
<ModalWrapper
|
||||
modalOpen={true}
|
||||
onClose={onClose}
|
||||
title={`Add Sites for Replication`}
|
||||
titleIcon={<RecoverIcon />}
|
||||
data-test-id={"add-site-replication-modal"}
|
||||
>
|
||||
{isAdding ? <LinearProgress /> : null}
|
||||
<form
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
return addSiteReplication();
|
||||
}}
|
||||
>
|
||||
<Grid item xs={12} marginBottom={"15px"}>
|
||||
<Box
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ fontWeight: 600 }}>Note:</Box>{" "}
|
||||
<Box sx={{ marginLeft: "8px" }}>
|
||||
Access Key and Secret Key should be same on all sites.
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
marginBottom={"15px"}
|
||||
sx={{
|
||||
"& label span": {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InputBoxWrapper
|
||||
id="add-rep-peer-accKey"
|
||||
name="add-rep-peer-accKey"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
marginBottom={"30px"}
|
||||
sx={{
|
||||
"& label span": {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InputBoxWrapper
|
||||
id="add-rep-peer-secKey"
|
||||
name="add-rep-peer-secKey"
|
||||
type={"password"}
|
||||
required={true}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSecretKey(event.target.value);
|
||||
}}
|
||||
error={secretKey === "" ? "Secret Key is required." : ""}
|
||||
label="Secret Key"
|
||||
value={secretKey}
|
||||
data-test-id={"add-site-rep-sec-key"}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ marginBottom: "15px", fontSize: "14px", fontWeight: 600 }}>
|
||||
Peer Sites
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: ".8fr 1.2fr .2fr",
|
||||
border: "1px solid #eaeaea",
|
||||
padding: "15px",
|
||||
gap: "10px",
|
||||
maxHeight: "430px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
marginLeft: "5px",
|
||||
}}
|
||||
>
|
||||
Site Name
|
||||
</Box>
|
||||
<Box sx={{ fontSize: "14px", marginLeft: "5px" }}>Endpoint {"*"}</Box>
|
||||
<Box> </Box>
|
||||
{existingSites?.map((si, index) => {
|
||||
return (
|
||||
<Fragment key={si.name}>
|
||||
<Box>
|
||||
<InputBoxWrapper
|
||||
id={`add-rep-ex-peer-site-${index}`}
|
||||
name={`add-rep-ex-peer-site-${index}`}
|
||||
extraInputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
label=""
|
||||
value={si.name}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<InputBoxWrapper
|
||||
id={`add-rep-ex-peer-site-ep-${index}`}
|
||||
name={`add-rep-ex-peer-site-ep-${index}`}
|
||||
extraInputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
label=""
|
||||
value={si.endpoint}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</Box>
|
||||
<Grid item xs={12}>
|
||||
{" "}
|
||||
</Grid>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{siteConfig.map((sci, index) => {
|
||||
let isDelDisabled = false;
|
||||
|
||||
if (existingSites?.length && index === 0) {
|
||||
isDelDisabled = true;
|
||||
} else if (!existingSites?.length && index < 2) {
|
||||
isDelDisabled = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`${index}`}>
|
||||
<Box>
|
||||
<InputBoxWrapper
|
||||
id={`add-rep-peer-site-${index}`}
|
||||
name={`add-rep-peer-site-${index}`}
|
||||
placeholder={`dr-site-${index}`}
|
||||
label=""
|
||||
value={`${sci.name}`}
|
||||
onChange={(e) => {
|
||||
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}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<InputBoxWrapper
|
||||
id={`add-rep-peer-site-ep-${index}`}
|
||||
name={`add-rep-peer-site-ep-${index}`}
|
||||
placeholder={`https://dr.minio-storage:900${index}`}
|
||||
label=""
|
||||
error={isValidEndPoint(siteConfig[index].endpoint)}
|
||||
value={`${sci.endpoint}`}
|
||||
onChange={(e) => {
|
||||
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}`}
|
||||
/>
|
||||
</Box>
|
||||
<Grid item xs={12} alignItems={"center"} display={"flex"}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "baseline",
|
||||
marginTop: "4px",
|
||||
|
||||
"& button": {
|
||||
borderColor: "#696969",
|
||||
color: "#696969",
|
||||
borderRadius: "50%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RBIconButton
|
||||
tooltip={"Add a Row"}
|
||||
text={""}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
icon={<AddIcon />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const newRows = [...siteConfig];
|
||||
//add at the next index
|
||||
newRows.splice(index + 1, 0, {
|
||||
name: "",
|
||||
endpoint: "",
|
||||
});
|
||||
|
||||
setSiteConfig(newRows);
|
||||
}}
|
||||
/>
|
||||
<RBIconButton
|
||||
tooltip={"Remove Row"}
|
||||
text={""}
|
||||
variant="outlined"
|
||||
disabled={isDelDisabled}
|
||||
color="primary"
|
||||
icon={<RemoveIcon />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSiteConfig(
|
||||
siteConfig.filter((_, idx) => idx !== index)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: "20px",
|
||||
gap: "15px",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
disabled={isAdding}
|
||||
onClick={resetForm}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={
|
||||
isAdding || !accessKey || !secretKey || !isAllEndpointsValid
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</form>
|
||||
</ModalWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const connector = connect(null, {
|
||||
setErrorSnackMessage,
|
||||
setSnackBarMessage,
|
||||
});
|
||||
export default connector(AddReplicationSitesModal);
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<string>("");
|
||||
|
||||
const [deleteSiteKey, setIsDeleteSiteKey] = useState<string>("");
|
||||
const [editSite, setEditSite] = useState<any>(null);
|
||||
const [editEndPointName, setEditEndPointName] = useState<string>("");
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<List
|
||||
sx={{
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
padding: "0",
|
||||
marginTop: "25px",
|
||||
height: "calc( 100vh - 450px )",
|
||||
border: "1px solid #eaeaea",
|
||||
marginBottom: "25px",
|
||||
}}
|
||||
component="nav"
|
||||
aria-labelledby="nested-list-subheader"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
borderBottom: "1px solid #f1f1f1",
|
||||
padding: "25px 25px 25px 20px",
|
||||
}}
|
||||
>
|
||||
List of Replicated Sites
|
||||
</Box>
|
||||
{sites.map((siteInfo, index) => {
|
||||
const key = `${siteInfo.name}`;
|
||||
const isExpanded = expanded === siteInfo.name;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isExpanded) {
|
||||
handleClick(key);
|
||||
} else {
|
||||
handleClick("");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<ListItemButton
|
||||
disableRipple
|
||||
className={isExpanded ? "expanded" : ""}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
border: "1px solid #f1f1f1",
|
||||
borderLeft: "0",
|
||||
borderRight: "0",
|
||||
borderTop: "0",
|
||||
padding: "6px 10px 6px 20px",
|
||||
"&:hover": {
|
||||
background: "#bebbbb0d",
|
||||
},
|
||||
"&.expanded": {
|
||||
marginBottom: "0",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 2,
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
sm: "1fr 1fr ",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{siteInfo.name}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{siteInfo.isCurrent ? (
|
||||
<Tooltip title={"This site/cluster"} placement="top">
|
||||
<Box
|
||||
sx={{
|
||||
"& .min-icon": {
|
||||
height: "12px",
|
||||
fill: "green",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CircleIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title={siteInfo.endpoint}>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
marginLeft: siteInfo.isCurrent ? "" : "24px",
|
||||
}}
|
||||
>
|
||||
{siteInfo.endpoint}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
marginLeft: "25px",
|
||||
marginRight: "25px",
|
||||
width: "60px",
|
||||
flexShrink: 0,
|
||||
|
||||
"& button": {
|
||||
borderRadius: "50%",
|
||||
background: "#F8F8F8",
|
||||
border: "none",
|
||||
|
||||
"&:hover": {
|
||||
background: "#E2E2E2",
|
||||
},
|
||||
|
||||
"& svg": {
|
||||
fill: "#696565",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RBIconButton
|
||||
tooltip={
|
||||
sites.length <= 2
|
||||
? "Minimum two sites are required for replication"
|
||||
: "Delete Site"
|
||||
}
|
||||
text={""}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={sites.length <= 2}
|
||||
icon={<TrashIcon />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDeleteSiteKey(key);
|
||||
}}
|
||||
/>
|
||||
<RBIconButton
|
||||
tooltip={"Edit Endpoint"}
|
||||
text={""}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
icon={<EditIcon />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setEditSite(siteInfo);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{hasExpand ? (
|
||||
<Box
|
||||
sx={{
|
||||
height: "25px",
|
||||
width: "25px",
|
||||
background: "#FBFAFA",
|
||||
borderRadius: "2px",
|
||||
border: "1px solid #eaeaea",
|
||||
"&:hover": {
|
||||
background: "#fafafa",
|
||||
},
|
||||
display: {
|
||||
md: "block",
|
||||
xs: "none",
|
||||
},
|
||||
"& .collapse-icon": {
|
||||
fill: "#494949",
|
||||
"& g rect": {
|
||||
fill: "#ffffff",
|
||||
},
|
||||
},
|
||||
"& .expand-icon": {
|
||||
fill: "#494949",
|
||||
"& rect": {
|
||||
fill: "#ffffff",
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<MenuCollapsedIcon className="collapse-icon" />
|
||||
) : (
|
||||
<MenuExpandedIcon className="expand-icon" />
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "25px",
|
||||
width: "25px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
{isExpanded ? (
|
||||
<Box
|
||||
key={`${siteInfo.name}`}
|
||||
sx={{
|
||||
border: "1px solid #f1f1f1",
|
||||
borderLeft: "0",
|
||||
borderRight: "0",
|
||||
borderTop: "0",
|
||||
}}
|
||||
>
|
||||
<ListSubheader
|
||||
key={`${index}-drive-details`}
|
||||
component="div"
|
||||
sx={{ paddingLeft: "30px" }}
|
||||
>
|
||||
Replication status
|
||||
</ListSubheader>
|
||||
|
||||
<Collapse
|
||||
in={isExpanded}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
sx={{
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
padding: { md: "15px 30px", xs: "10px 10px" },
|
||||
"& .MuiCollapse-wrapperInner": {
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: "15px",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Status info
|
||||
</Collapse>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{deleteSiteKey === key ? (
|
||||
<ConfirmDialog
|
||||
title={`Delete Replication Site`}
|
||||
confirmText={"Delete"}
|
||||
isOpen={true}
|
||||
titleIcon={<ConfirmDeleteIcon />}
|
||||
isLoading={false}
|
||||
onConfirm={() => {
|
||||
onDeleteSite(false, [key]);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsDeleteSiteKey("");
|
||||
}}
|
||||
confirmationContent={
|
||||
<DialogContentText>
|
||||
Are you sure you want to remove the replication site:{" "}
|
||||
{key}.?
|
||||
</DialogContentText>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editSite?.name === key ? (
|
||||
<ModalWrapper
|
||||
title={`Edit Replication Endpoint `}
|
||||
modalOpen={true}
|
||||
titleIcon={<EditIcon />}
|
||||
onClose={() => {
|
||||
setEditSite(null);
|
||||
}}
|
||||
>
|
||||
<DialogContentText>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ marginBottom: "10px" }}>
|
||||
<strong>Site:</strong> {" "}
|
||||
{editSite.name}
|
||||
</Box>
|
||||
<Box sx={{ marginBottom: "10px" }}>
|
||||
<strong>Current Endpoint:</strong> {" "}
|
||||
{editSite.endpoint}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ marginBottom: "5px" }}> New Endpoint:</Box>
|
||||
<InputBoxWrapper
|
||||
id="edit-rep-peer-endpoint"
|
||||
name="edit-rep-peer-endpoint"
|
||||
placeholder={"https://dr.minio-storage:9000"}
|
||||
onChange={(
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setEditEndPointName(event.target.value);
|
||||
}}
|
||||
label=""
|
||||
value={editEndPointName}
|
||||
/>
|
||||
</Grid>
|
||||
</DialogContentText>
|
||||
|
||||
<Grid item xs={12} className={classes.modalButtonBar}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setEditSite(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={isEditing || !isValidEndPointUrl}
|
||||
onClick={() => {
|
||||
updatePeerSite();
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Grid>
|
||||
</ModalWrapper>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const connector = connect(null, {
|
||||
setErrorSnackMessage,
|
||||
setSnackBarMessage,
|
||||
});
|
||||
export default connector(withStyles(styles)(ReplicationSites));
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 (
|
||||
<Fragment>
|
||||
<PageHeader label={"Site Replication"} />
|
||||
<PageLayout>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{hasSites ? (
|
||||
<Box>
|
||||
<RBIconButton
|
||||
tooltip={"Delete All"}
|
||||
text={"Delete All"}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={isRemoving}
|
||||
icon={<TrashIcon />}
|
||||
onClick={() => {
|
||||
setIsDeleteAll(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<RBIconButton
|
||||
tooltip={"Add Replication Sites"}
|
||||
text={"Add Sites"}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={isRemoving}
|
||||
icon={<AddIcon />}
|
||||
onClick={() => {
|
||||
setIsAddOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{hasSites ? (
|
||||
<ReplicationSites
|
||||
sites={sites}
|
||||
onDeleteSite={removeSites}
|
||||
onRefresh={getSites}
|
||||
/>
|
||||
) : null}
|
||||
{isSiteInfoLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "calc( 100vh - 450px )",
|
||||
}}
|
||||
>
|
||||
<Loader style={{ width: 16, height: 16 }} />
|
||||
</Box>
|
||||
) : null}
|
||||
{!hasSites && !isSiteInfoLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
padding: "30px",
|
||||
border: "1px solid #eaeaea",
|
||||
marginTop: "15px",
|
||||
marginBottom: "15px",
|
||||
height: "calc( 100vh - 450px )",
|
||||
}}
|
||||
>
|
||||
Site Replication is not configured.
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{isAddOpen ? (
|
||||
<AddReplicationSitesModal
|
||||
existingSites={sites}
|
||||
onClose={() => {
|
||||
setIsAddOpen(false);
|
||||
getSites();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HelpBox
|
||||
title={"Site Replication"}
|
||||
iconComponent={<RecoverIcon />}
|
||||
help={
|
||||
<Fragment>
|
||||
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.
|
||||
<br />
|
||||
<br />
|
||||
You can learn more at our{" "}
|
||||
<a
|
||||
href="https://github.com/minio/minio/tree/master/docs/site-replication?ref=con"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
.
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
|
||||
{deleteAll ? (
|
||||
<ConfirmDialog
|
||||
title={`Delete All`}
|
||||
confirmText={"Delete"}
|
||||
isOpen={true}
|
||||
titleIcon={<ConfirmDeleteIcon />}
|
||||
isLoading={false}
|
||||
onConfirm={() => {
|
||||
const siteNames = sites.map((s: any) => s.name);
|
||||
removeSites(true, siteNames);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsDeleteAll(false);
|
||||
}}
|
||||
confirmationContent={
|
||||
<DialogContentText>
|
||||
Are you sure you want to remove all the replication sites?.
|
||||
</DialogContentText>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</PageLayout>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const connector = connect(null, {
|
||||
setErrorSnackMessage,
|
||||
setSnackBarMessage,
|
||||
});
|
||||
|
||||
export default connector(SiteReplication);
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
54
portal-ui/tests/permissions-2/site-replication.ts
Normal file
54
portal-ui/tests/permissions-2/site-replication.ts
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user