Преглед изворни кода

Merge pull request #2011 from porter-dev/nico/por-391-tag-grouping-for-applications-and-jobs-backup-2

[POR-391] Tag grouping for applications and jobs backup
Porter Support пре 4 година
родитељ
комит
8e491bdd90
35 измењених фајлова са 1528 додато и 75 уклоњено
  1. 52 0
      api/server/handlers/project/create_tag.go
  2. 1 1
      api/server/handlers/project/get_policy.go
  3. 1 1
      api/server/handlers/project/list.go
  4. 37 0
      api/server/handlers/project/list_tags.go
  5. 8 0
      api/server/handlers/release/create.go
  6. 88 0
      api/server/handlers/release/update_tags.go
  7. 55 0
      api/server/router/project.go
  8. 32 0
      api/server/router/release.go
  9. 6 0
      api/types/release.go
  10. 6 0
      api/types/tag.go
  11. 122 3
      dashboard/package-lock.json
  12. 4 0
      dashboard/package.json
  13. 245 0
      dashboard/src/components/SearchSelector.tsx
  14. 49 59
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  15. 2 0
      dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx
  16. 71 0
      dashboard/src/main/home/cluster-dashboard/TagFilter.tsx
  17. 12 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  18. 15 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  19. 403 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/TagSelector.tsx
  20. 30 5
      dashboard/src/shared/api.tsx
  21. 7 0
      dashboard/src/shared/baseApi.ts
  22. 1 0
      dashboard/src/shared/types.tsx
  23. 9 0
      internal/models/release.go
  24. 17 0
      internal/models/tag.go
  25. 2 0
      internal/repository/gorm/helpers_test.go
  26. 1 0
      internal/repository/gorm/migrate.go
  27. 3 3
      internal/repository/gorm/release.go
  28. 1 0
      internal/repository/gorm/release_test.go
  29. 6 0
      internal/repository/gorm/repository.go
  30. 115 0
      internal/repository/gorm/tag.go
  31. 70 0
      internal/repository/gorm/tag_test.go
  32. 1 0
      internal/repository/repository.go
  33. 15 0
      internal/repository/tag.go
  34. 6 0
      internal/repository/test/repository.go
  35. 35 0
      internal/repository/test/tag.go

+ 52 - 0
api/server/handlers/project/create_tag.go

@@ -0,0 +1,52 @@
+package project
+
+import (
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateTagHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateTagHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateTagHandler {
+	return &CreateTagHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	newTag := &types.CreateTagRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, newTag); !ok {
+		return
+	}
+
+	tag, err := c.Repo().Tag().CreateTag(&models.Tag{
+		Name:      newTag.Name,
+		Color:     newTag.Color,
+		ProjectID: project.ID,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, tag)
+}

+ 1 - 1
api/server/handlers/project/get_policy.go

@@ -30,7 +30,7 @@ func (p *ProjectGetPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	policyDocLoader := policy.NewBasicPolicyDocumentLoader(p.Config().Repo.Project())
+	policyDocLoader := policy.NewBasicPolicyDocumentLoader(p.Repo().Project())
 
 	policyDocs, err := policyDocLoader.LoadPolicyDocuments(user.ID, proj.ID)
 

+ 1 - 1
api/server/handlers/project/list.go

@@ -29,7 +29,7 @@ func (p *ProjectListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 
 	// read all projects for this user
-	projects, err := p.Config().Repo.Project().ListProjectsByUserID(user.ID)
+	projects, err := p.Repo().Project().ListProjectsByUserID(user.ID)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 37 - 0
api/server/handlers/project/list_tags.go

@@ -0,0 +1,37 @@
+package project
+
+import (
+	"net/http"
+
+	"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/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetTagsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewGetTagsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetTagsHandler {
+	return &GetTagsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *GetTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	tags, err := p.Repo().Tag().ListTagsByProjectId(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	p.WriteResult(w, r, tags)
+}

+ 8 - 0
api/server/handlers/release/create.go

@@ -117,6 +117,14 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	if request.Tags != nil {
+		tags, err := c.Repo().Tag().LinkTagsToRelease(request.Tags, release)
+
+		if err == nil {
+			release.Tags = append(release.Tags, tags...)
+		}
+	}
+
 	if request.GithubActionConfig != nil {
 		_, _, err := createGitAction(
 			c.Config(),

+ 88 - 0
api/server/handlers/release/update_tags.go

@@ -0,0 +1,88 @@
+package release
+
+import (
+	"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"
+)
+
+type UpdateReleaseTagsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateReleaseTagsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateReleaseTagsHandler {
+	return &UpdateReleaseTagsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateReleaseTagsHandler) 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.PatchUpdateReleaseTags{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	tagsToDelete := difference(release.ToReleaseType().Tags, request.Tags)
+
+	err = c.Repo().Tag().UnlinkTagsFromRelease(tagsToDelete, release)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = c.Repo().Tag().LinkTagsToRelease(request.Tags, release)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	release, err = c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, release)
+}
+
+func difference(a, b []string) []string {
+	mb := make(map[string]struct{}, len(b))
+	for _, x := range b {
+		mb[x] = struct{}{}
+	}
+	var diff []string
+	for _, x := range a {
+		if _, found := mb[x]; !found {
+			diff = append(diff, x)
+		}
+	}
+	return diff
+}

+ 55 - 0
api/server/router/project.go

@@ -974,5 +974,60 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/tags -> project.NewGetTagsHandler
+	getTagsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getTagsHandler := project.NewGetTagsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getTagsEndpoint,
+		Handler:  getTagsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/tags -> project.NewCreateTagHandler
+	createTagEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createTagHandler := project.NewCreateTagHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createTagEndpoint,
+		Handler:  createTagHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

@@ -782,5 +782,37 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_tags ->
+	// release.NewGetLatestJobRunHandler
+	updateReleaseTagsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/update_tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	updateReleaseTagsHandler := release.NewUpdateReleaseTagsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateReleaseTagsEndpoint,
+		Handler:  updateReleaseTagsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 6 - 0
api/types/release.go

@@ -21,6 +21,7 @@ type PorterRelease struct {
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
 	ImageRepoURI    string           `json:"image_repo_uri"`
 	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
+	Tags            []string         `json:"tags,omitempty"`
 }
 
 type GetReleaseResponse Release
@@ -47,6 +48,7 @@ type CreateReleaseRequest struct {
 	ImageURL           string                        `json:"image_url" form:"required"`
 	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
 	BuildConfig        *CreateBuildConfigRequest     `json:"build_config,omitempty"`
+	Tags               []string                      `json:"tags,omitempty"`
 }
 
 type CreateAddonRequest struct {
@@ -136,3 +138,7 @@ type DNSRecord struct {
 }
 
 type GetReleaseAllPodsResponse []v1.Pod
+
+type PatchUpdateReleaseTags struct {
+	Tags []string `json:"tags"`
+}

+ 6 - 0
api/types/tag.go

@@ -0,0 +1,6 @@
+package types
+
+type CreateTagRequest struct {
+	Name  string `json:"name" form:"required"`
+	Color string `json:"color" form:"required"`
+}

+ 122 - 3
dashboard/package-lock.json

@@ -15324,6 +15324,11 @@
       "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==",
       "dev": true
     },
+    "@icons/material": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw=="
+    },
     "@ironplans/api": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@ironplans/api/-/api-0.4.1.tgz",
@@ -15402,6 +15407,18 @@
         "react-transition-group": "^4.4.0"
       }
     },
+    "@material-ui/lab": {
+      "version": "4.0.0-alpha.61",
+      "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz",
+      "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==",
+      "requires": {
+        "@babel/runtime": "^7.4.4",
+        "@material-ui/utils": "^4.11.3",
+        "clsx": "^1.0.4",
+        "prop-types": "^15.7.2",
+        "react-is": "^16.8.0 || ^17.0.0"
+      }
+    },
     "@material-ui/styles": {
       "version": "4.11.4",
       "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz",
@@ -15443,9 +15460,9 @@
       "requires": {}
     },
     "@material-ui/utils": {
-      "version": "4.11.2",
-      "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz",
-      "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==",
+      "version": "4.11.3",
+      "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz",
+      "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==",
       "requires": {
         "@babel/runtime": "^7.4.4",
         "prop-types": "^15.7.2",
@@ -16046,6 +16063,16 @@
         "@types/react": "*"
       }
     },
+    "@types/react-color": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz",
+      "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*",
+        "@types/reactcss": "*"
+      }
+    },
     "@types/react-dom": {
       "version": "16.9.14",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.14.tgz",
@@ -16101,6 +16128,15 @@
         "@types/react": "*"
       }
     },
+    "@types/reactcss": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz",
+      "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/scheduler": {
       "version": "0.16.2",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@@ -17751,6 +17787,30 @@
         "object-visit": "^1.0.0"
       }
     },
+    "color": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+      "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+      "requires": {
+        "color-convert": "^2.0.1",
+        "color-string": "^1.9.0"
+      },
+      "dependencies": {
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        }
+      }
+    },
     "color-convert": {
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -17764,6 +17824,15 @@
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
       "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
     },
+    "color-string": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+      "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+      "requires": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
     "commander": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -20044,6 +20113,11 @@
         "has-tostringtag": "^1.0.0"
       }
     },
+    "is-arrayish": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+      "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
+    },
     "is-bigint": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
@@ -20534,6 +20608,11 @@
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
+    "lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
     "lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -20640,6 +20719,11 @@
       "integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w==",
       "requires": {}
     },
+    "material-colors": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
+      "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg=="
+    },
     "math-expression-evaluator": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.3.8.tgz",
@@ -21929,6 +22013,20 @@
         "prop-types": "^15.7.2"
       }
     },
+    "react-color": {
+      "version": "2.19.3",
+      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
+      "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
+      "requires": {
+        "@icons/material": "^0.2.4",
+        "lodash": "^4.17.15",
+        "lodash-es": "^4.17.15",
+        "material-colors": "^1.2.1",
+        "prop-types": "^15.5.10",
+        "reactcss": "^1.2.0",
+        "tinycolor2": "^1.4.1"
+      }
+    },
     "react-dom": {
       "version": "16.14.0",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
@@ -22046,6 +22144,14 @@
         "debounce": "^1.2.0"
       }
     },
+    "reactcss": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+      "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
+      "requires": {
+        "lodash": "^4.0.1"
+      }
+    },
     "readable-stream": {
       "version": "2.3.7",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
@@ -22670,6 +22776,14 @@
       "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==",
       "dev": true
     },
+    "simple-swizzle": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+      "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
+      "requires": {
+        "is-arrayish": "^0.3.1"
+      }
+    },
     "sirv": {
       "version": "1.0.18",
       "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.18.tgz",
@@ -23489,6 +23603,11 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "tinycolor2": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
+      "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="
+    },
     "to-arraybuffer": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",

+ 4 - 0
dashboard/package.json

@@ -6,6 +6,7 @@
     "@ironplans/react": "^0.4.0",
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
+    "@material-ui/lab": "^4.0.0-alpha.61",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@visx/axis": "^1.6.1",
@@ -24,6 +25,7 @@
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",
+    "color": "^4.2.3",
     "cohere-sentry": "^1.0.1",
     "core-js": "^3.16.1",
     "cron-parser": "^4.3.0",
@@ -42,6 +44,7 @@
     "random-words": "^1.1.1",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
+    "react-color": "^2.19.3",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
     "react-infinite-scroll-component": "^6.1.0",
@@ -84,6 +87,7 @@
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
     "@types/react": "^16.14.14",
+    "@types/react-color": "^3.0.6",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",

+ 245 - 0
dashboard/src/components/SearchSelector.tsx

@@ -0,0 +1,245 @@
+import _ from "lodash";
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  options: any[];
+  onSelect: (option: any) => void;
+  label?: string;
+  dropdownLabel?: string;
+  getOptionLabel?: (option: any) => string;
+  filterBy?: ((option: any) => string) | string;
+  noOptionsText?: string;
+  dropdownMaxHeight?: string;
+  renderAddButton?: any;
+  className?: string;
+  renderOptionIcon?: (option: any) => React.ReactNode;
+};
+
+const SearchSelector = ({
+  options,
+  onSelect,
+  label,
+  dropdownLabel,
+  getOptionLabel,
+  filterBy,
+  noOptionsText,
+  dropdownMaxHeight,
+  renderAddButton,
+  className,
+  renderOptionIcon,
+}: Props) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [filter, setFilter] = useState("");
+
+  const handleOptionClick = (e: any, option: any) => {
+    setIsExpanded(false);
+    onSelect(option);
+  };
+
+  const getLabel = (option: any) => {
+    if (typeof getOptionLabel === "function") {
+      return getOptionLabel(option);
+    }
+
+    return React.isValidElement(option) ? option : "";
+  };
+
+  const filteredOptions = useMemo(() => {
+    if (typeof filterBy === "function") {
+      return options.filter((option) => filterBy(option).includes(filter));
+    }
+
+    if (typeof filterBy === "string") {
+      return options.filter((option) =>
+        _.get(option, filterBy).includes(filter)
+      );
+    }
+
+    return options.filter((option) => option.includes(filter));
+  }, [filter, options]);
+
+  return (
+    <>
+      {label?.length ? <Label>{label}</Label> : null}
+      <InputWrapper
+        onBlur={() => {
+          setIsExpanded(false);
+        }}
+        className={className}
+      >
+        <Input
+          value={filter}
+          placeholder="Find or add a tag..."
+          onClick={(e) => {
+            setIsExpanded(false);
+            e.stopPropagation();
+            setIsExpanded(true);
+          }}
+          onChange={(e) => setFilter(e.target.value)}
+        />
+        {isExpanded ? (
+          <DropdownWrapper>
+            <Dropdown dropdownMaxHeight={dropdownMaxHeight}>
+              {!filteredOptions.length ? (
+                <>
+                { !renderAddButton ? (
+                  <DropdownLabel>
+                    {noOptionsText || "No options available for this filter"}
+                  </DropdownLabel>
+                  ) : (
+                    <div 
+                      onMouseDown={(e) => {
+                        e.stopPropagation();
+                        e.preventDefault();
+                        setFilter("");
+                      }}
+                    >
+                      {renderAddButton()}
+                    </div>
+                  )
+                }
+                </>
+              ) : (
+                <>
+                  {renderAddButton && (
+                    <div 
+                      onMouseDown={(e) => {
+                        e.stopPropagation();
+                        e.preventDefault();
+                        setFilter("");
+                      }}
+                    >
+                      {renderAddButton()}
+                    </div>
+                  )}
+                  {!renderAddButton && dropdownLabel && (
+                    <DropdownLabel>{dropdownLabel}</DropdownLabel>
+                  )}
+                  {filteredOptions.map((option, i) => (
+                    <Option
+                      key={i}
+                      onMouseDown={(e) => {
+                        e.stopPropagation();
+                        e.preventDefault();
+                        setFilter("");
+                      }}
+                      onClick={(e) => handleOptionClick(e, option)}
+                    >
+                      {typeof renderOptionIcon === "function"
+                        ? renderOptionIcon(option)
+                        : null}
+                      {getLabel(option)}
+                    </Option>
+                  ))}
+                </>
+              )}
+            </Dropdown>
+          </DropdownWrapper>
+        ) : null}
+      </InputWrapper>
+    </>
+  );
+};
+
+export default SearchSelector;
+
+const InputWrapper = styled.div`
+  display: flex;
+  margin-bottom: -1px;
+  align-items: center;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  background: #ffffff11;
+  position: relative;
+  width: 100%;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  color: #ffffff;
+  padding: 5px 10px;
+  min-height: 35px;
+  max-height: 45px;
+  width: 100%;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const DropdownWrapper = styled.div`
+  position: absolute;
+  width: 100%;
+  right: 0;
+  z-index: 9999;
+  top: calc(100% + 5px);
+`;
+
+const Dropdown = styled.div`
+  background: #26282f;
+
+  max-height: ${(props: { dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 8px 20px 0px #00000088;
+`;
+
+const DropdownLabel = styled.div`
+  font-size: 13px;
+  color: #ffffff44;
+  font-weight: 500;
+  margin: 10px 13px;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff15;
+  min-height: 35px;
+  font-size: 13px;
+  align-items: center;
+  display: flex;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :last-child {
+    border-bottom: 1px solid #ffffff00;
+  }
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Icon = styled.div`
+  height: 20px;
+  width: 30px;
+  margin-left: -5px;
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+
+  > img {
+    height: 18px;
+    width: auto;
+  }
+`;

+ 49 - 59
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -32,6 +32,7 @@ import JobRunTable from "./chart/JobRunTable";
 import SwitchBase from "@material-ui/core/internal/SwitchBase";
 import Selector from "components/Selector";
 import TabSelector from "components/TabSelector";
+import TagFilter from "./TagFilter";
 
 // @ts-ignore
 const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
@@ -60,6 +61,7 @@ type StateType = {
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
   showRuns: boolean;
+  selectedTag: any;
 };
 
 // TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
@@ -73,6 +75,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
     showRuns: false,
+    selectedTag: "none",
   };
 
   componentDidMount() {
@@ -114,7 +117,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         () => pushQueryParams(this.props, { namespace: "default" })
       );
     }
-
     if (prevProps.currentView !== this.props.currentView) {
       let params = this.props.match.params as any;
       let currentNamespace = params.namespace;
@@ -136,12 +138,34 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
   }
 
-  getDescription = (currentView: string): string => {
-    if (currentView === "jobs") {
-      return "Scripts and tasks that run once or on a repeating interval.";
-    } else {
-      return "Continuously running web services, workers, and add-ons.";
-    }
+  renderCommonFilters = () => {
+    const { currentView } = this.props;
+
+    return (
+      <>
+        <TagFilter
+          onSelect={(newSelectedTag) =>
+            this.setState({ selectedTag: newSelectedTag })
+          }
+        />
+        <NamespaceSelector
+          setNamespace={(namespace) =>
+            this.setState({ namespace }, () => {
+              console.log(window.location, namespace);
+              pushQueryParams(this.props, {
+                namespace: this.state.namespace || "ALL",
+              });
+            })
+          }
+          namespace={this.state.namespace}
+        />
+        <SortSelector
+          setSortType={(sortType) => this.setState({ sortType })}
+          sortType={this.state.sortType}
+          currentView={currentView}
+        />
+      </>
+    );
   };
 
   renderBodyForApps = () => {
@@ -151,6 +175,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       [],
       ["get", "create"]
     );
+
     return (
       <>
         <ControlRow>
@@ -163,31 +188,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               <i className="material-icons">add</i> Launch Template
             </Button>
           )}
-          <SortFilterWrapper>
-            {currentView === "jobs" && (
-              <LastRunStatusSelector
-                lastRunStatus={this.state.lastRunStatus}
-                setLastRunStatus={(lastRunStatus: JobStatusType) => {
-                  this.setState({ lastRunStatus });
-                }}
-              />
-            )}
-            <NamespaceSelector
-              setNamespace={(namespace) =>
-                this.setState({ namespace }, () => {
-                  pushQueryParams(this.props, {
-                    namespace: this.state.namespace || "ALL",
-                  });
-                })
-              }
-              namespace={this.state.namespace}
-            />
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-              currentView={currentView}
-            />
-          </SortFilterWrapper>
+          <SortFilterWrapper>{this.renderCommonFilters()}</SortFilterWrapper>
         </ControlRow>
 
         <ChartList
@@ -196,6 +197,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           lastRunStatus={this.state.lastRunStatus}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
+          selectedTag={this.state.selectedTag}
         />
       </>
     );
@@ -208,6 +210,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       [],
       ["get", "create"]
     );
+
     return (
       <>
         <TabSelector
@@ -235,29 +238,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </Button>
           )}
           <SortFilterWrapper>
-            {currentView === "jobs" && (
-              <LastRunStatusSelector
-                lastRunStatus={this.state.lastRunStatus}
-                setLastRunStatus={(lastRunStatus: JobStatusType) => {
-                  this.setState({ lastRunStatus });
-                }}
-              />
-            )}
-            <NamespaceSelector
-              setNamespace={(namespace) =>
-                this.setState({ namespace }, () => {
-                  pushQueryParams(this.props, {
-                    namespace: this.state.namespace || "ALL",
-                  });
-                })
-              }
-              namespace={this.state.namespace}
-            />
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-              currentView={currentView}
+            <LastRunStatusSelector
+              lastRunStatus={this.state.lastRunStatus}
+              setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                this.setState({ lastRunStatus });
+              }}
             />
+            {this.renderCommonFilters()}
           </SortFilterWrapper>
         </ControlRow>
         <HidableElement show={this.state.showRuns}>
@@ -274,6 +261,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             lastRunStatus={this.state.lastRunStatus}
             namespace={this.state.namespace}
             sortType={this.state.sortType}
+            selectedTag={this.state.selectedTag}
           />
         </HidableElement>
       </>
@@ -303,7 +291,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           <DashboardHeader
             image={monojob}
             title={currentView}
-            description={this.getDescription(currentView)}
+            description="Scripts and tasks that run once or on a repeating interval."
             disableLineBreak
           />
 
@@ -315,11 +303,10 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {/* {this.renderContents()} */}
           <DashboardHeader
             image={monoweb}
             title={currentView}
-            description={this.getDescription(currentView)}
+            description="Continuously running web services, workers, and add-ons."
           />
 
           {this.renderBodyForApps()}
@@ -361,7 +348,7 @@ const ControlRow = styled.div`
   margin-left: auto;
   justify-content: space-between;
   align-items: center;
-  margin-bottom: 35px;
+  flex-wrap: wrap;
   padding-left: 0px;
 `;
 
@@ -409,7 +396,9 @@ const Button = styled.div`
   border-radius: 20px;
   color: white;
   height: 35px;
+  margin-bottom: 35px;
   padding: 0px 8px;
+  min-width: 155px;
   padding-bottom: 1px;
   margin-right: 10px;
   font-weight: 500;
@@ -506,7 +495,8 @@ const Img = styled.img`
 const SortFilterWrapper = styled.div`
   display: flex;
   justify-content: space-between;
+  margin-bottom: 35px;
   > div:not(:first-child) {
     margin-left: 30px;
   }
-`;
+`;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx

@@ -46,6 +46,7 @@ export default LastRunStatusSelector;
 const Label = styled.div`
   display: flex;
   align-items: center;
+  min-width: 130px;
   margin-right: 12px;
 
   > i {
@@ -57,5 +58,6 @@ const Label = styled.div`
 const StyledLastRunStatusSelector = styled.div`
   display: flex;
   align-items: center;
+  margin-right: -3px;
   font-size: 13px;
 `;

+ 71 - 0
dashboard/src/main/home/cluster-dashboard/TagFilter.tsx

@@ -0,0 +1,71 @@
+import Selector from "components/Selector";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+const TagFilter = ({ onSelect }: { onSelect: (tag: any) => void }) => {
+  const { currentProject } = useContext(Context);
+  const [selectedTag, setSelectedTag] = useState("none");
+  const [tags, setTags] = useState([]);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getTagsByProjectId("<token>", {}, { project_id: currentProject.id })
+      .then((res) => {
+        const newTags = res.data;
+
+        setTags(newTags);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject]);
+
+  useEffect(() => {
+    const currentTag = tags.find((tag) => tag.name === selectedTag);
+    onSelect(currentTag);
+  }, [selectedTag]);
+
+  return (
+    <StyledTagSelector>
+      <Label>
+        <i className="material-icons">tag</i>
+        Tag
+      </Label>
+      <Selector
+        activeValue={selectedTag}
+        options={[{ label: "No tag selected", value: "none" }].concat(
+          tags.map((tag) => ({
+            value: tag.name,
+            label: tag.name,
+          }))
+        )}
+        setActiveValue={(newVal) => setSelectedTag(newVal)}
+        width={"150px"}
+        dropdownWidth="fit-content"
+      />
+    </StyledTagSelector>
+  );
+};
+
+export default TagFilter;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledTagSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

+ 12 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -27,6 +27,7 @@ type Props = {
   currentView: PorterUrl;
   disableBottomPadding?: boolean;
   closeChartRedirectUrl?: string;
+  selectedTag?: any;
 };
 
 interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
@@ -40,6 +41,7 @@ const ChartList: React.FunctionComponent<Props> = ({
   currentView,
   disableBottomPadding,
   closeChartRedirectUrl,
+  selectedTag,
 }) => {
   const {
     newWebsocket,
@@ -324,6 +326,15 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
 
     const result = charts
+      .filter((chart) => {
+        if (!selectedTag) {
+          return true;
+        }
+
+        return !!selectedTag.releases?.find((release: ChartType) => {
+          return release.name === chart.name;
+        });
+      })
       .filter((chart: ChartType) => {
         return (
           (currentView == "jobs" && chart.chart.metadata.name == "job") ||
@@ -399,7 +410,7 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
 
     return result;
-  }, [charts, sortType, jobStatus, lastRunStatus]);
+  }, [charts, sortType, jobStatus, lastRunStatus, selectedTag]);
 
   const renderChartList = () => {
     if (isLoading || (!namespace && namespace !== "")) {

+ 15 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -17,10 +17,11 @@ import Loading from "components/Loading";
 import NotificationSettingsSection from "./NotificationSettingsSection";
 import { Link } from "react-router-dom";
 import { isDeployedFromGithub } from "shared/release/utils";
+import TagSelector from "./TagSelector";
 
 type PropsType = {
   currentChart: ChartType;
-  refreshChart: () => void;
+  refreshChart: () => Promise<void>;
   setShowDeleteOverlay: (x: boolean) => void;
   saveButtonText?: string | null;
 };
@@ -54,6 +55,7 @@ const SettingsSection: React.FC<PropsType> = ({
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
   );
+
   const [isAuthorized] = useAuth();
 
   useEffect(() => {
@@ -85,7 +87,9 @@ const SettingsSection: React.FC<PropsType> = ({
       .catch(console.log)
       .finally(() => setLoadingWebhookToken(false));
 
-    return () => (isSubscribed = false);
+    return () => {
+      isSubscribed = false;
+    };
   }, [currentChart, currentCluster, currentProject]);
 
   const handleSubmit = async () => {
@@ -276,6 +280,9 @@ const SettingsSection: React.FC<PropsType> = ({
             </Webhook>
           )}
         </>
+        <Heading>Application Tags</Heading>
+        <Helper>Add tags for filtering applications.</Helper>
+        <TagSelector release={currentChart} onSave={(val) => refreshChart()} />
       </>
     );
   };
@@ -337,6 +344,12 @@ const SettingsSection: React.FC<PropsType> = ({
 
 export default SettingsSection;
 
+const DarkMatter = styled.div`
+  width: 100%;
+  height: 0;
+  margin-top: -10px;
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 10px;

+ 403 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/TagSelector.tsx

@@ -0,0 +1,403 @@
+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 TagSelector = ({ 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);
+    }
+  };
+
+  useEffect(() => {
+    api
+      .getTagsByProjectId<any[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      )
+      .then(({ data }) => {
+        const releaseTags = data.filter((tag) =>
+          release.tags?.includes(tag.name)
+        );
+        const tmpAvailableTags = differenceBy(data, releaseTags, "name");
+
+        setValues(releaseTags);
+        setAvailableTags(tmpAvailableTags);
+      });
+  }, [currentProject]);
+
+  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]);
+
+  /*
+          { 
+          values.length === 0 && "This application has no tags"
+        }
+        <Wrapper>
+          <Tooltip title="Create a new tag">
+            <AddButton
+              className="material-icons-outlined"
+              onClick={() => setOpenModal((prev) => !prev)}
+            >
+              add
+            </AddButton>
+          </Tooltip>
+        </Wrapper>
+  */
+  return (
+    <>
+      {openModal ? (
+        <CreateTagModal
+          onSave={async (newTag) => {
+            const newValues = [...values, newTag];
+            await onSave(newValues);
+            setValues(newValues);
+          }}
+          onClose={() => setOpenModal(false)}
+          release={release}
+        />
+      ) : null}
+      <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 Wrapper = styled.div`
+  position: relative;
+  display: flex;
+  align-items: center;
+`;
+
+const TagSelectorWrapper = styled.div`
+  position: absolute;
+  bottom: -260px;
+  right: 0;
+  width: 330px;
+  border-radius: 5px;
+  height: 250px;
+  background: #33353b;
+  border-radius: 5px;
+  z-index: 999;
+  overflow-y: auto;
+  box-shadow: 0px 4px 15px 0px #000000aa;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
+const CreateTagModal = ({
+  onSave,
+  onClose,
+  release,
+}: {
+  onSave: ((tag: any) => void) | ((tag: any) => Promise<void>);
+  onClose: () => void;
+  release: ChartType;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [color, setColor] = useState("#ffffff");
+  const [name, setName] = useState("some-random-tag");
+
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const createTag = async () => {
+    setButtonStatus("loading");
+    try {
+      await api.createTag(
+        "<token>",
+        { name, color },
+        {
+          project_id: currentProject.id,
+        }
+      );
+    } catch (error) {
+      setCurrentError(error);
+      setButtonStatus("Couldn't create the tag");
+      return;
+    }
+
+    try {
+      await api.updateReleaseTags(
+        "<token>",
+        { tags: [...(release.tags || []), name] },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: release.namespace,
+          release_name: release.name,
+        }
+      );
+      setButtonStatus("successful");
+      await onSave({ name, color });
+      setTimeout(() => {
+        onClose();
+      }, 800);
+    } catch (error) {
+      console.log(error);
+      setCurrentError(
+        "We couldn't link the tag to the release, please link it manually from the settings tab."
+      );
+      setButtonStatus("Couldn't link the tag to the release");
+      return;
+    }
+  };
+
+  return (
+    <Modal title="Create a new tag" onRequestClose={onClose} height="auto">
+      <Helper>
+        Create a new tag and link the release you're currently at to the brand
+        new tag.
+      </Helper>
+
+      <InputRow
+        type="text"
+        label="Tag name"
+        value={name}
+        setValue={(val) => setName(val as string)}
+        isRequired
+        width="300px"
+      ></InputRow>
+      <Label>Tag color</Label>
+      <TwitterPicker
+        triangle="hide"
+        color={color}
+        onChange={(newColor) => setColor(newColor.hex)}
+      ></TwitterPicker>
+
+      <Label style={{ marginTop: "15px" }}>Result</Label>
+      <Tag color={color} style={{ maxWidth: "none", marginTop: "0px" }}>
+        <TagText>{name}</TagText>
+      </Tag>
+      <Flex
+        style={{
+          justifyContent: "flex-end",
+        }}
+      >
+        <SaveButton
+          clearPosition
+          onClick={() => createTag()}
+          text={"Create Tag"}
+          disabled={!name.length || buttonStatus === "loading"}
+        ></SaveButton>
+      </Flex>
+    </Modal>
+  );
+};
+
+export default TagSelector;
+
+const Flex = styled.div`
+  display: flex;
+  position: relative;
+`;
+
+const AddButton = styled.div`
+  border-radius: 50%;
+  border: 1px solid #ffffff11;
+  padding: 5px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 10px;
+  margin-top: 2px;
+  background: #ffffff11;
+  color: #ffffff88;
+  font-size: 18px;
+  :hover {
+    background: #ffffff22;
+    color: #ffffff;
+    cursor: pointer;
+  }
+`;
+
+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;
+  padding-right: 24px;
+  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 {
+    position: absolute;
+    top: 4px;
+    right: 4px;
+    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};
+`;

+ 30 - 5
dashboard/src/shared/api.tsx

@@ -370,11 +370,7 @@ const deletePRDeployment = baseApi<
     deployment_id: number;
   }
 >("DELETE", (pathParams) => {
-  const {
-    cluster_id,
-    project_id,
-    deployment_id,
-  } = pathParams;
+  const { cluster_id, project_id, deployment_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}`;
 });
 
@@ -1757,6 +1753,32 @@ const triggerPreviewEnvWorkflow = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}/trigger_workflow`
 );
 
+const getTagsByProjectId = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/tags`
+);
+
+const createTag = baseApi<
+  { name: string; color: string },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/tags`);
+
+const updateReleaseTags = baseApi<
+  {
+    tags: 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_tags`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1921,4 +1943,7 @@ export default {
   updateBuildConfig,
   reRunGHWorkflow,
   triggerPreviewEnvWorkflow,
+  getTagsByProjectId,
+  createTag,
+  updateReleaseTags,
 };

+ 7 - 0
dashboard/src/shared/baseApi.ts

@@ -58,6 +58,13 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     };
   }
 
+  if (method.toUpperCase() === "PATCH") {
+    return {
+      ...config,
+      data: params,
+    };
+  }
+
   return config;
 };
 

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

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

+ 9 - 0
internal/models/release.go

@@ -25,6 +25,7 @@ type Release struct {
 	EventContainer     uint
 	NotificationConfig uint
 	BuildConfig        uint
+	Tags               []*Tag `json:"tags" gorm:"many2many:release_tags"`
 }
 
 func (r *Release) ToReleaseType() *types.PorterRelease {
@@ -38,5 +39,13 @@ func (r *Release) ToReleaseType() *types.PorterRelease {
 		res.GitActionConfig = r.GitActionConfig.ToGitActionConfigType()
 	}
 
+	tagsCount := len(r.Tags)
+
+	if tagsCount > 0 {
+		for i := 0; i < tagsCount; i++ {
+			res.Tags = append(res.Tags, r.Tags[i].Name)
+		}
+	}
+
 	return res
 }

+ 17 - 0
internal/models/tag.go

@@ -0,0 +1,17 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Tag model used to group releases.
+
+// Tag type that extends gorm.Model
+type Tag struct {
+	gorm.Model
+
+	ProjectID uint       `json:"project_id"`
+	Name      string     `json:"name"`
+	Color     string     `json:"color"`
+	Releases  []*Release `json:"releases" gorm:"many2many:release_tags"`
+}

+ 2 - 0
internal/repository/gorm/helpers_test.go

@@ -40,6 +40,7 @@ type tester struct {
 	initGCPs       []*ints.GCPIntegration
 	initAWSs       []*ints.AWSIntegration
 	initAllowlist  []*models.Allowlist
+	initTags       []*models.Tag
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -76,6 +77,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.KubeSubEvent{},
 		&models.Onboarding{},
 		&models.Allowlist{},
+		&models.Tag{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -48,6 +48,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.CredentialsExchangeToken{},
 		&models.BuildConfig{},
 		&models.Allowlist{},
+		&models.Tag{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 3 - 3
internal/repository/gorm/release.go

@@ -28,7 +28,7 @@ func (repo *ReleaseRepository) CreateRelease(release *models.Release) (*models.R
 // ReadRelease finds a single release based on their unique name and namespace pair.
 func (repo *ReleaseRepository) ReadRelease(clusterID uint, name, namespace string) (*models.Release, error) {
 	release := &models.Release{}
-	if err := repo.db.Preload("GitActionConfig").Order("id desc").Where("cluster_id = ? AND name = ? AND namespace = ?", clusterID, name, namespace).First(&release).Error; err != nil {
+	if err := repo.db.Preload("GitActionConfig").Preload("Tags").Order("id desc").Where("cluster_id = ? AND name = ? AND namespace = ?", clusterID, name, namespace).First(&release).Error; err != nil {
 		return nil, err
 	}
 	return release, nil
@@ -42,7 +42,7 @@ func (repo *ReleaseRepository) ListReleasesByImageRepoURI(clusterID uint, imageR
 		return releases, nil
 	}
 
-	if err := repo.db.Preload("GitActionConfig").Where("cluster_id = ?", clusterID).Where("image_repo_uri = ?", imageRepoURI).Find(&releases).Error; err != nil {
+	if err := repo.db.Preload("GitActionConfig").Preload("Tags").Where("cluster_id = ?", clusterID).Where("image_repo_uri = ?", imageRepoURI).Find(&releases).Error; err != nil {
 		return nil, err
 	}
 
@@ -52,7 +52,7 @@ func (repo *ReleaseRepository) ListReleasesByImageRepoURI(clusterID uint, imageR
 // ReadReleaseByWebhookToken finds a single release based on their unique webhook token.
 func (repo *ReleaseRepository) ReadReleaseByWebhookToken(token string) (*models.Release, error) {
 	release := &models.Release{}
-	if err := repo.db.Preload("GitActionConfig").Where("webhook_token = ?", token).First(&release).Error; err != nil {
+	if err := repo.db.Preload("GitActionConfig").Preload("Tags").Where("webhook_token = ?", token).First(&release).Error; err != nil {
 		return nil, err
 	}
 	return release, nil

+ 1 - 0
internal/repository/gorm/release_test.go

@@ -83,6 +83,7 @@ func TestListReleasesByImageRepoURI(t *testing.T) {
 			ClusterID:    1,
 			WebhookToken: fmt.Sprintf("abcdefgh-%d", i),
 			ImageRepoURI: uri,
+			Tags:         make([]*models.Tag, 0),
 		}
 
 		release, err := tester.repo.Release().CreateRelease(release)

+ 6 - 0
internal/repository/gorm/repository.go

@@ -42,6 +42,7 @@ type GormRepository struct {
 	ceToken                   repository.CredentialsExchangeTokenRepository
 	buildConfig               repository.BuildConfigRepository
 	allowlist                 repository.AllowlistRepository
+	tag                       repository.TagRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -184,6 +185,10 @@ func (t *GormRepository) Allowlist() repository.AllowlistRepository {
 	return t.allowlist
 }
 
+func (t *GormRepository) Tag() repository.TagRepository {
+	return t.tag
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -223,5 +228,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		ceToken:                   NewCredentialsExchangeTokenRepository(db),
 		buildConfig:               NewBuildConfigRepository(db),
 		allowlist:                 NewAllowlistRepository(db),
+		tag:                       NewTagRepository(db),
 	}
 }

+ 115 - 0
internal/repository/gorm/tag.go

@@ -0,0 +1,115 @@
+package gorm
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AllowlistRepository uses gorm.DB for querying the database
+type TagRepository struct {
+	db *gorm.DB
+}
+
+// NewAllowlistRepository returns a AllowListRepository which uses
+// gorm.DB for querying the database.
+func NewTagRepository(db *gorm.DB) repository.TagRepository {
+	return &TagRepository{db}
+}
+
+func (repo *TagRepository) CreateTag(tag *models.Tag) (*models.Tag, error) {
+	existingTag, _ := repo.ReadTagByNameAndProjectId(tag.Name, tag.ProjectID)
+
+	if existingTag != nil {
+		return nil, fmt.Errorf("tag already exists")
+	}
+
+	if err := repo.db.Create(tag).Error; err != nil {
+		return nil, err
+	}
+	return tag, nil
+}
+
+func (repo *TagRepository) LinkTagsToRelease(tags []string, release *models.Release) ([]*models.Tag, error) {
+	populatedTags := make([]*models.Tag, 0)
+	err := repo.db.Where("name IN ?", tags).Where("project_id = ?", release.ProjectID).Find(&populatedTags).Error
+
+	if err != nil {
+		return nil, err
+	}
+
+	release.Tags = populatedTags
+
+	err = repo.db.Save(release).Error
+
+	if err != nil {
+		return nil, err
+	}
+
+	return populatedTags, nil
+}
+
+func (repo *TagRepository) UnlinkTagsFromRelease(tags []string, release *models.Release) error {
+	populatedTags := make([]*models.Tag, 0)
+	err := repo.db.Where("name IN ?", tags).Where("project_id = ?", release.ProjectID).Find(&populatedTags).Error
+
+	if err != nil {
+		return err
+	}
+
+	err = repo.db.Model(&release).Association("Tags").Delete(populatedTags)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (repo *TagRepository) ReadTagByNameAndProjectId(tagName string, projectId uint) (*models.Tag, error) {
+	tag := &models.Tag{}
+
+	err := repo.db.Where("name = ? AND project_id = ?", tagName, projectId).First(tag).Error
+
+	if err != nil {
+		return nil, err
+	}
+
+	return tag, nil
+}
+
+func (repo *TagRepository) ListTagsByProjectId(projectId uint) ([]*models.Tag, error) {
+	tags := make([]*models.Tag, 0)
+
+	err := repo.db.Preload("Releases").Where("project_id = ?", projectId).Find(&tags).Error
+
+	if err != nil {
+		return nil, err
+	}
+
+	return tags, nil
+}
+
+func (repo *TagRepository) UpdateTag(tag *models.Tag) (*models.Tag, error) {
+	existingTag, _ := repo.ReadTagByNameAndProjectId(tag.Name, tag.ProjectID)
+
+	if existingTag != nil {
+		return nil, fmt.Errorf("tag already exists")
+	}
+
+	if err := repo.db.Save(tag).Error; err != nil {
+		return nil, err
+	}
+
+	return tag, nil
+}
+
+func (repo *TagRepository) DeleteTag(id uint) error {
+	if err := repo.db.Delete(&models.Tag{}, id).Error; err != nil {
+		return err
+	}
+
+	return nil
+}

+ 70 - 0
internal/repository/gorm/tag_test.go

@@ -0,0 +1,70 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestCreateNewTag(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_tag.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	tag := &models.Tag{
+		ProjectID: 1,
+		Name:      "very-first-tag",
+		Color:     "#ffffff",
+	}
+
+	_, err := tester.repo.Tag().CreateTag(tag)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+}
+
+func TestCreateTagThatAlreadyExistsOnProject(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_tag_already_exists.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+	t.SkipNow()
+}
+
+func TestCreateTagThatAlreadyExistOnOtherProject(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_tag_exists_on_other_project.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+	t.SkipNow()
+}
+
+func TestUpdateTag(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_update_tag.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+	t.SkipNow()
+}
+
+func TestDeleteTag(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_delete_tag.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+	t.SkipNow()
+}

+ 1 - 0
internal/repository/repository.go

@@ -36,4 +36,5 @@ type Repository interface {
 	CredentialsExchangeToken() CredentialsExchangeTokenRepository
 	BuildConfig() BuildConfigRepository
 	Allowlist() AllowlistRepository
+	Tag() TagRepository
 }

+ 15 - 0
internal/repository/tag.go

@@ -0,0 +1,15 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// GitRepoRepository represents the set of queries on the
+// GitRepo model
+type TagRepository interface {
+	CreateTag(tag *models.Tag) (*models.Tag, error)
+	ReadTagByNameAndProjectId(tagName string, projectID uint) (*models.Tag, error)
+	ListTagsByProjectId(projectId uint) ([]*models.Tag, error)
+	UpdateTag(tag *models.Tag) (*models.Tag, error)
+	DeleteTag(id uint) error
+	UnlinkTagsFromRelease(tags []string, release *models.Release) error
+	LinkTagsToRelease(tags []string, release *models.Release) ([]*models.Tag, error)
+}

+ 6 - 0
internal/repository/test/repository.go

@@ -40,6 +40,7 @@ type TestRepository struct {
 	buildConfig               repository.BuildConfigRepository
 	database                  repository.DatabaseRepository
 	allowlist                 repository.AllowlistRepository
+	tag                       repository.TagRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -182,6 +183,10 @@ func (t *TestRepository) Allowlist() repository.AllowlistRepository {
 	return t.allowlist
 }
 
+func (t *TestRepository) Tag() repository.TagRepository {
+	return t.tag
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -221,5 +226,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		buildConfig:               NewBuildConfigRepository(canQuery),
 		database:                  NewDatabaseRepository(),
 		allowlist:                 NewAllowlistRepository(canQuery),
+		tag:                       NewTagRepository(),
 	}
 }

+ 35 - 0
internal/repository/test/tag.go

@@ -0,0 +1,35 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type TagRepository struct {
+}
+
+func NewTagRepository() repository.TagRepository {
+	return &TagRepository{}
+}
+
+func (repo *TagRepository) CreateTag(tag *models.Tag) (*models.Tag, error) {
+	panic("not implemented")
+}
+func (repo *TagRepository) ReadTagByNameAndProjectId(tagName string, projectID uint) (*models.Tag, error) {
+	panic("not implemented")
+}
+func (repo *TagRepository) ListTagsByProjectId(projectId uint) ([]*models.Tag, error) {
+	panic("not implemented")
+}
+func (repo *TagRepository) UpdateTag(tag *models.Tag) (*models.Tag, error) {
+	panic("not implemented")
+}
+func (repo *TagRepository) DeleteTag(id uint) error {
+	panic("not implemented")
+}
+func (repo *TagRepository) UnlinkTagsFromRelease(tags []string, release *models.Release) error {
+	panic("not implemented")
+}
+func (repo *TagRepository) LinkTagsToRelease(tags []string, release *models.Release) ([]*models.Tag, error) {
+	panic("not implemented")
+}