Kaynağa Gözat

initial canonical name work

Mohammed Nafees 3 yıl önce
ebeveyn
işleme
ce7ef19b97

+ 70 - 0
api/server/handlers/release/update_canonical_name.go

@@ -0,0 +1,70 @@
+package release
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateCanonicalNameHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateCanonicalNameHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateCanonicalNameHandler {
+	return &UpdateCanonicalNameHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateCanonicalNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateCanonicalNameRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("release %s not found", name)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if release.CanonicalName != request.CanonicalName {
+		release.CanonicalName = request.CanonicalName
+
+		release, err = c.Repo().Release().UpdateRelease(release)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, release.ToReleaseType())
+}

+ 32 - 2
api/server/router/release.go

@@ -783,8 +783,7 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
-	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_tags ->
-	// release.NewGetLatestJobRunHandler
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_tags -> release.NewUpdateReleaseTagsHandler
 	updateReleaseTagsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
@@ -815,6 +814,37 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_canonical_name -> release.NewUpdateCanonicalNameHandler
+	updateCanonicalNameEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/update_canonical_name",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	updateCanonicalNameHandler := release.NewUpdateCanonicalNameHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateCanonicalNameEndpoint,
+		Handler:  updateCanonicalNameHandler,
+		Router:   r,
+	})
+
 	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/git_action_config -> release.NewUpdateGitActionConfigHandler
 	updateGitActionConfigEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 7 - 0
api/types/release.go

@@ -38,6 +38,9 @@ type PorterRelease struct {
 
 	// Whether this release is tied to a stack or not
 	StackID string `json:"stack_id"`
+
+	// The canonical name of this release
+	CanonicalName string `json:"canonical_name,omitempty"`
 }
 
 // swagger:model
@@ -211,3 +214,7 @@ type PartialGitActionConfig struct {
 type UpdateGitActionConfigRequest struct {
 	GitActionConfig *PartialGitActionConfig `json:"git_action_config"`
 }
+
+type UpdateCanonicalNameRequest struct {
+	CanonicalName string `json:"canonical_name" form:"required"`
+}

+ 218 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/CanonicalName.tsx

@@ -0,0 +1,218 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import { Tooltip } from "@material-ui/core";
+import Modal from "main/home/modals/Modal";
+import { TwitterPicker } from "react-color";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import api from "shared/api";
+import Color from "color";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import Helper from "components/form-components/Helper";
+import { differenceBy } from "lodash";
+import SearchSelector from "components/SearchSelector";
+
+type Props = {
+  onSave: ((values: any[]) => void) | ((values: any[]) => Promise<void>);
+  release: ChartType;
+};
+
+const CanonicalName = ({ onSave, release }: Props) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [values, setValues] = useState([]);
+  const [availableTags, setAvailableTags] = useState([]);
+  const [openModal, setOpenModal] = useState(false);
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const onDelete = (index: number) => {
+    setValues((prev) => {
+      const newValues = [...prev];
+      const removedTag = newValues.splice(index, 1);
+      setAvailableTags((prevAt) => [...prevAt, ...removedTag]);
+      return newValues;
+    });
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+
+    try {
+      await api.updateReleaseTags(
+        "<token>",
+        { tags: [...values.map((tag) => tag.name)] },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: release.namespace,
+          release_name: release.name,
+        }
+      );
+      await onSave(values);
+      setButtonStatus("successful");
+    } catch (error) {
+      console.log(error);
+      setCurrentError(
+        "We couldn't link the tag to the release, please try again."
+      );
+      setButtonStatus("Couldn't link the tag to the release");
+      return;
+    } finally {
+      setTimeout(() => {
+        setButtonStatus("");
+      }, 800);
+    }
+  };
+
+  const hasUnsavedChanges = useMemo(() => {
+    const hasAddedSomething = !!differenceBy(
+      values,
+      release.tags?.map((tagName: string) => ({ name: tagName })) || [],
+      "name"
+    ).length;
+
+    const hasDeletedSomething = !!differenceBy(
+      release.tags?.map((tagName: string) => ({ name: tagName })) || [],
+      values,
+      "name"
+    ).length;
+
+    return hasAddedSomething || hasDeletedSomething;
+  }, [values, release]);
+
+  return (
+    <>
+      <Flex>
+        {values.map((val, index) => {
+          return (
+            <Tag color={val.color} key={index}>
+              <Tooltip title={val.name}>
+                <TagText>{val.name}</TagText>
+              </Tooltip>
+              <i className="material-icons" onClick={() => onDelete(index)}>
+                cancel
+              </i>
+            </Tag>
+          );
+        })}
+      </Flex>
+      <SearchSelector
+        options={availableTags}
+        dropdownLabel="Select a tag"
+        renderAddButton={() => (
+          <AddTagButton
+            onClick={(e) => {
+              setOpenModal((prev) => !prev);
+            }}
+          >
+            + Create a new tag
+          </AddTagButton>
+        )}
+        filterBy="name"
+        onSelect={(value) => {
+          console.log(value);
+          setAvailableTags((prev) =>
+            prev.filter((prevVal) => prevVal.name !== value.name)
+          );
+          setValues((prev) => [...prev, value]);
+        }}
+        getOptionLabel={(option) => option.name}
+        renderOptionIcon={(option) => <TagColorBox color={option.color} />}
+      />
+      <Flex
+        style={{
+          marginTop: "25px",
+        }}
+      >
+        <SaveButton
+          helper={hasUnsavedChanges ? "Unsaved changes" : ""}
+          clearPosition
+          statusPosition="right"
+          text="Save changes"
+          onClick={() => handleSave()}
+          status={buttonStatus}
+          disabled={!hasUnsavedChanges || buttonStatus === "loading"}
+        ></SaveButton>
+      </Flex>
+      <Br />
+    </>
+  );
+};
+
+const AddTagButton = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  padding: 10px 0;
+  z-index: 999;
+  padding-left: 12px;
+  cursor: pointer;
+  :hover {
+    color: white;
+  }
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
+export default CanonicalName;
+
+const Flex = styled.div`
+  display: flex;
+  position: relative;
+`;
+
+const Tag = styled.div<{ color: string }>`
+  display: inline-flex;
+  color: ${(props) => Color(props.color).darken(0.4).string() || "inherit"};
+  user-select: none;
+  border: 1px solid ${(props) => Color(props.color).darken(0.4).string()};
+  border-radius: 5px;
+  padding: 4px 8px;
+  position: relative;
+  margin-bottom: 20px;
+  text-align: center;
+  align-items: center;
+  font-size: 13px;
+  background-color: ${(props) => props.color || "inherit"};
+
+  max-width: 150px;
+  min-width: 60px;
+
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+
+  > .material-icons {
+    font-size: 16px;
+    :hover {
+      cursor: pointer;
+    }
+  }
+`;
+
+const TagText = styled.span`
+  overflow-x: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const TagColorBox = styled.div`
+  width: 15px;
+  height: 15px;
+  margin-right: 10px;
+  border-radius: 0px;
+  background-color: ${(props: { color: string }) => props.color};
+`;

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -873,7 +873,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   icon={currentChart.chart.metadata.icon}
                   iconWidth="33px"
                 >
-                  {currentChart.name}
+                  {currentChart.canonical_name === ""
+                    ? currentChart.canonical_name
+                    : currentChart.name}
                   <DeploymentType currentChart={currentChart} />
                   <TagWrapper>
                     Namespace{" "}

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -20,6 +20,7 @@ import { isDeployedFromGithub } from "shared/release/utils";
 import TagSelector from "./TagSelector";
 import { PORTER_IMAGE_TEMPLATES } from "shared/common";
 import DynamicLink from "components/DynamicLink";
+import CanonicalName from "./CanonicalName";
 
 type PropsType = {
   currentChart: ChartType;
@@ -243,6 +244,13 @@ const SettingsSection: React.FC<PropsType> = ({
         ) : null}
 
         <>
+          <Heading>Canonical Name</Heading>
+          <Helper>Set a canonical name for this application</Helper>
+          <CanonicalName
+            release={currentChart}
+            onSave={(val) => refreshChart()}
+          />
+
           <Heading>Redeploy Webhook</Heading>
           <Helper>
             Programmatically deploy by calling this secret webhook.

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

@@ -1958,6 +1958,22 @@ const updateReleaseTags = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_tags`
 );
 
+const updateCanonicalName = baseApi<
+  {
+    canonical_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, release_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_canonical_name`
+);
+
 const getGitProviders = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/integrations/git`
@@ -2476,6 +2492,7 @@ export default {
   getTagsByProjectId,
   createTag,
   updateReleaseTags,
+  updateCanonicalName,
   getGitProviders,
   getGitlabRepos,
   getGitlabBranches,

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

@@ -56,6 +56,7 @@ export interface ChartType {
   namespace: string;
   latest_version: string;
   tags: any;
+  canonical_name: string;
 }
 
 export interface ChartTypeWithExtendedConfig extends ChartType {

+ 3 - 0
internal/models/release.go

@@ -28,6 +28,9 @@ type Release struct {
 	NotificationConfig uint
 	BuildConfig        uint
 	Tags               []*Tag `json:"tags" gorm:"many2many:release_tags"`
+
+	// A configurable canonical name of a Porter release
+	CanonicalName string
 }
 
 func (r *Release) ToReleaseType() *types.PorterRelease {