浏览代码

boilerplate addon dashboard code (#4526)

Feroze Mohideen 2 年之前
父节点
当前提交
41362b6167
共有 49 个文件被更改,包括 5191 次插入612 次删除
  1. 76 0
      api/server/handlers/addons/delete.go
  2. 104 0
      api/server/handlers/addons/get.go
  3. 6 22
      api/server/handlers/addons/list.go
  4. 107 0
      api/server/handlers/addons/tailscale_services.go
  5. 97 0
      api/server/handlers/addons/update.go
  6. 0 31
      api/server/router/addons.go
  7. 146 0
      api/server/router/deployment_target.go
  8. 1 0
      api/types/request.go
  9. 7 7
      dashboard/package-lock.json
  10. 1 1
      dashboard/package.json
  11. 1 1
      dashboard/src/components/CreateDeploymentTargetModal.tsx
  12. 13 0
      dashboard/src/lib/addons/datadog.ts
  13. 261 21
      dashboard/src/lib/addons/index.ts
  14. 24 0
      dashboard/src/lib/addons/metabase.ts
  15. 7 0
      dashboard/src/lib/addons/mezmo.ts
  16. 15 0
      dashboard/src/lib/addons/newrelic.ts
  17. 11 0
      dashboard/src/lib/addons/tailscale.ts
  18. 210 0
      dashboard/src/lib/addons/template.ts
  19. 776 0
      dashboard/src/lib/hooks/useAddon.ts
  20. 29 9
      dashboard/src/main/home/Home.tsx
  21. 116 151
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  22. 142 0
      dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx
  23. 170 0
      dashboard/src/main/home/add-on-dashboard/AddonForm.tsx
  24. 134 0
      dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx
  25. 205 0
      dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx
  26. 34 0
      dashboard/src/main/home/add-on-dashboard/AddonSaveButton.tsx
  27. 120 0
      dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx
  28. 171 0
      dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx
  29. 70 0
      dashboard/src/main/home/add-on-dashboard/AddonView.tsx
  30. 0 284
      dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx
  31. 26 0
      dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx
  32. 243 0
      dashboard/src/main/home/add-on-dashboard/common/Logs.tsx
  33. 89 0
      dashboard/src/main/home/add-on-dashboard/common/Settings.tsx
  34. 121 0
      dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx
  35. 398 0
      dashboard/src/main/home/add-on-dashboard/legacy_AddOnDashboard.tsx
  36. 302 0
      dashboard/src/main/home/add-on-dashboard/legacy_NewAddOnFlow.tsx
  37. 354 0
      dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx
  38. 43 0
      dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx
  39. 180 0
      dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx
  40. 131 0
      dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleForm.tsx
  41. 116 0
      dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx
  42. 6 5
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  43. 58 64
      dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx
  44. 53 7
      dashboard/src/shared/api.tsx
  45. 1 1
      go.mod
  46. 2 0
      go.sum
  47. 10 2
      internal/kubernetes/agent.go
  48. 4 0
      internal/telemetry/span.go
  49. 0 6
      package-lock.json

+ 76 - 0
api/server/handlers/addons/delete.go

@@ -0,0 +1,76 @@
+package addons
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeleteAddonHandler handles requests to the /addons/delete endpoint
+type DeleteAddonHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewDeleteAddonHandler returns a new DeleteAddonHandler
+func NewDeleteAddonHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteAddonHandler {
+	return &DeleteAddonHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *DeleteAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-addon")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	addonName, reqErr := requestutils.GetURLParamString(r, types.URLParamAddonName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error parsing addon name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if deploymentTarget.ID != uuid.Nil {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: deploymentTarget.ID.String(),
+		}
+	}
+
+	if addonName == "" {
+		err := telemetry.Error(ctx, span, nil, "no addon name provided")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deleteAddonRequest := connect.NewRequest(&porterv1.DeleteAddonRequest{
+		ProjectId:                  int64(project.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
+		AddonName:                  addonName,
+	})
+
+	_, err := c.Config().ClusterControlPlaneClient.DeleteAddon(ctx, deleteAddonRequest)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error deleting addon")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 104 - 0
api/server/handlers/addons/get.go

@@ -0,0 +1,104 @@
+package addons
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AddonHandler handles requests to the /addons/{addon_name} endpoint
+type AddonHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewAddonHandler returns a new AddonHandler
+func NewAddonHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AddonHandler {
+	return &AddonHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// AddonResponse represents the response from the /addons/{addon_name} endpoints
+type AddonResponse struct {
+	Addon string `json:"addon"`
+}
+
+func (c *AddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-addon")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	addonName, reqErr := requestutils.GetURLParamString(r, types.URLParamAddonName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error parsing addon name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "addon-name", Value: addonName})
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if deploymentTarget.ID != uuid.Nil {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: deploymentTarget.ID.String(),
+		}
+	}
+
+	if addonName == "" {
+		err := telemetry.Error(ctx, span, nil, "no addon name provided")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	addonRequest := connect.NewRequest(&porterv1.AddonRequest{
+		ProjectId:                  int64(project.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
+		AddonName:                  addonName,
+	})
+
+	resp, err := c.Config().ClusterControlPlaneClient.Addon(ctx, addonRequest)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting addon")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if resp == nil || resp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "addon response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	by, err := helpers.MarshalContractObject(ctx, resp.Msg.Addon)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error marshaling addon")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	encoded := base64.StdEncoding.EncodeToString(by)
+
+	res := &AddonResponse{
+		Addon: encoded,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 6 - 22
api/server/handlers/addons/list.go

@@ -5,6 +5,7 @@ import (
 	"net/http"
 
 	"connectrpc.com/connect"
+	"github.com/google/uuid"
 	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -16,7 +17,7 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// LatestAddonsHandler handles requests to the /addons/latest endpoint
+// LatestAddonsHandler handles requests to the /addons endpoint
 type LatestAddonsHandler struct {
 	handlers.PorterHandlerReadWriter
 }
@@ -32,12 +33,7 @@ func NewLatestAddonsHandler(
 	}
 }
 
-// LatestAddonsRequest represents the request for the /addons/latest endpoint
-type LatestAddonsRequest struct {
-	DeploymentTargetID string `schema:"deployment_target_id"`
-}
-
-// LatestAddonsResponse represents the response from the /addons/latest endpoint
+// LatestAddonsResponse represents the response from the /addons endpoint
 type LatestAddonsResponse struct {
 	Base64Addons []string `json:"base64_addons"`
 }
@@ -47,29 +43,17 @@ func (c *LatestAddonsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	defer span.End()
 
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
-	request := &LatestAddonsRequest{}
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding request")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
-	)
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
 
 	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
-	if request.DeploymentTargetID != "" {
+	if deploymentTarget.ID != uuid.Nil {
 		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
-			Id: request.DeploymentTargetID,
+			Id: deploymentTarget.ID.String(),
 		}
 	}
 
 	latestAddonsReq := connect.NewRequest(&porterv1.LatestAddonsRequest{
 		ProjectId:                  int64(project.ID),
-		ClusterId:                  int64(cluster.ID),
 		DeploymentTargetIdentifier: deploymentTargetIdentifier,
 	})
 

+ 107 - 0
api/server/handlers/addons/tailscale_services.go

@@ -0,0 +1,107 @@
+package addons
+
+import (
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// TailscaleServicesHandler handles requests to the /addons/tailscale-services endpoint
+type TailscaleServicesHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewTailscaleServicesHandler returns a new TailscaleServicesHandler
+func NewTailscaleServicesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *TailscaleServicesHandler {
+	return &TailscaleServicesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// TailscaleServicesResponse represents the response from the /addons/tailscale-services endpoints
+type TailscaleServicesResponse struct {
+	Services []TailscaleService `json:"services"`
+}
+
+// TailscaleService represents a Tailscale service
+type TailscaleService struct {
+	Name string `json:"name"`
+	IP   string `json:"ip"`
+	Port int    `json:"port"`
+}
+
+// ServeHTTP returns all services that can be accessed through Tailscale
+// TODO: move this logic to CCP
+func (c *TailscaleServicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-tailscale-services")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "namespace", Value: deploymentTarget.Namespace},
+		telemetry.AttributeKV{Key: "cluster-id", Value: deploymentTarget.ClusterID},
+	)
+
+	cluster, err := c.Repo().Cluster().ReadCluster(project.ID, deploymentTarget.ClusterID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	svcList, err := agent.ListServices(ctx, deploymentTarget.Namespace, "porter.run/tailscale-svc=true")
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing services")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var services []TailscaleService
+	for _, svc := range svcList.Items {
+		var port int
+		if len(svc.Spec.Ports) > 0 {
+			port = int(svc.Spec.Ports[0].Port)
+		}
+		service := TailscaleService{
+			Name: svc.Name,
+			IP:   svc.Spec.ClusterIP,
+			Port: port,
+		}
+		if appName, ok := svc.Labels["porter.run/app-name"]; ok {
+			if serviceName, ok := svc.Labels["porter.run/service-name"]; ok {
+				service.Name = fmt.Sprintf("%s (%s)", serviceName, appName)
+			}
+		}
+
+		services = append(services, service)
+	}
+
+	resp := TailscaleServicesResponse{
+		Services: services,
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 97 - 0
api/server/handlers/addons/update.go

@@ -0,0 +1,97 @@
+package addons
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// UpdateAddonHandler handles requests to the /addons/update endpoint
+type UpdateAddonHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewUpdateAddonHandler returns a new UpdateAddonHandler
+func NewUpdateAddonHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateAddonHandler {
+	return &UpdateAddonHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// UpdateAddonRequest represents the request for the /addons/update endpoint
+type UpdateAddonRequest struct {
+	B64Addon string `json:"b64_addon"`
+}
+
+func (c *UpdateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-addon")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	request := &UpdateAddonRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if deploymentTarget.ID != uuid.Nil {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: deploymentTarget.ID.String(),
+		}
+	}
+
+	if request.B64Addon == "" {
+		err := telemetry.Error(ctx, span, nil, "no addon provided")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(request.B64Addon)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error decoding yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	addon := &porterv1.Addon{}
+	err = helpers.UnmarshalContractObject(decoded, addon)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error unmarshalling addon")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	updateAddonRequest := connect.NewRequest(&porterv1.UpdateAddonRequest{
+		ProjectId:                  int64(project.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
+		Addon:                      addon,
+	})
+
+	_, err = c.Config().ClusterControlPlaneClient.UpdateAddon(ctx, updateAddonRequest)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error updating addon")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 0 - 31
api/server/router/addons.go

@@ -1,10 +1,7 @@
 package router
 
 import (
-	"fmt"
-
 	"github.com/go-chi/chi/v5"
-	"github.com/porter-dev/porter/api/server/handlers/addons"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -57,34 +54,6 @@ func getAddonRoutes(
 
 	var routes []*router.Route
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/addons/latest -> addons.LatestAddonsHandler
-	latestAddonsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/latest", relPath),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	latestAddonsHandler := addons.NewLatestAddonsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: latestAddonsEndpoint,
-		Handler:  latestAddonsHandler,
-		Router:   r,
-	})
 
 	return routes, newPath
 }

+ 146 - 0
api/server/router/deployment_target.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/addons"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -145,6 +146,151 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/targets/{deployment_target_identifier}/addons -> addons.LatestAddonsHandler
+	listAddonsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/addons", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	listAddonsHandler := addons.NewLatestAddonsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listAddonsEndpoint,
+		Handler:  listAddonsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/{addon_name} -> addons.AddonHandler
+	addonEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/addons/{%s}", relPath, types.URLParamAddonName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	addonHandler := addons.NewAddonHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addonEndpoint,
+		Handler:  addonHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/tailscale-services -> addons.TailscaleServicesHandler
+	tailscaleServicesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/addons/tailscale-services", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	tailscaleServicesHandler := addons.NewTailscaleServicesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: tailscaleServicesEndpoint,
+		Handler:  tailscaleServicesHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/update -> addons.UpdateAddonHandler
+	updateAddonEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/addons/update", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	updateAddonHandler := addons.NewUpdateAddonHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateAddonEndpoint,
+		Handler:  updateAddonHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/targets/{deployment_target_identifier}/addons/{addon_name} -> addons.DeleteAddonHandler
+	deleteAddonEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/addons/{%s}", relPath, types.URLParamAddonName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	deleteAddonHandler := addons.NewDeleteAddonHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteAddonEndpoint,
+		Handler:  deleteAddonHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/app-event-webhooks -> porter_app.NewAppEventWebhooksHandler
 	appEventWebhooks := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 0
api/types/request.go

@@ -61,6 +61,7 @@ const (
 	URLParamCloudProviderType     URLParam = "cloud_provider_type"
 	URLParamCloudProviderID       URLParam = "cloud_provider_id"
 	URLParamDeploymentTargetID    URLParam = "deployment_target_id"
+	URLParamAddonName             URLParam = "addon_name"
 	// URLParamDeploymentTargetIdentifier can be either the deployment target id or deployment target name
 	URLParamDeploymentTargetIdentifier URLParam = "deployment_target_identifier"
 	URLParamWebhookID                  URLParam = "webhook_id"

+ 7 - 7
dashboard/package-lock.json

@@ -98,7 +98,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.142",
+        "@porter-dev/api-contracts": "^0.2.155",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2757,9 +2757,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.142",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.142.tgz",
-      "integrity": "sha512-WRIuZGQ8VXx6CIG4ODtfb+wlOSWCSJOm5uBXyn67eAwvNQsnj+RAzhSBqNUz+2XlIVGv2SET1BXV6uJhB2gQ8g==",
+      "version": "0.2.155",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.155.tgz",
+      "integrity": "sha512-Tar/IsKoUSmz8Q8Fw9ozflrAI+yAGzOIdx5WmZ5iCSCkvudSLnDp7xQ0po/traPzYLUldZjaNsw0KKXnOb1myQ==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
@@ -20271,9 +20271,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.142",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.142.tgz",
-      "integrity": "sha512-WRIuZGQ8VXx6CIG4ODtfb+wlOSWCSJOm5uBXyn67eAwvNQsnj+RAzhSBqNUz+2XlIVGv2SET1BXV6uJhB2gQ8g==",
+      "version": "0.2.155",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.155.tgz",
+      "integrity": "sha512-Tar/IsKoUSmz8Q8Fw9ozflrAI+yAGzOIdx5WmZ5iCSCkvudSLnDp7xQ0po/traPzYLUldZjaNsw0KKXnOb1myQ==",
       "dev": true,
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -105,7 +105,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.142",
+    "@porter-dev/api-contracts": "^0.2.155",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 1 - 1
dashboard/src/components/CreateDeploymentTargetModal.tsx

@@ -6,7 +6,7 @@ import { z } from "zod";
 import target from "assets/target.svg";
 
 import { useDeploymentTargetList } from "../lib/hooks/useDeploymentTarget";
-import { RestrictedNamespaces } from "../main/home/add-on-dashboard/AddOnDashboard";
+import { RestrictedNamespaces } from "../main/home/add-on-dashboard/legacy_AddOnDashboard";
 import api from "../shared/api";
 import { Context } from "../shared/Context";
 import InputRow from "./form-components/InputRow";

+ 13 - 0
dashboard/src/lib/addons/datadog.ts

@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+export const datadogConfigValidator = z.object({
+  type: z.literal("datadog"),
+  cpuCores: z.number().default(0.5),
+  ramMegabytes: z.number().default(512),
+  site: z.string().nonempty().default("datadoghq.com"),
+  apiKey: z.string().nonempty().default("*******"),
+  loggingEnabled: z.boolean().default(false),
+  apmEnabled: z.boolean().default(false),
+  dogstatsdEnabled: z.boolean().default(false),
+});
+export type DatadogConfigValidator = z.infer<typeof datadogConfigValidator>;

+ 261 - 21
dashboard/src/lib/addons/index.ts

@@ -1,20 +1,43 @@
+import { DomainType } from "@porter-dev/api-contracts";
 import {
   Addon,
   AddonType,
+  Datadog,
+  Metabase,
+  Mezmo,
+  Newrelic,
+  Postgres,
+  Redis,
+  Tailscale,
 } from "@porter-dev/api-contracts/src/porter/v1/addons_pb";
 import { match } from "ts-pattern";
 import { z } from "zod";
 
 import { serviceStringValidator } from "lib/porter-apps/values";
 
+import { datadogConfigValidator } from "./datadog";
+import { metabaseConfigValidator } from "./metabase";
+import { mezmoConfigValidator } from "./mezmo";
+import { newrelicConfigValidator } from "./newrelic";
 import { defaultPostgresAddon, postgresConfigValidator } from "./postgres";
 import { redisConfigValidator } from "./redis";
+import { tailscaleConfigValidator } from "./tailscale";
+import {
+  ADDON_TEMPLATE_DATADOG,
+  ADDON_TEMPLATE_METABASE,
+  ADDON_TEMPLATE_MEZMO,
+  ADDON_TEMPLATE_NEWRELIC,
+  ADDON_TEMPLATE_POSTGRES,
+  ADDON_TEMPLATE_REDIS,
+  ADDON_TEMPLATE_TAILSCALE,
+  type AddonTemplate,
+} from "./template";
 
 export const clientAddonValidator = z.object({
   expanded: z.boolean().default(false),
   canDelete: z.boolean().default(true),
   name: z.object({
-    readOnly: z.boolean(),
+    readOnly: z.boolean().default(false),
     value: z
       .string()
       .min(1, { message: "Name must be at least 1 character" })
@@ -27,30 +50,106 @@ export const clientAddonValidator = z.object({
   config: z.discriminatedUnion("type", [
     postgresConfigValidator,
     redisConfigValidator,
+    datadogConfigValidator,
+    mezmoConfigValidator,
+    metabaseConfigValidator,
+    newrelicConfigValidator,
+    tailscaleConfigValidator,
   ]),
 });
-export type ClientAddon = z.infer<typeof clientAddonValidator>;
+export type ClientAddon = z.infer<typeof clientAddonValidator> & {
+  template: AddonTemplate;
+};
+export const legacyAddonValidator = z.object({
+  name: z.string(),
+  namespace: z.string(),
+  info: z.object({
+    last_deployed: z.string(),
+  }),
+  chart: z.object({
+    metadata: z
+      .object({
+        name: z.string().optional(),
+        icon: z.string().optional(),
+      })
+      .optional(),
+  }),
+});
+export type LegacyClientAddon = z.infer<typeof legacyAddonValidator>;
 
 export function defaultClientAddon(
   type: ClientAddon["config"]["type"]
 ): ClientAddon {
   return match(type)
-    .with("postgres", () =>
-      clientAddonValidator.parse({
+    .returnType<ClientAddon>()
+    .with("postgres", () => ({
+      ...clientAddonValidator.parse({
         expanded: true,
         name: { readOnly: false, value: "postgres" },
         config: defaultPostgresAddon(),
-      })
-    )
-    .with("redis", () =>
-      clientAddonValidator.parse({
+      }),
+      template: ADDON_TEMPLATE_POSTGRES,
+    }))
+    .with("redis", () => ({
+      ...clientAddonValidator.parse({
         expanded: true,
         name: { readOnly: false, value: "redis" },
         config: redisConfigValidator.parse({
           type: "redis",
         }),
-      })
-    )
+      }),
+      template: ADDON_TEMPLATE_REDIS,
+    }))
+    .with("datadog", () => ({
+      ...clientAddonValidator.parse({
+        expanded: true,
+        name: { readOnly: false, value: "datadog" },
+        config: datadogConfigValidator.parse({
+          type: "datadog",
+        }),
+      }),
+      template: ADDON_TEMPLATE_DATADOG,
+    }))
+    .with("mezmo", () => ({
+      ...clientAddonValidator.parse({
+        expanded: true,
+        name: { readOnly: false, value: "mezmo" },
+        config: mezmoConfigValidator.parse({
+          type: "mezmo",
+        }),
+      }),
+      template: ADDON_TEMPLATE_MEZMO,
+    }))
+    .with("metabase", () => ({
+      ...clientAddonValidator.parse({
+        expanded: true,
+        name: { readOnly: false, value: "metabase" },
+        config: metabaseConfigValidator.parse({
+          type: "metabase",
+        }),
+      }),
+      template: ADDON_TEMPLATE_METABASE,
+    }))
+    .with("newrelic", () => ({
+      ...clientAddonValidator.parse({
+        expanded: true,
+        name: { readOnly: false, value: "newrelic" },
+        config: newrelicConfigValidator.parse({
+          type: "newrelic",
+        }),
+      }),
+      template: ADDON_TEMPLATE_NEWRELIC,
+    }))
+    .with("tailscale", () => ({
+      ...clientAddonValidator.parse({
+        expanded: true,
+        name: { readOnly: false, value: "tailscale" },
+        config: tailscaleConfigValidator.parse({
+          type: "tailscale",
+        }),
+      }),
+      template: ADDON_TEMPLATE_TAILSCALE,
+    }))
     .exhaustive();
 }
 
@@ -58,27 +157,98 @@ function addonTypeEnumProto(type: ClientAddon["config"]["type"]): AddonType {
   return match(type)
     .with("postgres", () => AddonType.POSTGRES)
     .with("redis", () => AddonType.REDIS)
+    .with("datadog", () => AddonType.DATADOG)
+    .with("mezmo", () => AddonType.MEZMO)
+    .with("metabase", () => AddonType.METABASE)
+    .with("newrelic", () => AddonType.NEWRELIC)
+    .with("tailscale", () => AddonType.TAILSCALE)
     .exhaustive();
 }
 
 export function clientAddonToProto(addon: ClientAddon): Addon {
   const config = match(addon.config)
+    .returnType<Addon["config"]>()
     .with({ type: "postgres" }, (data) => ({
-      value: {
+      value: new Postgres({
         cpuCores: data.cpuCores.value,
         ramMegabytes: data.ramMegabytes.value,
         storageGigabytes: data.storageGigabytes.value,
-      },
+      }),
       case: "postgres" as const,
     }))
     .with({ type: "redis" }, (data) => ({
-      value: {
+      value: new Redis({
         cpuCores: data.cpuCores.value,
         ramMegabytes: data.ramMegabytes.value,
         storageGigabytes: data.storageGigabytes.value,
-      },
+      }),
       case: "redis" as const,
     }))
+    .with({ type: "datadog" }, (data) => ({
+      value: new Datadog({
+        cpuCores: data.cpuCores,
+        ramMegabytes: data.ramMegabytes,
+        site: data.site,
+        apiKey: data.apiKey,
+        loggingEnabled: data.loggingEnabled,
+        apmEnabled: data.apmEnabled,
+        dogstatsdEnabled: data.dogstatsdEnabled,
+      }),
+      case: "datadog" as const,
+    }))
+    .with({ type: "mezmo" }, (data) => ({
+      value: new Mezmo({
+        ingestionKey: data.ingestionKey,
+      }),
+      case: "mezmo" as const,
+    }))
+    .with({ type: "metabase" }, (data) => ({
+      value: new Metabase({
+        ingressEnabled: data.exposedToExternalTraffic,
+        domains: [
+          {
+            name: data.customDomain,
+            type: DomainType.UNSPECIFIED,
+          },
+          {
+            name: data.porterDomain,
+            type: DomainType.PORTER,
+          },
+          // if not exposed, remove all domains
+        ].filter((d) => d.name !== "" && data.exposedToExternalTraffic),
+        datastore: {
+          host: data.datastore.host,
+          port: BigInt(data.datastore.port),
+          databaseName: data.datastore.databaseName,
+          masterUsername: data.datastore.username,
+          masterUserPasswordLiteral: data.datastore.password,
+        },
+      }),
+      case: "metabase" as const,
+    }))
+    .with({ type: "newrelic" }, (data) => ({
+      value: new Newrelic({
+        licenseKey: data.licenseKey,
+        insightsKey: data.insightsKey,
+        personalApiKey: data.personalApiKey,
+        accountId: data.accountId,
+        loggingEnabled: data.loggingEnabled,
+        kubeEventsEnabled: data.kubeEventsEnabled,
+        metricsAdapterEnabled: data.metricsAdapterEnabled,
+        prometheusEnabled: data.prometheusEnabled,
+        pixieEnabled: data.pixieEnabled,
+      }),
+      case: "newrelic" as const,
+    }))
+    .with({ type: "tailscale" }, (data) => ({
+      value: new Tailscale({
+        authKey: data.authKey,
+        subnetRoutes: data.subnetRoutes
+          .map((r) => r.route)
+          .filter((r) => r !== ""),
+      }),
+      case: "tailscale" as const,
+    }))
     .exhaustive();
 
   const proto = new Addon({
@@ -107,6 +277,7 @@ export function clientAddonFromProto({
   }
 
   const config = match(addon.config)
+    .returnType<ClientAddon["config"]>()
     .with({ case: "postgres" }, (data) => ({
       type: "postgres" as const,
       cpuCores: {
@@ -140,15 +311,84 @@ export function clientAddonFromProto({
       },
       password: secrets.REDIS_PASSWORD,
     }))
+    .with({ case: "datadog" }, (data) => ({
+      type: "datadog" as const,
+      cpuCores: data.value.cpuCores ?? 0,
+      ramMegabytes: data.value.ramMegabytes ?? 0,
+      site: data.value.site ?? "",
+      apiKey: data.value.apiKey ?? "",
+      loggingEnabled: data.value.loggingEnabled ?? false,
+      apmEnabled: data.value.apmEnabled ?? false,
+      dogstatsdEnabled: data.value.dogstatsdEnabled ?? false,
+    }))
+    .with({ case: "mezmo" }, (data) => ({
+      type: "mezmo" as const,
+      ingestionKey: data.value.ingestionKey ?? "",
+    }))
+    .with({ case: "metabase" }, (data) => ({
+      type: "metabase" as const,
+      exposedToExternalTraffic: data.value.ingressEnabled ?? false,
+      porterDomain:
+        data.value.domains.find((domain) => domain.type === DomainType.PORTER)
+          ?.name ?? "",
+      customDomain:
+        data.value.domains.find(
+          (domain) => domain.type === DomainType.UNSPECIFIED
+        )?.name ?? "",
+      datastore: {
+        host: data.value.datastore?.host ?? "",
+        port: Number(data.value.datastore?.port) ?? 0,
+        databaseName: data.value.datastore?.databaseName ?? "",
+        username: data.value.datastore?.masterUsername ?? "",
+        password: data.value.datastore?.masterUserPasswordLiteral ?? "",
+      },
+    }))
+    .with({ case: "newrelic" }, (data) => ({
+      type: "newrelic" as const,
+      licenseKey: data.value.licenseKey ?? "",
+      insightsKey: data.value.insightsKey ?? "",
+      personalApiKey: data.value.personalApiKey ?? "",
+      accountId: data.value.accountId ?? "",
+      loggingEnabled: data.value.loggingEnabled ?? false,
+      kubeEventsEnabled: data.value.kubeEventsEnabled ?? false,
+      metricsAdapterEnabled: data.value.metricsAdapterEnabled ?? false,
+      prometheusEnabled: data.value.prometheusEnabled ?? false,
+      pixieEnabled: data.value.pixieEnabled ?? false,
+    }))
+    .with({ case: "tailscale" }, (data) => ({
+      type: "tailscale" as const,
+      authKey: data.value.authKey ?? "",
+      subnetRoutes: data.value.subnetRoutes.map((r) => ({ route: r })),
+    }))
     .exhaustive();
 
-  const clientAddon = clientAddonValidator.parse({
-    name: { readOnly: false, value: addon.name },
-    envGroups: addon.envGroups.map((envGroup) => ({
-      value: envGroup.name,
-    })),
-    config,
-  });
+  const template = match(addon.config)
+    .with({ case: "postgres" }, () => ADDON_TEMPLATE_POSTGRES)
+    .with({ case: "redis" }, () => ADDON_TEMPLATE_REDIS)
+    .with({ case: "datadog" }, () => ADDON_TEMPLATE_DATADOG)
+    .with({ case: "mezmo" }, () => ADDON_TEMPLATE_MEZMO)
+    .with({ case: "metabase" }, () => ADDON_TEMPLATE_METABASE)
+    .with({ case: "newrelic" }, () => ADDON_TEMPLATE_NEWRELIC)
+    .with({ case: "tailscale" }, () => ADDON_TEMPLATE_TAILSCALE)
+    .exhaustive();
+
+  const clientAddon = {
+    ...clientAddonValidator.parse({
+      name: { readOnly: false, value: addon.name },
+      envGroups: addon.envGroups.map((envGroup) => ({
+        value: envGroup.name,
+      })),
+      config,
+    }),
+    template,
+  };
 
   return clientAddon;
 }
+
+export const tailscaleServiceValidator = z.object({
+  name: z.string(),
+  ip: z.string(),
+  port: z.number(),
+});
+export type ClientTailscaleService = z.infer<typeof tailscaleServiceValidator>;

+ 24 - 0
dashboard/src/lib/addons/metabase.ts

@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+export const metabaseConfigValidator = z.object({
+  type: z.literal("metabase"),
+  exposedToExternalTraffic: z.boolean().default(true),
+  porterDomain: z.string().default(""),
+  customDomain: z.string().default(""),
+  datastore: z
+    .object({
+      host: z.string().nonempty(),
+      port: z.number(),
+      databaseName: z.string().nonempty(),
+      username: z.string().nonempty(),
+      password: z.string().nonempty(),
+    })
+    .default({
+      host: "<host>",
+      port: 0,
+      databaseName: "<db-name>",
+      username: "<username>",
+      password: "<password>",
+    }),
+});
+export type MetabaseConfigValidator = z.infer<typeof metabaseConfigValidator>;

+ 7 - 0
dashboard/src/lib/addons/mezmo.ts

@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const mezmoConfigValidator = z.object({
+  type: z.literal("mezmo"),
+  ingestionKey: z.string().nonempty().default("*******"),
+});
+export type MezmoConfigValidator = z.infer<typeof mezmoConfigValidator>;

+ 15 - 0
dashboard/src/lib/addons/newrelic.ts

@@ -0,0 +1,15 @@
+import { z } from "zod";
+
+export const newrelicConfigValidator = z.object({
+  type: z.literal("newrelic"),
+  licenseKey: z.string().nonempty().default("*******"),
+  insightsKey: z.string().nonempty().default("*******"),
+  personalApiKey: z.string().nonempty().default("*******"),
+  accountId: z.string().nonempty().default("<account-id>"),
+  loggingEnabled: z.boolean().default(false),
+  kubeEventsEnabled: z.boolean().default(false),
+  metricsAdapterEnabled: z.boolean().default(false),
+  prometheusEnabled: z.boolean().default(false),
+  pixieEnabled: z.boolean().default(false),
+});
+export type NewrelicConfigValidator = z.infer<typeof newrelicConfigValidator>;

+ 11 - 0
dashboard/src/lib/addons/tailscale.ts

@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+const subnetRouteValidator = z.object({
+  route: z.string(),
+});
+export const tailscaleConfigValidator = z.object({
+  type: z.literal("tailscale"),
+  authKey: z.string().nonempty().default("*******"),
+  subnetRoutes: z.array(subnetRouteValidator).default([]),
+});
+export type TailscaleConfigValidator = z.infer<typeof tailscaleConfigValidator>;

+ 210 - 0
dashboard/src/lib/addons/template.ts

@@ -0,0 +1,210 @@
+import Logs from "main/home/add-on-dashboard/common/Logs";
+import Settings from "main/home/add-on-dashboard/common/Settings";
+import DatadogForm from "main/home/add-on-dashboard/datadog/DatadogForm";
+import MetabaseForm from "main/home/add-on-dashboard/metabase/MetabaseForm";
+import MezmoForm from "main/home/add-on-dashboard/mezmo/MezmoForm";
+import NewRelicForm from "main/home/add-on-dashboard/newrelic/NewRelicForm";
+import TailscaleForm from "main/home/add-on-dashboard/tailscale/TailscaleForm";
+import TailscaleOverview from "main/home/add-on-dashboard/tailscale/TailscaleOverview";
+
+import { type ClientAddon } from ".";
+
+export type AddonTemplateTag =
+  | "Monitoring"
+  | "Logging"
+  | "Analytics"
+  | "Networking"
+  | "Database";
+
+export const AddonTemplateTagColor: {
+  [key in AddonTemplateTag]: string;
+} = {
+  Monitoring: "#774B9E",
+  Logging: "#F72585",
+  Analytics: "#1CCAD8",
+  Networking: "#FF680A",
+  Database: "#5FAD56",
+};
+
+export type AddonTab = {
+  name: string;
+  displayName: string;
+  component: React.FC;
+  isOnlyForPorterOperators?: boolean;
+};
+
+export const DEFAULT_ADDON_TAB = {
+  name: "configuration",
+  displayName: "Configuration",
+  component: () => null,
+};
+
+export type AddonTemplate = {
+  type: ClientAddon["config"]["type"];
+  displayName: string;
+  description: string;
+  icon: string;
+  tags: AddonTemplateTag[];
+  tabs: AddonTab[]; // this what is rendered on the dashboard after the addon is deployed
+};
+
+export const ADDON_TEMPLATE_REDIS: AddonTemplate = {
+  type: "redis",
+  displayName: "Redis",
+  description: "An in-memory database that persists on disk.",
+  icon: "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png",
+  tags: ["Database"],
+  tabs: [],
+};
+
+export const ADDON_TEMPLATE_POSTGRES: AddonTemplate = {
+  type: "postgres",
+  displayName: "Postgres",
+  description: "An object-relational database system.",
+  icon: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg",
+  tags: ["Database"],
+  tabs: [],
+};
+
+export const ADDON_TEMPLATE_DATADOG: AddonTemplate = {
+  type: "datadog",
+  displayName: "DataDog",
+  description:
+    "Pipe logs, metrics and APM data from your workloads to DataDog.",
+  icon: "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
+  tags: ["Monitoring"],
+  tabs: [
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: DatadogForm,
+    },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+      isOnlyForPorterOperators: true,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
+  ],
+};
+
+export const ADDON_TEMPLATE_MEZMO: AddonTemplate = {
+  type: "mezmo",
+  displayName: "Mezmo",
+  description: "A popular logging management system.",
+  icon: "https://media.licdn.com/dms/image/D560BAQEDU9GQqUZHsQ/company-logo_200_200/0/1664831631499/mezmo_logo?e=2147483647&v=beta&t=h-mCuJh3FSVhXKvvGcfFrL6w9LPaCexypRcw2QWboEs",
+  tags: ["Logging"],
+  tabs: [
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: MezmoForm,
+    },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+      isOnlyForPorterOperators: true,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
+  ],
+};
+
+export const ADDON_TEMPLATE_METABASE: AddonTemplate = {
+  type: "metabase",
+  displayName: "Metabase",
+  description: "An open-source business intelligence tool.",
+  icon: "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
+  tags: ["Analytics"],
+  tabs: [
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: MetabaseForm,
+    },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
+  ],
+};
+
+export const ADDON_TEMPLATE_NEWRELIC: AddonTemplate = {
+  type: "newrelic",
+  displayName: "New Relic",
+  description: "Monitor your applications and infrastructure.",
+  icon: "https://companieslogo.com/img/orig/NEWR-de5fcb2e.png?t=1681801483",
+  tags: ["Monitoring"],
+  tabs: [
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: NewRelicForm,
+    },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+      isOnlyForPorterOperators: true,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
+  ],
+};
+
+export const ADDON_TEMPLATE_TAILSCALE: AddonTemplate = {
+  type: "tailscale",
+  displayName: "Tailscale",
+  description: "A VPN for your applications and datastores.",
+  icon: "https://play-lh.googleusercontent.com/wczDL05-AOb39FcL58L32h6j_TrzzGTXDLlOrOmJ-aNsnoGsT1Gkk2vU4qyTb7tGxRw=w240-h480-rw",
+  tags: ["Networking"],
+  tabs: [
+    {
+      name: "overview",
+      displayName: "Overview",
+      component: TailscaleOverview,
+    },
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: TailscaleForm,
+    },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+      isOnlyForPorterOperators: true,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
+  ],
+};
+
+export const SUPPORTED_ADDON_TEMPLATES: AddonTemplate[] = [
+  ADDON_TEMPLATE_DATADOG,
+  ADDON_TEMPLATE_MEZMO,
+  ADDON_TEMPLATE_METABASE,
+  ADDON_TEMPLATE_NEWRELIC,
+  ADDON_TEMPLATE_TAILSCALE,
+];

+ 776 - 0
dashboard/src/lib/hooks/useAddon.ts

@@ -0,0 +1,776 @@
+import { useEffect, useRef, useState } from "react";
+import { Addon, AddonWithEnvVars } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import Anser, { type AnserJsonEntry } from "anser";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
+import {
+  clientAddonFromProto,
+  clientAddonToProto,
+  legacyAddonValidator,
+  type ClientAddon,
+  type LegacyClientAddon,
+} from "lib/addons";
+
+import api from "shared/api";
+import {
+  useWebsockets,
+  type NewWebsocketOptions,
+} from "shared/hooks/useWebsockets";
+import { isJSON, valueExists } from "shared/util";
+
+import { type DeploymentTarget } from "./useDeploymentTarget";
+
+export const useAddonList = ({
+  projectId,
+  deploymentTarget,
+}: {
+  projectId?: number;
+  deploymentTarget?: DeploymentTarget;
+}): {
+  addons: ClientAddon[];
+  legacyAddons: LegacyClientAddon[];
+  isLoading: boolean;
+  isLegacyAddonsLoading: boolean;
+  isError: boolean;
+} => {
+  const {
+    data: addons = [],
+    isLoading,
+    isError,
+  } = useQuery(
+    ["listAddons", projectId, deploymentTarget],
+    async () => {
+      if (!projectId || projectId === -1 || !deploymentTarget) {
+        return;
+      }
+
+      const res = await api.listAddons(
+        "<token>",
+        {},
+        {
+          projectId,
+          deploymentTargetId: deploymentTarget.id,
+        }
+      );
+
+      const parsed = await z
+        .object({
+          base64_addons: z.array(z.string()),
+        })
+        .parseAsync(res.data);
+
+      const clientAddons: ClientAddon[] = parsed.base64_addons
+        .map((a) => {
+          const proto = AddonWithEnvVars.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          });
+          if (!proto.addon) {
+            return null;
+          }
+          return clientAddonFromProto({
+            addon: proto.addon,
+          });
+        })
+        .filter(valueExists);
+
+      return clientAddons;
+    },
+    {
+      enabled: !!projectId && projectId !== -1 && !!deploymentTarget,
+      refetchOnWindowFocus: false,
+      refetchInterval: 5000,
+    }
+  );
+
+  const { data: legacyAddons = [], isLoading: isLegacyAddonsLoading } =
+    useQuery(
+      ["listLegacyAddons", projectId, deploymentTarget],
+      async () => {
+        if (!projectId || projectId === -1 || !deploymentTarget) {
+          return;
+        }
+
+        const res = await api.getCharts(
+          "<token>",
+          {
+            limit: 50,
+            skip: 0,
+            byDate: false,
+            statusFilter: [
+              "deployed",
+              "uninstalled",
+              "pending",
+              "pending-install",
+              "pending-upgrade",
+              "pending-rollback",
+              "failed",
+            ],
+          },
+          {
+            id: projectId,
+            cluster_id: deploymentTarget.cluster_id,
+            namespace: "all",
+          }
+        );
+
+        const parsed = await z.array(legacyAddonValidator).parseAsync(res.data);
+
+        return parsed
+          .filter((a) => {
+            return ![
+              "web",
+              "worker",
+              "job",
+              "umbrella",
+              "postgresql-managed", // managed in datastores tab
+              "redis-managed", // managed in datastores tab
+            ].includes(a.chart?.metadata?.name ?? "");
+          })
+          .filter((a) => {
+            return ![
+              "ack-system",
+              "cert-manager",
+              "ingress-nginx",
+              "kube-node-lease",
+              "kube-public",
+              "kube-system",
+              "monitoring",
+              "porter-agent-system",
+              "external-secrets",
+            ].includes(a.namespace ?? "");
+          });
+      },
+      {
+        enabled: !!projectId && projectId !== -1 && !!deploymentTarget,
+        refetchOnWindowFocus: false,
+        refetchInterval: 5000,
+      }
+    );
+
+  return {
+    addons,
+    legacyAddons,
+    isLoading,
+    isLegacyAddonsLoading,
+    isError,
+  };
+};
+
+export const useAddon = (): {
+  updateAddon: ({
+    projectId,
+    deploymentTargetId,
+    addon,
+  }: {
+    projectId: number;
+    deploymentTargetId: string;
+    addon: ClientAddon;
+  }) => Promise<void>;
+  deleteAddon: ({
+    projectId,
+    deploymentTargetId,
+    addon,
+  }: {
+    projectId: number;
+    deploymentTargetId: string;
+    addon: ClientAddon;
+  }) => Promise<void>;
+  getAddon: ({
+    projectId,
+    deploymentTargetId,
+    addonName,
+    refreshIntervalSeconds,
+  }: {
+    projectId?: number;
+    deploymentTargetId: string;
+    addonName?: string;
+    refreshIntervalSeconds?: number;
+  }) => {
+    addon: ClientAddon | undefined;
+    isLoading: boolean;
+    isError: boolean;
+  };
+} => {
+  const updateAddon = async ({
+    projectId,
+    deploymentTargetId,
+    addon,
+  }: {
+    projectId: number;
+    deploymentTargetId: string;
+    addon: ClientAddon;
+  }): Promise<void> => {
+    const proto = clientAddonToProto(addon);
+
+    await api.updateAddon(
+      "<token>",
+      {
+        b64_addon: btoa(proto.toJsonString({ emitDefaultValues: true })),
+      },
+      {
+        projectId,
+        deploymentTargetId,
+      }
+    );
+  };
+
+  const deleteAddon = async ({
+    projectId,
+    deploymentTargetId,
+    addon,
+  }: {
+    projectId: number;
+    deploymentTargetId: string;
+    addon: ClientAddon;
+  }): Promise<void> => {
+    await api.deleteAddon(
+      "<token>",
+      {},
+      {
+        projectId,
+        deploymentTargetId,
+        addonName: addon.name.value,
+      }
+    );
+  };
+
+  const getAddon = ({
+    projectId,
+    deploymentTargetId,
+    addonName,
+    refreshIntervalSeconds = 0,
+  }: {
+    projectId?: number;
+    deploymentTargetId: string;
+    addonName?: string;
+    refreshIntervalSeconds?: number;
+  }): {
+    addon: ClientAddon | undefined;
+    isLoading: boolean;
+    isError: boolean;
+  } => {
+    const { data, isLoading, isError } = useQuery(
+      ["getAddon", projectId, deploymentTargetId, addonName],
+      async () => {
+        if (!projectId || projectId === -1 || !addonName) {
+          return undefined;
+        }
+
+        const res = await api.getAddon(
+          "<token>",
+          {},
+          {
+            projectId,
+            deploymentTargetId,
+            addonName,
+          }
+        );
+
+        const parsed = await z
+          .object({
+            addon: z.string(),
+          })
+          .parseAsync(res.data);
+
+        const proto = Addon.fromJsonString(atob(parsed.addon), {
+          ignoreUnknownFields: true,
+        });
+
+        if (!proto) {
+          return undefined;
+        }
+
+        return clientAddonFromProto({
+          addon: proto,
+        });
+      },
+      {
+        enabled: !!projectId && projectId !== -1 && !!addonName,
+        retryDelay: 5000,
+        refetchInterval: refreshIntervalSeconds * 1000,
+      }
+    );
+
+    return {
+      addon: data,
+      isLoading,
+      isError,
+    };
+  };
+
+  return {
+    updateAddon,
+    deleteAddon,
+    getAddon,
+  };
+};
+
+const addonControllersValidator = z.array(
+  z.object({
+    metadata: z.object({
+      uid: z.string(),
+      name: z.string(),
+    }),
+    spec: z.object({
+      selector: z.object({
+        matchLabels: z.record(z.string()),
+      }),
+    }),
+  })
+);
+const addonPodValidator = z.object({
+  metadata: z.object({
+    name: z.string(),
+  }),
+  status: z.object({
+    phase: z
+      .string()
+      .pipe(
+        z.enum(["UNKNOWN", "Running", "Pending", "Failed"]).catch("UNKNOWN")
+      ),
+  }),
+});
+export type ClientAddonPod = {
+  name: string;
+  status: "running" | "pending" | "failed";
+};
+export type ClientAddonStatus = {
+  pods: ClientAddonPod[];
+  isLoading: boolean;
+};
+export const useAddonStatus = ({
+  projectId,
+  deploymentTarget,
+  addon,
+}: {
+  projectId?: number;
+  deploymentTarget: DeploymentTarget;
+  addon?: ClientAddon;
+}): ClientAddonStatus => {
+  const [isInitializingStatus, setIsInitializingStatus] =
+    useState<boolean>(false);
+  const [controllerPodMap, setControllerPodMap] = useState<
+    Record<string, ClientAddonPod[]>
+  >({});
+
+  const { newWebsocket, openWebsocket, closeAllWebsockets, closeWebsocket } =
+    useWebsockets();
+
+  const controllersResp = useQuery(
+    ["listControllers", projectId, addon],
+    async () => {
+      if (!projectId || projectId === -1 || !addon) {
+        return;
+      }
+
+      const resp = await api.getChartControllers(
+        "<token>",
+        {},
+        {
+          name: addon.name.value,
+          namespace: deploymentTarget.namespace,
+          cluster_id: deploymentTarget.cluster_id,
+          revision: 0,
+          id: projectId,
+        }
+      );
+      const parsed = await addonControllersValidator.parseAsync(resp.data);
+
+      return parsed;
+    },
+    {
+      enabled: !!projectId && projectId !== -1 && !!addon,
+      retryDelay: 5000,
+    }
+  );
+
+  useEffect(() => {
+    setIsInitializingStatus(true);
+    if (!controllersResp.isSuccess || !controllersResp.data) {
+      return;
+    }
+
+    const setupPodWebsocketWithSelectors = (
+      controllerUid: string,
+      selectors: string
+    ): void => {
+      if (!projectId || projectId === -1 || !deploymentTarget) {
+        return;
+      }
+      const websocketKey = `${Math.random().toString(36).substring(2, 15)}`;
+      const apiEndpoint = `/api/projects/${projectId}/clusters/${deploymentTarget.cluster_id}/pod/status?selectors=${selectors}`;
+
+      const options: NewWebsocketOptions = {
+        onopen: () => {
+          // console.log("connected to websocket for selectors: ", selectors);
+        },
+        onmessage: (evt: MessageEvent) => {
+          const event = JSON.parse(evt.data);
+          const object = event.Object;
+          object.metadata.kind = event.Kind;
+
+          void updatePodsForController(controllerUid, selectors);
+        },
+        onclose: () => {
+          // console.log("closing websocket");
+        },
+        onerror: () => {
+          // console.log(err);
+          closeWebsocket(websocketKey);
+        },
+      };
+
+      newWebsocket(websocketKey, apiEndpoint, options);
+      openWebsocket(websocketKey);
+    };
+
+    const controllers = controllersResp.data;
+
+    const initializeControllers = async (): Promise<void> => {
+      try {
+        // this initializes the controllerPodMap on mount
+        const controllerPodMap: Record<string, ClientAddonPod[]> = {};
+        for (const controller of controllers) {
+          const selectors = Object.keys(
+            controller.spec.selector.matchLabels
+          ).map((key) => `${key}=${controller.spec.selector.matchLabels[key]}`);
+          const pods = await getPodsForSelectors(selectors.join(","));
+          controllerPodMap[controller.metadata.uid] = pods;
+        }
+        setControllerPodMap(controllerPodMap);
+
+        // this sets up websockets for each controller, for pod updates
+        for (const controller of controllers) {
+          const selectors = Object.keys(
+            controller.spec.selector.matchLabels
+          ).map((key) => `${key}=${controller.spec.selector.matchLabels[key]}`);
+          setupPodWebsocketWithSelectors(
+            controller.metadata.uid,
+            selectors.join(",")
+          );
+        }
+      } catch (err) {
+        // TODO: handle error
+      } finally {
+        setIsInitializingStatus(false);
+      }
+    };
+
+    void initializeControllers();
+  }, [controllersResp.data]);
+
+  const getPodsForSelectors = async (
+    selectors: string
+  ): Promise<ClientAddonPod[]> => {
+    if (!projectId || projectId === -1 || !deploymentTarget) {
+      return [];
+    }
+    try {
+      const res = await api.getMatchingPods(
+        "<token>",
+        {
+          namespace: deploymentTarget.namespace,
+          selectors: [selectors],
+        },
+        {
+          id: projectId,
+          cluster_id: deploymentTarget.cluster_id,
+        }
+      );
+      const parsed = z.array(addonPodValidator).safeParse(res.data);
+      if (!parsed.success) {
+        // console.log(parsed.error);
+        return [];
+      }
+      const clientPods: ClientAddonPod[] = parsed.data
+        .map((pod) => {
+          if (pod.status.phase === "UNKNOWN") {
+            return undefined;
+          }
+
+          return {
+            name: pod.metadata.name,
+            status: match(pod.status.phase)
+              .with("Running", () => "running" as const)
+              .with("Pending", () => "pending" as const)
+              .with("Failed", () => "failed" as const)
+              .exhaustive(),
+          };
+        })
+        .filter(valueExists);
+
+      return clientPods;
+    } catch (err) {
+      return [];
+    }
+  };
+
+  const updatePodsForController = async (
+    controllerUid: string,
+    selectors: string
+  ): Promise<void> => {
+    const pods = await getPodsForSelectors(selectors);
+
+    setControllerPodMap((prev) => {
+      return {
+        ...prev,
+        [controllerUid]: pods,
+      };
+    });
+  };
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  return {
+    pods: Object.keys(controllerPodMap)
+      .map((c) => controllerPodMap[c])
+      .flat(),
+    isLoading: isInitializingStatus,
+  };
+};
+
+export type Log = {
+  line: AnserJsonEntry[];
+  lineNumber: number;
+  timestamp?: string;
+  controllerName: string;
+  podName: string;
+};
+
+export const useAddonLogs = ({
+  projectId,
+  deploymentTarget,
+  addon,
+}: {
+  projectId?: number;
+  deploymentTarget: DeploymentTarget;
+  addon?: ClientAddon;
+}): { logs: Log[]; refresh: () => void; isInitializing: boolean } => {
+  const [logs, setLogs] = useState<Log[]>([]);
+  const logsBufferRef = useRef<Log[]>([]);
+  const { newWebsocket, openWebsocket, closeAllWebsockets } = useWebsockets();
+  const [isInitializing, setIsInitializing] = useState<boolean>(true);
+
+  const fetchControllers = async (): Promise<
+    z.infer<typeof addonControllersValidator>
+  > => {
+    if (!projectId || projectId === -1 || !addon) {
+      throw new Error("Invalid parameters");
+    }
+
+    const resp = await api.getChartControllers(
+      "<token>",
+      {},
+      {
+        name: addon.name.value,
+        namespace: deploymentTarget.namespace,
+        cluster_id: deploymentTarget.cluster_id,
+        revision: 0,
+        id: projectId,
+      }
+    );
+    const parsed = await addonControllersValidator.parseAsync(resp.data);
+
+    return parsed;
+  };
+
+  const controllersQuery = useQuery(
+    ["listControllers", projectId, addon],
+    fetchControllers,
+    {
+      retryDelay: 5000,
+      enabled: !!projectId && projectId !== -1 && !!addon,
+    }
+  );
+
+  useEffect(() => {
+    const fetchPodsAndSetUpWebsockets = async (
+      controllers: z.infer<typeof addonControllersValidator>
+    ): Promise<void> => {
+      closeAllWebsockets();
+      for (const controller of controllers) {
+        const selectors = Object.keys(controller.spec.selector.matchLabels)
+          .map((key) => `${key}=${controller.spec.selector.matchLabels[key]}`)
+          .join(",");
+
+        const pods = await fetchPodsForSelectors(selectors);
+
+        for (const pod of pods) {
+          setupWebsocket(pod.metadata.name, controller.metadata.name);
+        }
+      }
+      setIsInitializing(false);
+    };
+    if (controllersQuery.isSuccess && controllersQuery.data) {
+      void fetchPodsAndSetUpWebsockets(controllersQuery.data);
+    }
+  }, [controllersQuery.data, controllersQuery.isSuccess]);
+
+  const fetchPodsForSelectors = async (
+    selectors: string
+  ): Promise<Array<z.infer<typeof addonPodValidator>>> => {
+    if (!projectId || projectId === -1 || !deploymentTarget) {
+      return [];
+    }
+    try {
+      const res = await api.getMatchingPods(
+        "<token>",
+        {
+          namespace: deploymentTarget.namespace,
+          selectors: [selectors],
+        },
+        {
+          id: projectId,
+          cluster_id: deploymentTarget.cluster_id,
+        }
+      );
+      const parsed = await z.array(addonPodValidator).parseAsync(res.data);
+      return parsed;
+    } catch (err) {
+      return [];
+    }
+  };
+
+  const parseLogs = (
+    logs: string[] = [],
+    controllerName: string,
+    podName: string
+  ): Log[] => {
+    return logs.filter(Boolean).map((logLine: string, idx) => {
+      try {
+        if (!isJSON(logLine)) {
+          return {
+            line: Anser.ansiToJson(logLine),
+            lineNumber: idx + 1,
+            timestamp: undefined,
+            controllerName,
+            podName,
+          };
+        }
+
+        const parsedLine = JSON.parse(logLine);
+        const ansiLog = Anser.ansiToJson(parsedLine.line);
+        return {
+          line: ansiLog,
+          lineNumber: idx + 1,
+          timestamp: parsedLine.timestamp,
+          controllerName,
+          podName,
+        };
+      } catch (err) {
+        // console.error(err, logLine);
+        return {
+          line: Anser.ansiToJson(logLine),
+          lineNumber: idx + 1,
+          timestamp: undefined,
+          controllerName,
+          podName,
+        };
+      }
+    });
+  };
+
+  const updateLogs = (newLogs: Log[]): void => {
+    if (!newLogs.length) {
+      return;
+    }
+
+    setLogs((logs) => {
+      const updatedLogs = [...logs, ...newLogs];
+      updatedLogs.sort((a, b) => {
+        if (a.timestamp && b.timestamp) {
+          return (
+            new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
+          );
+        }
+        return a.lineNumber - b.lineNumber;
+      });
+
+      return updatedLogs;
+    });
+  };
+
+  const flushLogsBuffer = (): void => {
+    updateLogs(logsBufferRef.current ?? []);
+    logsBufferRef.current = [];
+  };
+
+  const pushLogs = (newLogs: Log[]): void => {
+    logsBufferRef.current.push(...newLogs);
+  };
+
+  const setupWebsocket = (podName: string, controllerName: string): void => {
+    if (!projectId || projectId === -1 || !deploymentTarget || !addon) {
+      return;
+    }
+
+    const websocketKey = `${Math.random().toString(36).substring(2, 15)}`;
+    const params = new URLSearchParams({
+      pod_selector: podName,
+      namespace: deploymentTarget.namespace,
+    });
+
+    const apiEndpoint = `/api/projects/${projectId}/clusters/${
+      deploymentTarget.cluster_id
+    }/namespaces/${deploymentTarget.namespace}/logs/loki?${params.toString()}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        // console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        if (!evt?.data || typeof evt.data !== "string") {
+          return;
+        }
+        const newLogs = parseLogs(
+          evt.data.trim().split("\n"),
+          controllerName,
+          podName
+        );
+        pushLogs(newLogs);
+      },
+      onclose: () => {
+        // console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, apiEndpoint, config);
+    openWebsocket(websocketKey);
+  };
+
+  const refresh = async (): Promise<void> => {
+    if (!projectId || projectId === -1 || !addon || !deploymentTarget) {
+      return;
+    }
+    setLogs([]);
+    flushLogsBuffer();
+    setIsInitializing(true);
+    await controllersQuery.refetch();
+  };
+
+  useEffect(() => {
+    setTimeout(flushLogsBuffer, 500);
+    const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000);
+    return () => {
+      clearInterval(flushLogsBufferInterval);
+    };
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  return {
+    logs,
+    refresh,
+    isInitializing,
+  };
+};

+ 29 - 9
dashboard/src/main/home/Home.tsx

@@ -1,4 +1,6 @@
 import React, { useContext, useEffect, useRef, useState } from "react";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
 import { createPortal } from "react-dom";
 import {
   Route,
@@ -24,9 +26,6 @@ import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { Context } from "shared/Context";
 import DeploymentTargetProvider from "shared/DeploymentTargetContext";
 import { pushFiltered, pushQueryParams, type PorterUrl } from "shared/routing";
-import dayjs from "dayjs";
-import relativeTime from "dayjs/plugin/relativeTime";
-
 import midnight from "shared/themes/midnight";
 import standard from "shared/themes/standard";
 import {
@@ -35,8 +34,11 @@ import {
   type ProjectType,
 } from "shared/types";
 
-import AddOnDashboard from "./add-on-dashboard/AddOnDashboard";
-import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
+import AddonDashboard from "./add-on-dashboard/AddOnDashboard";
+import AddonTemplates from "./add-on-dashboard/AddonTemplates";
+import AddonView from "./add-on-dashboard/AddonView";
+import LegacyAddOnDashboard from "./add-on-dashboard/legacy_AddOnDashboard";
+import LegacyNewAddOnFlow from "./add-on-dashboard/legacy_NewAddOnFlow";
 import AppView from "./app-dashboard/app-view/AppView";
 import AppDashboard from "./app-dashboard/AppDashboard";
 import Apps from "./app-dashboard/apps/Apps";
@@ -219,7 +221,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -422,7 +424,7 @@ const Home: React.FC<Props> = (props) => {
                     >
                       connect a valid payment method
                     </Link>
-                    . Your free trial is ending {" "}
+                    . Your free trial is ending{" "}
                     {dayjs().to(dayjs(plan.trial_info.ending_before))}
                   </GlobalBanner>
                 )}
@@ -547,10 +549,28 @@ const Home: React.FC<Props> = (props) => {
               </Route>
 
               <Route path="/addons/new">
-                <NewAddOnFlow />
+                {currentProject?.capi_provisioner_enabled &&
+                currentProject?.simplified_view_enabled &&
+                currentProject?.beta_features_enabled ? (
+                  <AddonTemplates />
+                ) : (
+                  <LegacyNewAddOnFlow />
+                )}
+              </Route>
+              <Route path="/addons/:addonName/:tab">
+                <AddonView />
+              </Route>
+              <Route path="/addons/:addonName">
+                <AddonView />
               </Route>
               <Route path="/addons">
-                <AddOnDashboard />
+                {currentProject?.capi_provisioner_enabled &&
+                currentProject?.simplified_view_enabled &&
+                currentProject?.beta_features_enabled ? (
+                  <AddonDashboard />
+                ) : (
+                  <LegacyAddOnDashboard />
+                )}
               </Route>
               <Route
                 path="/new-project"

+ 116 - 151
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -1,10 +1,4 @@
-import React, {
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-  useState,
-} from "react";
+import React, { useContext, useMemo, useState } from "react";
 import _ from "lodash";
 import { Link } from "react-router-dom";
 import styled from "styled-components";
@@ -17,17 +11,15 @@ import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import Fieldset from "components/porter/Fieldset";
 import PorterLink from "components/porter/Link";
 import SearchBar from "components/porter/SearchBar";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
-import { useAuthState } from "main/auth/context";
+import { type ClientAddon, type LegacyClientAddon } from "lib/addons";
+import { useAddonList } from "lib/hooks/useAddon";
 import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
 
-import api from "shared/api";
 import { Context } from "shared/Context";
 import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
 import addOnGrad from "assets/add-on-grad.svg";
 import grid from "assets/grid.png";
@@ -38,105 +30,45 @@ import time from "assets/time.png";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 
-type Props = {};
-
-export const RestrictedNamespaces = [
-  "ack-system",
-  "cert-manager",
-  "ingress-nginx",
-  "kube-node-lease",
-  "kube-public",
-  "kube-system",
-  "monitoring",
-  "porter-agent-system",
-  "external-secrets",
-];
-
-const templateBlacklist = ["web", "worker", "job", "umbrella"];
+// filter out postgres and redis addons because those are managed in the datastores tab now
+const isDisplayableAddon = (addon: ClientAddon): boolean => {
+  return addon.config.type !== "postgres" && addon.config.type !== "redis";
+};
 
-const AddOnDashboard: React.FC<Props> = ({}) => {
-  const { defaultDeploymentTarget } = useDefaultDeploymentTarget();
+const AddonDashboard: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
-  const [addOns, setAddOns] = useState([]);
+  const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
+    useDefaultDeploymentTarget();
+
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState("grid");
-  const [isLoading, setIsLoading] = useState(true);
 
-  const filteredAddOns = useMemo(() => {
-    const filtered = addOns.filter((app) => {
-      return (
-        app.namespace === defaultDeploymentTarget.namespace &&
-        !templateBlacklist.includes(app.chart.metadata.name)
-      );
-    });
+  const {
+    addons,
+    legacyAddons,
+    isLoading: isAddonListLoading,
+    isLegacyAddonsLoading,
+  } = useAddonList({
+    projectId: currentProject?.id,
+    deploymentTarget: defaultDeploymentTarget,
+  });
 
-    const filteredBySearch = search(filtered ?? [], searchValue, {
-      keys: ["name", "chart.metadata.name"],
-      isCaseSensitive: false,
+  const filteredAddons: Array<ClientAddon | LegacyClientAddon> = useMemo(() => {
+    const displayableAddons = addons.filter(isDisplayableAddon);
+    const legacyDisplayableAddons = legacyAddons.sort((a, b) => {
+      return a.info.last_deployed > b.info.last_deployed ? -1 : 1;
     });
 
-    return _.sortBy(filteredBySearch);
-  }, [addOns, searchValue]);
-
-  const getAddOns = async () => {
-    try {
-      setIsLoading(true);
-      const res = await api.getCharts(
-        "<token>",
-        {
-          limit: 50,
-          skip: 0,
-          byDate: false,
-          statusFilter: [
-            "deployed",
-            "uninstalled",
-            "pending",
-            "pending-install",
-            "pending-upgrade",
-            "pending-rollback",
-            "failed",
-          ],
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: "all",
-        }
-      );
-      setIsLoading(false);
-      const charts = res.data || [];
-      setAddOns(charts);
-    } catch (err) {
-      setIsLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    // currentCluster sometimes returns as -1 and passes null check
-    if (currentProject?.id >= 0 && currentCluster?.id >= 0) {
-      getAddOns();
-    }
-  }, [currentCluster, currentProject]);
+    // If an addon name exists in both the legacy and new addon lists, show the new addon
+    const uniqueAddons: Array<ClientAddon | LegacyClientAddon> = [
+      ...displayableAddons,
+      ...legacyDisplayableAddons.filter(
+        (a) => !displayableAddons.some((b) => b.name.value === a.name)
+      ),
+    ];
 
-  const getExpandedChartLinkURL = useCallback(
-    (x: any) => {
-      const params = new Proxy(new URLSearchParams(window.location.search), {
-        get: (searchParams, prop: string) => searchParams.get(prop),
-      });
-      const cluster = currentCluster?.name;
-      const route = `/applications/${cluster}/${x.namespace}/${x.name}`;
-      const newParams = {
-        // @ts-expect-error
-        project_id: params.project_id,
-        closeChartRedirectUrl: "/addons",
-      };
-      const newURLSearchParams = new URLSearchParams(
-        _.omitBy(newParams, _.isNil)
-      );
-      return `${route}?${newURLSearchParams.toString()}`;
-    },
-    [currentCluster]
-  );
+    return uniqueAddons;
+  }, [addons, legacyAddons, defaultDeploymentTarget]);
 
   return (
     <StyledAppDashboard>
@@ -163,9 +95,10 @@ const AddOnDashboard: React.FC<Props> = ({}) => {
             </Button>
           </PorterLink>
         </DashboardPlaceholder>
-      ) : addOns.length === 0 ||
-        (filteredAddOns.length === 0 && searchValue === "") ? (
-        isLoading ? (
+      ) : filteredAddons.length === 0 ||
+        (filteredAddons.length === 0 && searchValue === "") ? (
+        isDefaultDeploymentTargetLoading ||
+        (isAddonListLoading && isLegacyAddonsLoading) ? (
           <Loading offset="-150px" />
         ) : (
           <DashboardPlaceholder>
@@ -212,7 +145,7 @@ const AddOnDashboard: React.FC<Props> = ({}) => {
           </Container>
           <Spacer y={1} />
 
-          {filteredAddOns.length === 0 ? (
+          {filteredAddons.length === 0 ? (
             <Fieldset>
               <Container row>
                 <PlaceholderIcon src={notFound} />
@@ -223,57 +156,96 @@ const AddOnDashboard: React.FC<Props> = ({}) => {
                 </Text>
               </Container>
             </Fieldset>
-          ) : isLoading ? (
+          ) : isDefaultDeploymentTargetLoading ||
+            (isAddonListLoading && isLegacyAddonsLoading) ? (
             <Loading offset="-150px" />
           ) : view === "grid" ? (
             <GridList>
-              {(filteredAddOns ?? []).map((app: any, i: number) => {
+              {filteredAddons.map((addon: ClientAddon | LegacyClientAddon) => {
+                const isLegacyAddon = "chart" in addon;
+                if (isLegacyAddon) {
+                  return (
+                    <Block
+                      to={`/applications/${currentCluster?.name}/${addon.namespace}/${addon.name}`}
+                      key={addon.name}
+                    >
+                      <Container row>
+                        <Icon
+                          src={
+                            hardcodedIcons[addon.chart?.metadata?.name ?? ""] ||
+                            addon.chart?.metadata?.icon
+                          }
+                        />
+                        <Text size={14}>{addon.name}</Text>
+                        <Spacer inline x={2} />
+                      </Container>
+                      <StatusIcon src={healthy} />
+                      <Container row>
+                        <SmallIcon opacity="0.4" src={time} />
+                        <Text size={13} color="#ffffff44">
+                          {readableDate(addon.info.last_deployed)}
+                        </Text>
+                      </Container>
+                    </Block>
+                  );
+                }
                 return (
-                  <Block to={getExpandedChartLinkURL(app)} key={i}>
+                  <Block
+                    to={`/addons/${addon.name.value}`}
+                    key={addon.name.value}
+                  >
                     <Container row>
-                      <Icon
-                        src={
-                          hardcodedIcons[app.chart.metadata.name] ||
-                          app.chart.metadata.icon
-                        }
-                      />
-                      <Text size={14}>{app.name}</Text>
+                      <Icon src={addon.template.icon} />
+                      <Text size={14}>{addon.name.value}</Text>
                       <Spacer inline x={2} />
                     </Container>
                     <StatusIcon src={healthy} />
-                    <Container row>
-                      <SmallIcon opacity="0.4" src={time} />
-                      <Text size={13} color="#ffffff44">
-                        {readableDate(app.info.last_deployed)}
-                      </Text>
-                    </Container>
                   </Block>
                 );
               })}
             </GridList>
           ) : (
             <List>
-              {(filteredAddOns ?? []).map((app: any, i: number) => {
+              {filteredAddons.map((addon: ClientAddon | LegacyClientAddon) => {
+                const isLegacyAddon = "chart" in addon;
+                if (isLegacyAddon) {
+                  return (
+                    <Row
+                      to={`/applications/${currentCluster?.name}/${addon.namespace}/${addon.name}`}
+                      key={addon.name}
+                    >
+                      <Container row>
+                        <MidIcon
+                          src={
+                            hardcodedIcons[addon.chart?.metadata?.name ?? ""] ||
+                            addon.chart?.metadata?.icon
+                          }
+                        />
+                        <Text size={14}>{addon.name}</Text>
+                        <Spacer inline x={1} />
+                        <MidIcon src={healthy} height="16px" />
+                      </Container>
+                      <Spacer height="15px" />
+                      <Container row>
+                        <SmallIcon opacity="0.4" src={time} />
+                        <Text size={13} color="#ffffff44">
+                          {readableDate(addon.info.last_deployed)}
+                        </Text>
+                      </Container>
+                    </Row>
+                  );
+                }
                 return (
-                  <Row to={getExpandedChartLinkURL(app)} key={i}>
+                  <Row
+                    to={`/addons/${addon.name.value}`}
+                    key={addon.name.value}
+                  >
                     <Container row>
-                      <MidIcon
-                        src={
-                          hardcodedIcons[app.chart.metadata.name] ||
-                          app.chart.metadata.icon
-                        }
-                      />
-                      <Text size={14}>{app.name}</Text>
+                      <MidIcon src={addon.template.icon} />
+                      <Text size={14}>{addon.name.value}</Text>
                       <Spacer inline x={1} />
                       <MidIcon src={healthy} height="16px" />
                     </Container>
-                    <Spacer height="15px" />
-                    <Container row>
-                      <SmallIcon opacity="0.4" src={time} />
-                      <Text size={13} color="#ffffff44">
-                        {readableDate(app.info.last_deployed)}
-                      </Text>
-                    </Container>
                   </Row>
                 );
               })}
@@ -286,7 +258,7 @@ const AddOnDashboard: React.FC<Props> = ({}) => {
   );
 };
 
-export default AddOnDashboard;
+export default AddonDashboard;
 
 const PlaceholderIcon = styled.img`
   height: 13px;
@@ -335,15 +307,8 @@ const MidIcon = styled.img<{ height?: string }>`
   margin-right: 11px;
 `;
 
-const SmallIcon = styled.img<{ opacity?: string }>`
-  margin-left: 2px;
-  height: 14px;
-  opacity: ${(props) => props.opacity || 1};
-  margin-right: 10px;
-`;
-
 const Block = styled(Link)`
-  height: 110px;
+  height: 75px;
   flex-direction: column;
   display: flex;
   justify-content: space-between;
@@ -390,9 +355,9 @@ const StyledAppDashboard = styled.div`
   height: 100%;
 `;
 
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
+const SmallIcon = styled.img<{ opacity?: string }>`
+  margin-left: 2px;
+  height: 14px;
+  opacity: ${(props) => props.opacity || 1};
+  margin-right: 10px;
 `;

+ 142 - 0
dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx

@@ -0,0 +1,142 @@
+import React, { createContext, useCallback, useContext } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+import {
+  useAddon,
+  useAddonStatus,
+  type ClientAddonStatus,
+} from "lib/hooks/useAddon";
+import {
+  useDefaultDeploymentTarget,
+  type DeploymentTarget,
+} from "lib/hooks/useDeploymentTarget";
+
+import { Context } from "shared/Context";
+import notFound from "assets/not-found.png";
+
+type AddonContextType = {
+  addon: ClientAddon;
+  projectId: number;
+  deploymentTarget: DeploymentTarget;
+  status: ClientAddonStatus;
+  deleteAddon: () => Promise<void>;
+};
+
+const AddonContext = createContext<AddonContextType | null>(null);
+
+export const useAddonContext = (): AddonContextType => {
+  const ctx = React.useContext(AddonContext);
+  if (!ctx) {
+    throw new Error(
+      "useAddonContext must be used within a AddonContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type AddonContextProviderProps = {
+  addonName?: string;
+  children: JSX.Element;
+};
+
+export const AddonContextProvider: React.FC<AddonContextProviderProps> = ({
+  addonName,
+  children,
+}) => {
+  const { currentProject } = useContext(Context);
+  const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
+    useDefaultDeploymentTarget();
+  const { getAddon, deleteAddon } = useAddon();
+  const {
+    addon,
+    isLoading: isAddonLoading,
+    isError,
+  } = getAddon({
+    projectId: currentProject?.id,
+    deploymentTargetId: defaultDeploymentTarget.id,
+    addonName,
+    refreshIntervalSeconds: 5,
+  });
+  const queryClient = useQueryClient();
+
+  const status = useAddonStatus({
+    projectId: currentProject?.id,
+    deploymentTarget: defaultDeploymentTarget,
+    addon,
+  });
+
+  const paramsExist =
+    !!addonName &&
+    !!defaultDeploymentTarget &&
+    !!currentProject &&
+    currentProject.id !== -1;
+
+  const deleteContextAddon = useCallback(async () => {
+    if (!paramsExist || !addon) {
+      return;
+    }
+
+    await deleteAddon({
+      projectId: currentProject.id,
+      deploymentTargetId: defaultDeploymentTarget.id,
+      addon,
+    });
+
+    await queryClient.invalidateQueries(["listAddons"]);
+  }, [paramsExist]);
+
+  if (isDefaultDeploymentTargetLoading || isAddonLoading || !paramsExist) {
+    return <Loading />;
+  }
+
+  if (isError || !addon) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No addon matching &quot;{addonName}&quot; was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/addons">Return to dashboard</Link>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <AddonContext.Provider
+      value={{
+        addon,
+        projectId: currentProject.id,
+        deploymentTarget: defaultDeploymentTarget,
+        status,
+        deleteAddon: deleteContextAddon,
+      }}
+    >
+      {children}
+    </AddonContext.Provider>
+  );
+};
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;

+ 170 - 0
dashboard/src/main/home/add-on-dashboard/AddonForm.tsx

@@ -0,0 +1,170 @@
+import React, { useContext, useEffect, useMemo } from "react";
+import { useFormContext } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Back from "components/porter/Back";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { defaultClientAddon, type ClientAddon } from "lib/addons";
+import { type AddonTemplate } from "lib/addons/template";
+import { useAddonList } from "lib/hooks/useAddon";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+
+import { Context } from "shared/Context";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import ClusterContextProvider from "../infrastructure-dashboard/ClusterContextProvider";
+import Configuration from "./common/Configuration";
+
+type Props = {
+  template: AddonTemplate;
+};
+const AddonForm: React.FC<Props> = ({ template }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
+    useDefaultDeploymentTarget();
+
+  const history = useHistory();
+  const {
+    addons,
+    isLoading: isAddonListLoading,
+    isLegacyAddonsLoading,
+    legacyAddons,
+  } = useAddonList({
+    projectId: currentProject?.id,
+    deploymentTarget: defaultDeploymentTarget,
+  });
+
+  const {
+    reset,
+    register,
+    watch,
+    setError,
+    clearErrors,
+    formState: { errors },
+  } = useFormContext<ClientAddon>();
+  const watchName = watch("name.value", "");
+  const currentStep = useMemo(() => {
+    if (!watchName) {
+      return 0;
+    }
+    return 1;
+  }, [watchName]);
+
+  useEffect(() => {
+    reset(defaultClientAddon(template.type));
+  }, [template]);
+
+  useEffect(() => {
+    if (
+      addons.some((a) => a.name.value === watchName) ||
+      legacyAddons.some((a) => a.name === watchName)
+    ) {
+      setError("name.value", {
+        message: "An addon with this name already exists",
+      });
+    } else {
+      clearErrors("name.value");
+    }
+  }, [watchName]);
+
+  if (
+    isDefaultDeploymentTargetLoading ||
+    isAddonListLoading ||
+    isLegacyAddonsLoading
+  ) {
+    return <Loading />;
+  }
+
+  return (
+    <ClusterContextProvider clusterId={currentCluster?.id} refetchInterval={0}>
+      <CenterWrapper>
+        <Div>
+          <StyledConfigureTemplate>
+            <Back
+              onClick={() => {
+                history.push(`/addons/new`);
+              }}
+            />
+            <DashboardHeader
+              prefix={<Icon src={template.icon} />}
+              title={`Configure new ${template.displayName} instance`}
+              capitalize={false}
+              disableLineBreak
+            />
+            <DarkMatter />
+            <VerticalSteps
+              currentStep={currentStep}
+              steps={[
+                <>
+                  <Text size={16}>Add-on name</Text>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Lowercase letters, numbers, and &quot;-&quot; only.
+                  </Text>
+                  <Spacer height="20px" />
+                  <ControlledInput
+                    type="text"
+                    width="300px"
+                    placeholder="ex: my-addon"
+                    error={errors.name?.value?.message}
+                    {...register("name.value")}
+                  />
+                </>,
+                <>
+                  <Configuration type={template.type} />
+                </>,
+              ]}
+            />
+            <Spacer height="80px" />
+          </StyledConfigureTemplate>
+        </Div>
+      </CenterWrapper>
+    </ClusterContextProvider>
+  );
+};
+
+export default AddonForm;
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 30px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;

+ 134 - 0
dashboard/src/main/home/add-on-dashboard/AddonFormContextProvider.tsx

@@ -0,0 +1,134 @@
+import React, { createContext, useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQueryClient } from "@tanstack/react-query";
+import { FormProvider, useForm } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import { Error as ErrorComponent } from "components/porter/Error";
+import { clientAddonValidator, type ClientAddon } from "lib/addons";
+import { useAddon } from "lib/hooks/useAddon";
+import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+
+import { type UpdateClusterButtonProps } from "../infrastructure-dashboard/ClusterFormContextProvider";
+
+type AddonFormContextType = {
+  updateAddonButtonProps: UpdateClusterButtonProps;
+  projectId: number;
+};
+
+const AddonFormContext = createContext<AddonFormContextType | null>(null);
+
+export const useAddonFormContext = (): AddonFormContextType => {
+  const ctx = React.useContext(AddonFormContext);
+  if (!ctx) {
+    throw new Error(
+      "useAddonFormContext must be used within a AddonFormContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type AddonFormContextProviderProps = {
+  projectId?: number;
+  redirectOnSubmit?: boolean;
+  children: JSX.Element;
+};
+
+const AddonFormContextProvider: React.FC<AddonFormContextProviderProps> = ({
+  projectId,
+  redirectOnSubmit,
+  children,
+}) => {
+  const [updateAddonError, setUpdateAddonError] = useState<string>("");
+  const { defaultDeploymentTarget } = useDefaultDeploymentTarget();
+  const { updateAddon } = useAddon();
+  const queryClient = useQueryClient();
+  const history = useHistory();
+
+  const addonForm = useForm<ClientAddon>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(clientAddonValidator),
+  });
+  const {
+    handleSubmit,
+    formState: { isSubmitting, errors },
+  } = addonForm;
+
+  const onSubmit = handleSubmit(async (data) => {
+    if (!projectId) {
+      return;
+    }
+    setUpdateAddonError("");
+    try {
+      await updateAddon({
+        projectId,
+        deploymentTargetId: defaultDeploymentTarget.id,
+        addon: data,
+      });
+
+      await queryClient.invalidateQueries(["getAddon"]);
+
+      if (redirectOnSubmit) {
+        history.push(`/addons/${data.name.value}`);
+      }
+    } catch (err) {
+      setUpdateAddonError(
+        getErrorMessageFromNetworkCall(err, "Addon deployment")
+      );
+    }
+  });
+
+  const updateAddonButtonProps = useMemo(() => {
+    const props: UpdateClusterButtonProps = {
+      status: "",
+      isDisabled: false,
+      loadingText: "Deploying addon...",
+    };
+    if (isSubmitting) {
+      props.status = "loading";
+      props.isDisabled = true;
+    }
+
+    if (updateAddonError) {
+      props.status = (
+        <ErrorComponent message={updateAddonError} maxWidth="600px" />
+      );
+    }
+    if (Object.keys(errors).length > 0) {
+      // TODO: remove this and properly handle form validation errors
+      console.log("errors", errors);
+    }
+
+    return props;
+  }, [isSubmitting, errors, errors?.name?.value]);
+
+  if (!projectId) {
+    return <Loading />;
+  }
+
+  return (
+    <AddonFormContext.Provider
+      value={{
+        updateAddonButtonProps,
+        projectId,
+      }}
+    >
+      <Wrapper>
+        <FormProvider {...addonForm}>
+          <form onSubmit={onSubmit}>{children}</form>
+        </FormProvider>
+      </Wrapper>
+    </AddonFormContext.Provider>
+  );
+};
+
+export default AddonFormContextProvider;
+
+const Wrapper = styled.div`
+  height: fit-content;
+  margin-bottom: 10px;
+  width: 100%;
+`;

+ 205 - 0
dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx

@@ -0,0 +1,205 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match, P } from "ts-pattern";
+
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Tooltip from "components/porter/Tooltip";
+import TitleSection from "components/TitleSection";
+import { type ClientAddonPod } from "lib/hooks/useAddon";
+import { prefixSubdomain } from "lib/porter-apps/services";
+
+import { useAddonContext } from "./AddonContextProvider";
+
+const AddonHeader: React.FC = () => {
+  const { addon, status } = useAddonContext();
+
+  const domain = useMemo(() => {
+    return match(addon.config)
+      .with({ type: "metabase" }, (config) => {
+        return config.customDomain || config.porterDomain;
+      })
+      .otherwise(() => "");
+  }, [addon]);
+
+  return (
+    <HeaderWrapper>
+      <TitleSection icon={addon.template.icon} iconWidth="33px">
+        {addon.name.value}
+      </TitleSection>
+      {domain && (
+        <>
+          <Spacer y={0.5} />
+          <Container>
+            <Text>
+              <a
+                href={prefixSubdomain(domain)}
+                target="_blank"
+                rel="noreferrer"
+              >
+                {domain}
+              </a>
+            </Text>
+          </Container>
+        </>
+      )}
+      <Spacer y={0.5} />
+      <div>
+        <Container row>
+          <Text size={16}>Deploy status</Text>
+          <Spacer x={1} inline />
+          {match(status)
+            .with({ isLoading: true }, () => (
+              <Text color="helper">Initializing...</Text>
+            ))
+            .with(
+              {
+                pods: P.when((pods: ClientAddonPod[]) =>
+                  pods.every((p) => p.status === "running")
+                ),
+              },
+              () => <Text color="#01a05d">Deployed</Text>
+            )
+            .with(
+              {
+                pods: P.when((pods) => pods.some((p) => p.status === "failed")),
+              },
+              () => <Text color="#E1322E">Failed</Text>
+            )
+            .with(
+              {
+                pods: P.when((pods) =>
+                  pods.some((p) => p.status === "pending")
+                ),
+              },
+              () => <Text color="#E49621">Deploying</Text>
+            )
+            .otherwise(() => null)}
+        </Container>
+        <Spacer y={0.5} />
+        {status.isLoading ? (
+          <LoadingBars />
+        ) : (
+          <StatusBars>
+            {status.pods.map((p, i) => {
+              return (
+                <Tooltip
+                  key={p.name}
+                  content={
+                    <Container>
+                      <Container row>
+                        <Text>{`Pod: ${p.name}`}</Text>
+                      </Container>
+                      <Spacer y={0.5} />
+                      <Container row>
+                        <Text color="gray">{p.status}</Text>
+                      </Container>
+                    </Container>
+                  }
+                  position="right"
+                >
+                  <div style={{ width: "20px" }}>
+                    <Bar
+                      isFirst={i === 0}
+                      isLast={i === status.pods.length - 1}
+                      status={p.status}
+                      animate={p.status === "pending"}
+                    />
+                  </div>
+                </Tooltip>
+              );
+            })}
+          </StatusBars>
+        )}
+      </div>
+    </HeaderWrapper>
+  );
+};
+
+export default AddonHeader;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const getBackgroundGradient = (status: string): string => {
+  switch (status) {
+    case "loading":
+      return "linear-gradient(#76767644, #76767622)";
+    case "running":
+      return "linear-gradient(#01a05d, #0f2527)";
+    case "failed":
+      return "linear-gradient(#E1322E, #25100f)";
+    case "pending":
+      return "linear-gradient(#E49621, #25270f)";
+    default:
+      return "linear-gradient(#76767644, #76767622)"; // Default or unknown status
+  }
+};
+
+const Bar = styled.div<{
+  isFirst: boolean;
+  isLast: boolean;
+  status: string;
+  animate?: boolean;
+}>`
+  height: 20px;
+  max-width: 20px;
+  display: flex;
+  flex: 1;
+  border-top-left-radius: ${(props) => (props.isFirst ? "5px" : "0")};
+  border-bottom-left-radius: ${(props) => (props.isFirst ? "5px" : "0")};
+  border-top-right-radius: ${(props) => (props.isLast ? "5px" : "0")};
+  border-bottom-right-radius: ${(props) => (props.isLast ? "5px" : "0")};
+  background: ${(props) => getBackgroundGradient(props.status)};
+  ${(props) =>
+    props.animate
+      ? "animation: loadingAnimation 1.5s infinite;"
+      : "animation: fadeIn 0.3s 0s;"}
+  @keyframes loadingAnimation {
+    0% {
+      opacity: 0.3;
+    }
+    50% {
+      opacity: 1;
+    }
+    100% {
+      opacity: 0.3;
+    }
+  }
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusBars = styled.div`
+  display: flex;
+  gap: 2px;
+`;
+
+const LoadingBars: React.FC = () => {
+  return (
+    <StyledLoadingBars>
+      {Array.from({ length: 5 }).map((_, i) => (
+        <Bar
+          key={i}
+          isFirst={i === 0}
+          isLast={i === 4}
+          status="loading"
+          animate
+        />
+      ))}
+    </StyledLoadingBars>
+  );
+};
+
+const StyledLoadingBars = styled.div`
+  display: flex;
+  gap: 2px;
+`;

+ 34 - 0
dashboard/src/main/home/add-on-dashboard/AddonSaveButton.tsx

@@ -0,0 +1,34 @@
+import React from "react";
+
+import Button from "components/porter/Button";
+
+import { useAddonFormContext } from "./AddonFormContextProvider";
+
+type Props = {
+  height?: string;
+  disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
+};
+const AddonSaveButton: React.FC<Props> = ({
+  height,
+  disabledTooltipPosition,
+}) => {
+  const { updateAddonButtonProps } = useAddonFormContext();
+
+  return (
+    <Button
+      type="submit"
+      status={updateAddonButtonProps.status}
+      loadingText={updateAddonButtonProps.loadingText}
+      disabled={updateAddonButtonProps.isDisabled}
+      disabledTooltipMessage={
+        "Please wait for the current update to complete before updating again."
+      }
+      height={height}
+      disabledTooltipPosition={disabledTooltipPosition}
+    >
+      Deploy
+    </Button>
+  );
+};
+
+export default AddonSaveButton;

+ 120 - 0
dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx

@@ -0,0 +1,120 @@
+import React, { useContext, useEffect, useMemo } from "react";
+import AnimateHeight from "react-animate-height";
+import { useFormContext } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import { type ClientAddon } from "lib/addons";
+import {
+  DEFAULT_ADDON_TAB,
+  SUPPORTED_ADDON_TEMPLATES,
+} from "lib/addons/template";
+
+import { Context } from "shared/Context";
+
+import { useAddonContext } from "./AddonContextProvider";
+import AddonSaveButton from "./AddonSaveButton";
+
+type Props = {
+  tabParam?: string;
+};
+
+const AddonTabs: React.FC<Props> = ({ tabParam }) => {
+  const history = useHistory();
+  const { addon } = useAddonContext();
+  const { user } = useContext(Context);
+
+  const {
+    reset,
+    formState: { isDirty },
+  } = useFormContext<ClientAddon>();
+
+  useEffect(() => {
+    reset(addon);
+  }, [addon]);
+
+  const addonTemplate = useMemo(() => {
+    return SUPPORTED_ADDON_TEMPLATES.find(
+      (template) => template.type === addon.config.type
+    );
+  }, [addon]);
+
+  const tabs = useMemo(() => {
+    if (addonTemplate) {
+      return addonTemplate.tabs
+        .filter(
+          (t) =>
+            !t.isOnlyForPorterOperators ||
+            (t.isOnlyForPorterOperators && user.isPorterUser)
+        )
+        .map((tab) => ({
+          label: tab.displayName,
+          value: tab.name,
+        }));
+    }
+    return [
+      {
+        label: DEFAULT_ADDON_TAB.displayName,
+        value: DEFAULT_ADDON_TAB.name,
+      },
+    ];
+  }, [addonTemplate]);
+
+  const currentTab = useMemo(() => {
+    if (tabParam && tabs.some((tab) => tab.value === tabParam)) {
+      return tabParam;
+    }
+    return tabs[0].value;
+  }, [tabParam, tabs]);
+
+  return (
+    <DashboardWrapper>
+      <AnimateHeight height={isDirty ? "auto" : 0}>
+        <Banner
+          type="warning"
+          suffix={
+            <AddonSaveButton
+              height={"10px"}
+              disabledTooltipPosition={"bottom"}
+            />
+          }
+        >
+          Changes you are currently previewing have not been saved.
+          <Spacer inline width="5px" />
+        </Banner>
+        <Spacer y={1} />
+      </AnimateHeight>
+      <TabSelector
+        options={tabs}
+        currentTab={currentTab}
+        setCurrentTab={(tab) => {
+          history.push(`/addons/${addon.name.value}/${tab}`);
+        }}
+      />
+      <Spacer y={1} />
+      {addonTemplate?.tabs
+        .filter(
+          (t) =>
+            !t.isOnlyForPorterOperators ||
+            (t.isOnlyForPorterOperators && user.isPorterUser)
+        )
+        .map((tab) =>
+          match(currentTab)
+            .with(tab.name, () => <tab.component key={tab.name} />)
+            .otherwise(() => null)
+        )}
+    </DashboardWrapper>
+  );
+};
+
+export default AddonTabs;
+
+const DashboardWrapper = styled.div`
+  width: 100%;
+  min-width: 300px;
+  height: fit-content;
+`;

+ 171 - 0
dashboard/src/main/home/add-on-dashboard/AddonTemplates.tsx

@@ -0,0 +1,171 @@
+import React, { useContext, useMemo } from "react";
+import { useHistory, useLocation } from "react-router";
+import styled from "styled-components";
+
+import Back from "components/porter/Back";
+import Spacer from "components/porter/Spacer";
+import {
+  AddonTemplateTagColor,
+  SUPPORTED_ADDON_TEMPLATES,
+  type AddonTemplate,
+} from "lib/addons/template";
+
+import { Context } from "shared/Context";
+import addOnGrad from "assets/add-on-grad.svg";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import AddonForm from "./AddonForm";
+import AddonFormContextProvider from "./AddonFormContextProvider";
+
+const AddonTemplates: React.FC = () => {
+  const { currentProject } = useContext(Context);
+  const { search } = useLocation();
+  const queryParams = new URLSearchParams(search);
+  const history = useHistory();
+
+  const templateMatch = useMemo(() => {
+    const addonName = queryParams.get("addon_name");
+    return SUPPORTED_ADDON_TEMPLATES.find((t) => t.type === addonName);
+  }, [queryParams]);
+
+  if (templateMatch) {
+    return (
+      <AddonFormContextProvider projectId={currentProject?.id} redirectOnSubmit>
+        <AddonForm template={templateMatch} />
+      </AddonFormContextProvider>
+    );
+  }
+
+  return (
+    <StyledTemplateComponent>
+      <Back to="/addons" />
+      <DashboardHeader
+        image={addOnGrad}
+        title="Create a new add-on"
+        capitalize={false}
+        description="Select an add-on to deploy to this project."
+        disableLineBreak
+      />
+      <TemplateListWrapper>
+        {SUPPORTED_ADDON_TEMPLATES.map((template: AddonTemplate) => {
+          return (
+            <TemplateBlock
+              key={template.type}
+              onClick={() => {
+                history.push(`/addons/new?addon_name=${template.type}`);
+              }}
+            >
+              <Icon src={template.icon} />
+              <TemplateTitle>{template.displayName}</TemplateTitle>
+              <TemplateDescription>{template.description}</TemplateDescription>
+              <Spacer y={0.5} />
+              {template.tags.map((t) => (
+                <Tag
+                  bottom="10px"
+                  left="12px"
+                  style={{ background: AddonTemplateTagColor[t] }}
+                  key={t}
+                >
+                  {t}
+                </Tag>
+              ))}
+            </TemplateBlock>
+          );
+        })}
+      </TemplateListWrapper>
+    </StyledTemplateComponent>
+  );
+};
+
+export default AddonTemplates;
+
+const StyledTemplateComponent = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const Tag = styled.div<{ size?: string; bottom?: string; left?: string }>`
+  position: absolute;
+  bottom: ${(props) => props.bottom || "auto"};
+  left: ${(props) => props.left || "auto"};
+  font-size: 10px;
+  background: linear-gradient(
+    45deg,
+    rgba(88, 24, 219, 1) 0%,
+    rgba(72, 12, 168, 1) 100%
+  ); // added gradient for shiny effect
+  padding: 10px;
+  border-radius: 4px;
+  opacity: 0.85;
+  box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
+`;
+
+const TemplateDescription = styled.div`
+  margin-bottom: 26px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  padding: 0px 25px;
+  line-height: 1.4;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const TemplateTitle = styled.div`
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 180px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TemplateListWrapper = styled.div`
+  overflow: visible;
+  margin-top: 15px;
+  padding-bottom: 50px;
+  display: grid;
+  grid-column-gap: 30px;
+  grid-row-gap: 30px;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+`;
+
+const Icon = styled.img`
+  height: 25px;
+  margin-top: 30px;
+  margin-bottom: 5px;
+`;

+ 70 - 0
dashboard/src/main/home/add-on-dashboard/AddonView.tsx

@@ -0,0 +1,70 @@
+import React, { useContext, useMemo } from "react";
+import { withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
+import { z } from "zod";
+
+import Back from "components/porter/Back";
+import Spacer from "components/porter/Spacer";
+
+import { Context } from "shared/Context";
+
+import ClusterContextProvider from "../infrastructure-dashboard/ClusterContextProvider";
+import { AddonContextProvider } from "./AddonContextProvider";
+import AddonFormContextProvider from "./AddonFormContextProvider";
+import AddonHeader from "./AddonHeader";
+import AddonTabs from "./AddonTabs";
+
+type Props = RouteComponentProps;
+
+const AddonView: React.FC<Props> = ({ match }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const params = useMemo(() => {
+    const { params } = match;
+    const validParams = z
+      .object({
+        tab: z.string().optional(),
+        addonName: z.string().optional(),
+      })
+      .safeParse(params);
+
+    if (!validParams.success) {
+      return {
+        tab: undefined,
+      };
+    }
+
+    return validParams.data;
+  }, [match]);
+
+  return (
+    <ClusterContextProvider clusterId={currentCluster?.id} refetchInterval={0}>
+      <AddonContextProvider addonName={params.addonName}>
+        <AddonFormContextProvider projectId={currentProject?.id}>
+          <StyledExpandedAddon>
+            <Back to="/addons" />
+            <AddonHeader />
+            <Spacer y={1} />
+            <AddonTabs tabParam={params.tab} />
+          </StyledExpandedAddon>
+        </AddonFormContextProvider>
+      </AddonContextProvider>
+    </ClusterContextProvider>
+  );
+};
+
+export default withRouter(AddonView);
+
+const StyledExpandedAddon = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 0 - 284
dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx

@@ -1,284 +0,0 @@
-import React, { useEffect, useState, useContext, useMemo } from "react";
-import styled from "styled-components";
-import DashboardHeader from "../cluster-dashboard/DashboardHeader";
-import semver from "semver";
-import _ from "lodash";
-
-import addOnGrad from "assets/add-on-grad.svg";
-import notFound from "assets/not-found.png";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { search } from "shared/search";
-
-import TemplateList from "../launch/TemplateList";
-import SearchBar from "components/porter/SearchBar";
-import Spacer from "components/porter/Spacer";
-import Loading from "components/Loading";
-import ExpandedTemplate from "./ExpandedTemplate";
-import ConfigureTemplate from "./ConfigureTemplate";
-import Back from "components/porter/Back";
-import Fieldset from "components/porter/Fieldset";
-import Text from "components/porter/Text";
-import Container from "components/porter/Container";
-import Select from "components/porter/Select";
-
-type Props = {
-};
-
-const HIDDEN_CHARTS = [
-  "agent",
-  "elasticache-chart",
-  "elasticache-memcached",
-  "elasticache-redis",
-  "loki",
-  "porter-agent",
-  "rds-chart",
-  "rds-postgresql",
-  "rds-postgresql-aurora",
-  "postgresql-managed",
-  "redis-managed",
-];
-
-// For Charts that don't exist locally we need to add them in manually
-const TAG_MAPPING = {
-  "DATA_STORE": ["mysql"],
-  "DATA_BASE": ["mysql"]
-}
-
-const NewAddOnFlow: React.FC<Props> = ({
-}) => {
-  const { capabilities, currentProject, currentCluster, user } = useContext(Context);
-  const [isLoading, setIsLoading] = useState<boolean>(true);
-  const [searchValue, setSearchValue] = useState("");
-  const [addOnTemplates, setAddOnTemplates] = useState<any[]>([]);
-  const [currentTemplate, setCurrentTemplate] = useState<any>(null);
-  const [currentForm, setCurrentForm] = useState<any>(null);
-  const [selectedTag, setSelectedTag] = useState<string | null>(null);
-
-  const allFilteredTemplates = useMemo(() => {
-    const filteredBySearch = search(
-      addOnTemplates ?? [],
-      searchValue,
-      {
-        keys: ["name"],
-        isCaseSensitive: false,
-      }
-    );
-
-    return _.sortBy(filteredBySearch);
-  }, [addOnTemplates, searchValue]);
-
-  const appTemplates = useMemo(() => {
-    return allFilteredTemplates.filter(template =>
-      template.tags?.includes("APP"));
-  }, [allFilteredTemplates]);
-
-  const dataStoreTemplates = useMemo(() => {
-    return allFilteredTemplates.filter(template => template.tags?.includes("DATA_STORE"));
-  }, [allFilteredTemplates]);
-
-  const filteredTemplates = useMemo(() => {
-    return _.differenceBy(
-      allFilteredTemplates,
-      [...appTemplates, ...dataStoreTemplates]
-    );
-  }, [allFilteredTemplates, appTemplates, dataStoreTemplates]);
-
-  const getTemplates = async () => {
-    setIsLoading(true);
-    const default_addon_helm_repo_url = capabilities?.default_addon_helm_repo_url;
-    try {
-      const res = await api.getTemplates(
-        "<token>",
-        {
-          repo_url: default_addon_helm_repo_url,
-        },
-        {
-          project_id: currentProject.id,
-        }
-      );
-      setIsLoading(false);
-      let sortedVersionData = res.data.map((template: any) => {
-        let versions = template.versions.reverse();
-        versions = template.versions.sort(semver.rcompare);
-        return {
-          ...template,
-          versions,
-          currentVersion: versions[0],
-        };
-      });
-      sortedVersionData.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      sortedVersionData = sortedVersionData.filter(
-        (template: any) => !HIDDEN_CHARTS.includes(template?.name)
-      );
-
-      sortedVersionData = sortedVersionData.map((template: any) => {
-        const testTemplate: string[] = template?.tags || []
-        // Assign tags based on TAG_MAPPING
-        for (const tag in TAG_MAPPING) {
-          if (TAG_MAPPING[tag].includes(template.name)) {
-            testTemplate?.push(tag);
-          }
-        }
-
-        return { ...template, tags: testTemplate };
-      });
-      setAddOnTemplates(sortedVersionData);
-    } catch (error) {
-      setIsLoading(false);
-    }
-  };
-
-
-  useEffect(() => {
-    getTemplates();
-  }, [currentProject, currentCluster]);
-
-  return (
-    <StyledTemplateComponent>
-      {
-        (currentForm && currentTemplate) ? (
-          <ConfigureTemplate
-            currentTemplate={currentTemplate}
-            currentForm={currentForm}
-            goBack={() => { setCurrentForm(null); }}
-          />
-        ) : (
-          <>
-            <Back to="/addons" />
-            <DashboardHeader
-              image={addOnGrad}
-              title="Create a new add-on"
-              capitalize={false}
-              description="Select an add-on to deploy to this project."
-              disableLineBreak
-
-            />
-            {
-              currentTemplate ? (
-                <ExpandedTemplate
-                  currentTemplate={currentTemplate}
-                  proceed={(form?: any) => { setCurrentForm(form); }}
-                  goBack={() => { setCurrentTemplate(null); }}
-                />
-              ) : (
-                <>
-                  <Container row>
-                    <SearchBar
-                      value={searchValue}
-                      setValue={setSearchValue}
-                      placeholder="Search available add-ons . . ."
-                      width="100%"
-                    />
-                    <Spacer inline x={1} />
-                    {/* <Select
-                      width={"150px"}
-                      options={[
-                        { label: "Filter...", value: "" },
-                        { label: "Worker", value: "worker" },
-                        { label: "Cron Job", value: "job" },]}
-                      height={"25px"} /> */}
-                  </Container>
-                  <Spacer y={1} />
-
-                  {allFilteredTemplates.length === 0 && (
-                    <Fieldset>
-                      <Container row>
-                        <PlaceholderIcon src={notFound} />
-                        <Text color="helper">No matching add-ons were found.</Text>
-                      </Container>
-                    </Fieldset>
-                  )}
-                  {isLoading ? <Loading offset="-150px" /> : (
-                    <>
-                      <DarkMatter />
-
-                      {appTemplates?.length > 0 &&
-                        <>
-                          <Spacer y={1.5} />
-                          <div>
-                            <Text color="#fff" size={15}>Apps and Services</Text>
-                          </div>
-                          <div>
-                            <Text color="helper">For developer productivity.</Text>
-                          </div>
-                          <TemplateList
-                            templates={appTemplates} // This is where you provide only APP templates
-                            setCurrentTemplate={(x) => { setCurrentTemplate(x); }}
-                          />
-                        </>}
-
-                      {dataStoreTemplates?.length > 0 &&
-                        <>
-                          <div>
-                            <Text color="#fff" size={15}>Pre-Production Datastores</Text>
-                          </div>
-                          <div>
-                            <Text color="helper">Pre-production datastores are not highly available and use ephemeral storage.</Text>
-                          </div>
-                          <TemplateList
-                            templates={dataStoreTemplates} // This is where you provide only DATA_STORE templates
-                            setCurrentTemplate={(x) => { setCurrentTemplate(x); }}
-                          />
-                        </>}
-
-
-                      {filteredTemplates?.length > 0 && (currentProject?.full_add_ons || user.isPorterUser) &&
-                        <>
-                          <div>
-                            <Text color="#fff" size={15}>All Add-Ons</Text>
-                          </div>
-                          <div>
-                            <Text color="helper">Full list of add-ons</Text>
-                          </div>
-
-                          <TemplateList
-                            templates={filteredTemplates} // This is where you provide only DATA_STORE templates
-                            setCurrentTemplate={(x) => { setCurrentTemplate(x); }}
-                          />
-                        </>
-                      }
-                    </>
-                  )}
-                </>
-              )
-            }
-          </>
-        )
-      }
-    </StyledTemplateComponent >
-  );
-};
-
-export default NewAddOnFlow;
-
-const PlaceholderIcon = styled.img`
-      height: 13px;
-      margin-right: 12px;
-      opacity: 0.65;
-      `;
-
-const DarkMatter = styled.div`
-      width: 100%;
-      margin-top: -35px;
-      `;
-
-const I = styled.i`
-      font-size: 16px;
-      padding: 4px;
-      cursor: pointer;
-      border-radius: 50%;
-      margin-right: 15px;
-      background: ${props => props.theme.fg};
-      color: ${props => props.theme.text.primary};
-      border: 1px solid ${props => props.theme.border};
-      :hover {
-        filter: brightness(150%);
-  }
-      `;
-
-const StyledTemplateComponent = styled.div`
-      width: 100%;
-      height: 100%;
-      `;

+ 26 - 0
dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx

@@ -0,0 +1,26 @@
+import React from "react";
+import { match } from "ts-pattern";
+
+import { type ClientAddon } from "lib/addons";
+
+import DatadogForm from "../datadog/DatadogForm";
+import MetabaseForm from "../metabase/MetabaseForm";
+import MezmoForm from "../mezmo/MezmoForm";
+import NewRelicForm from "../newrelic/NewRelicForm";
+import TailscaleForm from "../tailscale/TailscaleForm";
+
+type Props = {
+  type: ClientAddon["config"]["type"];
+};
+const Configuration: React.FC<Props> = ({ type }) => {
+  return match(type)
+    .returnType<JSX.Element | null>()
+    .with("datadog", () => <DatadogForm />)
+    .with("mezmo", () => <MezmoForm />)
+    .with("metabase", () => <MetabaseForm />)
+    .with("newrelic", () => <NewRelicForm />)
+    .with("tailscale", () => <TailscaleForm />)
+    .otherwise(() => null);
+};
+
+export default Configuration;

+ 243 - 0
dashboard/src/main/home/add-on-dashboard/common/Logs.tsx

@@ -0,0 +1,243 @@
+import React, { useEffect, useRef } from "react";
+import type Anser from "anser";
+import dayjs from "dayjs";
+import styled from "styled-components";
+import { match, P } from "ts-pattern";
+
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useAddonLogs } from "lib/hooks/useAddon";
+
+import { useAddonContext } from "../AddonContextProvider";
+
+const getPodNameColor = (podName: string): string => {
+  const colors = ["#7B61FF", "#FF7B61", "#61FF7B", "#FF61FF", "#61FFFF"];
+
+  const numericalValue = podName
+    .split("")
+    .reduce((acc, char) => acc + char.charCodeAt(0), 0);
+
+  const colorIndex = numericalValue % colors.length;
+
+  return colors[colorIndex];
+};
+
+const Logs: React.FC = () => {
+  const { projectId, deploymentTarget, addon } = useAddonContext();
+  const logsResp = useAddonLogs({
+    projectId,
+    deploymentTarget,
+    addon,
+  });
+  const scrollToBottomRef = useRef<HTMLDivElement | null>(null);
+
+  useEffect(() => {
+    if (scrollToBottomRef.current) {
+      scrollToBottomRef.current.scrollIntoView({ behavior: "smooth" });
+    }
+  }, [logsResp.logs]);
+
+  return (
+    <div>
+      <Text color="warner">
+        This tab is a work in development and is only available to Porter
+        operators.
+      </Text>
+      <Spacer y={0.5} />
+      <StyledLogsSection>
+        <StyledLogsTable>
+          <StyledLogsTableBody>
+            {match(logsResp)
+              .with({ isInitializing: true }, () => <LoadingLogLine />)
+              .with({ logs: P.when((logs) => logs.length === 0) }, () => (
+                <StyledLogsTableRow>
+                  <StyledLogsTableData width={"120px"}>
+                    <LineTimestamp className="line-timestamp">
+                      No logs found. Waiting for logs...
+                    </LineTimestamp>
+                  </StyledLogsTableData>
+                </StyledLogsTableRow>
+              ))
+              .otherwise(() => (
+                <>
+                  {logsResp.logs.map((log, i) => {
+                    const timestampStr = log.timestamp
+                      ? dayjs(log.timestamp).format("MM/DD HH:mm:ss")
+                      : "-";
+                    return (
+                      <StyledLogsTableRow key={[log.lineNumber, i].join(".")}>
+                        <StyledLogsTableData width={"120px"}>
+                          <LineTimestamp className="line-timestamp">
+                            {timestampStr}
+                          </LineTimestamp>
+                        </StyledLogsTableData>
+                        <StyledLogsTableData width={"100px"}>
+                          <LogInnerPill color={"#7B61FF"}>
+                            {log.controllerName}
+                          </LogInnerPill>
+                        </StyledLogsTableData>
+                        <StyledLogsTableData width={"100px"}>
+                          <LogInnerPill color={getPodNameColor(log.podName)}>
+                            {log.podName}
+                          </LogInnerPill>
+                        </StyledLogsTableData>
+                        <StyledLogsTableData>
+                          <LogOuter key={[log.lineNumber, i].join(".")}>
+                            {log.line?.map((ansi, j) => {
+                              if (ansi.clearLine) {
+                                return null;
+                              }
+
+                              return (
+                                <LogInnerSpan
+                                  key={[log.lineNumber, i, j].join(".")}
+                                  ansi={ansi}
+                                >
+                                  {ansi.content.replace(/ /g, "\u00a0")}
+                                </LogInnerSpan>
+                              );
+                            })}
+                          </LogOuter>
+                        </StyledLogsTableData>
+                      </StyledLogsTableRow>
+                    );
+                  })}
+                </>
+              ))}
+          </StyledLogsTableBody>
+        </StyledLogsTable>
+        <div ref={scrollToBottomRef} />
+      </StyledLogsSection>
+    </div>
+  );
+};
+
+export default Logs;
+
+const LoadingLogLine: React.FC = () => {
+  return (
+    <StyledLogsTableRow>
+      <StyledLogsTableData width={"120px"}>
+        <CellLoadingAnimation color="#767676" />
+      </StyledLogsTableData>
+      <StyledLogsTableData width={"100px"}>
+        <CellLoadingAnimation color="#767676" />
+      </StyledLogsTableData>
+      <StyledLogsTableData width={"100px"}>
+        <CellLoadingAnimation color="#767676" />
+      </StyledLogsTableData>
+      <StyledLogsTableData>
+        <CellLoadingAnimation color="#767676" />
+      </StyledLogsTableData>
+    </StyledLogsTableRow>
+  );
+};
+
+const StyledLogsTable = styled.table`
+  border-collapse: collapse;
+`;
+
+const StyledLogsTableBody = styled.tbody``;
+
+const StyledLogsTableRow = styled.tr``;
+
+const StyledLogsTableData = styled.td<{ width?: string }>`
+  padding: 2px;
+  vertical-align: top;
+  ${(props) => props.width && `max-width: ${props.width};`}
+`;
+
+const LineTimestamp = styled.div`
+  height: 100%;
+  color: #949effff;
+  opacity: 0.5;
+  font-family: monospace;
+  white-space: nowrap;
+`;
+
+const LogInnerPill = styled.div<{ color: string }>`
+  display: inline-block;
+  vertical-align: middle;
+  width: 100%;
+  padding: 0px 5px;
+  height: 20px;
+  color: black;
+  background-color: ${(props) => props.color};
+  border-radius: 5px;
+  opacity: 1;
+  font-family: monospace;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const LogOuter = styled.div`
+  user-select: text;
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  user-select: text;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration === "bold"
+      ? "700"
+      : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;
+
+const StyledLogsSection = styled.div`
+  width: 100%;
+  height: 600px;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  background: #000000;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const CellLoadingAnimation = styled(LogInnerPill)`
+  background: linear-gradient(
+    270deg,
+    ${(props) => props.color}22,
+    ${(props) => props.color}55,
+    ${(props) => props.color}22
+  );
+  background-size: 200% 200%;
+  animation: loadingEffect 1.5s infinite;
+
+  @keyframes loadingEffect {
+    0% {
+      background-position: 200% 0;
+    }
+    100% {
+      background-position: -200% 0;
+    }
+  }
+`;

+ 89 - 0
dashboard/src/main/home/add-on-dashboard/common/Settings.tsx

@@ -0,0 +1,89 @@
+import React, { useContext, useMemo, useState } from "react";
+import { useHistory } from "react-router";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { Code } from "main/home/managed-addons/tabs/shared";
+import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import { Context } from "shared/Context";
+import trash from "assets/trash.png";
+
+import { useAddonContext } from "../AddonContextProvider";
+import { useAddonFormContext } from "../AddonFormContextProvider";
+
+const Settings: React.FC = () => {
+  const { addon, deleteAddon } = useAddonContext();
+  const { updateAddonButtonProps } = useAddonFormContext();
+  const history = useHistory();
+  const { setCurrentOverlay = () => ({}) } = useContext(Context);
+  const { showIntercomWithMessage } = useIntercom();
+  const [isDeleting, setIsDeleting] = useState(false);
+  const [errorMessage, setErrorMessage] = useState("");
+
+  const handleDeletionSubmit = async (): Promise<void> => {
+    try {
+      setCurrentOverlay(null);
+      setIsDeleting(true);
+      await deleteAddon();
+      history.push("/addons");
+    } catch (err) {
+      showIntercomWithMessage({
+        message: "I am running into an issue deleting my addon.",
+      });
+      setErrorMessage(getErrorMessageFromNetworkCall(err, "Addon deletion"));
+    } finally {
+      setIsDeleting(false);
+      setCurrentOverlay(null);
+    }
+  };
+
+  const handleDeletionClick = async (): Promise<void> => {
+    setCurrentOverlay({
+      message: `Are you sure you want to delete ${addon.name.value}?`,
+      onYes: handleDeletionSubmit,
+      onNo: () => {
+        setCurrentOverlay(null);
+      },
+    });
+  };
+
+  const buttonStatus = useMemo(() => {
+    if (isDeleting) {
+      return "loading";
+    }
+    if (errorMessage) {
+      return <ErrorComponent message={errorMessage} maxWidth="600px" />;
+    }
+
+    return "";
+  }, [isDeleting, errorMessage]);
+
+  return (
+    <Container>
+      <Text size={16}>
+        Delete <Code>{addon.name.value}</Code>
+      </Text>
+      <Spacer y={0.5} />
+      <Button
+        color="#b91133"
+        onClick={handleDeletionClick}
+        status={buttonStatus}
+        disabled={isDeleting || updateAddonButtonProps.isDisabled}
+        loadingText={"Deleting..."}
+        disabledTooltipMessage={"Unable to delete while the addon is updating."}
+      >
+        <Icon src={trash} height={"15px"} />
+        <Spacer inline x={0.5} />
+        Delete
+      </Button>
+    </Container>
+  );
+};
+
+export default Settings;

+ 121 - 0
dashboard/src/main/home/add-on-dashboard/datadog/DatadogForm.tsx

@@ -0,0 +1,121 @@
+import React from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import Checkbox from "components/porter/Checkbox";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { Code } from "main/home/managed-addons/tabs/shared";
+import { type ClientAddon } from "lib/addons";
+
+import AddonSaveButton from "../AddonSaveButton";
+
+const DatadogForm: React.FC = () => {
+  const {
+    register,
+    control,
+    formState: { errors },
+  } = useFormContext<ClientAddon>();
+  return (
+    <div>
+      <Text size={16}>DataDog configuration</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        This installs the DataDog agent, which forwards all logs & metrics from
+        your applications to DataDog for ingestion. It may take around 30
+        minutes for the logs to arrive in your DataDog instance.
+      </Text>
+      <Spacer y={0.5} />
+      <Text>DataDog Site</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.site")}
+        placeholder="datadoghq.com"
+        error={errors.config?.site?.message}
+      />
+      <Spacer y={0.5} />
+      <Text>DataDog API Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.apiKey")}
+        placeholder=""
+        error={errors.config?.apiKey?.message}
+      />
+      <Spacer y={1} />
+      <Text size={14}>Logging</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Forward logs from all containers to DataDog. Be aware that this may
+        incur additional cost based on your retention settings.
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.loggingEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Logging enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <Text size={14}>DogStatsD</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Gather custom application metrics with DogStatsD. This automatically
+        injects <Code>DD_AGENT_HOST</Code> as an environment variable to your
+        pods to use in the code.
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.dogstatsdEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>DogStatsD enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <Text size={14}>APM</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Use APM to trace your applications. This automatically injects
+        environment variables to be used by standard DataDog tracing libraries.
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.apmEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>APM enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <AddonSaveButton />
+    </div>
+  );
+};
+
+export default DatadogForm;

+ 398 - 0
dashboard/src/main/home/add-on-dashboard/legacy_AddOnDashboard.tsx

@@ -0,0 +1,398 @@
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import _ from "lodash";
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import Fieldset from "components/porter/Fieldset";
+import PorterLink from "components/porter/Link";
+import SearchBar from "components/porter/SearchBar";
+import ShowIntercomButton from "components/porter/ShowIntercomButton";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Toggle from "components/porter/Toggle";
+import { useAuthState } from "main/auth/context";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { search } from "shared/search";
+import { readableDate } from "shared/string_utils";
+import addOnGrad from "assets/add-on-grad.svg";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import notFound from "assets/not-found.png";
+import healthy from "assets/status-healthy.png";
+import time from "assets/time.png";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+
+type Props = {};
+
+export const RestrictedNamespaces = [
+  "ack-system",
+  "cert-manager",
+  "ingress-nginx",
+  "kube-node-lease",
+  "kube-public",
+  "kube-system",
+  "monitoring",
+  "porter-agent-system",
+  "external-secrets",
+];
+
+const templateBlacklist = ["web", "worker", "job", "umbrella"];
+
+const LegacyAddOnDashboard: React.FC<Props> = ({}) => {
+  const { defaultDeploymentTarget } = useDefaultDeploymentTarget();
+  const { currentProject, currentCluster } = useContext(Context);
+  const [addOns, setAddOns] = useState([]);
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState("grid");
+  const [isLoading, setIsLoading] = useState(true);
+
+  const filteredAddOns = useMemo(() => {
+    const filtered = addOns.filter((app) => {
+      return (
+        app.namespace === defaultDeploymentTarget.namespace &&
+        !templateBlacklist.includes(app.chart.metadata.name)
+      );
+    });
+
+    const filteredBySearch = search(filtered ?? [], searchValue, {
+      keys: ["name", "chart.metadata.name"],
+      isCaseSensitive: false,
+    });
+
+    return _.sortBy(filteredBySearch);
+  }, [addOns, searchValue]);
+
+  const getAddOns = async () => {
+    try {
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "failed",
+          ],
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: "all",
+        }
+      );
+      setIsLoading(false);
+      const charts = res.data || [];
+      setAddOns(charts);
+    } catch (err) {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    // currentCluster sometimes returns as -1 and passes null check
+    if (currentProject?.id >= 0 && currentCluster?.id >= 0) {
+      getAddOns();
+    }
+  }, [currentCluster, currentProject]);
+
+  const getExpandedChartLinkURL = useCallback(
+    (x: any) => {
+      const params = new Proxy(new URLSearchParams(window.location.search), {
+        get: (searchParams, prop: string) => searchParams.get(prop),
+      });
+      const cluster = currentCluster?.name;
+      const route = `/applications/${cluster}/${x.namespace}/${x.name}`;
+      const newParams = {
+        // @ts-expect-error
+        project_id: params.project_id,
+        closeChartRedirectUrl: "/addons",
+      };
+      const newURLSearchParams = new URLSearchParams(
+        _.omitBy(newParams, _.isNil)
+      );
+      return `${route}?${newURLSearchParams.toString()}`;
+    },
+    [currentCluster]
+  );
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={addOnGrad}
+        title="Add-ons"
+        capitalize={false}
+        description="Add-ons and supporting workloads for this project."
+        disableLineBreak
+      />
+      {currentCluster?.status === "UPDATING_UNAVAILABLE" ? (
+        <ClusterProvisioningPlaceholder />
+      ) : currentProject?.sandbox_enabled ? (
+        <DashboardPlaceholder>
+          <Text size={16}>Add-ons are not enabled for sandbox users</Text>
+          <Spacer y={0.5} />
+          <Text color={"helper"}>
+            Eject to your own cloud account to enable Porter add-ons.
+          </Text>
+          <Spacer y={1} />
+          <PorterLink to="https://docs.porter.run/other/eject">
+            <Button alt height="35px">
+              Request ejection
+            </Button>
+          </PorterLink>
+        </DashboardPlaceholder>
+      ) : addOns.length === 0 ||
+        (filteredAddOns.length === 0 && searchValue === "") ? (
+        isLoading ? (
+          <Loading offset="-150px" />
+        ) : (
+          <DashboardPlaceholder>
+            <Text size={16}>No add-ons have been created yet</Text>
+            <Spacer y={0.5} />
+            <Text color={"helper"}>
+              Deploy from our suite of curated add-ons.
+            </Text>
+            <Spacer y={1} />
+            <Link to="/addons/new">
+              <Button alt onClick={() => {}} height="35px">
+                Deploy a new add-on <Spacer inline x={1} />{" "}
+                <i className="material-icons" style={{ fontSize: "18px" }}>
+                  east
+                </i>
+              </Button>
+            </Link>
+          </DashboardPlaceholder>
+        )
+      ) : (
+        <>
+          <Container row spaced>
+            <SearchBar
+              value={searchValue}
+              setValue={setSearchValue}
+              placeholder="Search add-ons . . ."
+              width="100%"
+            />
+            <Spacer inline x={2} />
+            <Toggle
+              items={[
+                { label: <ToggleIcon src={grid} />, value: "grid" },
+                { label: <ToggleIcon src={list} />, value: "list" },
+              ]}
+              active={view}
+              setActive={setView}
+            />
+            <Spacer inline x={2} />
+            <Link to="/addons/new">
+              <Button onClick={() => {}} height="30px" width="130px">
+                <I className="material-icons">add</I> New add-on
+              </Button>
+            </Link>
+          </Container>
+          <Spacer y={1} />
+
+          {filteredAddOns.length === 0 ? (
+            <Fieldset>
+              <Container row>
+                <PlaceholderIcon src={notFound} />
+                <Text color="helper">
+                  {searchValue === ""
+                    ? "No add-ons have been deployed yet."
+                    : "No matching add-ons were found."}
+                </Text>
+              </Container>
+            </Fieldset>
+          ) : isLoading ? (
+            <Loading offset="-150px" />
+          ) : view === "grid" ? (
+            <GridList>
+              {(filteredAddOns ?? []).map((app: any, i: number) => {
+                return (
+                  <Block to={getExpandedChartLinkURL(app)} key={i}>
+                    <Container row>
+                      <Icon
+                        src={
+                          hardcodedIcons[app.chart.metadata.name] ||
+                          app.chart.metadata.icon
+                        }
+                      />
+                      <Text size={14}>{app.name}</Text>
+                      <Spacer inline x={2} />
+                    </Container>
+                    <StatusIcon src={healthy} />
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={time} />
+                      <Text size={13} color="#ffffff44">
+                        {readableDate(app.info.last_deployed)}
+                      </Text>
+                    </Container>
+                  </Block>
+                );
+              })}
+            </GridList>
+          ) : (
+            <List>
+              {(filteredAddOns ?? []).map((app: any, i: number) => {
+                return (
+                  <Row to={getExpandedChartLinkURL(app)} key={i}>
+                    <Container row>
+                      <MidIcon
+                        src={
+                          hardcodedIcons[app.chart.metadata.name] ||
+                          app.chart.metadata.icon
+                        }
+                      />
+                      <Text size={14}>{app.name}</Text>
+                      <Spacer inline x={1} />
+                      <MidIcon src={healthy} height="16px" />
+                    </Container>
+                    <Spacer height="15px" />
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={time} />
+                      <Text size={13} color="#ffffff44">
+                        {readableDate(app.info.last_deployed)}
+                      </Text>
+                    </Container>
+                  </Row>
+                );
+              })}
+            </List>
+          )}
+        </>
+      )}
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default LegacyAddOnDashboard;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const Row = styled(Link)<{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  display: block;
+  padding: 15px;
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+  margin-right: 13px;
+`;
+
+const MidIcon = styled.img<{ height?: string }>`
+  height: ${(props) => props.height || "18px"};
+  margin-right: 11px;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string }>`
+  margin-left: 2px;
+  height: 14px;
+  opacity: ${(props) => props.opacity || 1};
+  margin-right: 10px;
+`;
+
+const Block = styled(Link)`
+  height: 110px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const CentralContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: left;
+  align-items: left;
+`;

+ 302 - 0
dashboard/src/main/home/add-on-dashboard/legacy_NewAddOnFlow.tsx

@@ -0,0 +1,302 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import _ from "lodash";
+import semver from "semver";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Back from "components/porter/Back";
+import Container from "components/porter/Container";
+import Fieldset from "components/porter/Fieldset";
+import SearchBar from "components/porter/SearchBar";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+import addOnGrad from "assets/add-on-grad.svg";
+import notFound from "assets/not-found.png";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import TemplateList from "../launch/TemplateList";
+import ConfigureTemplate from "./ConfigureTemplate";
+import ExpandedTemplate from "./ExpandedTemplate";
+
+type Props = {};
+
+const HIDDEN_CHARTS = [
+  "agent",
+  "elasticache-chart",
+  "elasticache-memcached",
+  "elasticache-redis",
+  "loki",
+  "porter-agent",
+  "rds-chart",
+  "rds-postgresql",
+  "rds-postgresql-aurora",
+  "postgresql-managed",
+  "redis-managed",
+];
+
+// For Charts that don't exist locally we need to add them in manually
+const TAG_MAPPING = {
+  DATA_STORE: ["mysql"],
+  DATA_BASE: ["mysql"],
+};
+
+const LegacyNewAddOnFlow: React.FC<Props> = ({}) => {
+  const { capabilities, currentProject, currentCluster, user } =
+    useContext(Context);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [searchValue, setSearchValue] = useState("");
+  const [addOnTemplates, setAddOnTemplates] = useState<any[]>([]);
+  const [currentTemplate, setCurrentTemplate] = useState<any>(null);
+  const [currentForm, setCurrentForm] = useState<any>(null);
+  const [selectedTag, setSelectedTag] = useState<string | null>(null);
+
+  const allFilteredTemplates = useMemo(() => {
+    const filteredBySearch = search(addOnTemplates ?? [], searchValue, {
+      keys: ["name"],
+      isCaseSensitive: false,
+    });
+
+    return _.sortBy(filteredBySearch);
+  }, [addOnTemplates, searchValue]);
+
+  const appTemplates = useMemo(() => {
+    return allFilteredTemplates.filter(
+      (template) => template.tags?.includes("APP")
+    );
+  }, [allFilteredTemplates]);
+
+  const dataStoreTemplates = useMemo(() => {
+    return allFilteredTemplates.filter(
+      (template) => template.tags?.includes("DATA_STORE")
+    );
+  }, [allFilteredTemplates]);
+
+  const filteredTemplates = useMemo(() => {
+    return _.differenceBy(allFilteredTemplates, [
+      ...appTemplates,
+      ...dataStoreTemplates,
+    ]);
+  }, [allFilteredTemplates, appTemplates, dataStoreTemplates]);
+
+  const getTemplates = async () => {
+    setIsLoading(true);
+    const default_addon_helm_repo_url =
+      capabilities?.default_addon_helm_repo_url;
+    try {
+      const res = await api.getTemplates(
+        "<token>",
+        {
+          repo_url: default_addon_helm_repo_url,
+        },
+        {
+          project_id: currentProject.id,
+        }
+      );
+      setIsLoading(false);
+      let sortedVersionData = res.data.map((template: any) => {
+        let versions = template.versions.reverse();
+        versions = template.versions.sort(semver.rcompare);
+        return {
+          ...template,
+          versions,
+          currentVersion: versions[0],
+        };
+      });
+      sortedVersionData.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      sortedVersionData = sortedVersionData.filter(
+        (template: any) => !HIDDEN_CHARTS.includes(template?.name)
+      );
+
+      sortedVersionData = sortedVersionData.map((template: any) => {
+        const testTemplate: string[] = template?.tags || [];
+        // Assign tags based on TAG_MAPPING
+        for (const tag in TAG_MAPPING) {
+          if (TAG_MAPPING[tag].includes(template.name)) {
+            testTemplate?.push(tag);
+          }
+        }
+
+        return { ...template, tags: testTemplate };
+      });
+      setAddOnTemplates(sortedVersionData);
+    } catch (error) {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    getTemplates();
+  }, [currentProject, currentCluster]);
+
+  return (
+    <StyledTemplateComponent>
+      {currentForm && currentTemplate ? (
+        <ConfigureTemplate
+          currentTemplate={currentTemplate}
+          currentForm={currentForm}
+          goBack={() => {
+            setCurrentForm(null);
+          }}
+        />
+      ) : (
+        <>
+          <Back to="/addons" />
+          <DashboardHeader
+            image={addOnGrad}
+            title="Create a new add-on"
+            capitalize={false}
+            description="Select an add-on to deploy to this project."
+            disableLineBreak
+          />
+          {currentTemplate ? (
+            <ExpandedTemplate
+              currentTemplate={currentTemplate}
+              proceed={(form?: any) => {
+                setCurrentForm(form);
+              }}
+              goBack={() => {
+                setCurrentTemplate(null);
+              }}
+            />
+          ) : (
+            <>
+              <Container row>
+                <SearchBar
+                  value={searchValue}
+                  setValue={setSearchValue}
+                  placeholder="Search available add-ons . . ."
+                  width="100%"
+                />
+                <Spacer inline x={1} />
+                {/* <Select
+                      width={"150px"}
+                      options={[
+                        { label: "Filter...", value: "" },
+                        { label: "Worker", value: "worker" },
+                        { label: "Cron Job", value: "job" },]}
+                      height={"25px"} /> */}
+              </Container>
+              <Spacer y={1} />
+
+              {allFilteredTemplates.length === 0 && (
+                <Fieldset>
+                  <Container row>
+                    <PlaceholderIcon src={notFound} />
+                    <Text color="helper">No matching add-ons were found.</Text>
+                  </Container>
+                </Fieldset>
+              )}
+              {isLoading ? (
+                <Loading offset="-150px" />
+              ) : (
+                <>
+                  <DarkMatter />
+
+                  {appTemplates?.length > 0 && (
+                    <>
+                      <Spacer y={1.5} />
+                      <div>
+                        <Text color="#fff" size={15}>
+                          Apps and Services
+                        </Text>
+                      </div>
+                      <div>
+                        <Text color="helper">For developer productivity.</Text>
+                      </div>
+                      <TemplateList
+                        templates={appTemplates} // This is where you provide only APP templates
+                        setCurrentTemplate={(x) => {
+                          setCurrentTemplate(x);
+                        }}
+                      />
+                    </>
+                  )}
+
+                  {dataStoreTemplates?.length > 0 && (
+                    <>
+                      <div>
+                        <Text color="#fff" size={15}>
+                          Pre-Production Datastores
+                        </Text>
+                      </div>
+                      <div>
+                        <Text color="helper">
+                          Pre-production datastores are not highly available and
+                          use ephemeral storage.
+                        </Text>
+                      </div>
+                      <TemplateList
+                        templates={dataStoreTemplates} // This is where you provide only DATA_STORE templates
+                        setCurrentTemplate={(x) => {
+                          setCurrentTemplate(x);
+                        }}
+                      />
+                    </>
+                  )}
+
+                  {filteredTemplates?.length > 0 &&
+                    (currentProject?.full_add_ons || user.isPorterUser) && (
+                      <>
+                        <div>
+                          <Text color="#fff" size={15}>
+                            All Add-Ons
+                          </Text>
+                        </div>
+                        <div>
+                          <Text color="helper">Full list of add-ons</Text>
+                        </div>
+
+                        <TemplateList
+                          templates={filteredTemplates} // This is where you provide only DATA_STORE templates
+                          setCurrentTemplate={(x) => {
+                            setCurrentTemplate(x);
+                          }}
+                        />
+                      </>
+                    )}
+                </>
+              )}
+            </>
+          )}
+        </>
+      )}
+    </StyledTemplateComponent>
+  );
+};
+
+export default LegacyNewAddOnFlow;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -35px;
+`;
+
+const I = styled.i`
+  font-size: 16px;
+  padding: 4px;
+  cursor: pointer;
+  border-radius: 50%;
+  margin-right: 15px;
+  background: ${(props) => props.theme.fg};
+  color: ${(props) => props.theme.text.primary};
+  border: 1px solid ${(props) => props.theme.border};
+  :hover {
+    filter: brightness(150%);
+  }
+`;
+
+const StyledTemplateComponent = styled.div`
+  width: 100%;
+  height: 100%;
+`;

+ 354 - 0
dashboard/src/main/home/add-on-dashboard/metabase/MetabaseForm.tsx

@@ -0,0 +1,354 @@
+import React, { useMemo, useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+import { z } from "zod";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import CollapsibleContainer from "components/porter/CollapsibleContainer";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Image from "components/porter/Image";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+import { datastoreValidator, type ClientDatastore } from "lib/databases/types";
+import { useDatastoreList } from "lib/hooks/useDatabaseList";
+
+import api from "shared/api";
+import { stringifiedDNSRecordType } from "utils/ip";
+import copy from "assets/copy-left.svg";
+import upload from "assets/upload.svg";
+
+import { DATASTORE_ENGINE_POSTGRES } from "../../database-dashboard/constants";
+import { DatastoreList } from "../../database-dashboard/DatabaseDashboard";
+import { useClusterContext } from "../../infrastructure-dashboard/ClusterContextProvider";
+import { useAddonFormContext } from "../AddonFormContextProvider";
+import AddonSaveButton from "../AddonSaveButton";
+
+const MetabaseForm: React.FC = () => {
+  const { cluster } = useClusterContext();
+  const {
+    register,
+    formState: { errors },
+    control,
+    watch,
+  } = useFormContext<ClientAddon>();
+  const watchExposedToExternalTraffic = watch(
+    "config.exposedToExternalTraffic",
+    false
+  );
+  return (
+    <div>
+      <Text size={16}>Metabase configuration</Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={"config.exposedToExternalTraffic"}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Expose to external traffic</Text>
+          </Checkbox>
+        )}
+      />
+      <CollapsibleContainer isOpened={watchExposedToExternalTraffic}>
+        <Spacer y={0.5} />
+        <Text>Custom domain</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Add an optional custom domain to access Metabase. If you do not
+          provide a custom domain, Porter will provision a domain for you.
+        </Text>
+        {cluster.ingress_ip !== "" && (
+          <>
+            <Spacer y={0.5} />
+            <div style={{ width: "100%" }}>
+              <Text color="helper">
+                To configure a custom domain, you must add{" "}
+                {stringifiedDNSRecordType(cluster.ingress_ip)} pointing to the
+                following Ingress IP for your cluster:{" "}
+              </Text>
+            </div>
+            <Spacer y={0.5} />
+            <IdContainer>
+              <Code>{cluster.ingress_ip}</Code>
+              <CopyContainer>
+                <CopyToClipboard text={cluster.ingress_ip}>
+                  <CopyIcon src={copy} alt="copy" />
+                </CopyToClipboard>
+              </CopyContainer>
+            </IdContainer>
+            <Spacer y={0.5} />
+          </>
+        )}
+        <ControlledInput
+          type="text"
+          width="300px"
+          {...register("config.customDomain")}
+          placeholder="metabase.my-domain.com"
+          error={errors.config?.customDomain?.message}
+        />
+      </CollapsibleContainer>
+
+      <Spacer y={1} />
+      <Text>Datastore connection info</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Specify the connection credentials for your datastore.
+      </Text>
+      <Spacer y={0.5} />
+      <MetabaseDatastoreConnection />
+      <Spacer y={1} />
+      <AddonSaveButton />
+    </div>
+  );
+};
+
+const MetabaseDatastoreConnection: React.FC = () => {
+  const {
+    register,
+    formState: { errors },
+  } = useFormContext<ClientAddon>();
+  const [showInjectCredentialsModal, setShowInjectCredentialsModal] =
+    useState(false);
+  const history = useHistory();
+  return (
+    <div>
+      <table>
+        <tr>
+          <td>
+            <Text>Host</Text>
+          </td>
+          <td>
+            <ControlledInput
+              type="text"
+              width="600px"
+              {...register("config.datastore.host")}
+              error={errors.config?.datastore?.host?.message}
+            />
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <Text>Port</Text>
+          </td>
+          <td>
+            <ControlledInput
+              type="number"
+              width="600px"
+              {...register("config.datastore.port")}
+              placeholder="5432"
+              error={errors.config?.datastore?.port?.message}
+            />
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <Text>Database name</Text>
+          </td>
+          <td>
+            <ControlledInput
+              type="text"
+              width="600px"
+              {...register("config.datastore.databaseName")}
+              placeholder="postgres"
+              error={errors.config?.datastore?.databaseName?.message}
+            />
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <Text>Username</Text>
+          </td>
+          <td>
+            <ControlledInput
+              type="text"
+              width="600px"
+              {...register("config.datastore.username")}
+              error={errors.config?.datastore?.username?.message}
+            />
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <Text>Password</Text>
+          </td>
+          <td>
+            <ControlledInput
+              type="text"
+              width="600px"
+              {...register("config.datastore.password")}
+              error={errors.config?.datastore?.password?.message}
+            />
+          </td>
+        </tr>
+      </table>
+      <Spacer y={0.5} />
+      <Container row>
+        <Button
+          alt
+          onClick={() => {
+            setShowInjectCredentialsModal(true);
+          }}
+        >
+          <Image src={upload} size={16} />
+          <Spacer inline x={0.5} />
+          Inject credentials from a Porter datastore
+        </Button>
+        <Spacer inline x={0.5} />
+        <Button
+          alt
+          onClick={() => {
+            history.push("/datastores/new");
+          }}
+        >
+          <I className="material-icons">add</I>
+          Create a new datastore
+        </Button>
+      </Container>
+      {showInjectCredentialsModal && (
+        <InjectDatastoreCredentialsModal
+          onClose={() => {
+            setShowInjectCredentialsModal(false);
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+type ModalProps = {
+  onClose: () => void;
+};
+const InjectDatastoreCredentialsModal: React.FC<ModalProps> = ({ onClose }) => {
+  const [isInjectingCredentials, setIsInjectingCredentials] = useState(false);
+  const { datastores } = useDatastoreList();
+  const { projectId } = useAddonFormContext();
+  const postgresDatastores = useMemo(() => {
+    return datastores.filter(
+      (d) => d.template.highLevelType === DATASTORE_ENGINE_POSTGRES
+    );
+  }, [datastores]);
+  const { setValue } = useFormContext<ClientAddon>();
+
+  const injectCredentials = async (
+    datastore: ClientDatastore
+  ): Promise<void> => {
+    try {
+      setIsInjectingCredentials(true);
+      const response = await api.getDatastore(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          datastore_name: datastore.name,
+        }
+      );
+
+      const results = await z
+        .object({ datastore: datastoreValidator })
+        .parseAsync(response.data);
+
+      const credential = results.datastore.credential;
+      setValue("config.datastore.host", credential.host);
+      setValue("config.datastore.port", credential.port);
+      setValue("config.datastore.databaseName", credential.database_name);
+      setValue("config.datastore.username", credential.username);
+      setValue("config.datastore.password", credential.password);
+      onClose();
+    } catch (err) {
+      console.log(err);
+    } finally {
+      setIsInjectingCredentials(false);
+    }
+  };
+
+  return (
+    <Modal closeModal={onClose}>
+      <InnerModalContents>
+        <Container row>
+          <Text>Inject credentials from a Porter Postgres datastore</Text>
+          {isInjectingCredentials && (
+            <>
+              <Spacer inline x={0.5} />
+              <Loading offset="0px" width="20px" height="20px" />
+            </>
+          )}
+        </Container>
+        <Spacer y={0.5} />
+        {postgresDatastores.length === 0 ? (
+          <Text color="helper">
+            No postgres datastores were found. Please create a postgres
+            datastore in the Datastores tab first.
+          </Text>
+        ) : (
+          <>
+            <Text color="helper">
+              Select a datastore to inject its connection credentials into
+              Metabase.
+            </Text>
+            <Spacer y={1} />
+            <DatastoreList
+              datastores={postgresDatastores}
+              onClick={injectCredentials}
+            />
+          </>
+        )}
+      </InnerModalContents>
+    </Modal>
+  );
+};
+
+const InnerModalContents = styled.div`
+  overflow-y: auto;
+  max-height: 80vh;
+`;
+
+export default MetabaseForm;
+
+const I = styled.i`
+  font-size: 16px;
+  margin-right: 7px;
+`;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const IdContainer = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  padding: 10px;
+  display: flex;
+  width: 100%;
+  border-radius: 5px;
+  border: 1px solid ${({ theme }) => theme.border};
+  align-items: center;
+  user-select: text;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 15px;
+  height: 15px;
+  :hover {
+    opacity: 0.8;
+  }
+`;

+ 43 - 0
dashboard/src/main/home/add-on-dashboard/mezmo/MezmoForm.tsx

@@ -0,0 +1,43 @@
+import React from "react";
+import { useFormContext } from "react-hook-form";
+
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+
+import AddonSaveButton from "../AddonSaveButton";
+
+const MezmoForm: React.FC = () => {
+  const {
+    register,
+    formState: { errors },
+  } = useFormContext<ClientAddon>();
+  return (
+    <div>
+      <Text size={16}>Mezmo configuration</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        This installs the Mezmo agent, which forwards all logs from Porter to
+        Mezmo for ingestion. It may take around 30 minutes for the logs to
+        arrive in your Mezmo instance. Be aware that this incurs additional
+        costs based on your retention settings. By default, all logs are
+        ingested - to reduce costs, you can filter out the logs from Mezmo.
+      </Text>
+      <Spacer y={0.5} />
+      <Text>Ingestion Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.ingestionKey")}
+        placeholder="*****"
+        error={errors.config?.ingestionKey?.message}
+      />
+      <Spacer y={1} />
+      <AddonSaveButton />
+    </div>
+  );
+};
+
+export default MezmoForm;

+ 180 - 0
dashboard/src/main/home/add-on-dashboard/newrelic/NewRelicForm.tsx

@@ -0,0 +1,180 @@
+import React from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import Checkbox from "components/porter/Checkbox";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+
+import AddonSaveButton from "../AddonSaveButton";
+
+const NewRelicForm: React.FC = () => {
+  const {
+    register,
+    formState: { errors },
+    control,
+  } = useFormContext<ClientAddon>();
+
+  return (
+    <div>
+      <Text size={16}>NewRelic configuration</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        This installs the NewRelic agent, which forwards all logs & metrics from
+        your applications to NewRelic for ingestion.
+      </Text>
+      <Spacer y={0.5} />
+      <Text>NewRelic License Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.licenseKey")}
+        placeholder="*****"
+        error={errors.config?.licenseKey?.message}
+      />
+      <Spacer y={0.5} />
+      <Text>NewRelic Insights Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.insightsKey")}
+        placeholder="*****"
+        error={errors.config?.insightsKey?.message}
+      />
+      <Spacer y={0.5} />
+      <Text>NewRelic Personal API Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.personalApiKey")}
+        placeholder="*****"
+        error={errors.config?.personalApiKey?.message}
+      />
+      <Spacer y={0.5} />
+      <Text>NewRelic Account ID</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.accountId")}
+        placeholder="1234"
+        error={errors.config?.accountId?.message}
+      />
+      <Spacer y={1} />
+      <Text size={14}>Logging</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Enable logging and forward all logs to newRelic
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.loggingEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Logging enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <Text size={14}>Kubernetes Events</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Enable forwarding of Kubernetes events to NewRelic
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.kubeEventsEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Kubernetes events forwarding enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <Text size={14}>Metrics Adapter</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Enable the metrics adapter to forward metrics to NewRelic
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.metricsAdapterEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Metrics Adapter enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <Text size={14}>Prometheus</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Enable the NewRelic prometheus collector for apps exposing Prometheus
+        metrics
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.prometheusEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Prometheus enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <Text size={14}>Pixie</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Enable Pixie - an open-source observability tool for Kubernetes
+        applications
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`config.pixieEnabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Pixie enabled</Text>
+          </Checkbox>
+        )}
+      />
+      <Spacer y={1} />
+      <AddonSaveButton />
+    </div>
+  );
+};
+
+export default NewRelicForm;

+ 131 - 0
dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleForm.tsx

@@ -0,0 +1,131 @@
+import React from "react";
+import { useFieldArray, useFormContext } from "react-hook-form";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+
+import AddonSaveButton from "../AddonSaveButton";
+
+const TailscaleForm: React.FC = () => {
+  const {
+    register,
+    formState: { errors },
+    control,
+  } = useFormContext<ClientAddon>();
+
+  const {
+    append,
+    fields: routes,
+    remove,
+  } = useFieldArray({
+    control,
+    name: "config.subnetRoutes",
+  });
+
+  return (
+    <div>
+      <Text size={16}>Tailscale configuration</Text>
+      <Spacer y={0.5} />
+      <Text>Auth key</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        You can generate an auth key from the Tailscale dashboard by going to
+        &quot;Settings&quot; -{">"} &quot;Auth Keys&quot; and generating a
+        one-off key. Auth keys will expire after 90 days by default. To disable
+        key expiry{" "}
+        <a
+          href="https://tailscale.com/kb/1028/key-expiry"
+          target="_blank"
+          rel="noreferrer"
+        >
+          consult the Tailscale docs.
+        </a>
+      </Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="500px"
+        {...register("config.authKey")}
+        placeholder="*****"
+        error={errors.config?.authKey?.message}
+      />
+      <Spacer y={1} />
+      <Text>Subnet routes (optional)</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        By default, the subnet routes for this cluster and all datastores
+        connected to this cluster are routed through Tailscale. Enter any
+        additional subnet routes you would like to route through Tailscale here.
+      </Text>
+      <Spacer y={0.5} />
+      {routes.map((route, i) => {
+        return (
+          <div key={route.id}>
+            <AnnotationContainer>
+              <ControlledInput
+                type="text"
+                placeholder="ex: 10.123.456.0/20"
+                width="275px"
+                {...register(`config.subnetRoutes.${i}.route`)}
+              />
+              <DeleteButton
+                onClick={() => {
+                  remove(i);
+                }}
+              >
+                <i className="material-icons">cancel</i>
+              </DeleteButton>
+            </AnnotationContainer>
+            <Spacer y={0.25} />
+          </div>
+        );
+      })}
+      <Button
+        alt
+        onClick={() => {
+          append({
+            route: "",
+          });
+        }}
+      >
+        + Add subnet route
+      </Button>
+      <Spacer y={1} />
+      <AddonSaveButton />
+    </div>
+  );
+};
+
+export default TailscaleForm;
+
+const AnnotationContainer = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 5px;
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;

+ 116 - 0
dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx

@@ -0,0 +1,116 @@
+import React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
+import Loading from "components/Loading";
+import {
+  StyledTable,
+  StyledTd,
+  StyledTh,
+  StyledTHead,
+  StyledTr,
+} from "components/OldTable";
+import ClickToCopy from "components/porter/ClickToCopy";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  tailscaleServiceValidator,
+  type ClientTailscaleService,
+} from "lib/addons";
+
+import api from "shared/api";
+
+import { useAddonContext } from "../AddonContextProvider";
+
+const TailscaleOverview: React.FC = () => {
+  const { projectId, deploymentTarget } = useAddonContext();
+
+  const tailscaleServicesResp = useQuery<ClientTailscaleService[]>(
+    ["getTailscaleServices", projectId, deploymentTarget],
+    async () => {
+      if (!projectId || projectId === -1) {
+        return [];
+      }
+
+      const res = await api.getTailscaleServices(
+        "<token>",
+        {},
+        {
+          projectId,
+          deploymentTargetId: deploymentTarget.id,
+        }
+      );
+
+      const parsed = await z
+        .object({
+          services: z.array(tailscaleServiceValidator),
+        })
+        .parseAsync(res.data);
+
+      return parsed.services;
+    },
+    {
+      enabled: !!projectId && projectId !== -1,
+      retryDelay: 5000,
+    }
+  );
+
+  return (
+    <div>
+      <Text size={16}>Networking</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Please make sure that you{" "}
+        <a
+          href="https://docs.porter.run/other/tailscale#adding-routes-to-your-tailnet"
+          target="_blank"
+          rel="noreferrer"
+        >
+          approve all advertised subnet routes in Tailscale.
+        </a>{" "}
+        Once that is completed, the following services can be reached through
+        your Tailscale VPN by IP:
+      </Text>
+      <Spacer y={0.5} />
+      {match(tailscaleServicesResp)
+        .with({ status: "loading" }, () => <Loading />)
+        .with({ status: "error" }, ({ error }) => (
+          <ErrorComponent message={(error as Error).message} />
+        ))
+        .with({ status: "success", data: [] }, () => (
+          <Text>No services found</Text>
+        ))
+        .with({ status: "success" }, ({ data }) => (
+          <StyledTable>
+            <StyledTHead>
+              <StyledTr>
+                <StyledTh>Name</StyledTh>
+                <StyledTh>IP</StyledTh>
+                <StyledTh>Port</StyledTh>
+              </StyledTr>
+            </StyledTHead>
+            <tbody>
+              {data.map((service) => (
+                <StyledTr key={service.name}>
+                  <StyledTd>
+                    <ClickToCopy>{service.name}</ClickToCopy>
+                  </StyledTd>
+                  <StyledTd>
+                    <ClickToCopy>{service.ip}</ClickToCopy>
+                  </StyledTd>
+                  <StyledTd>
+                    <ClickToCopy>{service.port}</ClickToCopy>
+                  </StyledTd>
+                </StyledTr>
+              ))}
+            </tbody>
+          </StyledTable>
+        ))
+        .exhaustive()}
+    </div>
+  );
+};
+
+export default TailscaleOverview;

+ 6 - 5
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -144,12 +144,13 @@ const Apps: React.FC = () => {
             return;
           }
 
-          const res = await api.listLatestAddons(
+          const res = await api.listAddons(
             "<token>",
+            {},
             {
-              deployment_target_id: currentDeploymentTarget.id,
-            },
-            { clusterId: currentCluster.id, projectId: currentProject.id }
+              deploymentTargetId: currentDeploymentTarget.id,
+              projectId: currentProject.id,
+            }
           );
 
           const parsed = await z
@@ -292,7 +293,7 @@ const Apps: React.FC = () => {
                 back={() => {
                   setShowBillingModal(false);
                 }}
-                onCreate={() => {
+                onCreate={async () => {
                   history.push("/apps/new/app");
                 }}
               />

+ 58 - 64
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -1,6 +1,6 @@
 import React, { useContext, useMemo, useState } from "react";
 import _ from "lodash";
-import { Link } from "react-router-dom";
+import { Link, useHistory } from "react-router-dom";
 import styled from "styled-components";
 
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
@@ -19,9 +19,7 @@ import StatusDot from "components/porter/StatusDot";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
-import { isAWSCluster } from "lib/clusters/types";
 import { type ClientDatastore } from "lib/databases/types";
-import { useClusterList } from "lib/hooks/useCluster";
 import { useDatastoreList } from "lib/hooks/useDatabaseList";
 
 import { Context } from "shared/Context";
@@ -39,7 +37,6 @@ import EngineTag from "./tags/EngineTag";
 
 const DatabaseDashboard: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
-  const { clusters, isLoading: isLoadingClusters } = useClusterList();
 
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState<"grid" | "list">("grid");
@@ -49,6 +46,7 @@ const DatabaseDashboard: React.FC = () => {
   const [engineFilter, setEngineFilter] = useState<
     "all" | "POSTGRES" | "AURORA-POSTGRES" | "REDIS"
   >("all");
+  const history = useHistory();
 
   const { datastores, isLoading } = useDatastoreList({
     refetchIntervalMilliseconds: 5000,
@@ -96,7 +94,7 @@ const DatabaseDashboard: React.FC = () => {
           <Spacer y={1} />
           <PorterLink to="https://docs.porter.run/other/eject">
             <Button alt height="35px">
-             Eject to AWS, Azure, or GCP
+              Eject to AWS, Azure, or GCP
             </Button>
           </PorterLink>
         </DashboardPlaceholder>
@@ -124,32 +122,10 @@ const DatabaseDashboard: React.FC = () => {
       );
     }
 
-    if (datastores === undefined || isLoading || isLoadingClusters) {
+    if (datastores === undefined || isLoading) {
       return <Loading offset="-150px" />;
     }
 
-    if (clusters.filter(isAWSCluster).length === 0) {
-      return (
-        <Fieldset>
-          <Text size={16}>Datastores are not supported for this project.</Text>
-          <Spacer y={0.5} />
-          <Text color="helper">
-            Datastores are only supported for projects with a provisioned AWS
-            cluster.
-          </Text>
-          <Spacer y={0.5} />
-          <Text color="helper">
-            To get started with datastores, you will need to create an AWS
-            cluster. Contact our team if you are interested in enabling
-            multi-cluster support.
-          </Text>
-          <Spacer y={0.5} />
-          <ShowIntercomButton
-            message={`I would like to enable multi-cluster support for my project.`}
-          />
-        </Fieldset>
-      );
-    }
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
     }
@@ -330,41 +306,12 @@ const DatabaseDashboard: React.FC = () => {
             )}
           </GridList>
         ) : (
-          <List>
-            {(filteredDatastores ?? []).map(
-              (datastore: ClientDatastore, i: number) => {
-                return (
-                  <Row to={`/datastores/${datastore.name}`} key={i}>
-                    <Container row spaced>
-                      <Container row>
-                        <MidIcon src={datastore.template.icon} />
-                        <Text size={14}>{datastore.name}</Text>
-                      </Container>
-                      <StatusDot
-                        status={
-                          datastore.status === "AVAILABLE"
-                            ? "available"
-                            : "pending"
-                        }
-                        heightPixels={9}
-                      />
-                    </Container>
-                    <Spacer y={0.5} />
-                    <Container row>
-                      <EngineTag engine={datastore.template.engine} />
-                      <Spacer inline x={1} />
-                      <Container>
-                        <SmallIcon opacity="0.4" src={time} />
-                        <Text size={13} color="#ffffff44">
-                          {readableDate(datastore.created_at)}
-                        </Text>
-                      </Container>
-                    </Container>
-                  </Row>
-                );
-              }
-            )}
-          </List>
+          <DatastoreList
+            datastores={filteredDatastores}
+            onClick={(d: ClientDatastore) => {
+              history.push(`/datastores/${d.name}`);
+            }}
+          />
         )}
       </>
     );
@@ -386,12 +333,56 @@ const DatabaseDashboard: React.FC = () => {
 
 export default DatabaseDashboard;
 
+export const DatastoreList: React.FC<{
+  datastores: ClientDatastore[];
+  onClick: (datastore: ClientDatastore) => void | Promise<void>;
+}> = ({ datastores, onClick }) => {
+  return (
+    <List>
+      {datastores.map((datastore: ClientDatastore, i: number) => {
+        return (
+          <Row
+            key={i}
+            onClick={async () => {
+              await onClick(datastore);
+            }}
+          >
+            <Container row spaced>
+              <Container row>
+                <MidIcon src={datastore.template.icon} />
+                <Text size={14}>{datastore.name}</Text>
+              </Container>
+              <StatusDot
+                status={
+                  datastore.status === "AVAILABLE" ? "available" : "pending"
+                }
+                heightPixels={9}
+              />
+            </Container>
+            <Spacer y={0.5} />
+            <Container row>
+              <EngineTag engine={datastore.template.engine} />
+              <Spacer inline x={1} />
+              <Container>
+                <SmallIcon opacity="0.4" src={time} />
+                <Text size={13} color="#ffffff44">
+                  {readableDate(datastore.created_at)}
+                </Text>
+              </Container>
+            </Container>
+          </Row>
+        );
+      })}
+    </List>
+  );
+};
+
 const MidIcon = styled.img<{ height?: string }>`
   height: ${(props) => props.height || "18px"};
   margin-right: 11px;
 `;
 
-const Row = styled(Link)<{ isAtBottom?: boolean }>`
+const Row = styled.div<{ isAtBottom?: boolean }>`
   cursor: pointer;
   display: block;
   padding: 15px;
@@ -403,6 +394,9 @@ const Row = styled(Link)<{ isAtBottom?: boolean }>`
   border-radius: 5px;
   margin-bottom: 15px;
   animation: fadeIn 0.3s 0s;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
 `;
 
 const List = styled.div`

+ 53 - 7
dashboard/src/shared/api.tsx

@@ -1302,16 +1302,58 @@ const getAppTemplate = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
 });
 
-const listLatestAddons = baseApi<
+const listAddons = baseApi<
+  {},
   {
-    deployment_target_id?: string;
+    projectId: number;
+    deploymentTargetId: string;
+  }
+>("GET", ({ projectId, deploymentTargetId }) => {
+  return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons`;
+});
+
+const getAddon = baseApi<
+  {},
+  {
+    projectId: number;
+    deploymentTargetId: string;
+    addonName: string;
+  }
+>("GET", ({ projectId, deploymentTargetId, addonName }) => {
+  return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/${addonName}`;
+});
+
+const getTailscaleServices = baseApi<
+  {},
+  {
+    projectId: number;
+    deploymentTargetId: string;
+  }
+>("GET", ({ projectId, deploymentTargetId }) => {
+  return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/tailscale-services`;
+});
+
+const updateAddon = baseApi<
+  {
+    b64_addon: string;
   },
   {
     projectId: number;
-    clusterId: number;
+    deploymentTargetId: string;
   }
->("GET", ({ projectId, clusterId }) => {
-  return `/api/projects/${projectId}/clusters/${clusterId}/addons/latest`;
+>("POST", ({ projectId, deploymentTargetId }) => {
+  return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/update`;
+});
+
+const deleteAddon = baseApi<
+  {},
+  {
+    projectId: number;
+    deploymentTargetId: string;
+    addonName: string;
+  }
+>("DELETE", ({ projectId, deploymentTargetId, addonName }) => {
+  return `/api/projects/${projectId}/targets/${deploymentTargetId}/addons/${addonName}`;
 });
 
 const getGitlabProcfileContents = baseApi<
@@ -2316,7 +2358,7 @@ const createEnvironmentGroups = baseApi<
     infisical_env?: {
       slug: string;
       path: string;
-    }
+    };
   },
   {
     id: number;
@@ -3797,7 +3839,11 @@ export default {
   createDeploymentTarget,
   getDeploymentTarget,
   getAppTemplate,
-  listLatestAddons,
+  listAddons,
+  getAddon,
+  getTailscaleServices,
+  updateAddon,
+  deleteAddon,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 1 - 1
go.mod

@@ -85,7 +85,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.156
+	github.com/porter-dev/api-contracts v0.2.157
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 0
go.sum

@@ -1554,6 +1554,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/porter-dev/api-contracts v0.2.156 h1:IooB1l6tl+jiGecj2IzYsPoIJxnePaJntDpKSwJBxgc=
 github.com/porter-dev/api-contracts v0.2.156/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.157 h1:xjC1q4/8ZUl5QLVyCkTfIiMZn+k8h0c9AO9nrCFcZ1Y=
+github.com/porter-dev/api-contracts v0.2.157/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 10 - 2
internal/kubernetes/agent.go

@@ -587,6 +587,16 @@ func (a *Agent) GetSecret(name string, namespace string) (*v1.Secret, error) {
 	)
 }
 
+// ListServices lists services in a namespace
+func (a *Agent) ListServices(ctx context.Context, namespace string, labelSelector string) (*v1.ServiceList, error) {
+	return a.Clientset.CoreV1().Services(namespace).List(
+		ctx,
+		metav1.ListOptions{
+			LabelSelector: labelSelector,
+		},
+	)
+}
+
 // CreateSecret creates the secret given its name and namespace
 func (a *Agent) CreateSecret(secret *v1.Secret, namespace string) (*v1.Secret, error) {
 	_, err := a.Clientset.CoreV1().Secrets(namespace).Get(
@@ -1924,7 +1934,6 @@ func (a *Agent) StreamPorterAgentLokiLog(
 				Stdout: rw,
 				Stderr: rw,
 			})
-
 			if err != nil {
 				errorchan <- err
 				return
@@ -1984,7 +1993,6 @@ func (a *Agent) CreateImagePullSecrets(
 				},
 				metav1.CreateOptions{},
 			)
-
 			if err != nil {
 				return nil, err
 			}

+ 4 - 0
internal/telemetry/span.go

@@ -45,6 +45,10 @@ func AddKnownContextVariablesToSpan(ctx context.Context, span trace.Span) {
 	if project, ok := ctx.Value(types.ProjectScope).(*models.Project); ok {
 		WithAttributes(span, AttributeKV{Key: "project-id", Value: project.ID})
 	}
+
+	if deploymentTarget, ok := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget); ok {
+		WithAttributes(span, AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID})
+	}
 }
 
 // AttributeKV is a wrapper for otel attributes KV

+ 0 - 6
package-lock.json

@@ -1,6 +0,0 @@
-{
-  "name": "porter",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {}
-}