From ee3affd140bd873721cf2922b2f90e7ba409dbe9 Mon Sep 17 00:00:00 2001
From: Prakash Senthil Vel <23444145+prakashsvmx@users.noreply.github.com>
Date: Sat, 9 Apr 2022 23:27:25 +0000
Subject: [PATCH] UI site replication (#1807)
---
.../src/common/SecureComponent/permissions.ts | 5 +
.../AddReplicationSitesModal.tsx | 419 ++++++++++++++++
.../SiteReplication/ReplicationSites.tsx | 464 ++++++++++++++++++
.../SiteReplication/SiteReplication.tsx | 240 +++++++++
portal-ui/src/screens/Console/Console.tsx | 6 +-
portal-ui/src/screens/Console/valid-routes.ts | 8 +
.../tests/permissions-2/site-replication.ts | 54 ++
7 files changed, 1195 insertions(+), 1 deletion(-)
create mode 100644 portal-ui/src/screens/Console/Configurations/SiteReplication/AddReplicationSitesModal.tsx
create mode 100644 portal-ui/src/screens/Console/Configurations/SiteReplication/ReplicationSites.tsx
create mode 100644 portal-ui/src/screens/Console/Configurations/SiteReplication/SiteReplication.tsx
create mode 100644 portal-ui/tests/permissions-2/site-replication.ts
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}
+
+
+ );
+};
+
+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);
+});