소스 검색

Merge pull request #2729 from porter-dev/rename-clusters

Add support for cluster vanity names
jusrhee 3 년 전
부모
커밋
8c5b28de40

+ 49 - 0
api/server/handlers/cluster/rename.go

@@ -0,0 +1,49 @@
+package cluster
+
+import (
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"net/http"
+)
+
+type RenameClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRenameClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RenameClusterHandler {
+	return &RenameClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RenameClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateClusterRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Name != "" && cluster.VanityName != request.Name {
+		cluster.VanityName = request.Name
+	}
+
+	cluster, err := c.Repo().Cluster().UpdateCluster(cluster)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, cluster.ToClusterType())
+}

+ 30 - 0
api/server/router/cluster.go

@@ -558,6 +558,36 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/rename -> cluster.NewRenameClusterHandler
+		renameClusterEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/rename",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+					types.PreviewEnvironmentScope,
+				},
+			},
+		)
+
+		renameClusterHandler := cluster.NewRenameClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: renameClusterEndpoint,
+			Handler:  renameClusterHandler,
+			Router:   r,
+		})
+
 		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id} ->
 		// environment.NewDeleteDeploymentHandler
 		deleteDeploymentEndpoint := factory.NewAPIEndpoint(

+ 4 - 0
api/types/cluster.go

@@ -308,6 +308,10 @@ type UpdateClusterRequest struct {
 	PreviewEnvsEnabled *bool `json:"preview_envs_enabled"`
 }
 
+type RenameClusterRequest struct {
+	Name string `json:"name"`
+}
+
 type ListClusterResponse []*Cluster
 
 type CreateClusterCandidateResponse []*ClusterCandidate

+ 0 - 8
dashboard/src/components/ProvisionerSettings.tsx

@@ -115,7 +115,6 @@ const ProvisionerSettings: React.FC<Props> = props => {
       data["cluster"]["clusterId"] = props.clusterId;
     }
 
-    console.log(0);
     try {
       const res = await api.createContract(
         "<token>",
@@ -123,12 +122,8 @@ const ProvisionerSettings: React.FC<Props> = props => {
         { project_id: currentProject.id }
       );
 
-      console.log("res is:", res);
-      console.log("cluster id is:", res.data.contract_revision?.cluster_id);
-
       // Only refresh and set clusters on initial create
       if (!props.clusterId) {
-        console.log(1);
         setShouldRefreshClusters(true);
         api.getClusters(
           "<token>",
@@ -136,12 +131,9 @@ const ProvisionerSettings: React.FC<Props> = props => {
           { id: currentProject.id },
         )
           .then(({ data }) => {
-            console.log(2);
             data.forEach((cluster: ClusterType) => {
-              console.log("cluster id:", cluster.id)
               if (cluster.id === res.data.contract_revision?.cluster_id) {
                 // setHasFinishedOnboarding(true);
-                console.log(3);
                 setCurrentCluster(cluster);
                 OFState.actions.goTo("clean_up");
                 pushFiltered(props, "/cluster-dashboard", ["project_id"], {

+ 135 - 0
dashboard/src/components/porter/Button.tsx

@@ -0,0 +1,135 @@
+import React, { useEffect, useState } from "react";
+import styled, { keyframes } from "styled-components";
+
+import loading from "assets/loading.gif";
+
+type Props = {
+  children: React.ReactNode;
+  onClick: () => void;
+  disabled?: boolean;
+  status?: string;
+  loadingText?: string;
+  successText?: string;
+};
+
+const Button: React.FC<Props> = ({
+  children,
+  onClick,
+  disabled,
+  status,
+  loadingText,
+  successText,
+}) => {
+  const renderStatus = () => {
+    switch(status) {
+      case "success":
+        return (
+          <StatusWrapper success={true}>
+            <i className="material-icons">done</i>
+            {successText || "Successfully updated"}
+          </StatusWrapper>
+        );
+      case "loading":
+        return (
+          <StatusWrapper success={false}>
+            <Loading src={loading} />
+            {loadingText || "Updating . . ."}
+          </StatusWrapper>
+        );
+      default:
+        return (
+          <StatusWrapper success={false}>
+            <i className="material-icons">error_outline</i>
+            Could not update
+          </StatusWrapper>
+        );
+    }
+  };
+
+  return (
+    <Wrapper>
+      <StyledButton
+        disabled={disabled}
+        onClick={() => !disabled && onClick()}
+      >
+        <Text>{children}</Text>
+      </StyledButton>
+      {status && renderStatus()}
+    </Wrapper>
+  );
+};
+
+export default Button;
+
+const Loading = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const floatIn = keyframes`
+  0% {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+`;
+
+const StatusWrapper = styled.div<{
+  success?: boolean;
+}>`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-left: 15px;
+  max-width: 500px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  animation: ${floatIn} 0.5s;
+  animation-fill-mode: forwards;
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: ${props => props.success ? "#4797ff" : "#fcba03"};
+  }
+`;
+
+const Wrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Text = styled.div`
+  display: flex;
+  align-items: center;
+  height: 100%;
+`;
+
+const StyledButton = styled.button<{
+  disabled: boolean;
+}>`
+  height: 35px;
+  font-size: 13px;
+  cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
+  padding: 15px;
+  border: none;
+  outline: none;
+  font-weight: 500;
+  color: white;
+  background: ${props => props.disabled ? "#aaaabb" : "#5561C0"};
+  display: flex;
+  ailgn-items: center;
+  justify-content: center;
+  border-radius: 5px;
+
+  :hover {
+    filter: ${props => props.disabled ? "" : "brightness(120%)"};
+  }
+`;

+ 44 - 0
dashboard/src/components/porter/Input.tsx

@@ -0,0 +1,44 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  placeholder: string;
+  width?: string;
+  value: string;
+  setValue: (value: string) => void;
+};
+
+const Input: React.FC<Props> = ({
+  placeholder,
+  width,
+  value,
+  setValue,
+}) => {
+  return (
+    <StyledInput
+      value={value}
+      onChange={e => setValue(e.target.value)}
+      placeholder={placeholder}
+      width={width}
+    />
+  );
+};
+
+export default Input;
+
+const StyledInput = styled.input<{
+  width: string;
+}>`
+  height: 35px;
+  padding: 5px 10px;
+  width: ${props => props.width || "200px"};
+  color: white;
+  font-saize: 13px;
+  outline: none;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;

+ 12 - 5
dashboard/src/components/porter/Spacer.tsx

@@ -13,19 +13,26 @@ const Spacer: React.FC<Props> = ({
   inline,
 }) => {
   const getCalcHeight = () => {
-    return 25 * y;
+    if (y) {
+      return 25 * y + "px";
+    }
+    return null
   };
   
   return (
     <StyledSpacer
-      height={height || (getCalcHeight() + "px")}
+      height={height || getCalcHeight()}
+      width={inline && "15px"}
     />
   );
 };
 
 export default Spacer;
 
-const StyledSpacer = styled.div<{ height: string }>`
-  height: ${props => props.height};
-  width: ${props => props.height ? "100%" : ""};
+const StyledSpacer = styled.div<{ 
+  height: string;
+  width: string;
+}>`
+  height: ${props => props.height || "100%"};
+  width: ${props => props.height ? "100%" : props.width};
 `;

+ 7 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -84,8 +84,15 @@ const ModalHandler: React.FC<{
     }
   }, [currentModal, currentProject]);
 
+  const renderModal = () => {
+    if (modal && typeof modal !== 'string') {
+      return modal;
+    }
+  }
+
   return (
     <>
+      {renderModal()}
       {modal === "RedirectToOnboardingModal" && (
         <Modal width="600px" height="180px" title="You're almost ready...">
           <RedirectToOnboardingModal />

+ 150 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettingsModal.tsx

@@ -0,0 +1,150 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Modal from "main/home/modals/Modal";
+import Input from "components/porter/Input";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+};
+
+const ClusterSettingsModal: React.FC<Props> = ({
+}) => {
+  const { 
+    setCurrentModal, 
+    currentCluster, 
+    currentProject,
+    setShouldRefreshClusters,
+  } = useContext(Context);
+  const [clusterName, setClusterName] = useState("");
+  const [status, setStatus] = useState("");
+
+  useEffect(() => {
+    setClusterName(currentCluster.vanity_name || currentCluster.name);
+  }, []);
+
+  const renameCluster = async () => {
+    setStatus("loading");
+    try {
+      const res = await api.renameCluster(
+        "<token>",
+        { name: clusterName },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      setStatus("success");
+      setShouldRefreshClusters(true);
+    } catch (err) {
+      setStatus("error");
+      console.log(err);
+    }
+  }
+
+  return (
+    <Modal
+      width="600px"
+      height="auto"
+      onRequestClose={() => setCurrentModal(null, null)}
+      title="Cluster name"
+    >
+      <Spacer height="15px" />
+      <Flex>
+        <IconWrapper>
+        <svg
+          width="18"
+          height="18"
+          viewBox="0 0 19 19"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+        </svg>
+        </IconWrapper>
+        <Spacer inline />
+        <Input 
+          placeholder="ex: my-cluster" 
+          width="100%"
+          value={clusterName}
+          setValue={setClusterName}
+        />
+      </Flex>
+      <Spacer y={1} />
+      <Button 
+        onClick={renameCluster}
+        disabled={clusterName === ""}
+        status={status}
+      >
+        Save
+      </Button>
+    </Modal>
+  );
+};
+
+export default ClusterSettingsModal;
+
+const IconWrapper = styled.div`
+  min-width: 35px;
+  height: 35px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  cursor: not-allowed;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;

+ 93 - 7
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,7 +1,7 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import { useLocation } from "react-router";
-import settings from "assets/settings-centered.svg";
+import settings from "assets/settings.svg";
 
 import api from "shared/api";
 import { DetailedIngressError } from "shared/types";
@@ -18,6 +18,7 @@ import NodeList from "./NodeList";
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import Metrics from "./Metrics";
+import ClusterSettingsModal from "./ClusterSettingsModal";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import Loading from "components/Loading";
@@ -59,7 +60,6 @@ export const Dashboard: React.FunctionComponent = () => {
               clusterId={context.currentCluster.id}
               credentialId={context.currentCluster.cloud_provider_credential_identifier}
             />
-            <Div />
           </>
         );
       default:
@@ -229,8 +229,74 @@ export const Dashboard: React.FunctionComponent = () => {
   return (
     <>
       <DashboardHeader
-        image={settings}
-        title={context.currentCluster.vanity_name || context.currentCluster.name}
+        title={
+          <Flex>
+            <Flex>
+              <svg
+                width="23"
+                height="23"
+                viewBox="0 0 19 19"
+                fill="none"
+                xmlns="http://www.w3.org/2000/svg"
+              >
+                <path
+                  d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  stroke-linejoin="round"
+                />
+              </svg>
+              <Spacer inline />
+              {context.currentCluster.vanity_name || context.currentCluster.name}
+              <Spacer inline />
+            </Flex>
+            <SettingsIcon onClick={() => {
+              context.setCurrentModal(<ClusterSettingsModal />);
+            }}>
+              <img src={settings} />
+            </SettingsIcon>
+          </Flex>
+        }
         description={
           ingressIp ? (
             <>{renderIngressIp(ingressIp, ingressError)}</>
@@ -247,9 +313,29 @@ export const Dashboard: React.FunctionComponent = () => {
   );
 };
 
-const Div = styled.div`
-  width: 100%;
-  height: 50px;
+const SettingsIcon = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: 3px;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 40px;
+  margin-bottom: -2px;
+  :hover {
+    background: #ffffff18;
+  }
+  > img {
+    width: 22px;
+    opacity: 0.4;
+    margin-bottom: -4px;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
 `;
 
 const Br = styled.div`

+ 10 - 8
dashboard/src/main/home/modals/Modal.tsx

@@ -103,8 +103,8 @@ const CloseButton = styled.div`
   justify-content: center;
   z-index: 1;
   border-radius: 50%;
-  right: 15px;
-  top: 12px;
+  right: 12px;
+  top: 10px;
   cursor: pointer;
   :hover {
     background-color: #ffffff11;
@@ -131,16 +131,18 @@ const Overlay = styled.div`
   justify-content: center;
 `;
 
-const StyledModal = styled.div`
+const StyledModal = styled.div<{
+  width: string;
+  height: string;
+}>`
   position: absolute;
-  width: ${(props: { width?: string; height?: string }) =>
-    props.width ? props.width : "760px"};
+  width: ${props => props.width || "760px"};
   max-width: 80vw;
-  height: ${(props: { width?: string; height?: string }) =>
-    props.height ? props.height : "425px"};
+  height: ${props => props.height || "425px"};
   max-height: calc(100vh - 30px);
   overflow: visible;
-  padding: 25px 32px;
+  padding: 25px;
+  padding-bottom: 30px;
   z-index: 999;
   font-size: 13px;
   border-radius: 10px;

+ 13 - 0
dashboard/src/shared/api.tsx

@@ -113,6 +113,18 @@ const updateCluster = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
+const renameCluster = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/rename`;
+});
+
 const createAzureIntegration = baseApi<
   {
     azure_client_id: string;
@@ -2391,6 +2403,7 @@ export default {
   createAWSIntegration,
   overwriteAWSIntegration,
   updateCluster,
+  renameCluster,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,

+ 1 - 1
dashboard/src/shared/types.tsx

@@ -339,7 +339,7 @@ export interface CapabilityType {
 export interface ContextProps {
   currentModal?: string;
   currentModalData: any;
-  setCurrentModal: (currentModal: string, currentModalData?: any) => void;
+  setCurrentModal: (currentModal: any, currentModalData?: any) => void;
   currentOverlay: {
     message: string;
     onYes: any;