Explorar o código

Merge branch 'master' of github.com:porter-dev/porter into improve-metrics

Feroze Mohideen %!s(int64=2) %!d(string=hai) anos
pai
achega
ae3dfcce57
Modificáronse 100 ficheiros con 4186 adicións e 930 borrados
  1. 36 0
      .github/workflows/pr_push_checks.yaml
  2. 5 1
      Tiltfile
  3. 1 1
      api/authmanagement/api_token.go
  4. 8 13
      api/authmanagement/server.go
  5. 148 0
      api/client/porter_app.go
  6. 0 68
      api/client/stack.go
  7. 2 1
      api/server/handlers/api_contract/update.go
  8. 1 1
      api/server/handlers/cluster/cluster_status.go
  9. 1 1
      api/server/handlers/cluster/delete.go
  10. 33 13
      api/server/handlers/cluster/detect_agent_installed.go
  11. 1 1
      api/server/handlers/cluster/get_kubeconfig.go
  12. 113 0
      api/server/handlers/environment_groups/create.go
  13. 71 0
      api/server/handlers/environment_groups/delete.go
  14. 124 0
      api/server/handlers/environment_groups/list.go
  15. 5 4
      api/server/handlers/porter_app/analytics.go
  16. 204 37
      api/server/handlers/porter_app/create.go
  17. 199 8
      api/server/handlers/porter_app/create_and_update_events.go
  18. 59 47
      api/server/handlers/porter_app/create_secret_and_open_pr.go
  19. 2 2
      api/server/handlers/porter_app/delete.go
  20. 6 2
      api/server/handlers/porter_app/get.go
  21. 75 0
      api/server/handlers/porter_app/helm_release.go
  22. 63 0
      api/server/handlers/porter_app/helm_release_history.go
  23. 8 8
      api/server/handlers/porter_app/list_events.go
  24. 241 26
      api/server/handlers/porter_app/parse.go
  25. 85 0
      api/server/handlers/porter_app/pods.go
  26. 85 11
      api/server/handlers/porter_app/rollback.go
  27. 2 2
      api/server/handlers/project/create.go
  28. 1 1
      api/server/handlers/project/create_test.go
  29. 1 1
      api/server/handlers/project/delete.go
  30. 10 0
      api/server/handlers/project/update_onboarding_step.go
  31. 1 1
      api/server/handlers/project_integration/create_aws.go
  32. 1 1
      api/server/handlers/project_integration/create_azure.go
  33. 1 1
      api/server/handlers/project_integration/preflight_check_aws_usage.go
  34. 1 1
      api/server/handlers/registry/get_token.go
  35. 20 9
      api/server/handlers/release/get.go
  36. 42 23
      api/server/handlers/release/get_all_pods.go
  37. 1 1
      api/server/handlers/release/get_gha_template.go
  38. 1 0
      api/server/handlers/user/create.go
  39. 88 0
      api/server/router/cluster.go
  40. 195 23
      api/server/router/porter_app.go
  41. 1 1
      api/server/router/router.go
  42. 58 0
      api/server/shared/features/features.go
  43. 1 0
      api/types/agent.go
  44. 51 3
      api/types/porter_app.go
  45. 4 2
      api/types/project.go
  46. 1 1
      api/types/request.go
  47. 5 3
      api/types/stack.go
  48. 6 5
      api/types/user.go
  49. 18 0
      api/utils/porter_app/namespace.go
  50. 6 6
      cli/cmd/apply.go
  51. 74 3
      cli/cmd/porter_app/apply.go
  52. 1 1
      cli/cmd/porter_app/build.go
  53. 1 1
      cli/cmd/porter_app/env.go
  54. 15 11
      cli/cmd/porter_app/hooks.go
  55. 1 1
      cli/cmd/porter_app/preDeploy.go
  56. 2 2
      cli/cmd/porter_app/types.go
  57. 1 1
      cli/cmd/porter_app/utils.go
  58. 1 1
      cli/cmd/porter_app/validate.go
  59. 17 13
      dashboard/package-lock.json
  60. 5 1
      dashboard/package.json
  61. 3 0
      dashboard/src/assets/canceled.svg
  62. 3 0
      dashboard/src/assets/copy-left.svg
  63. 3 0
      dashboard/src/assets/failure.svg
  64. 1 0
      dashboard/src/assets/filter-outline-icon.svg
  65. BIN=BIN
      dashboard/src/assets/filter-outline-new.png
  66. 3 0
      dashboard/src/assets/gear.svg
  67. 3 0
      dashboard/src/assets/globe.svg
  68. 3 0
      dashboard/src/assets/swap.svg
  69. 7 7
      dashboard/src/components/AWSCostConsent.tsx
  70. 1 0
      dashboard/src/components/AzureProvisionerSettings.tsx
  71. 3 3
      dashboard/src/components/ProvisionerFlow.tsx
  72. 282 203
      dashboard/src/components/ProvisionerSettings.tsx
  73. 13 0
      dashboard/src/components/porter-form/types.ts
  74. 1 1
      dashboard/src/components/porter/Icon.tsx
  75. 216 0
      dashboard/src/components/porter/InputSlider.tsx
  76. 38 18
      dashboard/src/components/porter/Link.tsx
  77. 3 0
      dashboard/src/components/porter/SearchBar.tsx
  78. 14 1
      dashboard/src/index.html
  79. 59 7
      dashboard/src/main/auth/Register.tsx
  80. 6 4
      dashboard/src/main/home/Home.tsx
  81. 4 3
      dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx
  82. 1 0
      dashboard/src/main/home/app-dashboard/build-settings/ProviderSelector.tsx
  83. 2 2
      dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogComponent.tsx
  84. 67 0
      dashboard/src/main/home/app-dashboard/expanded-app/DeleteApplicationModal.tsx
  85. 124 175
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  86. 548 0
      dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx
  87. 57 0
      dashboard/src/main/home/app-dashboard/expanded-app/SettingsTab.tsx
  88. 13 10
      dashboard/src/main/home/app-dashboard/expanded-app/StatusFooter.tsx
  89. 59 31
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx
  90. 22 43
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx
  91. 11 8
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx
  92. 138 35
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx
  93. 4 3
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx
  94. 4 4
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx
  95. 126 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/ServiceStatusDetail.tsx
  96. 3 3
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx
  97. 71 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/DeployEventFocusView.tsx
  98. 12 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx
  99. 4 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/PredeployEventFocusView.tsx
  100. 44 0
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts

+ 36 - 0
.github/workflows/pr_push_checks.yaml

@@ -1,6 +1,8 @@
 name: PR Checks
+
 on:
   - pull_request
+
 jobs:
   testing_matrix:
     strategy:
@@ -29,3 +31,37 @@ jobs:
         run: go vet ./${{ matrix.folder }}/...
       - name: Run Go tests
         run: go test ./${{ matrix.folder }}/...
+  build-npm:
+    name: Running smoke test npm build
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Setup Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16
+      - name: Setup NPM
+        working-directory: dashboard
+        run: |
+          # installing updated npm
+
+          # Verify npm works before capturing and ensure its stderr is inspectable later
+          version="$(jq -r '.engines.npm' package.json)"
+          npm --version 2>&1 1>/dev/null
+
+          npm_version="$(npm --version)"
+          echo "Bootstrapping npm $version (replacing $npm_version)..."
+          npm install --unsafe-perm -g --quiet "npm@$version"
+
+          # Verify npm works before capturing and ensure its stderr is inspectable later
+          npm --version 2>&1 1>/dev/null
+          echo "npm $(npm --version) installed"
+      - name: Install NPM Dependencies
+        working-directory: dashboard
+        run: |
+          npm i --legacy-peer-deps
+      - name: Run NPM Build
+        working-directory: dashboard
+        run: |
+          npm run build

+ 5 - 1
Tiltfile

@@ -134,8 +134,12 @@ local_resource(
     serve_cmd="npm start",
     serve_dir="dashboard",
     serve_env={
-        "ENV_FILE": "../zarf/helm/.dashboard.env"
+        "ENV_FILE": "../zarf/helm/.dashboard.env",
+        "NODE_OPTIONS":"--openssl-legacy-provider"
     },
+    deps=[
+      "dashboard/package.json",
+    ],
     resource_deps=["postgresql"],
     labels=["porter"]
 )

+ 1 - 1
api/authmanagement/api_token.go

@@ -14,7 +14,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/telemetry"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 )
 

+ 8 - 13
api/authmanagement/server.go

@@ -6,22 +6,17 @@ import (
 	"net/http"
 	"time"
 
-	grpcreflect "github.com/bufbuild/connect-grpcreflect-go"
-
-	"github.com/porter-dev/porter/ee/integrations/vault"
-	"github.com/porter-dev/porter/internal/repository/credentials"
-	"github.com/porter-dev/porter/internal/repository/gorm"
-
+	"connectrpc.com/connect"
+	grpcreflect "connectrpc.com/grpcreflect"
+	otelconnect "connectrpc.com/otelconnect"
+	"github.com/joeshaw/envdecode"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/ee/integrations/vault"
 	"github.com/porter-dev/porter/internal/adapter"
-
 	"github.com/porter-dev/porter/internal/repository"
-
-	"github.com/joeshaw/envdecode"
-
-	"github.com/bufbuild/connect-go"
-	otelconnect "github.com/bufbuild/connect-opentelemetry-go"
-	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
+	"github.com/porter-dev/porter/internal/repository/credentials"
+	"github.com/porter-dev/porter/internal/repository/gorm"
 	"golang.org/x/net/http2"
 	"golang.org/x/net/http2/h2c"
 )

+ 148 - 0
api/client/porter_app.go

@@ -0,0 +1,148 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+func (c *Client) NewGetPorterApp(
+	ctx context.Context,
+	projectID, clusterID uint,
+	appName string,
+) (*types.PorterApp, error) {
+	resp := &types.PorterApp{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/applications/%s",
+			projectID, clusterID, appName,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) NewCreatePorterApp(
+	ctx context.Context,
+	projectID, clusterID uint,
+	appName string,
+	req *types.CreatePorterAppRequest,
+) (*types.PorterApp, error) {
+	resp := &types.PorterApp{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/applications/%s",
+			projectID, clusterID, appName,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// NewCreateOrUpdatePorterAppEvent will create a porter app event if one does not exist, or else it will update the existing one if an ID is passed in the object
+func (c *Client) NewCreateOrUpdatePorterAppEvent(
+	ctx context.Context,
+	projectID, clusterID uint,
+	appName string,
+	req *types.CreateOrUpdatePorterAppEventRequest,
+) (types.PorterAppEvent, error) {
+	resp := &types.PorterAppEvent{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/applications/%s/events",
+			projectID, clusterID, appName,
+		),
+		req,
+		resp,
+	)
+
+	return *resp, err
+}
+
+// TODO: remove these functions once they are no longer called (check telemetry)
+func (c *Client) GetPorterApp(
+	ctx context.Context,
+	projectID, clusterID uint,
+	stackName string,
+) (*types.PorterApp, error) {
+	resp := &types.PorterApp{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/stacks/%s",
+			projectID, clusterID, stackName,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) CreatePorterApp(
+	ctx context.Context,
+	projectID, clusterID uint,
+	name string,
+	req *types.CreatePorterAppRequest,
+) (*types.PorterApp, error) {
+	resp := &types.PorterApp{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/stacks/%s",
+			projectID, clusterID, name,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// CreateOrUpdatePorterAppEvent will create a porter app event if one does not exist, or else it will update the existing one if an ID is passed in the object
+func (c *Client) CreateOrUpdatePorterAppEvent(
+	ctx context.Context,
+	projectID, clusterID uint,
+	name string,
+	req *types.CreateOrUpdatePorterAppEventRequest,
+) (types.PorterAppEvent, error) {
+	resp := &types.PorterAppEvent{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/stacks/%s/events",
+			projectID, clusterID, name,
+		),
+		req,
+		resp,
+	)
+
+	return *resp, err
+}
+
+// ListEnvGroups (List all Env Groups for a given cluster)
+func (c *Client) ListEnvGroups(
+	ctx context.Context,
+	projectID, clusterID uint,
+) (types.ListEnvironmentGroupsResponse, error) {
+	resp := &types.ListEnvironmentGroupsResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/environment-groups",
+			projectID, clusterID,
+		),
+		nil,
+		resp,
+	)
+
+	return *resp, err
+}

+ 0 - 68
api/client/stack.go

@@ -1,68 +0,0 @@
-package client
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/porter-dev/porter/api/types"
-)
-
-func (c *Client) GetPorterApp(
-	ctx context.Context,
-	projectID, clusterID uint,
-	stackName string,
-) (*types.PorterApp, error) {
-	resp := &types.PorterApp{}
-
-	err := c.getRequest(
-		fmt.Sprintf(
-			"/projects/%d/clusters/%d/stacks/%s",
-			projectID, clusterID, stackName,
-		),
-		nil,
-		resp,
-	)
-
-	return resp, err
-}
-
-func (c *Client) CreatePorterApp(
-	ctx context.Context,
-	projectID, clusterID uint,
-	name string,
-	req *types.CreatePorterAppRequest,
-) (*types.PorterApp, error) {
-	resp := &types.PorterApp{}
-
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/clusters/%d/stacks/%s",
-			projectID, clusterID, name,
-		),
-		req,
-		resp,
-	)
-
-	return resp, err
-}
-
-// CreateOrUpdatePorterAppEvent will create a porter app event if one does not exist, or else it will update the existing one if an ID is passed in the object
-func (c *Client) CreateOrUpdatePorterAppEvent(
-	ctx context.Context,
-	projectID, clusterID uint,
-	name string,
-	req *types.CreateOrUpdatePorterAppEventRequest,
-) (types.PorterAppEvent, error) {
-	resp := &types.PorterAppEvent{}
-
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/clusters/%d/stacks/%s/events",
-			projectID, clusterID, name,
-		),
-		req,
-		resp,
-	)
-
-	return *resp, err
-}

+ 2 - 1
api/server/handlers/api_contract/update.go

@@ -4,7 +4,8 @@ import (
 	"encoding/base64"
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
+
 	"github.com/google/uuid"
 	helpers "github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"

+ 1 - 1
api/server/handlers/cluster/cluster_status.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"

+ 1 - 1
api/server/handlers/cluster/delete.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"

+ 33 - 13
api/server/handlers/cluster/detect_agent_installed.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	v1 "k8s.io/api/apps/v1"
 )
 
@@ -32,30 +33,29 @@ func NewDetectAgentInstalledHandler(
 }
 
 func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "detect-agent-installed")
+	defer span.End()
 
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "failed to get k8s agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	depl, err := agent.GetPorterAgent()
-
-	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
-		http.NotFound(w, r)
+	res, err := GetAgentVersionResponse(agent)
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		err = telemetry.Error(ctx, span, err, "porter agent not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
 		return
 	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "porter agent not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	// detect the version of the agent which is installed
-	res := &types.DetectAgentResponse{
-		Version:       getAgentVersionFromDeployment(depl),
-		ShouldUpgrade: false,
-	}
-
 	if res.Version != "v3" {
 		res.ShouldUpgrade = true
 	}
@@ -65,6 +65,19 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	c.WriteResult(w, r, res)
 }
 
+func GetAgentVersionResponse(agent *kubernetes.Agent) (*types.DetectAgentResponse, error) {
+	depl, err := agent.GetPorterAgent()
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.DetectAgentResponse{
+		Version:       getAgentVersionFromDeployment(depl),
+		ShouldUpgrade: false,
+		Image:         getImageFromDeployment(depl),
+	}, nil
+}
+
 func getAgentVersionFromDeployment(depl *v1.Deployment) string {
 	versionAnn := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
 
@@ -74,3 +87,10 @@ func getAgentVersionFromDeployment(depl *v1.Deployment) string {
 
 	return "v1"
 }
+
+func getImageFromDeployment(depl *v1.Deployment) string {
+	if len(depl.Spec.Template.Spec.Containers) > 0 {
+		return depl.Spec.Template.Spec.Containers[0].Image
+	}
+	return ""
+}

+ 1 - 1
api/server/handlers/cluster/get_kubeconfig.go

@@ -4,7 +4,7 @@ import (
 	"encoding/base64"
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"

+ 113 - 0
api/server/handlers/environment_groups/create.go

@@ -0,0 +1,113 @@
+package environment_groups
+
+import (
+	"net/http"
+	"time"
+
+	"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/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type UpdateEnvironmentGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateEnvironmentGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateEnvironmentGroupHandler {
+	return &UpdateEnvironmentGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type UpdateEnvironmentGroupRequest struct {
+	// Name of the env group to create or update
+	Name string `json:"name"`
+
+	// Variables are values which are not sensitive. All values must be a string due to a kubernetes limitation.
+	Variables map[string]string `json:"variables"`
+
+	// SecretVariables are sensitive values. All values must be a string due to a kubernetes limitation.
+	SecretVariables map[string]string `json:"secret_variables"`
+}
+type UpdateEnvironmentGroupResponse struct {
+	// Name of the env group to create or update
+	Name string `json:"name"`
+
+	// Variables are variables which should are not sensitive. All values must be a string due to a kubernetes limitation.
+	Variables map[string]string `json:"variables,omitempty"`
+
+	// SecretVariables are sensitive variables. All values must be a string due to a kubernetes limitation.
+	SecretVariables map[string]string `json:"secret_variables,omitempty"`
+
+	CreatedAt time.Time `json:"created_at"`
+}
+
+func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-env-group")
+	defer span.End()
+
+	request := &UpdateEnvironmentGroupRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
+	)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	secrets := make(map[string][]byte)
+	for k, v := range request.SecretVariables {
+		secrets[k] = []byte(v)
+	}
+
+	envGroup := environment_groups.EnvironmentGroup{
+		Name:            request.Name,
+		Variables:       request.Variables,
+		SecretVariables: secrets,
+		CreatedAtUTC:    time.Now().UTC(),
+	}
+
+	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to create or update environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	envGroupResponse := &UpdateEnvironmentGroupResponse{
+		Name:      envGroup.Name,
+		CreatedAt: envGroup.CreatedAtUTC,
+	}
+	c.WriteResult(w, r, envGroupResponse)
+
+	// TODO: Syncing applications that are linked is currently done by the frontend. This should be done entirely
+	// applicationsToSync, err := environment_groups.LinkedApplications(ctx, agent, envGroup.Name)
+	// if err != nil {
+	// 	err := telemetry.Error(ctx, span, err, "unable to find linked applications for environment group")
+	// 	c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+	// 	return
+	// }
+	// for _, app := range applicationsToSync {
+	// 	TODO: Call porter app update
+	// }
+}

+ 71 - 0
api/server/handlers/environment_groups/delete.go

@@ -0,0 +1,71 @@
+package environment_groups
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type DeleteEnvironmentGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteEnvironmentGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteEnvironmentGroupHandler {
+	return &DeleteEnvironmentGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type DeleteEnvironmentGroupRequest struct {
+	// Name of the env group to delete
+	Name string `json:"name"`
+}
+
+func (c *DeleteEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-env-group")
+	defer span.End()
+
+	request := &DeleteEnvironmentGroupRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if request.Name == "" {
+		err := telemetry.Error(ctx, span, nil, "environment group name is required for deletion")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
+	)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	err = environment_groups.DeleteEnvironmentGroup(ctx, agent, request.Name)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to delete environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 124 - 0
api/server/handlers/environment_groups/list.go

@@ -0,0 +1,124 @@
+package environment_groups
+
+import (
+	"net/http"
+	"strings"
+	"time"
+
+	"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"
+	environmentgroups "github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type ListEnvironmentGroupsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListEnvironmentGroupsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListEnvironmentGroupsHandler {
+	return &ListEnvironmentGroupsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type ListEnvironmentGroupsResponse struct {
+	EnvironmentGroups []EnvironmentGroupListItem `json:"environment_groups,omitempty"`
+}
+
+type EnvironmentGroupListItem struct {
+	Name               string            `json:"name"`
+	LatestVersion      int               `json:"latest_version"`
+	Variables          map[string]string `json:"variables"`
+	SecretVariables    map[string]string `json:"secret_variables"`
+	CreatedAtUTC       time.Time         `json:"created_at"`
+	LinkedApplications []string          `json:"linked_applications,omitempty"`
+}
+
+func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-env-groups")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to connect to cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusServiceUnavailable))
+		return
+	}
+
+	allEnvGroupVersions, err := environmentgroups.ListEnvironmentGroups(ctx, agent, environmentgroups.WithNamespace(environmentgroups.Namespace_EnvironmentGroups))
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to list all environment groups")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	envGroupSet := make(map[string]struct{})
+	for _, envGroup := range allEnvGroupVersions {
+		if envGroup.Name == "" {
+			continue
+		}
+		if _, ok := envGroupSet[envGroup.Name]; !ok {
+			envGroupSet[envGroup.Name] = struct{}{}
+		}
+	}
+
+	var envGroups []EnvironmentGroupListItem
+	for envGroupName := range envGroupSet {
+		latestVersion, err := environmentgroups.LatestBaseEnvironmentGroup(ctx, agent, envGroupName)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "unable to get latest environment groups")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		applications, err := environmentgroups.LinkedApplications(ctx, agent, latestVersion.Name)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "unable to get linked applications")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		applicationSetForEnvGroup := make(map[string]struct{})
+		for _, app := range applications {
+			if app.Namespace == "" {
+				continue
+			}
+			if _, ok := applicationSetForEnvGroup[app.Namespace]; !ok {
+				applicationSetForEnvGroup[app.Namespace] = struct{}{}
+			}
+		}
+		var linkedApplications []string
+		for appNamespace := range applicationSetForEnvGroup {
+			porterAppName := strings.TrimPrefix(appNamespace, "porter-stack-")
+			linkedApplications = append(linkedApplications, porterAppName)
+		}
+
+		secrets := make(map[string]string)
+		for k, v := range latestVersion.SecretVariables {
+			secrets[k] = string(v)
+		}
+		envGroups = append(envGroups, EnvironmentGroupListItem{
+			Name:               latestVersion.Name,
+			LatestVersion:      latestVersion.Version,
+			Variables:          latestVersion.Variables,
+			SecretVariables:    secrets,
+			CreatedAtUTC:       latestVersion.CreatedAtUTC,
+			LinkedApplications: linkedApplications,
+		})
+	}
+
+	c.WriteResult(w, r, ListEnvironmentGroupsResponse{EnvironmentGroups: envGroups})
+}

+ 5 - 4
api/server/handlers/porter_app/analytics.go

@@ -87,6 +87,7 @@ func (v *PorterAppAnalyticsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			FirstName:              user.FirstName,
 			LastName:               user.LastName,
 			CompanyName:            user.CompanyName,
+			DeleteWorkflowFile:     request.DeleteWorkflowFile,
 		}))
 	}
 
@@ -99,9 +100,9 @@ func TrackStackBuildStatus(
 	project *models.Project,
 	stackName string,
 	errorMessage string,
-	status string,
+	status types.PorterAppEventStatus,
 ) error {
-	if status == "PROGRESSING" {
+	if status == types.PorterAppEventStatus_Progressing {
 		return config.AnalyticsClient.Track(analytics.StackBuildProgressingTrack(&analytics.StackBuildOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			StackName:              stackName,
@@ -112,7 +113,7 @@ func TrackStackBuildStatus(
 		}))
 	}
 
-	if status == "SUCCESS" {
+	if status == types.PorterAppEventStatus_Success {
 		return config.AnalyticsClient.Track(analytics.StackBuildSuccessTrack(&analytics.StackBuildOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			StackName:              stackName,
@@ -123,7 +124,7 @@ func TrackStackBuildStatus(
 		}))
 	}
 
-	if status == "FAILED" {
+	if status == types.PorterAppEventStatus_Failed {
 		return config.AnalyticsClient.Track(analytics.StackBuildFailureTrack(&analytics.StackBuildOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			StackName:              stackName,

+ 204 - 37
api/server/handlers/porter_app/create.go

@@ -3,6 +3,7 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
@@ -18,8 +19,10 @@ import (
 	"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/features"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	utils "github.com/porter-dev/porter/api/utils/porter_app"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
@@ -47,6 +50,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	ctx := r.Context()
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
 
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-porter-app")
 	defer span.End()
@@ -58,14 +62,14 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: stackName})
+	namespace := utils.NamespaceFromPorterAppName(appName)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
 
 	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
 	if err != nil {
@@ -81,13 +85,14 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	helmRelease, err := helmAgent.GetRelease(ctx, appName, 0, false)
 	shouldCreate := err != nil
 
 	porterYamlBase64 := request.PorterYAMLBase64
 	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error decoding porter yaml")
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
@@ -95,6 +100,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error listing registries")
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
@@ -113,6 +119,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 				imageInfo, err = attemptToGetImageInfoFromFullHelmValues(request.FullHelmValues)
 				if err != nil {
 					err = telemetry.Error(ctx, span, err, "error getting image info from full helm values")
+					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 					return
 				}
@@ -128,7 +135,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	if request.Builder == "" {
 		// attempt to get builder from db
-		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 		if err == nil {
 			request.Builder = app.Builder
 		}
@@ -142,19 +149,36 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		_, err = k8sAgent.CreateNamespace(namespace, nil)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating namespace")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 		cloneEnvGroup(c, w, r, k8sAgent, request.EnvGroups, namespace)
 	}
+
+	if imageInfo.Repository == "" || imageInfo.Tag == "" {
+		err = telemetry.Error(ctx, span, nil, "incomplete image info provided: must provide both repository and tag")
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var addCustomNodeSelector bool
+	if (cluster.ProvisionedBy == "CAPI" && cluster.CloudProvider == "GCP") || cluster.GCPIntegrationID != 0 {
+		addCustomNodeSelector = true
+	}
+
 	chart, values, preDeployJobValues, err := parse(
+		ctx,
 		ParseConf{
+			PorterAppName:             appName,
 			PorterYaml:                porterYaml,
 			ImageInfo:                 imageInfo,
 			ServerConfig:              c.Config(),
 			ProjectID:                 cluster.ProjectID,
 			UserUpdate:                request.UserUpdate,
 			EnvGroups:                 request.EnvGroups,
+			EnvironmentGroups:         request.EnvironmentGroups,
 			Namespace:                 namespace,
 			ExistingHelmValues:        releaseValues,
 			ExistingChartDependencies: releaseDependencies,
@@ -163,15 +187,17 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 				dnsRepo:        c.Repo().DNSRecord(),
 				powerDnsClient: c.Config().PowerDNSClient,
 				appRootDomain:  c.Config().ServerConf.AppRootDomain,
-				stackName:      stackName,
+				stackName:      appName,
 			},
 			InjectLauncherToStartCommand: injectLauncher,
 			ShouldValidateHelmValues:     shouldCreate,
 			FullHelmValues:               request.FullHelmValues,
+			AddCustomNodeSelector:        addCustomNodeSelector,
 		},
 	)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "parse error")
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
@@ -182,9 +208,9 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		// create the release job chart if it does not exist (only done by front-end currently, where we set overrideRelease=true)
 		if request.OverrideRelease && preDeployJobValues != nil {
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "installing-pre-deploy-job", Value: true})
-			conf, err := createReleaseJobChart(
+			conf, err := createPreDeployJobChart(
 				ctx,
-				stackName,
+				appName,
 				preDeployJobValues,
 				c.Config().ServerConf.DefaultApplicationHelmRepoURL,
 				registries,
@@ -193,6 +219,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			)
 			if err != nil {
 				err = telemetry.Error(ctx, span, err, "error making config for pre-deploy job chart")
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
@@ -200,8 +227,9 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			if err != nil {
 				err = telemetry.Error(ctx, span, err, "error installing pre-deploy job chart")
 				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "install-pre-deploy-job-error", Value: err})
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-				_, uninstallChartErr := helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", stackName))
+				_, uninstallChartErr := helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", appName))
 				if uninstallChartErr != nil {
 					uninstallChartErr = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart after failed install")
 					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(uninstallChartErr, http.StatusInternalServerError))
@@ -212,7 +240,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 		conf := &helm.InstallChartConfig{
 			Chart:      chart,
-			Name:       stackName,
+			Name:       appName,
 			Namespace:  namespace,
 			Values:     values,
 			Cluster:    cluster,
@@ -221,12 +249,12 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		}
 
 		// create the app chart
-		_, err = helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+		release, err := helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error installing app chart")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-
-			_, err = helmAgent.UninstallChart(ctx, stackName)
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
+			_, err = helmAgent.UninstallChart(ctx, appName)
 			if err != nil {
 				err = telemetry.Error(ctx, span, err, "error uninstalling app chart after failed install")
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
@@ -235,19 +263,21 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
-		existing, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+		existing, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error reading app from DB")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		} else if existing.Name != "" {
 			err = telemetry.Error(ctx, span, err, "app with name already exists in project")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
 			return
 		}
 
 		app := &models.PorterApp{
-			Name:      stackName,
+			Name:      appName,
 			ClusterID: cluster.ID,
 			ProjectID: project.ID,
 			RepoName:  request.RepoName,
@@ -267,44 +297,52 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		porterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error writing app to DB")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 
-		_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if features.AreAgentDeployEventsEnabled(user.Email, k8sAgent) {
+			serviceDeploymentStatusMap := getServiceDeploymentMetadataFromValues(values, types.PorterAppEventStatus_Progressing)
+			_, err = createNewPorterAppDeployEvent(ctx, serviceDeploymentStatusMap, types.PorterAppEventStatus_Progressing, porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		} else {
+			_, err = createOldPorterAppDeployEvent(ctx, types.PorterAppEventStatus_Success, porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		}
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating porter app event")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 
-		c.WriteResult(w, r, porterApp.ToPorterAppType())
+		c.WriteResult(w, r, porterApp.ToPorterAppTypeWithRevision(release.Version))
 	} else {
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "upgrading-application", Value: true})
 
-		// create/update the release job chart
+		// create/update the pre-deploy job chart
 		if request.OverrideRelease {
 			if preDeployJobValues == nil {
-				releaseJobName := fmt.Sprintf("%s-r", stackName)
-				_, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
+				preDeployJobName := fmt.Sprintf("%s-r", appName)
+				_, err := helmAgent.GetRelease(ctx, preDeployJobName, 0, false)
 				if err == nil {
 					// handle exception where the user has chosen to delete the release job
 					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deleting-pre-deploy-job", Value: true})
-					_, err = helmAgent.UninstallChart(ctx, releaseJobName)
+					_, err = helmAgent.UninstallChart(ctx, preDeployJobName)
 					if err != nil {
 						err = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart")
+						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 						return
 					}
 				}
 			} else {
-				releaseJobName := fmt.Sprintf("%s-r", stackName)
-				helmRelease, err := helmAgent.GetRelease(ctx, releaseJobName, 0, false)
+				preDeployJobName := fmt.Sprintf("%s-r", appName)
+				helmRelease, err := helmAgent.GetRelease(ctx, preDeployJobName, 0, false)
 				if err != nil {
 					telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "creating-pre-deploy-job", Value: true})
-					conf, err := createReleaseJobChart(
+					conf, err := createPreDeployJobChart(
 						ctx,
-						stackName,
+						appName,
 						preDeployJobValues,
 						c.Config().ServerConf.DefaultApplicationHelmRepoURL,
 						registries,
@@ -313,6 +351,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 					)
 					if err != nil {
 						err = telemetry.Error(ctx, span, err, "error making config for pre-deploy job chart")
+						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 						return
 					}
@@ -322,11 +361,12 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 						err = telemetry.Error(ctx, span, err, "error installing pre-deploy job chart")
 						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "install-pre-deploy-job-error", Value: err})
 						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-						_, uninstallChartErr := helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", stackName))
+						_, uninstallChartErr := helmAgent.UninstallChart(ctx, fmt.Sprintf("%s-r", appName))
 						if uninstallChartErr != nil {
 							uninstallChartErr = telemetry.Error(ctx, span, err, "error uninstalling pre-deploy job chart after failed install")
 							c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(uninstallChartErr, http.StatusInternalServerError))
 						}
+						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 						return
 					}
 				} else {
@@ -334,6 +374,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 					chart, err := loader.LoadChartPublic(ctx, c.Config().Metadata.DefaultAppHelmRepoURL, "job", "")
 					if err != nil {
 						err = telemetry.Error(ctx, span, err, "error loading latest job chart")
+						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 						return
 					}
@@ -349,6 +390,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 					_, err = helmAgent.UpgradeReleaseByValues(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection, false)
 					if err != nil {
 						err = telemetry.Error(ctx, span, err, "error upgrading pre-deploy job chart")
+						telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 						c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 						return
 					}
@@ -359,30 +401,34 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		// update the app chart
 		conf := &helm.InstallChartConfig{
 			Chart:      chart,
-			Name:       stackName,
+			Name:       appName,
 			Namespace:  namespace,
 			Values:     values,
 			Cluster:    cluster,
 			Repo:       c.Repo(),
 			Registries: registries,
 		}
+
 		// update the chart
-		_, err = helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+		release, err := helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error upgrading application")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 			return
 		}
 
 		// update the DB entry
-		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+		app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error reading app from DB")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 		if app == nil {
 			err = telemetry.Error(ctx, span, nil, "app with name does not exist in project")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
 			return
 		}
@@ -440,26 +486,37 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		updatedPorterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error writing updated app to DB")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 
-		_, err = createPorterAppEvent(ctx, "SUCCESS", updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if features.AreAgentDeployEventsEnabled(user.Email, k8sAgent) {
+			serviceDeploymentStatusMap := getServiceDeploymentMetadataFromValues(values, types.PorterAppEventStatus_Progressing)
+			_, err = createNewPorterAppDeployEvent(ctx, serviceDeploymentStatusMap, types.PorterAppEventStatus_Progressing, updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		} else {
+			_, err = createOldPorterAppDeployEvent(ctx, types.PorterAppEventStatus_Success, updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		}
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating porter app event")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-yaml-base64", Value: porterYamlBase64})
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 
-		c.WriteResult(w, r, updatedPorterApp.ToPorterAppType())
+		c.WriteResult(w, r, updatedPorterApp.ToPorterAppTypeWithRevision(release.Version))
 	}
 }
 
-// createPorterAppEvent creates an event for use in the activity feed
-func createPorterAppEvent(ctx context.Context, status string, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
+// createOldPorterAppDeployEvent creates an event for use in the activity feed
+// TODO: remove this method and all call-sites if this span no longer exists in telemetry for 4 consecutive weeks
+func createOldPorterAppDeployEvent(ctx context.Context, status types.PorterAppEventStatus, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-old-porter-app-deploy-event")
+	defer span.End()
+
 	event := models.PorterAppEvent{
 		ID:                 uuid.New(),
-		Status:             status,
+		Status:             string(status),
 		Type:               "DEPLOY",
 		TypeExternalSource: "KUBERNETES",
 		PorterAppID:        appID,
@@ -481,7 +538,117 @@ func createPorterAppEvent(ctx context.Context, status string, appID uint, revisi
 	return &event, nil
 }
 
-func createReleaseJobChart(
+// createNewPorterAppDeployEvent creates an event for use in the activity feed, supplemented with information about the
+// deployed services in serviceStatusMap as well as the image tag being deployed
+func createNewPorterAppDeployEvent(
+	ctx context.Context,
+	serviceStatusMap map[string]types.ServiceDeploymentMetadata,
+	status types.PorterAppEventStatus,
+	appID uint,
+	revision int,
+	tag string,
+	repo repository.PorterAppEventRepository,
+) (*models.PorterAppEvent, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-new-porter-app-deploy-event")
+	defer span.End()
+
+	// mark all pending deployments from the deploy event of the previous revision as canceled
+	updatePreviousPorterAppDeployEvent(ctx, appID, revision, repo)
+
+	event := models.PorterAppEvent{
+		ID:                 uuid.New(),
+		Status:             string(status),
+		Type:               "DEPLOY",
+		TypeExternalSource: "KUBERNETES",
+		PorterAppID:        appID,
+		Metadata: map[string]any{
+			"revision":                    revision,
+			"image_tag":                   tag,
+			"service_deployment_metadata": serviceStatusMap,
+		},
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revision}, telemetry.AttributeKV{Key: "image-tag", Value: tag})
+
+	err := repo.CreateEvent(ctx, &event)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating porter app event")
+		return nil, err
+	}
+
+	if event.ID == uuid.Nil {
+		return nil, telemetry.Error(ctx, span, nil, "event id for newly created app event is nil")
+	}
+
+	return &event, nil
+}
+
+// updatePreviousPorterAppDeployEvent updates the previous deploy event to change the event status as well as all service statuses to CANCELED
+// if it is still in the PROGRESSING state. This is done to prevent the activity feed from showing an old deploy event as still in progress.
+func updatePreviousPorterAppDeployEvent(ctx context.Context, appID uint, revision int, repo repository.PorterAppEventRepository) {
+	ctx, span := telemetry.NewSpan(ctx, "update-previous-porter-app-deploy-event")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-previous-event", Value: false}, telemetry.AttributeKV{Key: "new-revision", Value: revision})
+	if revision <= 1 {
+		return
+	}
+	revisionFloat64 := float64(revision - 1)
+	matchEvent, err := repo.ReadDeployEventByRevision(ctx, appID, revisionFloat64)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error reading deploy event by revision")
+		return
+	}
+	if matchEvent.ID == uuid.Nil {
+		_ = telemetry.Error(ctx, span, nil, "could not find previous deploy event")
+		return
+	}
+	if matchEvent.Status != string(types.PorterAppEventStatus_Progressing) {
+		return
+	}
+	serviceStatus, ok := matchEvent.Metadata["service_deployment_metadata"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
+		return
+	}
+	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata is not map[string]interface{}")
+		return
+	}
+	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
+	for k, v := range serviceDeploymentGenericMap {
+		by, err := json.Marshal(v)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to marshal")
+			return
+		}
+
+		var serviceDeploymentMetadata types.ServiceDeploymentMetadata
+		err = json.Unmarshal(by, &serviceDeploymentMetadata)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to unmarshal")
+			return
+		}
+		serviceDeploymentMap[k] = serviceDeploymentMetadata
+	}
+	for key, serviceDeploymentMetadata := range serviceDeploymentMap {
+		if serviceDeploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
+			serviceDeploymentMetadata.Status = types.PorterAppEventStatus_Canceled
+			serviceDeploymentMap[key] = serviceDeploymentMetadata
+		}
+	}
+	matchEvent.Metadata["service_deployment_metadata"] = serviceDeploymentMap
+	matchEvent.Status = string(types.PorterAppEventStatus_Canceled)
+	err = repo.UpdateEvent(ctx, &matchEvent)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error updating deploy event")
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-previous-event", Value: true})
+}
+
+func createPreDeployJobChart(
 	ctx context.Context,
 	stackName string,
 	values map[string]interface{},
@@ -495,8 +662,8 @@ func createReleaseJobChart(
 		return nil, err
 	}
 
-	releaseName := fmt.Sprintf("%s-r", stackName)
-	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+	releaseName := utils.PredeployJobNameFromPorterAppName(stackName)
+	namespace := utils.NamespaceFromPorterAppName(stackName)
 
 	return &helm.InstallChartConfig{
 		Chart:      chart,

+ 199 - 8
api/server/handlers/porter_app/create_and_update_events.go

@@ -2,6 +2,7 @@ package porter_app
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"strings"
@@ -48,14 +49,14 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		e := telemetry.Error(ctx, span, reqErr, "error parsing stack name from url")
 		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
 		return
 	}
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "porter-app-name", Value: stackName},
+		telemetry.AttributeKV{Key: "porter-app-name", Value: appName},
 		telemetry.AttributeKV{Key: "porter-app-event-type", Value: string(request.Type)},
 		telemetry.AttributeKV{Key: "porter-app-event-status", Value: request.Status},
 		telemetry.AttributeKV{Key: "porter-app-event-external-source", Value: request.TypeExternalSource},
@@ -63,11 +64,11 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 	)
 
 	if request.Type == types.PorterAppEventType_Build {
-		reportBuildStatus(ctx, request, p.Config(), user, project, stackName)
+		reportBuildStatus(ctx, request, p.Config(), user, project, appName)
 	}
 
 	if request.ID == "" {
-		event, err := p.createNewAppEvent(ctx, *cluster, stackName, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
+		event, err := p.createNewAppEvent(ctx, *cluster, appName, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
 		if err != nil {
 			e := telemetry.Error(ctx, span, err, "error creating new app event")
 			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
@@ -77,7 +78,7 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	event, err := p.updateExistingAppEvent(ctx, *cluster, stackName, *request)
+	event, err := p.updateExistingAppEvent(ctx, *cluster, appName, *request)
 	if err != nil {
 		e := telemetry.Error(ctx, span, err, "error creating new app event")
 		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
@@ -115,7 +116,7 @@ func reportBuildStatus(ctx context.Context, request *types.CreateOrUpdatePorterA
 }
 
 // createNewAppEvent will create a new app event for the given porter app name. If the app event is an agent event, then it will be created only if there is no existing event which has the agent ID. In the case that an existing event is found, that will be returned instead
-func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status string, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
+func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status types.PorterAppEventStatus, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-porter-app-event")
 	defer span.End()
 
@@ -123,6 +124,9 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 	if err != nil {
 		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app by name for cluster")
 	}
+	if app == nil || app.ID == 0 {
+		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app not found")
+	}
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID},
 		telemetry.AttributeKV{Key: "porter-app-name", Value: porterAppName},
@@ -156,9 +160,16 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 		}
 	}
 
+	if eventType == string(types.PorterAppEventType_Deploy) {
+		// Agent has no way to know what the porter app event id is, so update the deploy event if it exists
+		if _, ok := requestMetadata["deploy_status"]; ok {
+			return p.updateDeployEvent(ctx, porterAppName, app.ID, requestMetadata), nil
+		}
+	}
+
 	event := models.PorterAppEvent{
 		ID:                 uuid.New(),
-		Status:             status,
+		Status:             string(status),
 		Type:               eventType,
 		TypeExternalSource: externalSource,
 		PorterAppID:        app.ID,
@@ -210,7 +221,7 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 	}
 
 	if submittedEvent.Status != "" {
-		existingAppEvent.Status = submittedEvent.Status
+		existingAppEvent.Status = string(submittedEvent.Status)
 	}
 
 	if submittedEvent.Metadata != nil {
@@ -226,3 +237,183 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 
 	return existingAppEvent.ToPorterAppEvent(), nil
 }
+
+// updateDeployEvent attempts to update the deploy event with the deploy status of each service given in updatedStatusMetadata
+// an update is only made in the following cases:
+// 1. the deploy event is found
+// 2. the deploy event is in the PROGRESSING state
+// 3. the deploy event service deployment metadata is formatted correctly
+// 4. the services specified in the updatedStatusMetadata match the services in the deploy event metadata
+// 5. some of the above services are still in the PROGRESSING state
+// if one of these conditions is not met, then an empty event is returned and no update is made; otherwise, the matched event is returned
+func (p *CreateUpdatePorterAppEventHandler) updateDeployEvent(ctx context.Context, appName string, appID uint, updatedStatusMetadata map[string]any) types.PorterAppEvent {
+	ctx, span := telemetry.NewSpan(ctx, "update-deploy-event")
+	defer span.End()
+
+	revision, ok := updatedStatusMetadata["revision"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "revision not found in request metadata")
+		return types.PorterAppEvent{}
+	}
+	revisionFloat64, ok := revision.(float64)
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "revision not a float64")
+		return types.PorterAppEvent{}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revisionFloat64})
+
+	podName, ok := updatedStatusMetadata["pod_name"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "pod name not found in request metadata")
+		return types.PorterAppEvent{}
+	}
+	podNameStr, ok := podName.(string)
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "pod name not a string")
+		return types.PorterAppEvent{}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: podNameStr})
+
+	serviceName := getServiceNameFromPodName(podNameStr, appName)
+	if serviceName == "" {
+		_ = telemetry.Error(ctx, span, nil, "service name not found in pod name")
+		return types.PorterAppEvent{}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: serviceName})
+
+	newStatus, ok := updatedStatusMetadata["deploy_status"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "deploy status not found in request metadata")
+		return types.PorterAppEvent{}
+	}
+	newStatusStr, ok := newStatus.(string)
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "deploy status not a string")
+		return types.PorterAppEvent{}
+	}
+	var porterAppEventStatus types.PorterAppEventStatus
+	switch newStatusStr {
+	case string(types.PorterAppEventStatus_Success):
+		porterAppEventStatus = types.PorterAppEventStatus_Success
+	case string(types.PorterAppEventStatus_Failed):
+		porterAppEventStatus = types.PorterAppEventStatus_Failed
+	case string(types.PorterAppEventStatus_Progressing):
+		porterAppEventStatus = types.PorterAppEventStatus_Progressing
+	default:
+		_ = telemetry.Error(ctx, span, nil, "deploy status not valid")
+		return types.PorterAppEvent{}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-status", Value: string(porterAppEventStatus)})
+
+	matchEvent, err := p.Repo().PorterAppEvent().ReadDeployEventByRevision(ctx, appID, revisionFloat64)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "error finding matching deploy event")
+		return types.PorterAppEvent{}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: false})
+
+	// first check to see if the event is empty, meaning there was no match found, or not progressing, meaning it has already been updated
+	if matchEvent.ID == uuid.Nil || matchEvent.Status != string(types.PorterAppEventStatus_Progressing) {
+		return types.PorterAppEvent{}
+	}
+
+	serviceStatus, ok := matchEvent.Metadata["service_deployment_metadata"]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
+		return types.PorterAppEvent{}
+	}
+	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "service deployment metadata is not map[string]interface{}")
+		return types.PorterAppEvent{}
+	}
+	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
+	for k, v := range serviceDeploymentGenericMap {
+		by, err := json.Marshal(v)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to marshal")
+			return types.PorterAppEvent{}
+		}
+
+		var serviceDeploymentMetadata types.ServiceDeploymentMetadata
+		err = json.Unmarshal(by, &serviceDeploymentMetadata)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, nil, "unable to unmarshal")
+			return types.PorterAppEvent{}
+		}
+		serviceDeploymentMap[k] = serviceDeploymentMetadata
+	}
+	serviceDeploymentMetadata, ok := serviceDeploymentMap[serviceName]
+	if !ok {
+		_ = telemetry.Error(ctx, span, nil, "deployment metadata not found for service")
+		return types.PorterAppEvent{}
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-status", Value: serviceDeploymentMetadata.Status})
+
+	// only update service status if it has not been updated yet
+	if serviceDeploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
+		// update the map with the new status
+		serviceDeploymentMetadata.Status = porterAppEventStatus
+		serviceDeploymentMap[serviceName] = serviceDeploymentMetadata
+
+		// update the deploy event with new map and status if all services are done
+		// note: this assumes that all services are reported 'done' sequentially
+		// if two service statuses are updated at the same time, we might miss updating the parent deploy event
+		matchEvent.Metadata["service_deployment_metadata"] = serviceDeploymentMap
+		allServicesDone := true
+		anyServicesFailed := false
+		for _, deploymentMetadata := range serviceDeploymentMap {
+			if deploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
+				allServicesDone = false
+				break
+			}
+			if deploymentMetadata.Status == types.PorterAppEventStatus_Failed {
+				anyServicesFailed = true
+			}
+		}
+		if allServicesDone {
+			if anyServicesFailed {
+				matchEvent.Status = string(types.PorterAppEventStatus_Failed)
+			} else {
+				matchEvent.Status = string(types.PorterAppEventStatus_Success)
+			}
+		}
+
+		err = p.Repo().PorterAppEvent().UpdateEvent(ctx, &matchEvent)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error updating deploy event")
+			return matchEvent.ToPorterAppEvent()
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: true})
+		return matchEvent.ToPorterAppEvent()
+	}
+
+	return types.PorterAppEvent{}
+}
+
+func getServiceNameFromPodName(podName, porterAppName string) string {
+	prefix := porterAppName + "-"
+	if !strings.HasPrefix(podName, prefix) {
+		return ""
+	}
+
+	podName = strings.TrimPrefix(podName, prefix)
+	suffixes := []string{"-web", "-wkr", "-job"}
+	index := -1
+
+	for _, suffix := range suffixes {
+		newIndex := strings.LastIndex(podName, suffix)
+		if newIndex > index {
+			index = newIndex
+		}
+	}
+
+	if index != -1 {
+		return podName[:index]
+	}
+
+	return ""
+}

+ 59 - 47
api/server/handlers/porter_app/create_secret_and_open_pr.go

@@ -37,7 +37,7 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
 		return
@@ -54,46 +54,56 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// generate porter jwt token
-	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
-		return
-	}
-	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
-		return
-	}
+	var secretName string
+	if request.DeleteWorkflowFilename == "" {
+		// generate porter jwt token
+		jwt, err := token.GetTokenForAPI(user.ID, project.ID)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
+			return
+		}
+		encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
+			return
+		}
 
-	// create porter secret
-	secretName := fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
-	err = actions.CreateGithubSecret(
-		client,
-		secretName,
-		encoded,
-		request.GithubRepoOwner,
-		request.GithubRepoName,
-	)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating secret: %w", err)))
-		return
+		// create porter secret
+		secretName = fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
+		err = actions.CreateGithubSecret(
+			client,
+			secretName,
+			encoded,
+			request.GithubRepoOwner,
+			request.GithubRepoName,
+		)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating secret: %w", err)))
+			return
+		}
 	}
 
 	var pr *github.PullRequest
-	if request.OpenPr {
+	var prRequestBody string
+	if request.DeleteWorkflowFilename == "" {
+		prRequestBody = "Hello 👋 from Porter! Please merge this PR to finish setting up your application."
+	} else {
+		prRequestBody = "Please merge this PR to delete the workflow file associated with your application."
+	}
+	if request.OpenPr || request.DeleteWorkflowFilename != "" {
 		pr, err = actions.OpenGithubPR(&actions.GithubPROpts{
-			Client:         client,
-			GitRepoOwner:   request.GithubRepoOwner,
-			GitRepoName:    request.GithubRepoName,
-			StackName:      stackName,
-			ProjectID:      project.ID,
-			ClusterID:      cluster.ID,
-			ServerURL:      c.Config().ServerConf.ServerURL,
-			DefaultBranch:  request.Branch,
-			SecretName:     secretName,
-			PorterYamlPath: request.PorterYamlPath,
-			Body:           "Hello 👋 from Porter! Please merge this PR to finish setting up your application.",
+			Client:                 client,
+			GitRepoOwner:           request.GithubRepoOwner,
+			GitRepoName:            request.GithubRepoName,
+			StackName:              appName,
+			ProjectID:              project.ID,
+			ClusterID:              cluster.ID,
+			ServerURL:              c.Config().ServerConf.ServerURL,
+			DefaultBranch:          request.Branch,
+			SecretName:             secretName,
+			PorterYamlPath:         request.PorterYamlPath,
+			Body:                   prRequestBody,
+			DeleteWorkflowFilename: request.DeleteWorkflowFilename,
 		})
 	}
 
@@ -119,19 +129,21 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			URL: pr.GetHTMLURL(),
 		}
 
-		// update DB with the PR url
-		porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to get porter app db: %w", err)))
-			return
-		}
+		if request.DeleteWorkflowFilename == "" {
+			// update DB with the PR url
+			porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to get porter app db: %w", err)))
+				return
+			}
 
-		porterApp.PullRequestURL = pr.GetHTMLURL()
+			porterApp.PullRequestURL = pr.GetHTMLURL()
 
-		_, err = c.Repo().PorterApp().UpdatePorterApp(porterApp)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to write pr url to porter app db: %w", err)))
-			return
+			_, err = c.Repo().PorterApp().UpdatePorterApp(porterApp)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to write pr url to porter app db: %w", err)))
+				return
+			}
 		}
 	}
 

+ 2 - 2
api/server/handlers/porter_app/delete.go

@@ -33,13 +33,13 @@ func (c *DeletePorterAppByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.
 	ctx := r.Context()
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
 		return
 	}
 
-	porterApp, appErr := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	porterApp, appErr := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 	if appErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(appErr))
 		return

+ 6 - 2
api/server/handlers/porter_app/get.go

@@ -11,6 +11,7 @@ import (
 	"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"
 )
 
 type GetPorterAppHandler struct {
@@ -29,14 +30,17 @@ func NewGetPorterAppHandler(
 
 func (c *GetPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-porter-app")
+	defer span.End()
+
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
 		return
 	}
 
-	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return

+ 75 - 0
api/server/handlers/porter_app/helm_release.go

@@ -0,0 +1,75 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	utils "github.com/porter-dev/porter/api/utils/porter_app"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type PorterAppHelmReleaseGetHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewPorterAppHelmReleaseGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *PorterAppHelmReleaseGetHandler {
+	return &PorterAppHelmReleaseGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *PorterAppHelmReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-porter-app-helm-release")
+	defer span.End()
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	version, reqErr := requestutils.GetURLParamUint(r, types.URLParamReleaseVersion)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting version from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "version", Value: version})
+
+	namespace := utils.NamespaceFromPorterAppName(appName)
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	release, err := helmAgent.GetRelease(ctx, appName, int(version), false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	res := &types.Release{
+		Release: release,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 63 - 0
api/server/handlers/porter_app/helm_release_history.go

@@ -0,0 +1,63 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	utils "github.com/porter-dev/porter/api/utils/porter_app"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type PorterAppHelmReleaseHistoryGetHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewPorterAppHelmReleaseHistoryGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *PorterAppHelmReleaseHistoryGetHandler {
+	return &PorterAppHelmReleaseHistoryGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *PorterAppHelmReleaseHistoryGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-porter-app-helm-release-history")
+	defer span.End()
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	namespace := utils.NamespaceFromPorterAppName(appName)
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	history, err := helmAgent.GetReleaseHistory(ctx, appName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release history")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, history)
+}

+ 8 - 8
api/server/handlers/porter_app/list_events.go

@@ -45,7 +45,7 @@ func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	user, _ := ctx.Value(types.UserScope).(*models.User)
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		e := telemetry.Error(ctx, span, nil, "error parsing stack name from url")
 		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
@@ -61,7 +61,7 @@ func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -77,8 +77,8 @@ func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	}
 
 	for idx, appEvent := range porterAppEvents {
-		if appEvent.Status == "PROGRESSING" {
-			pae, err := p.updateExistingAppEvent(ctx, *cluster, stackName, *appEvent, user, project)
+		if appEvent.Status == string(types.PorterAppEventStatus_Progressing) {
+			pae, err := p.updateExistingAppEvent(ctx, *cluster, appName, *appEvent, user, project)
 			if err != nil {
 				telemetry.Error(ctx, span, nil, "unable to update existing porter app event")
 			}
@@ -219,11 +219,11 @@ func (p *PorterAppEventListHandler) updateBuildEvent_Github(
 
 	if *actionRun.Status == "completed" {
 		if *actionRun.Conclusion == "success" {
-			event.Status = "SUCCESS"
-			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", "SUCCESS")
+			event.Status = string(types.PorterAppEventStatus_Success)
+			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", types.PorterAppEventStatus_Success)
 		} else {
-			event.Status = "FAILED"
-			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", "FAILED")
+			event.Status = string(types.PorterAppEventStatus_Failed)
+			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", types.PorterAppEventStatus_Failed)
 		}
 		event.Metadata["end_time"] = actionRun.GetUpdatedAt().Time
 	}

+ 241 - 26
api/server/handlers/porter_app/parse.go

@@ -1,17 +1,22 @@
 package porter_app
 
 import (
+	"context"
 	"fmt"
 	"strconv"
 	"strings"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	porterAppUtils "github.com/porter-dev/porter/api/utils/porter_app"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/domain"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/kubernetes/porter_app"
 	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/stefanmcshane/helm/pkg/chart"
 
@@ -73,6 +78,8 @@ type SubdomainCreateOpts struct {
 }
 
 type ParseConf struct {
+	// PorterAppName is the name of the porter app
+	PorterAppName string
 	// PorterYaml is the raw porter yaml which is used to build the values + chart for helm upgrade
 	PorterYaml []byte
 	// ImageInfo contains the repository and tag of the image to use for the helm upgrade. Kept separate from the PorterYaml because the image info
@@ -86,6 +93,8 @@ type ParseConf struct {
 	UserUpdate bool
 	// EnvGroups used for synced env groups
 	EnvGroups []string
+	// EnvironmentGroups are used for syncing environment groups using ConfigMaps and Secrets from porter-env-groups namespace. This should be used instead of EnvGroups
+	EnvironmentGroups []string
 	// Namespace used for synced env groups
 	Namespace string
 	// ExistingHelmValues is the existing values for the helm release, if it exists
@@ -100,21 +109,28 @@ type ParseConf struct {
 	ShouldValidateHelmValues bool
 	// FullHelmValues if provided, override anything specified in porter.yaml. Used as an escape hatch for support
 	FullHelmValues string
+	// AddCustomNodeSelector is a flag to determine whether to add porter.run/workload-kind: application to the nodeselector attribute of the helm values
+	AddCustomNodeSelector bool
 }
 
-func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]interface{}, error) {
+func parse(ctx context.Context, conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]interface{}, error) {
+	ctx, span := telemetry.NewSpan(ctx, "parse-porter-yaml")
+	defer span.End()
+
 	parsed := &PorterStackYAML{}
 
 	if conf.FullHelmValues != "" {
 		parsedHelmValues, err := convertHelmValuesToPorterYaml(conf.FullHelmValues)
 		if err != nil {
-			return nil, nil, nil, fmt.Errorf("%s: %w", "error parsing raw helm values", err)
+			err = telemetry.Error(ctx, span, err, "error parsing raw helm values")
+			return nil, nil, nil, err
 		}
 		parsed = parsedHelmValues
 	} else {
 		err := yaml.Unmarshal(conf.PorterYaml, parsed)
 		if err != nil {
-			return nil, nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
+			err = telemetry.Error(ctx, span, err, "error parsing porter.yaml")
+			return nil, nil, nil, err
 		}
 	}
 
@@ -123,16 +139,19 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 	for i := range conf.EnvGroups {
 		cm, _, err := conf.SubdomainCreateOpts.k8sAgent.GetLatestVersionedConfigMap(conf.EnvGroups[i], conf.Namespace)
 		if err != nil {
-			return nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
+			err = telemetry.Error(ctx, span, err, "error getting latest versioned config map")
+			return nil, nil, nil, err
 		}
 
 		versionStr, ok := cm.ObjectMeta.Labels["version"]
 		if !ok {
-			return nil, nil, nil, fmt.Errorf("error extracting version from config map")
+			err = telemetry.Error(ctx, span, nil, "error extracting version from config map")
+			return nil, nil, nil, err
 		}
 		versionInt, err := strconv.Atoi(versionStr)
 		if err != nil {
-			return nil, nil, nil, fmt.Errorf("error converting version to int: %w", err)
+			err = telemetry.Error(ctx, span, err, "error converting version to int")
+			return nil, nil, nil, err
 		}
 
 		version := uint(versionInt)
@@ -156,7 +175,8 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 	}
 
 	if parsed.Apps != nil && parsed.Services != nil {
-		return nil, nil, nil, fmt.Errorf("'apps' and 'services' are synonymous but both were defined")
+		err := telemetry.Error(ctx, span, nil, "'apps' and 'services' are synonymous but both were defined")
+		return nil, nil, nil, err
 	}
 
 	var services map[string]*Service
@@ -168,6 +188,10 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 		services = parsed.Services
 	}
 
+	for serviceName := range services {
+		services[serviceName] = addLabelsToService(services[serviceName], conf.EnvironmentGroups, porter_app.LabelKey_PorterApplication)
+	}
+
 	application := &Application{
 		Env:      parsed.Env,
 		Services: services,
@@ -175,27 +199,35 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 		Release:  parsed.Release,
 	}
 
-	values, err := buildUmbrellaChartValues(application, synced_env, conf.ImageInfo, conf.ExistingHelmValues, conf.SubdomainCreateOpts, conf.InjectLauncherToStartCommand, conf.ShouldValidateHelmValues, conf.UserUpdate)
+	values, err := buildUmbrellaChartValues(ctx, application, synced_env, conf.ImageInfo, conf.ExistingHelmValues, conf.SubdomainCreateOpts, conf.InjectLauncherToStartCommand, conf.ShouldValidateHelmValues, conf.UserUpdate, conf.Namespace, conf.AddCustomNodeSelector)
 	if err != nil {
-		return nil, nil, nil, fmt.Errorf("%s: %w", "error building values", err)
+		err = telemetry.Error(ctx, span, err, "error building values")
+		return nil, nil, nil, err
+	}
+	convertedValues, ok := convertMap(values).(map[string]interface{})
+	if !ok {
+		err = telemetry.Error(ctx, span, nil, "error converting values")
+		return nil, nil, nil, err
 	}
-	convertedValues := convertMap(values).(map[string]interface{})
 
-	chart, err := buildUmbrellaChart(application, conf.ServerConfig, conf.ProjectID, conf.ExistingChartDependencies)
+	umbrellaChart, err := buildUmbrellaChart(application, conf.ServerConfig, conf.ProjectID, conf.ExistingChartDependencies)
 	if err != nil {
-		return nil, nil, nil, fmt.Errorf("%s: %w", "error building chart", err)
+		err = telemetry.Error(ctx, span, err, "error building umbrella chart")
+		return nil, nil, nil, err
 	}
 
 	// return the parsed release values for the release job chart, if they exist
 	var preDeployJobValues map[string]interface{}
 	if application.Release != nil && application.Release.Run != nil {
-		preDeployJobValues = buildPreDeployJobChartValues(application.Release, application.Env, synced_env, conf.ImageInfo, conf.InjectLauncherToStartCommand, conf.ExistingHelmValues, strings.TrimSuffix(strings.TrimPrefix(conf.Namespace, "porter-stack-"), "")+"-r", conf.UserUpdate)
+		application.Release = addLabelsToService(application.Release, conf.EnvironmentGroups, porter_app.LabelKey_PorterApplicationPreDeploy)
+		preDeployJobValues = buildPreDeployJobChartValues(application.Release, application.Env, synced_env, conf.ImageInfo, conf.InjectLauncherToStartCommand, conf.ExistingHelmValues, porterAppUtils.PredeployJobNameFromPorterAppName(conf.PorterAppName), conf.UserUpdate, conf.AddCustomNodeSelector)
 	}
 
-	return chart, convertedValues, preDeployJobValues, nil
+	return umbrellaChart, convertedValues, preDeployJobValues, nil
 }
 
 func buildUmbrellaChartValues(
+	ctx context.Context,
 	application *Application,
 	syncedEnv []*SyncedEnvSection,
 	imageInfo types.ImageInfo,
@@ -204,6 +236,8 @@ func buildUmbrellaChartValues(
 	injectLauncher bool,
 	shouldValidateHelmValues bool,
 	userUpdate bool,
+	namespace string,
+	addCustomNodeSelector bool,
 ) (map[string]interface{}, error) {
 	values := make(map[string]interface{})
 
@@ -216,7 +250,7 @@ func buildUmbrellaChartValues(
 	for name, service := range application.Services {
 		serviceType := getType(name, service)
 
-		defaultValues := getDefaultValues(service, application.Env, syncedEnv, serviceType, existingValues, name, userUpdate)
+		defaultValues := getDefaultValues(service, application.Env, syncedEnv, serviceType, existingValues, name, userUpdate, addCustomNodeSelector)
 		convertedConfig := convertMap(service.Config).(map[string]interface{})
 		helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
 
@@ -234,7 +268,12 @@ func buildUmbrellaChartValues(
 			return nil, fmt.Errorf("error validating service \"%s\": %s", name, validateErr)
 		}
 
-		err := createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
+		err := syncEnvironmentGroupToNamespaceIfLabelsExist(ctx, opts.k8sAgent, service, namespace)
+		if err != nil {
+			return nil, fmt.Errorf("error syncing environment group to namespace: %w", err)
+		}
+
+		err = createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
 		if err != nil {
 			return nil, err
 		}
@@ -273,18 +312,69 @@ func buildUmbrellaChartValues(
 		}
 	}
 
-	if imageInfo.Repository != "" && imageInfo.Tag != "" {
-		values["global"] = map[string]interface{}{
-			"image": map[string]interface{}{
-				"repository": imageInfo.Repository,
-				"tag":        imageInfo.Tag,
-			},
-		}
+	values["global"] = map[string]interface{}{
+		"image": map[string]interface{}{
+			"repository": imageInfo.Repository,
+			"tag":        imageInfo.Tag,
+		},
 	}
 
 	return values, nil
 }
 
+// syncEnvironmentGroupToNamespaceIfLabelsExist will sync the latest version of the environment group to the target namespace if the service has the appropriate label.
+func syncEnvironmentGroupToNamespaceIfLabelsExist(ctx context.Context, agent *kubernetes.Agent, service *Service, targetNamespace string) error {
+	var linkedGroupNames string
+
+	// patchwork because we are not consistent with the type of labels
+	if labels, ok := service.Config["labels"].(map[string]any); ok {
+		if linkedGroup, ok := labels[environment_groups.LabelKey_LinkedEnvironmentGroup].(string); ok {
+			linkedGroupNames = linkedGroup
+		}
+	}
+	if labels, ok := service.Config["labels"].(map[string]string); ok {
+		if linkedGroup, ok := labels[environment_groups.LabelKey_LinkedEnvironmentGroup]; ok {
+			linkedGroupNames = linkedGroup
+		}
+	}
+
+	for _, linkedGroupName := range strings.Split(linkedGroupNames, ".") {
+		inp := environment_groups.SyncLatestVersionToNamespaceInput{
+			BaseEnvironmentGroupName: linkedGroupName,
+			TargetNamespace:          targetNamespace,
+		}
+
+		syncedEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, agent, inp)
+		if err != nil {
+			return fmt.Errorf("error syncing environment group: %w", err)
+		}
+		if syncedEnvironment.EnvironmentGroupVersionedName != "" {
+			if service.Config["configMapRefs"] == nil {
+				service.Config["configMapRefs"] = []string{}
+			}
+			if service.Config["secretRefs"] == nil {
+				service.Config["secretRefs"] = []string{}
+			}
+
+			switch service.Config["configMapRefs"].(type) {
+			case []string:
+				service.Config["configMapRefs"] = append(service.Config["configMapRefs"].([]string), syncedEnvironment.EnvironmentGroupVersionedName)
+			case []any:
+				service.Config["configMapRefs"] = append(service.Config["configMapRefs"].([]any), syncedEnvironment.EnvironmentGroupVersionedName)
+			}
+
+			switch service.Config["configMapRefs"].(type) {
+			case []string:
+				service.Config["secretRefs"] = append(service.Config["secretRefs"].([]string), syncedEnvironment.EnvironmentGroupVersionedName)
+			case []any:
+				service.Config["secretRefs"] = append(service.Config["secretRefs"].([]any), syncedEnvironment.EnvironmentGroupVersionedName)
+			}
+		}
+	}
+
+	return nil
+}
+
 // we can add to this function up later or use an alternative
 func validateHelmValues(values map[string]interface{}, shouldValidateHelmValues bool, appType string) string {
 	if shouldValidateHelmValues {
@@ -314,8 +404,8 @@ func validateHelmValues(values map[string]interface{}, shouldValidateHelmValues
 	return ""
 }
 
-func buildPreDeployJobChartValues(release *Service, env map[string]string, synced_env []*SyncedEnvSection, imageInfo types.ImageInfo, injectLauncher bool, existingValues map[string]interface{}, name string, userUpdate bool) map[string]interface{} {
-	defaultValues := getDefaultValues(release, env, synced_env, "job", existingValues, name+"-r", userUpdate)
+func buildPreDeployJobChartValues(release *Service, env map[string]string, synced_env []*SyncedEnvSection, imageInfo types.ImageInfo, injectLauncher bool, existingValues map[string]interface{}, name string, userUpdate bool, addCustomNodeSelector bool) map[string]interface{} {
+	defaultValues := getDefaultValues(release, env, synced_env, "job", existingValues, name+"-r", userUpdate, addCustomNodeSelector)
 	convertedConfig := convertMap(release.Config).(map[string]interface{})
 	helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
 
@@ -355,7 +445,7 @@ func getType(name string, service *Service) string {
 	return "worker"
 }
 
-func getDefaultValues(service *Service, env map[string]string, synced_env []*SyncedEnvSection, appType string, existingValues map[string]interface{}, name string, userUpdate bool) map[string]interface{} {
+func getDefaultValues(service *Service, env map[string]string, synced_env []*SyncedEnvSection, appType string, existingValues map[string]interface{}, name string, userUpdate bool, addCustomNodeSelector bool) map[string]interface{} {
 	var defaultValues map[string]interface{}
 	var runCommand string
 	if service.Run != nil {
@@ -377,6 +467,13 @@ func getDefaultValues(service *Service, env map[string]string, synced_env []*Syn
 				"synced": syncedEnvs,
 			},
 		},
+		"nodeSelector": map[string]interface{}{},
+	}
+
+	if addCustomNodeSelector {
+		defaultValues["nodeSelector"] = map[string]interface{}{
+			"porter.run/workload-kind": "application",
+		}
 	}
 
 	return defaultValues
@@ -527,6 +624,12 @@ func convertMap(m interface{}) interface{} {
 		for k, v := range m {
 			m[k] = convertMap(v)
 		}
+	case map[string]string:
+		result := map[string]interface{}{}
+		for k, v := range m {
+			result[k] = v
+		}
+		return result
 	case map[interface{}]interface{}:
 		result := map[string]interface{}{}
 		for k, v := range m {
@@ -782,6 +885,7 @@ func convertHelmValuesToPorterYaml(helmValues string) (*PorterStackYAML, error)
 		return nil, err
 	}
 	services := make(map[string]*Service)
+
 	for k, v := range values {
 		if k == "global" {
 			continue
@@ -790,12 +894,123 @@ func convertHelmValuesToPorterYaml(helmValues string) (*PorterStackYAML, error)
 		if serviceName == "" {
 			return nil, fmt.Errorf("invalid service key: %s. make sure that service key ends in either -web, -wkr, or -job", k)
 		}
+
 		services[serviceName] = &Service{
 			Config: convertMap(v).(map[string]interface{}),
 			Type:   &serviceType,
 		}
 	}
+
 	return &PorterStackYAML{
 		Services: services,
 	}, nil
 }
+
+// addLabelsToService always adds the default label to the service, and if envGroups is not empty, it adds the corresponding environment group label as well.
+func addLabelsToService(service *Service, envGroups []string, defaultLabelKey string) *Service {
+	if _, ok := service.Config["labels"]; !ok {
+		service.Config["labels"] = make(map[string]string)
+	}
+	if len(envGroups) != 0 {
+		// delete the env group label so we can replace it
+		if _, ok := service.Config["labels"].(map[string]any); ok {
+			delete(service.Config["labels"].(map[string]any), environment_groups.LabelKey_LinkedEnvironmentGroup)
+		}
+	}
+
+	switch service.Config["labels"].(type) {
+	case map[string]string:
+		service.Config["labels"].(map[string]string)[defaultLabelKey] = porter_app.LabelValue_PorterApplication
+		if len(envGroups) != 0 {
+			service.Config["labels"].(map[string]string)[environment_groups.LabelKey_LinkedEnvironmentGroup] = strings.Join(envGroups, ".")
+		}
+	case map[string]any:
+		service.Config["labels"].(map[string]any)[defaultLabelKey] = porter_app.LabelValue_PorterApplication
+		if len(envGroups) != 0 {
+			service.Config["labels"].(map[string]any)[environment_groups.LabelKey_LinkedEnvironmentGroup] = strings.Join(envGroups, ".")
+		}
+	case any:
+		if val, ok := service.Config["labels"].(string); ok {
+			if val == "" {
+				service.Config["labels"] = map[string]string{
+					defaultLabelKey: porter_app.LabelValue_PorterApplication,
+				}
+				if len(envGroups) != 0 {
+					service.Config["labels"].(map[string]string)[environment_groups.LabelKey_LinkedEnvironmentGroup] = strings.Join(envGroups, ".")
+				}
+			}
+		}
+	}
+
+	if _, ok := service.Config["podLabels"]; !ok {
+		service.Config["podLabels"] = make(map[string]string)
+	}
+	switch service.Config["podLabels"].(type) {
+	case map[string]string:
+		service.Config["podLabels"].(map[string]string)[defaultLabelKey] = porter_app.LabelValue_PorterApplication
+	case map[string]any:
+		service.Config["podLabels"].(map[string]any)[defaultLabelKey] = porter_app.LabelValue_PorterApplication
+	case any:
+		if val, ok := service.Config["podLabels"].(string); ok {
+			if val == "" {
+				service.Config["podLabels"] = map[string]string{
+					defaultLabelKey: porter_app.LabelValue_PorterApplication,
+				}
+			}
+		}
+	}
+
+	return service
+}
+
+func getServiceDeploymentMetadataFromValues(values map[string]interface{}, status types.PorterAppEventStatus) map[string]types.ServiceDeploymentMetadata {
+	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
+
+	for key := range values {
+		if key != "global" {
+			serviceName, serviceType := getServiceNameAndTypeFromHelmName(key)
+			externalURI := getServiceExternalURIFromServiceValues(values[key].(map[string]interface{}))
+			// jobs don't technically have a deployment, so hardcode the deployment status to success
+			serviceStatus := status
+			if serviceType == "job" {
+				serviceStatus = types.PorterAppEventStatus_Success
+			}
+			serviceDeploymentMap[serviceName] = types.ServiceDeploymentMetadata{
+				ExternalURI: externalURI,
+				Status:      serviceStatus,
+				Type:        serviceType,
+			}
+		}
+	}
+	return serviceDeploymentMap
+}
+
+func getServiceExternalURIFromServiceValues(serviceValues map[string]interface{}) string {
+	ingressMap, err := getNestedMap(serviceValues, "ingress")
+	if err == nil {
+		enabledVal, enabledExists := ingressMap["enabled"]
+		if enabledExists {
+			enabled, eOK := enabledVal.(bool)
+			if eOK && enabled {
+				customDomVal, customDomExists := ingressMap["custom_domain"]
+				if customDomExists {
+					customDomain, cOK := customDomVal.(bool)
+					if cOK && customDomain {
+						hostsExists, hostsExistsOK := ingressMap["hosts"]
+						if hostsExistsOK {
+							if hosts, hostsOK := hostsExists.([]interface{}); hostsOK && len(hosts) == 1 {
+								return hosts[0].(string)
+							}
+						}
+					}
+				}
+
+				if porterHosts, ok := ingressMap["porter_hosts"].([]interface{}); ok && len(porterHosts) == 1 {
+					return porterHosts[0].(string)
+				}
+			}
+		}
+	}
+
+	return ""
+}

+ 85 - 0
api/server/handlers/porter_app/pods.go

@@ -0,0 +1,85 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"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"
+	utils "github.com/porter-dev/porter/api/utils/porter_app"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type PorterAppPodsGetHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewPorterAppPodsGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *PorterAppPodsGetHandler {
+	return &PorterAppPodsGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *PorterAppPodsGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-porter-app-pods")
+	defer span.End()
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	version, reqErr := requestutils.GetURLParamUint(r, types.URLParamReleaseVersion)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting version from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	namespace := utils.NamespaceFromPorterAppName(appName)
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	helmRelease, err := helmAgent.GetRelease(ctx, appName, int(version), false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting k8s agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	pods, err := release.GetPodsForRelease(ctx, helmRelease, k8sAgent)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting pods for release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, pods)
+}

+ 85 - 11
api/server/handlers/porter_app/rollback.go

@@ -1,18 +1,22 @@
 package porter_app
 
 import (
-	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/features"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	utils "github.com/porter-dev/porter/api/utils/porter_app"
+	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
+	"gopkg.in/yaml.v2"
 )
 
 type RollbackPorterAppHandler struct {
@@ -35,6 +39,7 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-rollback-porter-app")
 	defer span.End()
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
 
 	request := &types.RollbackPorterAppRequest{}
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
@@ -43,14 +48,14 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "stack-name", Value: stackName})
-	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "stack-name", Value: appName})
+	namespace := utils.NamespaceFromPorterAppName(appName)
 
 	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
 	if err != nil {
@@ -59,33 +64,102 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error getting helm release")
+		err = telemetry.Error(ctx, span, err, "error getting k8s agent")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	imageInfo := attemptToGetImageInfoFromRelease(helmRelease.Config)
+	helmReleaseFromRequestedRevision, err := helmAgent.GetRelease(ctx, appName, request.Revision, false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release for requested revision")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	latestHelmRelease, err := helmAgent.GetRelease(ctx, appName, 0, false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting latest helm release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	valuesYaml, err := yaml.Marshal(helmReleaseFromRequestedRevision.Config)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error marshalling helm release config to yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	imageInfo := attemptToGetImageInfoFromRelease(helmReleaseFromRequestedRevision.Config)
 	if imageInfo.Tag == "" {
 		imageInfo.Tag = "latest"
 	}
 
-	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error getting porter app")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
+	injectLauncher := strings.Contains(porterApp.Builder, "heroku") ||
+		strings.Contains(porterApp.Builder, "paketo")
 
-	err = helmAgent.RollbackRelease(ctx, helmRelease.Name, request.Revision)
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error rolling back release")
+		err = telemetry.Error(ctx, span, err, "error listing registries")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	chart, values, _, err := parse(
+		ctx,
+		ParseConf{
+			PorterAppName: appName,
+			ImageInfo:     imageInfo,
+			ServerConfig:  c.Config(),
+			ProjectID:     cluster.ProjectID,
+			Namespace:     namespace,
+			SubdomainCreateOpts: SubdomainCreateOpts{
+				k8sAgent:       k8sAgent,
+				dnsRepo:        c.Repo().DNSRecord(),
+				powerDnsClient: c.Config().PowerDNSClient,
+				appRootDomain:  c.Config().ServerConf.AppRootDomain,
+				stackName:      appName,
+			},
+			InjectLauncherToStartCommand: injectLauncher,
+			FullHelmValues:               string(valuesYaml),
+		},
+	)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error parsing helm chart")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       appName,
+		Namespace:  namespace,
+		Values:     values,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+	_, err = helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error upgrading application")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if features.AreAgentDeployEventsEnabled(user.Email, k8sAgent) {
+		serviceDeploymentStatusMap := getServiceDeploymentMetadataFromValues(values, types.PorterAppEventStatus_Progressing)
+		_, err = createNewPorterAppDeployEvent(ctx, serviceDeploymentStatusMap, types.PorterAppEventStatus_Progressing, porterApp.ID, latestHelmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	} else {
+		_, err = createOldPorterAppDeployEvent(ctx, types.PorterAppEventStatus_Success, porterApp.ID, latestHelmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	}
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error creating porter app event")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))

+ 2 - 2
api/server/handlers/project/create.go

@@ -44,7 +44,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		CapiProvisionerEnabled: true,
 		SimplifiedViewEnabled:  true,
 		HelmValuesEnabled:      false,
-		EnvGroupEnabled:        false,
+		MultiCluster:           false,
 	}
 
 	var err error
@@ -91,7 +91,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 	}
 
-	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
+	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateDeleteTrackOpts{
 		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
 	}))
 }

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

@@ -45,7 +45,7 @@ func TestCreateProjectSuccessful(t *testing.T) {
 		CapiProvisionerEnabled: true,
 		SimplifiedViewEnabled:  true,
 		HelmValuesEnabled:      false,
-		EnvGroupEnabled:        false,
+		MultiCluster:           false,
 	}
 
 	gotProject := &types.CreateProjectResponse{}

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

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	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"

+ 10 - 0
api/server/handlers/project/update_onboarding_step.go

@@ -34,6 +34,16 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
+	if request.Step == "project-delete" {
+		v.Config().AnalyticsClient.Track(analytics.ProjectDeleteTrack(&analytics.ProjectCreateDeleteTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
+			Email:                  user.Email,
+			FirstName:              user.FirstName,
+			LastName:               user.LastName,
+			CompanyName:            user.CompanyName,
+		}))
+	}
+
 	if request.Step == "cost-consent-opened" {
 		v.Config().AnalyticsClient.Track(analytics.CostConsentOpenedTrack(&analytics.CostConsentOpenedTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),

+ 1 - 1
api/server/handlers/project_integration/create_aws.go

@@ -3,7 +3,7 @@ package project_integration
 import (
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	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"

+ 1 - 1
api/server/handlers/project_integration/create_azure.go

@@ -5,7 +5,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/telemetry"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 

+ 1 - 1
api/server/handlers/project_integration/preflight_check_aws_usage.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/bufbuild/connect-go"
+	"connectrpc.com/connect"
 	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"

+ 1 - 1
api/server/handlers/registry/get_token.go

@@ -9,9 +9,9 @@ import (
 
 	"github.com/porter-dev/porter/internal/telemetry"
 
+	"connectrpc.com/connect"
 	"github.com/aws/aws-sdk-go/aws/arn"
 	"github.com/aws/aws-sdk-go/service/ecr"
-	"github.com/bufbuild/connect-go"
 	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"

+ 20 - 9
api/server/handlers/release/get.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"github.com/stefanmcshane/helm/pkg/release"
 	"gorm.io/gorm"
@@ -33,16 +34,20 @@ func NewReleaseGetHandler(
 }
 
 func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-release")
+	defer span.End()
+
+	helmRelease, _ := ctx.Value(types.ReleaseScope).(*release.Release)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "release-name", Value: helmRelease.Name})
 
 	res := &types.Release{
 		Release: helmRelease,
 	}
 
 	// look up the release in the database; if not found, do not populate Porter fields
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	release, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
-
 	if err == nil {
 		res.PorterRelease = release.ToReleaseType()
 
@@ -56,7 +61,8 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if release.BuildConfig != 0 {
 			bc, err := c.Repo().BuildConfig().GetBuildConfig(release.BuildConfig)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get build config")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
@@ -66,26 +72,30 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if release.StackResourceID != 0 {
 			stackResource, err := c.Repo().Stack().ReadStackResource(release.StackResourceID)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get stack resource")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
 			stackRevision, err := c.Repo().Stack().ReadStackRevision(stackResource.StackRevisionID)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get stack revision")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
 			stack, err := c.Repo().Stack().ReadStackByID(cluster.ProjectID, stackRevision.StackID)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get stack")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
 			res.StackID = stack.UID
 		}
 	} else if err != gorm.ErrRecordNotFound {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	} else {
 		res.PorterRelease = &types.PorterRelease{}
@@ -122,7 +132,8 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// look for the form using the dynamic client
 	dynClient, err := c.GetDynamicClient(r, cluster)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get dynamic client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 

+ 42 - 23
api/server/handlers/release/get_all_pods.go

@@ -1,6 +1,7 @@
 package release
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"strings"
@@ -14,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/stefanmcshane/helm/pkg/release"
 	v1 "k8s.io/api/core/v1"
 )
@@ -34,8 +36,12 @@ func NewGetAllPodsHandler(
 }
 
 func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-all-pods-for-release")
+	defer span.End()
+
+	helmRelease, _ := ctx.Value(types.ReleaseScope).(*release.Release)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
@@ -44,18 +50,35 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	pods, err := GetPodsForRelease(ctx, helmRelease, agent)
+	if err != nil {
+		err = fmt.Errorf("error getting pods: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, pods)
+}
+
+func GetPodsForRelease(ctx context.Context, helmRelease *release.Release, k8sAgent *kubernetes.Agent) ([]v1.Pod, error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-all-pods-for-release")
+	defer span.End()
+
 	yamlArr := grapher.ImportMultiDocYAML([]byte(helmRelease.Manifest))
 	controllers := grapher.ParseControllers(yamlArr)
 	pods := make([]v1.Pod, 0)
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "num-controllers", Value: len(controllers)})
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: helmRelease.Namespace})
+
 	// get current status of each controller
 	for _, controller := range controllers {
 		controller.Namespace = helmRelease.Namespace
-		_, selector, err := getController(controller, agent)
+		_, selector, err := getController(controller, k8sAgent)
 		if err != nil {
-			err = fmt.Errorf("error getting controller %s: %w", controller.Name, err)
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "controller-name", Value: controller.Name})
+			err = telemetry.Error(ctx, span, err, "error getting controller")
+			return nil, err
 		}
 
 		selectors := make([]string, 0)
@@ -74,11 +97,10 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				})
 			}
 
-			jobPods, err := getPodsForJobs(agent, helmRelease.Namespace, jobLabels)
+			jobPods, err := getPodsForJobs(k8sAgent, helmRelease.Namespace, jobLabels)
 			if err != nil {
-				err = fmt.Errorf("error getting cronjob pods in namespace %s with labels %+v : %w", helmRelease.Namespace, jobLabels, err)
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-				return
+				err = telemetry.Error(ctx, span, err, "error getting cronjob pods")
+				return nil, err
 			}
 
 			pods = append(pods, jobPods...)
@@ -94,20 +116,18 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
-		podList, err := agent.GetPodsByLabel(strings.Join(selectors, ","), helmRelease.Namespace)
+		podList, err := k8sAgent.GetPodsByLabel(strings.Join(selectors, ","), helmRelease.Namespace)
 		if err != nil {
-			err = fmt.Errorf("error getting pods in namespace %s with labels %+v : %w", helmRelease.Namespace, strings.Join(selectors, ","), err)
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
+			err = telemetry.Error(ctx, span, err, "error getting pods")
+			return nil, err
 		}
 
 		pods = append(pods, podList.Items...)
 
-		podList, err = agent.GetPodsByLabel(strings.Join(selectors, ","), "default")
+		podList, err = k8sAgent.GetPodsByLabel(strings.Join(selectors, ","), "default")
 		if err != nil {
-			err = fmt.Errorf("error getting pods in namespace %s with labels %+v : %w", helmRelease.Namespace, strings.Join(selectors, ","), err)
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
+			err = telemetry.Error(ctx, span, err, "error getting pods")
+			return nil, err
 		}
 
 		pods = append(pods, podList.Items...)
@@ -121,16 +141,15 @@ func (c *GetAllPodsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Val: fmt.Sprintf("%d", helmRelease.Version),
 	})
 
-	jobPods, err := getPodsForJobs(agent, helmRelease.Namespace, labels)
+	jobPods, err := getPodsForJobs(k8sAgent, helmRelease.Namespace, labels)
 	if err != nil {
-		err = fmt.Errorf("error getting cronjob pods in namespace %s with labels %+v : %w", helmRelease.Namespace, labels, err)
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		err = telemetry.Error(ctx, span, err, "error getting cronjob pods")
+		return nil, err
 	}
 
 	pods = append(pods, jobPods...)
 
-	c.WriteResult(w, r, pods)
+	return pods, nil
 }
 
 func getPodsForJobs(agent *kubernetes.Agent, namespace string, labels []kubernetes.Label) ([]v1.Pod, error) {

+ 1 - 1
api/server/handlers/release/get_gha_template.go

@@ -38,7 +38,7 @@ func (c *GetGHATemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	_, workflowYAML, err := createGitAction(
-		ctx, 
+		ctx,
 		c.Config(),
 		user.ID,
 		cluster.ProjectID,

+ 1 - 0
api/server/handlers/user/create.go

@@ -109,6 +109,7 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		FirstName:           user.FirstName,
 		LastName:            user.LastName,
 		CompanyName:         user.CompanyName,
+		ReferralMethod:      request.ReferralMethod,
 	}))
 
 	if redirect != "" {

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

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/database"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
+	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
 	"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"
@@ -1604,5 +1605,92 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/environment-groups
+	updateEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/environment-groups",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updateEnvironmentGroupHandler := environment_groups.NewUpdateEnvironmentGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateEnvironmentGroupEndpoint,
+		Handler:  updateEnvironmentGroupHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/environment-groups}
+	deleteEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/environment-groups",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteEnvironmentGroupHandler := environment_groups.NewDeleteEnvironmentGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEnvironmentGroupEndpoint,
+		Handler:  deleteEnvironmentGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/environment-groups
+	listEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/environment-groups",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listEnvironmentGroupHandler := environment_groups.NewListEnvironmentGroupsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvironmentGroupEndpoint,
+		Handler:  listEnvironmentGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 195 - 23
api/server/router/porter_app.go

@@ -11,21 +11,21 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-func NewStackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+func NewPorterAppScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
-		GetRoutes: GetStackScopedRoutes,
+		GetRoutes: GetPorterAppScopedRoutes,
 		Children:  children,
 	}
 }
 
-func GetStackScopedRoutes(
+func GetPorterAppScopedRoutes(
 	r chi.Router,
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
 	children ...*router.Registerer,
 ) []*router.Route {
-	routes, projPath := getStackRoutes(r, config, basePath, factory)
+	routes, projPath := getPorterAppRoutes(r, config, basePath, factory)
 
 	if len(children) > 0 {
 		r.Route(projPath.RelativePath, func(r chi.Router) {
@@ -40,13 +40,13 @@ func GetStackScopedRoutes(
 	return routes
 }
 
-func getStackRoutes(
+func getPorterAppRoutes(
 	r chi.Router,
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
 ) ([]*router.Route, *types.Path) {
-	relPath := "/stacks"
+	relPath := "/applications"
 
 	newPath := &types.Path{
 		Parent:       basePath,
@@ -55,14 +55,14 @@ func getStackRoutes(
 
 	var routes []*router.Route
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> porter_app.NewPorterAppGetHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications/{name} -> porter_app.NewPorterAppGetHandler
 	getPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -83,7 +83,91 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> porter_app.NewPorterAppListHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications/{name}/releases/{version} -> porter_app.NewPorterAppReleaseGetHandler
+	getPorterAppHelmReleaseEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/releases/{%s}", relPath, types.URLParamPorterAppName, types.URLParamReleaseVersion),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getPorterAppHelmReleaseHandler := porter_app.NewPorterAppHelmReleaseGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getPorterAppHelmReleaseEndpoint,
+		Handler:  getPorterAppHelmReleaseHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications/{name}/release-history -> porter_app.NewPorterAppHelmReleaseHistoryGetHandler
+	getPorterAppHelmReleaseHistoryGetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/release-history", relPath, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getPorterAppHelmReleaseHistoryGetHandler := porter_app.NewPorterAppHelmReleaseHistoryGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getPorterAppHelmReleaseHistoryGetEndpoint,
+		Handler:  getPorterAppHelmReleaseHistoryGetHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications/{name}/releases/{version}/pods/all -> porter_app.NewPorterAppPodsGetHandler
+	getPorterAppPodsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/releases/{%s}/pods/all", relPath, types.URLParamPorterAppName, types.URLParamReleaseVersion),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getPorterAppPodsHandler := porter_app.NewPorterAppPodsGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getPorterAppPodsEndpoint,
+		Handler:  getPorterAppPodsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications -> porter_app.NewPorterAppListHandler
 	listPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -111,14 +195,14 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/stacks -> release.NewDeletePorterAppByNameHandler
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/applications/{porter_app_name} -> release.NewDeletePorterAppByNameHandler
 	deletePorterAppByNameEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Method: types.HTTPVerbDelete,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -140,14 +224,14 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack} -> porter_app.NewCreatePorterAppHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/applications/{porter_app_name} -> porter_app.NewCreatePorterAppHandler
 	createPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -169,14 +253,14 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/rollback -> porter_app.NewRollbackPorterAppHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/applications/{porter_app_name}/rollback -> porter_app.NewRollbackPorterAppHandler
 	rollbackPorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/rollback", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}/rollback", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -198,14 +282,14 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/pr -> porter_app.NewOpenStackPRHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/applications/{porter_app_name}/pr -> porter_app.NewOpenStackPRHandler
 	createSecretAndOpenGitHubPullRequestEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/pr", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}/pr", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -227,14 +311,14 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewPorterAppEventListHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications/{porter_app_name}/events -> porter_app.NewPorterAppEventListHandler
 	listPorterAppEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/events", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}/events", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -255,14 +339,14 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewCreatePorterAppEventHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/applications/{name}/events -> porter_app.NewCreatePorterAppEventHandler
 	createPorterAppEventEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/events", relPath, types.URLParamStackName),
+				RelativePath: fmt.Sprintf("%s/{%s}/events", relPath, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -312,7 +396,7 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/analytics -> porter_app.NewPorterAppAnalyticsHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/applications/analytics -> porter_app.NewPorterAppAnalyticsHandler
 	porterAppAnalyticsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
@@ -341,7 +425,7 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/logs -> cluster.NewGetChartLogsWithinTimeRangeHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/applications/logs -> cluster.NewGetChartLogsWithinTimeRangeHandler
 	getChartLogsWithinTimeRangeEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -370,5 +454,93 @@ func getStackRoutes(
 		Router:   r,
 	})
 
+	// TODO: remove these three endpoints once these three 'stacks' routes are no longer used in telemetry
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> porter_app.NewPorterAppGetHandler
+	LEGACY_getPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/stacks/{%s}", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	LEGACY_getPorterAppHandler := porter_app.NewGetPorterAppHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: LEGACY_getPorterAppEndpoint,
+		Handler:  LEGACY_getPorterAppHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{porter_app_name} -> porter_app.NewCreatePorterAppHandler
+	LEGACY_createPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/stacks/{%s}", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	LEGACY_createPorterAppHandler := porter_app.NewCreatePorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: LEGACY_createPorterAppEndpoint,
+		Handler:  LEGACY_createPorterAppHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewCreatePorterAppEventHandler
+	LEGACY_createPorterAppEventEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/stacks/{%s}/events", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	LEGACY_createPorterAppEventHandler := porter_app.NewCreateUpdatePorterAppEventHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: LEGACY_createPorterAppEventEndpoint,
+		Handler:  LEGACY_createPorterAppEventHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 1
api/server/router/router.go

@@ -31,7 +31,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	releaseRegisterer := NewReleaseScopedRegisterer()
 	namespaceRegisterer := NewNamespaceScopedRegisterer(releaseRegisterer)
 	clusterIntegrationRegisterer := NewClusterIntegrationScopedRegisterer()
-	stackRegisterer := NewStackScopedRegisterer()
+	stackRegisterer := NewPorterAppScopedRegisterer()
 	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()

+ 58 - 0
api/server/shared/features/features.go

@@ -0,0 +1,58 @@
+package features
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/internal/kubernetes"
+)
+
+// isPorterAgentUpdated checks if the agent version is at least the version specified by the major, minor, and patch arguments
+func isPorterAgentUpdated(agent *kubernetes.Agent, major, minor, patch int) bool {
+	res, err := cluster.GetAgentVersionResponse(agent)
+	if err != nil {
+		return false
+	}
+	image := res.Image
+	parsed := strings.Split(image, ":")
+
+	if len(parsed) != 2 {
+		return false
+	}
+
+	tag := parsed[1]
+	if tag == "dev" {
+		return true
+	}
+
+	if !strings.HasPrefix(tag, "v") {
+		return false
+	}
+
+	tag = strings.TrimPrefix(tag, "v")
+	parsedTag := strings.Split(tag, ".")
+	if len(parsedTag) != 3 {
+		return false
+	}
+
+	parsedMajor, _ := strconv.Atoi(parsedTag[0])
+	parsedMinor, _ := strconv.Atoi(parsedTag[1])
+	parsedPatch, _ := strconv.Atoi(parsedTag[2])
+	if parsedMajor < major {
+		return false
+	}
+	if parsedMinor < minor {
+		return false
+	}
+	if parsedPatch < patch {
+		return false
+	}
+	return true
+}
+
+// Only create the PROGRESSING event if the cluster's agent is updated, because only the updated agent can update the status
+// TODO: remove dependence on porter email once we are ready to release this feature
+func AreAgentDeployEventsEnabled(email string, agent *kubernetes.Agent) bool {
+	return isPorterAgentUpdated(agent, 3, 1, 6) && strings.HasSuffix(email, "porter.run")
+}

+ 1 - 0
api/types/agent.go

@@ -4,6 +4,7 @@ type DetectAgentResponse struct {
 	Version       string `json:"version"`
 	LatestVersion string `json:"latest_version"`
 	ShouldUpgrade bool   `json:"should_upgrade"`
+	Image         string `json:"image"`
 }
 
 type GetAgentStatusResponse struct {

+ 51 - 3
api/types/porter_app.go

@@ -28,6 +28,9 @@ type PorterApp struct {
 	// Porter YAML
 	PorterYAMLBase64 string `json:"porter_yaml,omitempty"`
 	PorterYamlPath   string `json:"porter_yaml_path,omitempty"`
+
+	// Helm
+	HelmRevisionNumber int `json:"helm_revision_number,omitempty"`
 }
 
 // swagger:model
@@ -48,8 +51,10 @@ type CreatePorterAppRequest struct {
 	ImageInfo        ImageInfo `json:"image_info" form:"omitempty"`
 	OverrideRelease  bool      `json:"override_release"`
 	EnvGroups        []string  `json:"env_groups"`
-	UserUpdate       bool      `json:"user_update"`
-	FullHelmValues   string    `json:"full_helm_values"`
+	// EnvironmentGroups are the list of environment groups that this app is linked to. This should be used instead of EnvGroups.
+	EnvironmentGroups []string `json:"environment_groups"`
+	UserUpdate        bool     `json:"user_update"`
+	FullHelmValues    string   `json:"full_helm_values"`
 }
 
 type UpdatePorterAppRequest struct {
@@ -101,16 +106,59 @@ const (
 	PorterAppEventType_AppEvent PorterAppEventType = "APP_EVENT"
 )
 
+// PorterAppEventStatus is an alias for a string that represents a Porter Stack Event Status
+type PorterAppEventStatus string
+
+const (
+	// PorterAppEventStatus_Success represents a Porter Stack Event that was successful
+	PorterAppEventStatus_Success PorterAppEventStatus = "SUCCESS"
+	// PorterAppEventStatus_Failed represents a Porter Stack Event that failed
+	PorterAppEventStatus_Failed PorterAppEventStatus = "FAILED"
+	// PorterAppEventStatus_Progressing represents a Porter Stack Event that is in progress
+	PorterAppEventStatus_Progressing PorterAppEventStatus = "PROGRESSING"
+	// PorterAppEventStatus_Canceled represents a Porter Stack Event that has been canceled
+	PorterAppEventStatus_Canceled PorterAppEventStatus = "CANCELED"
+)
+
 // PorterAppEvent represents a simplified event for creating a Porter stack app event
 // swagger:model
 type CreateOrUpdatePorterAppEventRequest struct {
 	// ID, if supplied, will be assumed to be an update event
 	ID string `json:"id"`
 	// Status contains the accepted status' of a given event such as SUCCESS, FAILED, PROGRESSING, etc.
-	Status string `json:"status,omitempty"`
+	Status PorterAppEventStatus `json:"status,omitempty"`
 	// Type represents a supported Porter Stack Event
 	Type PorterAppEventType `json:"type"`
 	// TypeExternalSource represents an external event source such as Github, or Gitlab. This is not always required but will commonly be see in build events
 	TypeExternalSource string         `json:"type_source,omitempty"`
 	Metadata           map[string]any `json:"metadata,omitempty"`
 }
+
+// ServiceDeploymentMetadata contains information about a service when it deploys
+type ServiceDeploymentMetadata struct {
+	// Status is the status of the service deployment
+	Status PorterAppEventStatus `json:"status"`
+	// ExternalURI is the external URI of a service (if it is web)
+	ExternalURI string `json:"external_uri"`
+	// Type is the type of the service - one of web, worker, or job
+	Type string `json:"type"`
+}
+type ListEnvironmentGroupsResponse struct {
+	// EnvironmentGroups is a list of environment groups
+	EnvironmentGroups []EnvironmentGroupListItem `json:"environment_groups,omitempty"`
+}
+
+type EnvironmentGroupListItem struct {
+	// Name is the name of the environment group
+	Name string `json:"name"`
+	// LatestVersion is the latest version of the environment group
+	LatestVersion int `json:"latest_version"`
+	// Variables is a map of variables for the environment group
+	Variables map[string]string `json:"variables"`
+	// SecretVariables is a map of secret variables for the environment group
+	SecretVariables map[string]string `json:"secret_variables"`
+	// CreatedAtUTC is the time the environment group was created
+	CreatedAtUTC time.Time `json:"created_at"`
+	// LinkedApplications is the list of applications this env group is linked to
+	LinkedApplications []string `json:"linked_applications,omitempty"`
+}

+ 4 - 2
api/types/project.go

@@ -13,7 +13,8 @@ type Project struct {
 	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
 	AzureEnabled           bool    `json:"azure_enabled"`
 	HelmValuesEnabled      bool    `json:"helm_values_enabled"`
-	EnvGroupEnabled        bool    `json:"env_group_enabled"`
+	MultiCluster           bool    `json:"multi_cluster"`
+	ValidateApplyV2        bool    `json:"validate_apply_v2"`
 }
 
 type FeatureFlags struct {
@@ -25,7 +26,8 @@ type FeatureFlags struct {
 	SimplifiedViewEnabled      string `json:"simplified_view_enabled,omitempty"`
 	AzureEnabled               bool   `json:"azure_enabled,omitempty"`
 	HelmValuesEnabled          bool   `json:"helm_values_enabled,omitempty"`
-	EnvGroupEnabled            bool   `json:"env_group_enabled"`
+	MultiCluster               bool   `json:"multi_cluster,omitempty"`
+	ValidateApplyV2            bool   `json:"validate_apply_v2"`
 }
 
 type CreateProjectRequest struct {

+ 1 - 1
api/types/request.go

@@ -49,8 +49,8 @@ const (
 	URLParamWildcard              URLParam = "*"
 	URLParamIntegrationID         URLParam = "integration_id"
 	URLParamAPIContractRevisionID URLParam = "contract_revision_id"
-	URLParamStackName             URLParam = "stack_name"
 	URLParamStackEventID          URLParam = "stack_event_id"
+	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 )
 

+ 5 - 3
api/types/stack.go

@@ -18,6 +18,7 @@ type CreateSecretAndOpenGHPRRequest struct {
 	OpenPr                  bool   `json:"open_pr"`
 	Branch                  string `json:"branch"`
 	PorterYamlPath          string `json:"porter_yaml_path"`
+	DeleteWorkflowFilename  string `json:"delete_workflow_filename"`
 }
 
 type CreateSecretAndOpenGHPRResponse struct {
@@ -27,7 +28,8 @@ type CreateSecretAndOpenGHPRResponse struct {
 type GetStackResponse PorterApp
 
 type PorterAppAnalyticsRequest struct {
-	Step         string `json:"step" form:"required,max=255"`
-	StackName    string `json:"stack_name"`
-	ErrorMessage string `json:"error_message"`
+	Step               string `json:"step" form:"required,max=255"`
+	StackName          string `json:"stack_name"`
+	ErrorMessage       string `json:"error_message"`
+	DeleteWorkflowFile bool   `json:"delete_workflow_file"`
 }

+ 6 - 5
api/types/user.go

@@ -10,11 +10,12 @@ type User struct {
 }
 
 type CreateUserRequest struct {
-	Email       string `json:"email" form:"required,max=255,email"`
-	Password    string `json:"password" form:"required,max=255"`
-	FirstName   string `json:"first_name" form:"required,max=255"`
-	LastName    string `json:"last_name" form:"required,max=255"`
-	CompanyName string `json:"company_name" form:"required,max=255"`
+	Email          string `json:"email" form:"required,max=255,email"`
+	Password       string `json:"password" form:"required,max=255"`
+	FirstName      string `json:"first_name" form:"required,max=255"`
+	LastName       string `json:"last_name" form:"required,max=255"`
+	CompanyName    string `json:"company_name" form:"required,max=255"`
+	ReferralMethod string `json:"referral_method" form:"max=255"`
 }
 
 type CreateUserResponse User

+ 18 - 0
api/utils/porter_app/namespace.go

@@ -0,0 +1,18 @@
+package porter_app
+
+import (
+	"fmt"
+	"strings"
+)
+
+func NamespaceFromPorterAppName(porterAppName string) string {
+	return fmt.Sprintf("porter-stack-%s", porterAppName)
+}
+
+func PorterAppNameFromNamespace(namespace string) string {
+	return strings.TrimPrefix(namespace, "porter-stack-")
+}
+
+func PredeployJobNameFromPorterAppName(porterAppName string) string {
+	return fmt.Sprintf("%s-r", porterAppName)
+}

+ 6 - 6
cli/cmd/apply.go

@@ -21,9 +21,9 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
+	porter_app "github.com/porter-dev/porter/cli/cmd/porter_app"
 	"github.com/porter-dev/porter/cli/cmd/preview"
 	previewV2Beta1 "github.com/porter-dev/porter/cli/cmd/preview/v2beta1"
-	stack "github.com/porter-dev/porter/cli/cmd/stack"
 	previewInt "github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
@@ -158,7 +158,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 		}
 	} else if previewVersion.Version == "v1stack" || previewVersion.Version == "" {
 
-		parsed, err := stack.ValidateAndMarshal(fileBytes)
+		parsed, err := porter_app.ValidateAndMarshal(fileBytes)
 		if err != nil {
 			return fmt.Errorf("error parsing porter.yaml: %w", err)
 		}
@@ -175,7 +175,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 
 		if parsed.Applications != nil {
 			for appName, app := range parsed.Applications {
-				resources, err := stack.CreateApplicationDeploy(client, worker, app, appName, cliConf)
+				resources, err := porter_app.CreateApplicationDeploy(client, worker, app, appName, cliConf)
 				if err != nil {
 					return fmt.Errorf("error parsing porter.yaml for build resources: %w", err)
 				}
@@ -192,7 +192,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 				return fmt.Errorf("'apps' and 'services' are synonymous but both were defined")
 			}
 
-			var services map[string]*stack.Service
+			var services map[string]*porter_app.Service
 			if parsed.Apps != nil {
 				services = parsed.Apps
 			}
@@ -201,7 +201,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 				services = parsed.Services
 			}
 
-			app := &stack.Application{
+			app := &porter_app.Application{
 				Env:      parsed.Env,
 				Services: services,
 				Build:    parsed.Build,
@@ -212,7 +212,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 				return fmt.Errorf("error parsing porter.yaml for build resources: %w", err)
 			}
 
-			resources, err := stack.CreateApplicationDeploy(client, worker, app, appName, cliConf)
+			resources, err := porter_app.CreateApplicationDeploy(client, worker, app, appName, cliConf)
 			if err != nil {
 				return fmt.Errorf("error parsing porter.yaml for build resources: %w", err)
 			}

+ 74 - 3
cli/cmd/stack/apply.go → cli/cmd/porter_app/apply.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 import (
 	"context"
@@ -11,6 +11,7 @@ import (
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/internal/telemetry"
 	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
 	switchboardWorker "github.com/porter-dev/switchboard/pkg/worker"
 	"gopkg.in/yaml.v3"
@@ -172,9 +173,13 @@ func createStackConf(client *api.Client, app *Application, stackName string, pro
 	}
 
 	releaseEnvVars := getEnvFromRelease(client, stackName, projectID, clusterID)
-	if releaseEnvVars != nil {
+	releaseEnvGroupVars := getEnvGroupFromRelease(client, stackName, projectID, clusterID)
+	// releaseEnvVars will override releaseEnvGroupVars
+	totalEnv := mergeStringMaps(releaseEnvGroupVars, releaseEnvVars)
+
+	if totalEnv != nil {
 		color.New(color.FgYellow).Printf("Reading build env from release\n")
-		app.Env = mergeStringMaps(app.Env, releaseEnvVars)
+		app.Env = mergeStringMaps(app.Env, totalEnv)
 	}
 
 	return &StackConf{
@@ -255,6 +260,72 @@ func convertToBuild(porterApp *types.PorterApp) Build {
 	}
 }
 
+func getEnvGroupFromRelease(client *api.Client, stackName string, projectID uint, clusterID uint) map[string]string {
+	var envGroups []string
+	envVarsGroupStringMap := make(map[string]string)
+
+	ctx, span := telemetry.NewSpan(context.Background(), "get-env-from-release")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: projectID},
+		telemetry.AttributeKV{Key: "stack-name", Value: stackName},
+	)
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+	release, err := client.GetRelease(
+		ctx,
+		projectID,
+		clusterID,
+		namespace,
+		stackName,
+	)
+	if err != nil {
+		telemetry.Error(ctx, span, err, "error getting env groups from release")
+		span.End()
+		return envVarsGroupStringMap
+	}
+	if err == nil && release != nil {
+		for _, val := range release.Config {
+			// Check if the value is a map
+			if appConfig, ok := val.(map[string]interface{}); ok {
+				if labels, ok := appConfig["labels"]; ok {
+					if labelsMap, ok := labels.(map[string]interface{}); ok {
+						if envGroup, ok := labelsMap["porter.run/linked-environment-group"]; ok {
+							envGroups = append(envGroups, fmt.Sprintf("%v", envGroup))
+						}
+					}
+				}
+			}
+		}
+	}
+
+	if envGroups == nil {
+		return envVarsGroupStringMap
+	}
+	envGroupList, err := client.ListEnvGroups(
+		ctx,
+		projectID,
+		clusterID)
+	if err != nil {
+		telemetry.Error(ctx, span, err, "error getting env groups during build")
+		span.End()
+		return envVarsGroupStringMap
+	}
+	if err == nil {
+		for _, groupName := range envGroups {
+			for _, envGroupItem := range envGroupList.EnvironmentGroups {
+				if envGroupItem.Name == groupName {
+					for k, v := range envGroupItem.Variables {
+						envVarsGroupStringMap[k] = v
+					}
+					for k, v := range envGroupItem.SecretVariables {
+						envVarsGroupStringMap[k] = v
+					}
+				}
+			}
+		}
+	}
+	return envVarsGroupStringMap
+}
+
 func getEnvFromRelease(client *api.Client, stackName string, projectID uint, clusterID uint) map[string]string {
 	var envVarsStringMap map[string]string
 	namespace := fmt.Sprintf("porter-stack-%s", stackName)

+ 1 - 1
cli/cmd/stack/build.go → cli/cmd/porter_app/build.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 import (
 	"fmt"

+ 1 - 1
cli/cmd/stack/env.go → cli/cmd/porter_app/env.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 func CopyEnv(env map[string]string) map[string]string {
 	envCopy := make(map[string]string)

+ 15 - 11
cli/cmd/stack/hooks.go → cli/cmd/porter_app/hooks.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 import (
 	"context"
@@ -47,14 +47,6 @@ func (t *DeployAppHook) DataQueries() map[string]interface{} {
 
 // deploy the app
 func (t *DeployAppHook) PostApply(driverOutput map[string]interface{}) error {
-	eventRequest := types.CreateOrUpdatePorterAppEventRequest{
-		Status:   "SUCCESS",
-		Type:     types.PorterAppEventType_Build,
-		Metadata: map[string]any{},
-		ID:       t.BuildEventID,
-	}
-	_, _ = t.Client.CreateOrUpdatePorterAppEvent(context.Background(), t.ProjectID, t.ClusterID, t.ApplicationName, &eventRequest)
-
 	namespace := fmt.Sprintf("porter-stack-%s", t.ApplicationName)
 
 	_, err := t.Client.GetRelease(
@@ -73,10 +65,22 @@ func (t *DeployAppHook) PostApply(driverOutput map[string]interface{}) error {
 		color.New(color.FgGreen).Printf("Found release for app %s: attempting update\n", t.ApplicationName)
 	}
 
-	return t.applyApp(shouldCreate, driverOutput)
+	err = t.createOrUpdateApplication(shouldCreate, driverOutput)
+	if err != nil {
+		return err
+	}
+	eventRequest := types.CreateOrUpdatePorterAppEventRequest{
+		Status:   "SUCCESS",
+		Type:     types.PorterAppEventType_Build,
+		Metadata: map[string]any{},
+		ID:       t.BuildEventID,
+	}
+	_, _ = t.Client.CreateOrUpdatePorterAppEvent(context.Background(), t.ProjectID, t.ClusterID, t.ApplicationName, &eventRequest)
+
+	return nil
 }
 
-func (t *DeployAppHook) applyApp(shouldCreate bool, driverOutput map[string]interface{}) error {
+func (t *DeployAppHook) createOrUpdateApplication(shouldCreate bool, driverOutput map[string]interface{}) error {
 	var imageInfo types.ImageInfo
 	image, ok := driverOutput["image"].(string)
 	// if it contains a $, then it means the query didn't resolve to anything

+ 1 - 1
cli/cmd/stack/preDeploy.go → cli/cmd/porter_app/preDeploy.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 import (
 	"context"

+ 2 - 2
cli/cmd/stack/types.go → cli/cmd/porter_app/types.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 type PorterStackYAML struct {
 	Applications map[string]*Application `yaml:"applications" validate:"required_without=Services Apps"`
@@ -44,4 +44,4 @@ type SyncedEnvSection struct {
 type SyncedEnvSectionKey struct {
 	Name   string `json:"name" yaml:"name"`
 	Secret bool   `json:"secret" yaml:"secret"`
-}
+}

+ 1 - 1
cli/cmd/stack/utils.go → cli/cmd/porter_app/utils.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 import (
 	"fmt"

+ 1 - 1
cli/cmd/stack/validate.go → cli/cmd/porter_app/validate.go

@@ -1,4 +1,4 @@
-package stack
+package porter_app
 
 import (
 	"fmt"

+ 17 - 13
dashboard/package-lock.json

@@ -12,7 +12,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.70",
+        "@porter-dev/api-contracts": "^0.0.75",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
         "@tanstack/react-query": "^4.13.0",
@@ -133,6 +133,10 @@
         "webpack-bundle-analyzer": "^4.4.2",
         "webpack-cli": "^3.3.12",
         "webpack-dev-server": "^3.11.0"
+      },
+      "engines": {
+        "node": ">=16 <17",
+        "npm": "9.7.2"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -1940,9 +1944,9 @@
       }
     },
     "node_modules/@bufbuild/protobuf": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.2.1.tgz",
-      "integrity": "sha512-cwwGvLGqvoaOZmoP5+i4v/rbW+rHkguvTehuZyM2p/xpmaNSdT2h3B7kHw33aiffv35t1XrYHIkdJSEkSEMJuA=="
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.0.tgz",
+      "integrity": "sha512-G372ods0pLt46yxVRsnP/e2btVPuuzArcMPFpIDeIwiGPuuglEs9y75iG0HMvZgncsj5TvbYRWqbVyOe3PLCWQ=="
     },
     "node_modules/@discoveryjs/json-ext": {
       "version": "0.5.7",
@@ -2434,9 +2438,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.70",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.70.tgz",
-      "integrity": "sha512-JOaxn7ihyAQgikbq/4AIQmPoJqR6y+NxBlOtTGyFN/80hUegkWwIy4zCTVj8fqcvUoE0yWaROVFl9rZ7ofRIgg==",
+      "version": "0.0.75",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.75.tgz",
+      "integrity": "sha512-IHyLUCRPWyEwH0fljC6iu6134yGGki4qXHHa7j8wYOSEWMOed/r86OIpXY0tdNpYhDKAcPaoxlDwPbbeV6Wj0Q==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16329,9 +16333,9 @@
       }
     },
     "@bufbuild/protobuf": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.2.1.tgz",
-      "integrity": "sha512-cwwGvLGqvoaOZmoP5+i4v/rbW+rHkguvTehuZyM2p/xpmaNSdT2h3B7kHw33aiffv35t1XrYHIkdJSEkSEMJuA=="
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.0.tgz",
+      "integrity": "sha512-G372ods0pLt46yxVRsnP/e2btVPuuzArcMPFpIDeIwiGPuuglEs9y75iG0HMvZgncsj5TvbYRWqbVyOe3PLCWQ=="
     },
     "@discoveryjs/json-ext": {
       "version": "0.5.7",
@@ -16672,9 +16676,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.70",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.70.tgz",
-      "integrity": "sha512-JOaxn7ihyAQgikbq/4AIQmPoJqR6y+NxBlOtTGyFN/80hUegkWwIy4zCTVj8fqcvUoE0yWaROVFl9rZ7ofRIgg==",
+      "version": "0.0.75",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.75.tgz",
+      "integrity": "sha512-IHyLUCRPWyEwH0fljC6iu6134yGGki4qXHHa7j8wYOSEWMOed/r86OIpXY0tdNpYhDKAcPaoxlDwPbbeV6Wj0Q==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 5 - 1
dashboard/package.json

@@ -7,7 +7,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.70",
+    "@porter-dev/api-contracts": "^0.0.75",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",
@@ -70,6 +70,10 @@
     "valtio": "^1.2.4",
     "zod": "^3.20.2"
   },
+  "engines": {
+    "node": ">=16 <17",
+    "npm": "9.7.2"
+  },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js",

+ 3 - 0
dashboard/src/assets/canceled.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 1L1 11M11 11L1 0.999998" stroke="#FFBF00" stroke-width="1" stroke-linecap="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/copy-left.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M18 9.5999H20.4C21.0628 9.5999 21.6 10.1372 21.6 10.7999L21.6 19.5999C21.6 20.7045 20.7046 21.5999 19.6 21.5999L10.8 21.5999C10.1373 21.5999 9.60003 21.0626 9.60003 20.3999V17.9999M12 2.3999L4.80003 2.3999C3.47454 2.3999 2.40003 3.47442 2.40003 4.7999L2.40002 11.9999C2.40002 13.3254 3.47454 14.3999 4.80002 14.3999L12 14.3999C13.3255 14.3999 14.4 13.3254 14.4 11.9999L14.4 4.7999C14.4 3.47442 13.3255 2.3999 12 2.3999Z" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/failure.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 1L1 11M11 11L1 0.999998" stroke="#FF6060" stroke-width="1" stroke-linecap="round"/>
+</svg>

+ 1 - 0
dashboard/src/assets/filter-outline-icon.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 410.73"><path fill-rule="nonzero" d="M335.62 410.73H164.96V239.89L13.31 59.96C7.33 52.52 3.19 44.79 1.29 37.65c-1.79-6.72-1.76-13.28.34-19.1 2.3-6.44 6.92-11.63 13.91-14.9C20.35 1.41 26.3.13 33.4.1L472.7.04c7.93-.29 14.95.96 20.74 3.44 7.02 2.97 12.28 7.87 15.44 14.17 3.05 6.1 3.93 13.27 2.34 21.06-1.5 7.24-5.17 15.11-11.32 23.16l-151.94 178.1v170.76h-12.34zm95.61-347.71-69.16 81.05-18.67-16.01 69.16-81.05 18.67 16.01zm-84.8 99.39-24.45 28.66-18.68-16.01 24.45-28.66 18.68 16.01zM189.64 386.06h133.64V235.48l3-8L480.45 46.79c3.77-4.97 5.94-9.39 6.7-13.04.45-2.2.35-3.95-.24-5.12-.49-.97-1.58-1.87-3.19-2.55-2.53-1.13-6.06-1.64-10.44-1.42l-439.84.06c-3.33-.05-5.83.41-7.5 1.18-.68.32-1.09.65-1.18.92-.32.91-.2 2.48.33 4.46 1.05 3.96 3.61 8.57 7.38 13.28L186.7 227.59l2.94 7.89v150.58z"/></svg>

BIN=BIN
dashboard/src/assets/filter-outline-new.png


+ 3 - 0
dashboard/src/assets/gear.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M21.3531 6.60019L12 1.2002L2.64697 6.60019V17.4002L12 22.8002L21.3531 17.4002V6.60019ZM12 15.6002C13.9883 15.6002 15.6 13.9884 15.6 12.0002C15.6 10.012 13.9883 8.40019 12 8.40019C10.0118 8.40019 8.40005 10.012 8.40005 12.0002C8.40005 13.9884 10.0118 15.6002 12 15.6002Z" stroke="white" stroke-width="2" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/globe.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M18.2 17.3499L14.6923 13.2153H12.4769L11 11.7384L13.9538 7.30759H19.1231M5.83076 3.61529L7.39717 5.1817C8.23887 6.0234 8.49797 7.28572 8.05589 8.39092V8.39092C7.604 9.52065 6.50983 10.2614 5.29308 10.2614H2.13846M20.6 10.9999C20.6 16.3018 16.3019 20.5999 11 20.5999C5.69806 20.5999 1.39999 16.3018 1.39999 10.9999C1.39999 5.69797 5.69806 1.3999 11 1.3999C16.3019 1.3999 20.6 5.69797 20.6 10.9999Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/swap.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+  <path d="M6.00002 9.5999L2.40002 5.9999M2.40002 5.9999L6.00002 2.3999M2.40002 5.9999H21.6M18 14.3999L21.6 17.9999M21.6 17.9999L18 21.5999M21.6 17.9999H2.40002" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 7 - 7
dashboard/src/components/AWSCostConsent.tsx

@@ -46,7 +46,7 @@ const AWSCostConsent: React.FC<Props> = ({
           noWrapper
           expandText="[+] Show details"
           collapseText="[-] Hide details"
-          Header={<Cost>$315.94 / mo</Cost>}
+          Header={<Cost>$224.58 / mo</Cost>}
           ExpandedSection={
             <>
               <Spacer height="15px" />
@@ -59,15 +59,15 @@ const AWSCostConsent: React.FC<Props> = ({
                 <Spacer height="15px" />
                 <Tab />+ Monitoring workloads: t3.large instance (1) = $60.74/mo
                 <Spacer height="15px" />
-                <Tab />+ Application workloads: t3.xlarge instance (1) =
-                $121.47/mo
+                <Tab />+ Application workloads: t3.medium instance (1) =
+                $30.1/mo
               </Fieldset>
             </>
           }
         />
         <Spacer y={1} />
         <Text color="helper">
-          The base AWS infrastructure covers up to 4 vCPU and 16GB of RAM.
+          The base AWS infrastructure covers up to 2 vCPU and 4GB of RAM.
           Separate from the AWS cost, Porter charges based on your resource
           usage.
         </Text>
@@ -93,12 +93,12 @@ const AWSCostConsent: React.FC<Props> = ({
         <Spacer y={0.5} />
         <Text color="helper">
           All AWS resources will be automatically deleted when you delete your
-          Porter project. Please enter the AWS base cost ("315.94") below to
+          Porter project. Please enter the AWS base cost ("224.58") below to
           proceed:
         </Text>
         <Spacer y={1} />
         <Input
-          placeholder="315.94"
+          placeholder="224.58"
           value={confirmCost}
           setValue={setConfirmCost}
           width="100%"
@@ -106,7 +106,7 @@ const AWSCostConsent: React.FC<Props> = ({
         />
         <Spacer y={1} />
         <Button
-          disabled={confirmCost !== "315.94"}
+          disabled={confirmCost !== "224.58"}
           onClick={() => {
             setShowCostConfirmModal(false);
             setConfirmCost("");

+ 1 - 0
dashboard/src/components/AzureProvisionerSettings.tsx

@@ -415,6 +415,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
     margin-left: -7px;
     transform: ${(props) =>
     props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
   }
 `;
 

+ 3 - 3
dashboard/src/components/ProvisionerFlow.tsx

@@ -76,9 +76,9 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
                         provider === "gcp"
                       )
                     ) {
-                      // openCostConsentModal(provider);
-                      setSelectedProvider(provider);
-                      setCurrentStep("credentials");
+                      openCostConsentModal(provider);
+                      // setSelectedProvider(provider);
+                      // setCurrentStep("credentials");
                     }
                   }}
                 >

+ 282 - 203
dashboard/src/components/ProvisionerSettings.tsx

@@ -22,7 +22,7 @@ import {
   Cluster,
   LoadBalancer,
   LoadBalancerType,
-  EKSLogging
+  EKSLogging,
 } from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import Button from "./porter/Button";
@@ -103,21 +103,28 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   const [createStatus, setCreateStatus] = useState("");
   const [clusterName, setClusterName] = useState("");
   const [awsRegion, setAwsRegion] = useState("us-east-1");
-  const [machineType, setMachineType] = useState("t3.xlarge");
-  const [guardDutyEnabled, setGuardDutyEnabled] = useState<boolean>(false)
+  const [machineType, setMachineType] = useState("t3.medium");
+  const [guardDutyEnabled, setGuardDutyEnabled] = useState<boolean>(false);
+  const [kmsEncryptionEnabled, setKmsEncryptionEnabled] = useState<boolean>(
+    false
+  );
   const [loadBalancerType, setLoadBalancerType] = useState(false);
-  const [wildCardDomain, setWildCardDomain] = useState("")
-  const [IPAllowList, setIPAllowList] = useState<string>("")
-  const [controlPlaneLogs, setControlPlaneLogs] = useState<EKSLogging>(new EKSLogging())
+  const [wildCardDomain, setWildCardDomain] = useState("");
+  const [IPAllowList, setIPAllowList] = useState<string>("");
+  const [controlPlaneLogs, setControlPlaneLogs] = useState<EKSLogging>(
+    new EKSLogging()
+  );
   //const [accessS3Logs, setAccessS3Logs] = useState<boolean>(false)
-  const [wafV2Enabled, setWaf2Enabled] = useState<boolean>(false)
-  const [awsTags, setAwsTags] = useState<string>("")
-  const [wafV2ARN, setwafV2ARN] = useState("")
-  const [certificateARN, seCertificateARN] = useState("")
+  const [wafV2Enabled, setWaf2Enabled] = useState<boolean>(false);
+  const [awsTags, setAwsTags] = useState<string>("");
+  const [wafV2ARN, setwafV2ARN] = useState("");
+  const [certificateARN, seCertificateARN] = useState("");
   const [isExpanded, setIsExpanded] = useState(false);
   const [minInstances, setMinInstances] = useState(1);
   const [maxInstances, setMaxInstances] = useState(10);
-  const [additionalNodePolicies, setAdditionalNodePolicies] = useState<string[]>([]);
+  const [additionalNodePolicies, setAdditionalNodePolicies] = useState<
+    string[]
+  >([]);
   const [cidrRange, setCidrRange] = useState("10.78.0.0/16");
   const [clusterVersion, setClusterVersion] = useState("v1.24.0");
   const [isReadOnly, setIsReadOnly] = useState(false);
@@ -125,14 +132,16 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   const [isClicked, setIsClicked] = useState(false);
   const markStepStarted = async (step: string, errMessage?: string) => {
     try {
-      await api.updateOnboardingStep("<token>", {
-        step,
-        error_message: errMessage,
-        region: awsRegion,
-      },
+      await api.updateOnboardingStep(
+        "<token>",
         {
-          project_id: currentProject.id,
+          step,
+          error_message: errMessage,
+          region: awsRegion,
         },
+        {
+          project_id: currentProject.id,
+        }
       );
     } catch (err) {
       // console.log(err);
@@ -159,19 +168,18 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   };
   const validateInput = (wildCardDomainer) => {
     if (!wildCardDomainer) {
-      return "Required for ALB Load Balancer"
+      return "Required for ALB Load Balancer";
     }
     if (wildCardDomainer?.charAt(0) == "*") {
-      return "Wildcard domain cannot start with *"
+      return "Wildcard domain cannot start with *";
     }
     return false;
-
   };
   function validateIPInput(IPAllowList) {
     // This regular expression checks for an IP address with a subnet mask.
     const regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([0-9]|[1-2][0-9]|3[0-2])$/;
     if (!IPAllowList) {
-      return false
+      return false;
     }
     // Split the input string by comma and remove any empty elements
     const ipAddresses = IPAllowList.split(",").filter(Boolean);
@@ -201,30 +209,27 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     return false;
   }
   const clusterNameDoesNotExist = () => {
-    return (!clusterName)
-  }
+    return !clusterName;
+  };
   const userProvisioning = () => {
-    //If the cluster is updating or updating unavailabe but there are no errors do not allow re-provisioning 
-    return (isReadOnly && (props.provisionerError === ""))
-  }
+    //If the cluster is updating or updating unavailabe but there are no errors do not allow re-provisioning
+    return isReadOnly && props.provisionerError === "";
+  };
 
   const isDisabled = () => {
-
     return (
       !user?.isPorterUser &&
-      (clusterNameDoesNotExist() ||
-        userProvisioning() ||
-        isClicked)
+      (clusterNameDoesNotExist() || userProvisioning() || isClicked)
     );
   };
   function convertStringToTags(tagString) {
-    if (typeof tagString !== 'string' || tagString.trim() === '') {
+    if (typeof tagString !== "string" || tagString.trim() === "") {
       return [];
     }
 
     // Split the input string by comma, then reduce the resulting array to an object
     const tags = tagString.split(",").reduce((obj, item) => {
-      // Split each item by "=", 
+      // Split each item by "=",
       const [key, value] = item.split("=");
       // Add the key-value pair to the object
       obj[key] = value;
@@ -246,12 +251,11 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         loadBalancerObj.tags = convertStringToTags(awsTags);
       }
       if (IPAllowList) {
-        loadBalancerObj.allowlistIpRanges = IPAllowList
+        loadBalancerObj.allowlistIpRanges = IPAllowList;
       }
       if (wafV2Enabled) {
         loadBalancerObj.enableWafv2 = wafV2Enabled;
-      }
-      else {
+      } else {
         loadBalancerObj.enableWafv2 = false;
       }
       if (wafV2ARN) {
@@ -262,7 +266,6 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       }
     }
 
-
     let data = new Contract({
       cluster: new Cluster({
         projectId: currentProject.id,
@@ -279,6 +282,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
             loadBalancer: loadBalancerObj,
             logging: controlPlaneLogs,
             enableGuardDuty: guardDutyEnabled,
+            enableKmsEncryption: kmsEncryptionEnabled,
             nodeGroups: [
               new EKSNodeGroup({
                 instanceType: "t3.medium",
@@ -406,10 +410,12 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     if (contract?.cluster) {
       let eksValues: EKS = contract.cluster?.eksKind as EKS;
       if (eksValues == null) {
-        return
+        return;
       }
       eksValues.nodeGroups.map((nodeGroup: EKSNodeGroup) => {
-        if (nodeGroup.nodeGroupType.toString() === "NODE_GROUP_TYPE_APPLICATION") {
+        if (
+          nodeGroup.nodeGroupType.toString() === "NODE_GROUP_TYPE_APPLICATION"
+        ) {
           setMachineType(nodeGroup.instanceType);
           setMinInstances(nodeGroup.minInstances);
           setMaxInstances(nodeGroup.maxInstances);
@@ -426,19 +432,24 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       setClusterVersion(eksValues.clusterVersion);
       setCidrRange(eksValues.cidrRange);
       if (eksValues.loadBalancer != null) {
-        setIPAllowList(eksValues.loadBalancer.allowlistIpRanges)
-        setWildCardDomain(eksValues.loadBalancer.wildcardDomain)
+        setIPAllowList(eksValues.loadBalancer.allowlistIpRanges);
+        setWildCardDomain(eksValues.loadBalancer.wildcardDomain);
         //setAccessS3Logs(eksValues.loadBalancer.enableS3AccessLogs)
 
         if (eksValues.loadBalancer.tags) {
-          setAwsTags(Object.entries(eksValues.loadBalancer.tags)
-            .map(([key, value]) => `${key}=${value}`)
-            .join(','));
+          setAwsTags(
+            Object.entries(eksValues.loadBalancer.tags)
+              .map(([key, value]) => `${key}=${value}`)
+              .join(",")
+          );
         }
 
-        setLoadBalancerType(eksValues.loadBalancer.loadBalancerType?.toString() === "LOAD_BALANCER_TYPE_ALB")
-        setwafV2ARN(eksValues.loadBalancer.wafv2Arn)
-        setWaf2Enabled(eksValues.loadBalancer.enableWafv2)
+        setLoadBalancerType(
+          eksValues.loadBalancer.loadBalancerType?.toString() ===
+          "LOAD_BALANCER_TYPE_ALB"
+        );
+        setwafV2ARN(eksValues.loadBalancer.wafv2Arn);
+        setWaf2Enabled(eksValues.loadBalancer.enableWafv2);
       }
 
       if (eksValues.logging != null) {
@@ -446,13 +457,14 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         l.enableApiServerLogs = eksValues.logging.enableApiServerLogs;
         l.enableAuditLogs = eksValues.logging.enableAuditLogs;
         l.enableAuthenticatorLogs = eksValues.logging.enableAuthenticatorLogs;
-        l.enableControllerManagerLogs = eksValues.logging.enableControllerManagerLogs;
+        l.enableControllerManagerLogs =
+          eksValues.logging.enableControllerManagerLogs;
         l.enableSchedulerLogs = eksValues.logging.enableSchedulerLogs;
         setControlPlaneLogs(l);
       }
-      setGuardDutyEnabled(eksValues.enableGuardDuty)
+      setGuardDutyEnabled(eksValues.enableGuardDuty);
+      setKmsEncryptionEnabled(eksValues.enableKmsEncryption);
     }
-
   }, [isExpanded, props.selectedClusterVersion]);
 
   const renderForm = () => {
@@ -508,14 +520,16 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
 
         {isExpanded && (
           <>
-            {user?.isPorterUser && (<Select
-              options={clusterVersionOptions}
-              width="350px"
-              disabled={isReadOnly}
-              value={clusterVersion}
-              setValue={setClusterVersion}
-              label="Cluster version"
-            />)}
+            {user?.isPorterUser && (
+              <Select
+                options={clusterVersionOptions}
+                width="350px"
+                disabled={isReadOnly}
+                value={clusterVersion}
+                setValue={setClusterVersion}
+                label="Cluster version"
+              />
+            )}
             <Spacer y={1} />
             <Select
               options={machineTypeOptions}
@@ -555,20 +569,27 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
               label="VPC CIDR range"
               placeholder="ex: 10.78.0.0/16"
             />
-
-            {!currentProject.simplified_view_enabled &&
+            {!currentProject.simplified_view_enabled && (
               <>
-
                 <Spacer y={1} />
                 <Checkbox
                   checked={controlPlaneLogs.enableApiServerLogs}
                   disabled={isReadOnly}
                   toggleChecked={() => {
-                    setControlPlaneLogs(new EKSLogging({ ...controlPlaneLogs, enableApiServerLogs: !controlPlaneLogs.enableApiServerLogs }))
+                    setControlPlaneLogs(
+                      new EKSLogging({
+                        ...controlPlaneLogs,
+                        enableApiServerLogs: !controlPlaneLogs.enableApiServerLogs,
+                      })
+                    );
                   }}
-                  disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  disabledTooltip={
+                    "Wait for provisioning to complete before editing this field."
+                  }
                 >
-                  <Text color="helper">Enable API Server logs in CloudWatch for this cluster</Text>
+                  <Text color="helper">
+                    Enable API Server logs in CloudWatch for this cluster
+                  </Text>
                 </Checkbox>
 
                 <Spacer y={1} />
@@ -576,11 +597,20 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                   checked={controlPlaneLogs.enableAuditLogs}
                   disabled={isReadOnly}
                   toggleChecked={() => {
-                    setControlPlaneLogs(new EKSLogging({ ...controlPlaneLogs, enableAuditLogs: !controlPlaneLogs.enableAuditLogs }))
+                    setControlPlaneLogs(
+                      new EKSLogging({
+                        ...controlPlaneLogs,
+                        enableAuditLogs: !controlPlaneLogs.enableAuditLogs,
+                      })
+                    );
                   }}
-                  disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  disabledTooltip={
+                    "Wait for provisioning to complete before editing this field."
+                  }
                 >
-                  <Text color="helper">Enable Audit logs in CloudWatch for this cluster</Text>
+                  <Text color="helper">
+                    Enable Audit logs in CloudWatch for this cluster
+                  </Text>
                 </Checkbox>
 
                 <Spacer y={1} />
@@ -588,11 +618,20 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                   checked={controlPlaneLogs.enableAuthenticatorLogs}
                   disabled={isReadOnly}
                   toggleChecked={() => {
-                    setControlPlaneLogs(new EKSLogging({ ...controlPlaneLogs, enableAuthenticatorLogs: !controlPlaneLogs.enableAuthenticatorLogs }))
+                    setControlPlaneLogs(
+                      new EKSLogging({
+                        ...controlPlaneLogs,
+                        enableAuthenticatorLogs: !controlPlaneLogs.enableAuthenticatorLogs,
+                      })
+                    );
                   }}
-                  disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  disabledTooltip={
+                    "Wait for provisioning to complete before editing this field."
+                  }
                 >
-                  <Text color="helper">Enable Authenticator logs in CloudWatch for this cluster</Text>
+                  <Text color="helper">
+                    Enable Authenticator logs in CloudWatch for this cluster
+                  </Text>
                 </Checkbox>
 
                 <Spacer y={1} />
@@ -600,11 +639,21 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                   checked={controlPlaneLogs.enableControllerManagerLogs}
                   disabled={isReadOnly}
                   toggleChecked={() => {
-                    setControlPlaneLogs(new EKSLogging({ ...controlPlaneLogs, enableControllerManagerLogs: !controlPlaneLogs.enableControllerManagerLogs }))
+                    setControlPlaneLogs(
+                      new EKSLogging({
+                        ...controlPlaneLogs,
+                        enableControllerManagerLogs: !controlPlaneLogs.enableControllerManagerLogs,
+                      })
+                    );
                   }}
-                  disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  disabledTooltip={
+                    "Wait for provisioning to complete before editing this field."
+                  }
                 >
-                  <Text color="helper">Enable Controller Manager logs in CloudWatch for this cluster</Text>
+                  <Text color="helper">
+                    Enable Controller Manager logs in CloudWatch for this
+                    cluster
+                  </Text>
                 </Checkbox>
 
                 <Spacer y={1} />
@@ -612,11 +661,20 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                   checked={controlPlaneLogs.enableSchedulerLogs}
                   disabled={isReadOnly}
                   toggleChecked={() => {
-                    setControlPlaneLogs(new EKSLogging({ ...controlPlaneLogs, enableSchedulerLogs: !controlPlaneLogs.enableSchedulerLogs }))
+                    setControlPlaneLogs(
+                      new EKSLogging({
+                        ...controlPlaneLogs,
+                        enableSchedulerLogs: !controlPlaneLogs.enableSchedulerLogs,
+                      })
+                    );
                   }}
-                  disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  disabledTooltip={
+                    "Wait for provisioning to complete before editing this field."
+                  }
                 >
-                  <Text color="helper">Enable Scheduler logs in CloudWatch for this cluster</Text>
+                  <Text color="helper">
+                    Enable Scheduler logs in CloudWatch for this cluster
+                  </Text>
                 </Checkbox>
 
                 <Spacer y={1} />
@@ -634,171 +692,184 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                       //setAccessS3Logs(false);
                     }
 
-                    setLoadBalancerType(!loadBalancerType)
+                    setLoadBalancerType(!loadBalancerType);
                   }}
-                  disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  disabledTooltip={
+                    "Wait for provisioning to complete before editing this field."
+                  }
                 >
                   <Text color="helper">Set Load Balancer Type to ALB</Text>
                 </Checkbox>
                 <Spacer y={1} />
-                {loadBalancerType && (<>
-
-                  <FlexCenter>
-                    <Input
-                      width="350px"
-                      disabled={isReadOnly}
-                      value={wildCardDomain}
-                      setValue={(x: string) => setWildCardDomain(x)}
-                      label="Wildcard domain"
-                      placeholder="user-2.porter.run"
-                    />
-                    <Wrapper>
-                      <Tooltip
-                        children={<Icon src={info} />}
-                        content={'The provided domain should have a wildcard subdomain pointed to the LoadBalancer address. Using testing.porter.run will create a certificate for testing.porter.run with a SAN *.testing.porter.run'}
-                        position="right"
-                      />
-                    </Wrapper>
-
-                  </FlexCenter>
-
-                  {validateInput(wildCardDomain) && <ErrorInLine>
-                    <i className="material-icons">error</i>
-                    {validateInput(wildCardDomain)}
-                  </ErrorInLine>}
-                  <Spacer y={1} />
-
-                  <FlexCenter>
-                    <>
+                {loadBalancerType && (
+                  <>
+                    <FlexCenter>
                       <Input
                         width="350px"
                         disabled={isReadOnly}
-                        value={IPAllowList}
-                        setValue={(x: string) => setIPAllowList(x)}
-                        label="IP Allow List"
-                        placeholder="160.72.72.58/32,160.72.72.59/32"
+                        value={wildCardDomain}
+                        setValue={(x: string) => setWildCardDomain(x)}
+                        label="Wildcard domain"
+                        placeholder="user-2.porter.run"
                       />
                       <Wrapper>
                         <Tooltip
                           children={<Icon src={info} />}
-                          content={'Each range should be a CIDR, including netmask such as 10.1.2.3/21. To use multiple values, they should be comma-separated with no spaces'}
+                          content={
+                            "The provided domain should have a wildcard subdomain pointed to the LoadBalancer address. Using testing.porter.run will create a certificate for testing.porter.run with a SAN *.testing.porter.run"
+                          }
                           position="right"
                         />
                       </Wrapper>
-                    </>
-                  </FlexCenter>
-                  {validateIPInput(IPAllowList) && <ErrorInLine>
-                    <i className="material-icons">error</i>
-                    {"Needs to be Comma Separated Valid IP addresses"}
-                  </ErrorInLine>}
-                  <Spacer y={1} />
-
-                  <Input
-                    width="350px"
-                    disabled={isReadOnly}
-                    value={certificateARN}
-                    setValue={(x: string) => seCertificateARN(x)}
-                    label="Certificate ARN"
-                    placeholder="arn:aws:acm:REGION:ACCOUNT_ID:certificate/ACM_ID"
-                  />
-                  <Spacer y={1} />
+                    </FlexCenter>
 
+                    {validateInput(wildCardDomain) && (
+                      <ErrorInLine>
+                        <i className="material-icons">error</i>
+                        {validateInput(wildCardDomain)}
+                      </ErrorInLine>
+                    )}
+                    <Spacer y={1} />
 
-                  <FlexCenter>
-                    <>
-                      <Input
-                        width="350px"
-                        disabled={isReadOnly}
-                        value={awsTags}
-                        setValue={(x: string) => setAwsTags(x)}
-                        label="AWS Tags"
-                        placeholder="costcenter=1,environment=10,project=32"
-                      />
-                      <Wrapper>
-                        <Tooltip
-                          children={<Icon src={info} />}
-                          content={"Each tag should be of the format 'key=value'. To use multiple values, they should be comma-separated with no spaces."}
-                          position="right"
+                    <FlexCenter>
+                      <>
+                        <Input
+                          width="350px"
+                          disabled={isReadOnly}
+                          value={IPAllowList}
+                          setValue={(x: string) => setIPAllowList(x)}
+                          label="IP Allow List"
+                          placeholder="160.72.72.58/32,160.72.72.59/32"
                         />
-                      </Wrapper>
-                    </>
-                  </FlexCenter>
-                  {validateTags(awsTags) && <ErrorInLine>
-                    <i className="material-icons">error</i>
-                    {"Needs to be Comma Separated Valid Tags"}
-                  </ErrorInLine>}
-
-                  <Spacer y={1} />
-                  {/* <Checkbox
-                    checked={accessS3Logs}
-                    disabled={isReadOnly}
-                    toggleChecked={() => {
-                      {
-                        console.log(!accessS3Logs)
-                      }
-                      setAccessS3Logs(!accessS3Logs)
-                    }}
-                    disabledTooltip={"Wait for provisioning to complete before editing this field."}
-                  >
-                    <Text color="helper">Access Logs to S3</Text>
-                  </Checkbox> */}
-                  <Spacer y={1} />
-                  <Checkbox
-                    checked={wafV2Enabled}
-                    disabled={isReadOnly}
-                    toggleChecked={() => {
-                      if (wafV2Enabled) {
-                        setwafV2ARN("");
-                      }
-                      setWaf2Enabled(!wafV2Enabled);
-                    }}
-                    disabledTooltip={"Wait for provisioning to complete before editing this field."}
-                  >
-                    <Text color="helper">WAFv2 Enabled</Text>
-                  </Checkbox>
-                  {wafV2Enabled && <>
+                        <Wrapper>
+                          <Tooltip
+                            children={<Icon src={info} />}
+                            content={
+                              "Each range should be a CIDR, including netmask such as 10.1.2.3/21. To use multiple values, they should be comma-separated with no spaces"
+                            }
+                            position="right"
+                          />
+                        </Wrapper>
+                      </>
+                    </FlexCenter>
+                    {validateIPInput(IPAllowList) && (
+                      <ErrorInLine>
+                        <i className="material-icons">error</i>
+                        {"Needs to be Comma Separated Valid IP addresses"}
+                      </ErrorInLine>
+                    )}
                     <Spacer y={1} />
 
+                    <Input
+                      width="350px"
+                      disabled={isReadOnly}
+                      value={certificateARN}
+                      setValue={(x: string) => seCertificateARN(x)}
+                      label="Certificate ARN"
+                      placeholder="arn:aws:acm:REGION:ACCOUNT_ID:certificate/ACM_ID"
+                    />
+                    <Spacer y={1} />
 
                     <FlexCenter>
                       <>
                         <Input
-                          width="500px"
-                          type="string"
-                          label="WAFv2 ARN"
+                          width="350px"
                           disabled={isReadOnly}
-                          value={wafV2ARN}
-                          setValue={(x: string) => setwafV2ARN(x)}
-                          placeholder="arn:aws:wafv2:REGION:ACCOUNT_ID:regional/webacl/ACL_NAME/RULE_ID"
-
+                          value={awsTags}
+                          setValue={(x: string) => setAwsTags(x)}
+                          label="AWS Tags"
+                          placeholder="costcenter=1,environment=10,project=32"
                         />
                         <Wrapper>
                           <Tooltip
                             children={<Icon src={info} />}
-                            content={'Only Regional WAFv2 is supported. To find your ARN, navigate to the WAF console, click the Gear icon in the top right, and toggle "ARN" to on'}
+                            content={
+                              "Each tag should be of the format 'key=value'. To use multiple values, they should be comma-separated with no spaces."
+                            }
                             position="right"
                           />
                         </Wrapper>
                       </>
                     </FlexCenter>
-
-                    {(wafV2ARN == undefined || wafV2ARN?.length == 0) &&
-
+                    {validateTags(awsTags) && (
                       <ErrorInLine>
                         <i className="material-icons">error</i>
-                        {"Required if WafV2 is enabled"}
+                        {"Needs to be Comma Separated Valid Tags"}
                       </ErrorInLine>
+                    )}
 
-                    }
-                  </>}
-                  <Spacer y={1} />
-                </>
+                    <Spacer y={1} />
+                    {/* <Checkbox
+                    checked={accessS3Logs}
+                    disabled={isReadOnly}
+                    toggleChecked={() => {
+                      {
+                        console.log(!accessS3Logs)
+                      }
+                      setAccessS3Logs(!accessS3Logs)
+                    }}
+                    disabledTooltip={"Wait for provisioning to complete before editing this field."}
+                  >
+                    <Text color="helper">Access Logs to S3</Text>
+                  </Checkbox> */}
+                    <Spacer y={1} />
+                    <Checkbox
+                      checked={wafV2Enabled}
+                      disabled={isReadOnly}
+                      toggleChecked={() => {
+                        if (wafV2Enabled) {
+                          setwafV2ARN("");
+                        }
+                        setWaf2Enabled(!wafV2Enabled);
+                      }}
+                      disabledTooltip={
+                        "Wait for provisioning to complete before editing this field."
+                      }
+                    >
+                      <Text color="helper">WAFv2 Enabled</Text>
+                    </Checkbox>
+                    {wafV2Enabled && (
+                      <>
+                        <Spacer y={1} />
+
+                        <FlexCenter>
+                          <>
+                            <Input
+                              width="500px"
+                              type="string"
+                              label="WAFv2 ARN"
+                              disabled={isReadOnly}
+                              value={wafV2ARN}
+                              setValue={(x: string) => setwafV2ARN(x)}
+                              placeholder="arn:aws:wafv2:REGION:ACCOUNT_ID:regional/webacl/ACL_NAME/RULE_ID"
+                            />
+                            <Wrapper>
+                              <Tooltip
+                                children={<Icon src={info} />}
+                                content={
+                                  'Only Regional WAFv2 is supported. To find your ARN, navigate to the WAF console, click the Gear icon in the top right, and toggle "ARN" to on'
+                                }
+                                position="right"
+                              />
+                            </Wrapper>
+                          </>
+                        </FlexCenter>
+
+                        {(wafV2ARN == undefined || wafV2ARN?.length == 0) && (
+                          <ErrorInLine>
+                            <i className="material-icons">error</i>
+                            {"Required if WafV2 is enabled"}
+                          </ErrorInLine>
+                        )}
+                      </>
+                    )}
+                    <Spacer y={1} />
+                  </>
                 )}
               </>
-            }
+            )}
           </>
-        )
-        }
+        )}
       </>
     );
   };
@@ -807,7 +878,14 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     <>
       <StyledForm>{renderForm()}</StyledForm>
       <Button
-        disabled={isDisabled()}
+        // disabled={isDisabled()}
+        disabled={
+          user?.email === "admin@porter.run" || currentProject?.id === 7760 || currentProject?.id === 7257 || currentProject?.id === 7645
+            ? false
+            : currentCluster
+              ? true
+              : isDisabled()
+        }
         onClick={createCluster}
         status={getStatus()}
       >
@@ -828,6 +906,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
     margin-left: -7px;
     transform: ${(props) =>
     props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
   }
 `;
 
@@ -843,9 +922,9 @@ const StyledForm = styled.div`
 
 const FlexCenter = styled.div`
   display: flex;
-  align-items: center  ;
+  align-items: center;
   gap: 3px;
-`
+`;
 const Wrapper = styled.div`
   transform: translateY(+13px);
 `;
@@ -1149,4 +1228,4 @@ const errorMessageToModal = (errorMessage: string) => {
     default:
       return null;
   }
-};
+};

+ 13 - 0
dashboard/src/components/porter-form/types.ts

@@ -233,6 +233,19 @@ export type PopulatedEnvGroup = {
   meta_version: number;
   stack_id?: string;
 };
+
+export type NewPopulatedEnvGroup = {
+  name: string;
+  current_version: number;
+  variables: {
+    [key: string]: string;
+  };
+  secret_variables: {
+    [key: string]: string;
+  };
+  linked_applications: any[];
+  created_at: number;
+};
 export interface KeyValueArrayFieldState {
   values: {
     key: string;

+ 1 - 1
dashboard/src/components/porter/Icon.tsx

@@ -19,7 +19,7 @@ const Icon: React.FC<Props> = ({
 
 export default Icon;
 
-const StyledIcon = styled.img<{ 
+const StyledIcon = styled.img<{
   height?: string;
   opacity?: number;
 }>`

+ 216 - 0
dashboard/src/components/porter/InputSlider.tsx

@@ -0,0 +1,216 @@
+import React from 'react';
+import Slider, { Mark } from '@material-ui/core/Slider';
+import Tooltip from '@material-ui/core/Tooltip';
+import Typography from '@material-ui/core/Typography';
+import styled from 'styled-components';
+import { withStyles } from '@material-ui/core/styles';
+import Container from './Container';
+
+
+type InputSliderProps = {
+  label?: string;
+  unit?: string;
+  min: number;
+  max: number;
+  value: string;
+  setValue: (value: number) => void;
+  disabled?: boolean;
+  disabledTooltip?: string;
+  color?: string;
+  width?: string;
+  step?: number;
+};
+
+const ValueLabelComponent: React.FC<any> = (props) => {
+  const { children, value } = props;
+
+  return (
+    <StyledTooltip
+      placement="bottom"
+      title={value}
+      arrow
+    >
+      {children}
+    </StyledTooltip>
+  );
+}
+
+const InputSlider: React.FC<InputSliderProps> = ({
+  label,
+  unit,
+  min,
+  max,
+  value,
+  setValue,
+  disabled,
+  disabledTooltip,
+  color,
+  step,
+  width,
+
+}) => {
+  const marks: Mark[] = [
+
+    {
+      value: max,
+      label: max.toString(),
+    },
+  ];
+
+  return (
+    <SliderContainer width={width}>
+      <LabelContainer>
+        {label && <Label>{label}</Label>}
+        <Value>{`${value} ${unit}`}</Value>
+      </LabelContainer>
+      <DisabledTooltip title={disabled ? disabledTooltip || '' : ''} arrow>
+
+        <div style={{ position: 'relative' }}>
+          <StyledSlider
+            ValueLabelComponent={ValueLabelComponent}
+            aria-label="input slider"
+            min={min}
+            max={max}
+            value={Number(value)}
+            onChange={(event, newValue) => {
+              setValue(newValue as number);
+            }}
+            disabled={disabled}
+            marks={marks}
+            step={step ? step : 1}
+            style={{
+              color: disabled ? "gray" : color,
+            }}
+          />
+          {disabled && (
+            <div
+              style={{
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                right: 0,
+                bottom: 0,
+                cursor: 'not-allowed',
+                zIndex: 1
+              }}
+            />
+          )}
+        </div>
+      </DisabledTooltip>
+
+    </SliderContainer >
+  );
+};
+
+
+export default InputSlider;
+
+const SliderContainer = styled.div<{ width?: string }>`
+  width: ${({ width }) => width || '300px'};
+  margin: 1px 0;
+`;
+
+const Label = styled.div<{ color?: string }>`
+  font-size: 13px;
+  margin-right: 5px;
+  margin-bottom: 10px;
+  color: #aaaabb;
+`;
+
+const Value = styled.div<{ color?: string }>`
+  font-size: 13px;
+  margin-bottom: 10px;
+  color: #ffff;
+`;
+
+const DisabledTooltip = withStyles(theme => ({
+  tooltip: {
+    backgroundColor: '#333',
+    color: '#fff',
+    padding: '8px',
+    borderRadius: '4px',
+    fontSize: '14px',
+    textAlign: 'center',
+    whiteSpace: 'pre-wrap',
+    wordWrap: 'break-word',
+    maxWidth: '200px',
+    width: '200px',
+    [theme.breakpoints.up('sm')]: {
+      margin: '0 14px',
+    },
+  },
+  arrow: {
+    color: '#333',
+  },
+}))(Tooltip);
+
+
+const StyledSlider = withStyles({
+  root: {
+    height: '8px', //height of the track
+  },
+  mark: {
+    backgroundColor: '#fff',  // mark color
+    height: 4, // size of the mark
+    width: 1, // size of the mark
+    borderRadius: '50%',
+    marginTop: 6,
+    marginLeft: -1,
+  },
+  markActive: {
+    backgroundColor: '#fff',
+  },
+  markLabel: {
+    color: '#6e717d',
+    fontSize: '12px',
+    marginRight: 5,
+
+  },
+  markLabelActive: {
+    color: '#6e717d',
+    marginRight: 5,
+  },
+  thumb: {
+    height: 16, // Size of the thumb
+    width: 16, // Size of the thumb
+    backgroundColor: '#fff',
+    border: '2px solid currentColor',
+    '&:focus, &:hover, &$active': {
+      boxShadow: 'inherit',
+    },
+    '&$disabled': { // Targeting the thumb when the slider is disabled
+      height: 16,
+      width: 16,
+    },
+  },
+  track: {
+    height: 8, // Same as root height for consistency
+    borderRadius: 4,
+  },
+  rail: {
+    height: 8, // Same as root height for consistency
+    borderRadius: 4,
+  },
+  valueLabel: {
+    top: -22,
+    '& *': {
+      background: 'transparent',
+      border: 'none', // remove the default border
+    },
+  },
+  disabled: {},
+})(Slider);
+
+
+const StyledTooltip = withStyles({
+  tooltip: {
+    fontSize: 12,
+    padding: "5px 10px",
+
+  }
+})(Tooltip);
+
+const LabelContainer = styled.div`
+  display: flex;
+  align-items: center;
+`;

+ 38 - 18
dashboard/src/components/porter/Link.tsx

@@ -1,15 +1,15 @@
 import DynamicLink from "components/DynamicLink";
-import React, { useEffect, useState } from "react";
+import React from "react";
 import styled from "styled-components";
 
-import Icon from "components/porter/Icon";
-
 type Props = {
   to?: string;
   onClick?: () => void;
   children: React.ReactNode;
   target?: string;
   hasunderline?: boolean;
+  color?: string;
+  hoverColor?: string;
 };
 
 const Link: React.FC<Props> = ({
@@ -18,24 +18,26 @@ const Link: React.FC<Props> = ({
   children,
   target,
   hasunderline,
+  color = "#ffffff",
+  hoverColor,
 }) => {
   return (
-    <LinkWrapper>
+    <LinkWrapper hoverColor={hoverColor} color={color}>
       {to ? (
-        <StyledLink to={to} target={target}>
+        <StyledLink to={to} target={target} color={color}>
           {children}
           {target === "_blank" && (
             <div>
-            <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
+              <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
             </div>
           )}
         </StyledLink>
       ) : (
-        <Div onClick={onClick}>
+        <Div onClick={onClick} color={color}>
           {children}
         </Div>
       )}
-      {hasunderline && <Underline />}
+      {hasunderline && <Underline color={color} />}
     </LinkWrapper>
   );
 };
@@ -50,28 +52,46 @@ const Svg = styled.svg`
   stroke-width: 2;
 `;
 
-const Underline = styled.div`
+const Underline = styled.div<{ color: string }>`
   position: absolute;
   left: 0px;
+  bottom: -2px;
   height: 1px;
   width: 100%;
-  background: #ffffff;
+  background: ${(props) => props.color};
 `;
 
-const LinkWrapper = styled.span`
-  position: relative;
+const StyledLink = styled(DynamicLink) <{ hasunderline?: boolean, color: string }>`
+  color: ${(props) => props.color};
+  display: inline-flex;
+  font-size: 13px;
+  cursor: pointer;
+  align-items: center;
 `;
 
-const Div = styled.span`
-  color: #ffffff;
+const Div = styled.span<{ color: string }>`
+  color: ${(props) => props.color};
   cursor: pointer;
   font-size: 13px;
   display: inline-flex;
+  align-items: center;
 `;
 
-const StyledLink = styled(DynamicLink)<{ hasunderline?: boolean }>`
-  color: #ffffff;
+const LinkWrapper = styled.span<{ hoverColor?: string, color: string }>`
+  position: relative;
   display: inline-flex;
-  font-size: 13px;
-  cursor: pointer;
+  align-items: center;
+  :hover {
+    ${StyledLink} {
+      color: ${({ hoverColor, color }) => hoverColor ?? color};
+    }
+
+    ${Div} {
+      color: ${({ hoverColor, color }) => hoverColor ?? color};
+    }
+
+    ${Underline} {
+      background-color: ${({ hoverColor, color }) => hoverColor ?? color};
+    }
+  };
 `;

+ 3 - 0
dashboard/src/components/porter/SearchBar.tsx

@@ -13,6 +13,7 @@ type Props = {
   type?: string;
   error?: string;
   children?: React.ReactNode;
+  autoFocus?: boolean;
 };
 
 const SearchBar: React.FC<Props> = ({
@@ -25,6 +26,7 @@ const SearchBar: React.FC<Props> = ({
   type,
   error,
   children,
+  autoFocus,
 }) => {
   return (
     <Block width={width}>
@@ -44,6 +46,7 @@ const SearchBar: React.FC<Props> = ({
           onChange={e => setValue(e.target.value)}
           placeholder={placeholder}
           type={type || "text"}
+          autoFocus={autoFocus}
         />
         {
           error && (

+ 14 - 1
dashboard/src/index.html

@@ -2,6 +2,14 @@
 <html lang="en">
 
 <head>
+  <!-- Google Tag Manager -->
+  <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
+  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
+  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
+  'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
+  })(window,document,'script','dataLayer','GTM-P8D92VJ');</script>
+  <!-- End Google Tag Manager -->
+  
   <title>Porter | Dashboard</title>
   <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
   <meta name="description" content="Kubernetes powered PaaS that runs in your own cloud." />
@@ -24,8 +32,13 @@
 </head>
 
 <body>
+  <!-- Google Tag Manager (noscript) -->
+  <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-P8D92VJ"
+  height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
+  <!-- End Google Tag Manager (noscript) -->
+  
   <div id="output"></div>
   <div id="modal-root"></div>
 </body>
 
-</html>
+</html>

+ 59 - 7
dashboard/src/main/auth/Register.tsx

@@ -16,6 +16,7 @@ import Input from "components/porter/Input";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Link from "components/porter/Link";
+import Select from "components/porter/Select";
 
 type Props = {
   authenticate: () => void;
@@ -47,6 +48,24 @@ const Register: React.FC<Props> = ({
   const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
   const [buttonDisabled, setButtonDisabled] = useState(false);
 
+  const [chosenReferralOption, setChosenReferralOption] = useState<string>("(None provided)");
+  const [referralOtherText, setReferralOtherText] = useState<string>("");
+
+  const referralOptions = [
+    { value: "(None provided)", label: "Please select an option:" },
+    { value: "Email", label: "Email" },
+    { value: "Word of mouth", label: "Word of mouth (friend, colleague, etc.)" },
+    { value: "YC", label: "YC" },
+    { value: "YC Startup School", label: "YC Startup School" },
+    { value: "Facebook", label: "Facebook" },
+    { value: "Instagram", label: "Instagram" },
+    { value: "Twitter", label: "Twitter" },
+    { value: "Search engine", label: "Search engine (Google, Bing, etc.)" },
+    { value: "LinkedIn", label: "LinkedIn" },
+    { value: "Porter blog", label: "Porter blog" },
+    { value: "Other", label: "Other" },
+  ]
+
   const handleRegister = (): void => {
     if (!emailRegex.test(email)) {
       setEmailError(true);
@@ -70,24 +89,25 @@ const Register: React.FC<Props> = ({
 
     // Check for valid input
     if (
-      emailRegex.test(email) && 
+      emailRegex.test(email) &&
       firstName !== "" &&
       lastName !== "" &&
       password !== "" &&
       companyName !== ""
     ) {
       setButtonDisabled(true);
-      
+
       // Attempt user registration
       api
         .registerUser(
           "",
-          { 
+          {
             email: email,
             password: password,
             first_name: firstName,
             last_name: lastName,
             company_name: companyName,
+            referral_method: chosenReferralOption === "Other" ? `Other: ${referralOtherText}` : chosenReferralOption,
           },
           {}
         )
@@ -138,12 +158,12 @@ const Register: React.FC<Props> = ({
     let qs = window.location.search;
     let urlParams = new URLSearchParams(qs);
     let email = urlParams.get('email');
-    
+
     if (email) {
       setEmail(email);
       setDisabled(true);
     }
-    
+
   }, []);
 
   useEffect(() => {
@@ -314,7 +334,26 @@ const Register: React.FC<Props> = ({
               type="password"
               error={(passwordError && "")}
             />
-            <Spacer height="30px" />
+            <Spacer y={1} />
+            <Text color="helper">(Optional) How did you hear about us?</Text>
+            <Spacer y={0.5} />
+            <Select
+              width="100%"
+              options={referralOptions}
+              setValue={setChosenReferralOption}
+            />
+            {chosenReferralOption === "Other" && (
+              <>
+                <Spacer y={0.5} />
+                <FeedbackInput
+                  autoFocus={true}
+                  value={referralOtherText}
+                  onChange={(e) => setReferralOtherText(e.target.value)}
+                  placeholder="Tell us more..."
+                />
+              </>
+            )}
+            <Spacer y={1} />
             <Button disabled={buttonDisabled} onClick={handleRegister} width="100%" height="40px">
               Continue
             </Button>
@@ -323,7 +362,7 @@ const Register: React.FC<Props> = ({
         {!disabled && (
           <>
             <Spacer y={1} />
-            <Text 
+            <Text
               size={13}
               color="helper"
             >
@@ -380,6 +419,19 @@ const Logo = styled.img`
   user-select: none;
 `;
 
+const FeedbackInput = styled.textarea`
+  resize: none;
+  width: 100%;
+  height: 80px;
+  outline: 0;
+  padding: 14px;
+  color: white;
+  border: 0;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  background: #aaaabb11;
+`;
+
 const StyledGoogleIcon = styled(GoogleIcon)`
   width: 38px;
   height: 38px;

+ 6 - 4
dashboard/src/main/home/Home.tsx

@@ -328,9 +328,14 @@ const Home: React.FC<Props> = (props) => {
   };
 
   const handleDelete = async () => {
+    if (currentProject?.id == null) {
+      return;
+    }
+
     localStorage.removeItem(currentProject.id + "-cluster");
     try {
-      await api.deleteProject("<token>", {}, { id: currentProject?.id });
+      await api.updateOnboardingStep("<token>", { step: "project-delete" }, { project_id: currentProject.id });
+      await api.deleteProject("<token>", {}, { id: currentProject.id });
       projectOverlayCall();
     } catch (error) {
       console.log(error);
@@ -407,9 +412,6 @@ const Home: React.FC<Props> = (props) => {
             <Route path="/apps/new/app">
               <NewAppFlow />
             </Route>
-            <Route path="/apps/:appName/events/:eventId">
-              <ExpandedApp />
-            </Route>
             <Route path="/apps/:appName/:tab">
               <ExpandedApp />
             </Route>

+ 4 - 3
dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx

@@ -48,7 +48,7 @@ const NewAddOnFlow: React.FC<Props> = ({
 
     return _.sortBy(filteredBySearch);
   }, [addOnTemplates, searchValue]);
-  
+
   const getTemplates = async () => {
     setIsLoading(true);
     const default_addon_helm_repo_url = capabilities?.default_addon_helm_repo_url;
@@ -104,17 +104,18 @@ const NewAddOnFlow: React.FC<Props> = ({
               capitalize={false}
               description="Select an add-on to deploy to this project."
               disableLineBreak
+
             />
             {
               currentTemplate ? (
-                <ExpandedTemplate 
+                <ExpandedTemplate
                   currentTemplate={currentTemplate}
                   proceed={(form?: any) => setCurrentForm(form)}
                   goBack={() => setCurrentTemplate(null)}
                 />
               ) : (
                 <>
-                  <SearchBar 
+                  <SearchBar
                     value={searchValue}
                     setValue={setSearchValue}
                     placeholder="Search available add-ons . . ."

+ 1 - 0
dashboard/src/main/home/app-dashboard/build-settings/ProviderSelector.tsx

@@ -87,6 +87,7 @@ const ProviderSelectorStyles = {
         margin-right: 10px;
         z-index: 0;
         transform: ${(props) => (props.isOpen ? "rotate(180deg)" : "")};
+        transition: transform 0.1s ease;
       }
     `,
     Button: styled.div`

+ 2 - 2
dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogComponent.tsx

@@ -74,7 +74,7 @@ const ChangeLogComponent: FC<Props> = ({ oldYaml, newYaml, appData }) => {
             const commitDiffLink = `https://github.com/${appData.app.repo_name}/compare/${oldCommit}...${newCommit}`;
             changes.push(
               <ChangeBox type="E">
-                {`Tag upated: ${oldCommit} -> ${newCommit}.   `}
+                {`Tag updated: ${oldCommit} -> ${newCommit}.   `}
 
                 <Link
                   target="_blank"
@@ -87,7 +87,7 @@ const ChangeLogComponent: FC<Props> = ({ oldYaml, newYaml, appData }) => {
             );
           } else {
             <ChangeBox type="E">
-              {`Tag upated: ${oldCommit} -> ${newCommit}.   `}
+              {`Tag updated: ${oldCommit} -> ${newCommit}.   `}
             </ChangeBox>
           }
         } else {

+ 67 - 0
dashboard/src/main/home/app-dashboard/expanded-app/DeleteApplicationModal.tsx

@@ -0,0 +1,67 @@
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+    closeModal: () => void;
+    githubWorkflowFilename: string;
+    deleteApplication: (deleteWorkflowFile?: boolean) => void;
+};
+
+const GithubActionModal: React.FC<Props> = ({
+    closeModal,
+    githubWorkflowFilename,
+    deleteApplication,
+}) => {
+    const [deleteGithubWorkflow, setDeleteGithubWorkflow] = useState(true);
+
+    const renderDeleteGithubWorkflowText = () => {
+        if (githubWorkflowFilename === "") {
+            return null;
+        }
+        return (
+            <>
+                <Text color="helper">You may also want to remove this application's associated CI file from your repository.</Text>
+                <Spacer y={0.5} />
+                <Checkbox
+                    checked={deleteGithubWorkflow}
+                    toggleChecked={() => setDeleteGithubWorkflow(!deleteGithubWorkflow)}
+                >
+                    <Text color="helper">
+                        Upon deletion, open a PR to remove this application's associated CI file (<Code>{githubWorkflowFilename}</Code>) from my repository.
+                    </Text>
+                </Checkbox>
+                <Spacer y={1} />
+            </>
+        )
+    }
+
+    return (
+        <Modal closeModal={closeModal}>
+            <Text size={16}>
+                Confirm deletion
+            </Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Click the button below to confirm deletion. This action is irreversible.</Text>
+            <Spacer y={0.5} />
+            {renderDeleteGithubWorkflowText()}
+            <Button
+                onClick={() => deleteApplication(deleteGithubWorkflow)}
+                color="#b91133"
+            >
+                Delete
+            </Button>
+        </Modal>
+    );
+};
+
+export default GithubActionModal;
+
+const Code = styled.span`
+  font-family: monospace;
+`;

+ 124 - 175
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState, useContext } from "react";
-import { RouteComponentProps, useParams, withRouter } from "react-router";
+import { RouteComponentProps, useHistory, useLocation, useParams, withRouter } from "react-router";
 import styled from "styled-components";
 import yaml from "js-yaml";
 
@@ -26,12 +26,10 @@ import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
 import Icon from "components/porter/Icon";
 import { ChartType, CreateUpdatePorterAppOptions } from "shared/types";
-import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
 import BuildSettingsTab from "../build-settings/BuildSettingsTab";
 import Button from "components/porter/Button";
 import Services from "../new-app-flow/Services";
-import { Service } from "../new-app-flow/serviceTypes";
-import ConfirmOverlay from "components/porter/ConfirmOverlay";
+import { ImageInfo, Service } from "../new-app-flow/serviceTypes";
 import Fieldset from "components/porter/Fieldset";
 import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
@@ -45,10 +43,12 @@ import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
-import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
+import { NewPopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
+import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import HelmValuesTab from "./HelmValuesTab";
-import ProjectDeleteConsent from "main/home/project-settings/ProjectDeleteConsent";
+import SettingsTab from "./SettingsTab";
+import PorterAppRevisionSection from "./PorterAppRevisionSection";
 
 type Props = RouteComponentProps & {};
 
@@ -62,6 +62,7 @@ const icons = [
 
 const validTabs = [
   "activity",
+  "events",
   "overview",
   "logs",
   "metrics",
@@ -70,6 +71,7 @@ const validTabs = [
   "build-settings",
   "settings",
   "helm-values",
+  "job-history",
 ] as const;
 const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
@@ -91,6 +93,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [workflowCheckPassed, setWorkflowCheckPassed] = useState<boolean>(
     false
   );
+  const [githubWorkflowFilename, setGithubWorkflowFilename] = useState<string>("");
   const [hasBuiltImage, setHasBuiltImage] = useState<boolean>(false);
 
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
@@ -98,7 +101,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   );
 
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
-  const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
 
   // this is what we read from their porter.yaml in github
   const [porterJson, setPorterJson] = useState<PorterJson | undefined>(undefined);
@@ -111,16 +113,26 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
-  const [syncedEnvGroups, setSyncedEnvGroups] = useState<PopulatedEnvGroup[]>([])
-  const [deletedEnvGroups, setDeleteEnvGroups] = useState<PopulatedEnvGroup[]>([])
+  const [syncedEnvGroups, setSyncedEnvGroups] = useState<NewPopulatedEnvGroup[]>([])
+  const [deletedEnvGroups, setDeleteEnvGroups] = useState<NewPopulatedEnvGroup[]>([])
   const [porterApp, setPorterApp] = useState<PorterApp>();
+
   // this is the version of the porterApp that is being edited. on save, we set the real porter app to be this version
   const [tempPorterApp, setTempPorterApp] = useState<PorterApp>();
   const [buildView, setBuildView] = useState<BuildMethod>("docker");
 
-  const { eventId, tab } = useParams<Params>();
-  const selectedTab: ValidTab = tab != null && validTabs.includes(tab) ? tab : DEFAULT_TAB;
+  const history = useHistory();
 
+  const { tab } = useParams<Params>();
+  const { search } = useLocation();
+  const queryParams = new URLSearchParams(search);
+  const queryParamOpts = {
+    revision: queryParams.get('version'),
+    output_stream: queryParams.get('output_stream'),
+    service: queryParams.get('service'),
+  }
+  const eventId = queryParams.get('event_id');
+  const selectedTab: ValidTab = tab != null && validTabs.includes(tab) ? tab : DEFAULT_TAB;
   useEffect(() => {
     if (!_.isEqual(_.omitBy(porterApp, _.isEmpty), _.omitBy(tempPorterApp, _.isEmpty))) {
       setButtonStatus("");
@@ -139,7 +151,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
   // this method fetches and reconstructs the porter yaml as well as the DB info (stored in PorterApp)
   const getPorterApp = async ({ revision }: { revision: number }) => {
-    setIsLoading(true);
     const { appName } = props.match.params as any;
     try {
       if (!currentCluster || !currentProject) {
@@ -165,7 +176,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           revision: revision,
         }
       );
-
       let preDeployChartData;
       // get the pre-deploy chart
       try {
@@ -184,7 +194,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       } catch (err) {
         // that's ok if there's an error, just means there is no pre-deploy chart
       }
-
       // update apps and release
       const newAppData = {
         app: resPorterApp?.data,
@@ -195,40 +204,30 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         resPorterApp?.data?.porter_yaml_path ?? "porter.yaml",
         newAppData
       );
-      let envGroups: PartialEnvGroup[] = [];
-      envGroups = await api
-        .listEnvGroups<PartialEnvGroup[]>(
+
+      const envGroups: NewPopulatedEnvGroup[] = await api
+        .getAllEnvGroups<any[]>(
           "<token>",
           {},
           {
-            id: currentProject.id,
-            namespace: "porter-env-group",
-            cluster_id: currentCluster.id,
+            id: currentProject?.id,
+            cluster_id: currentCluster?.id,
           }
         )
-        .then((res) => res.data);
+        .then((res) => res?.data?.environment_groups)
+        .catch((error) => {
+          console.error("Failed to fetch environment groups:", error);
+          return [];
+        });
+      let filteredEnvGroups: NewPopulatedEnvGroup[] = [];
 
-      const populateEnvGroupsPromises = envGroups?.map((envGroup) =>
-        api
-          .getEnvGroup<PopulatedEnvGroup>(
-            "<token>",
-            {},
-            {
-              id: currentProject.id,
-              cluster_id: currentCluster.id,
-              name: envGroup.name,
-              namespace: envGroup.namespace,
-              version: envGroup.version,
-            }
-          )
-          .then((res) => res.data)
-      );
-
-      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
-
-      const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(newAppData.chart.name));
+      if (envGroups) {
+        filteredEnvGroups = envGroups?.filter(envGroup =>
+          envGroup?.linked_applications?.length > 0 && envGroup?.linked_applications?.includes(appName)
+        );
+      }
 
-      setSyncedEnvGroups(filteredEnvGroups)
+      setSyncedEnvGroups(filteredEnvGroups || []);
       setPorterJson(porterJson);
       setAppData(newAppData);
       // annoying that we have to parse buildpacks like this but alas
@@ -249,10 +248,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         newAppData.app.builder != null && newAppData.app.builder.includes("heroku")
       );
       setPorterYaml(finalPorterYaml);
-
       // Only check GHA status if no built image is set
-      const hasBuiltImage = !!resChartData.data.config?.global?.image
-        ?.repository;
+      const globalImage = resChartData.data.config?.global?.image
+      const hasBuiltImage = globalImage != null &&
+        globalImage.repository != null &&
+        globalImage.tag != null &&
+        globalImage.repository !== ImageInfo.BASE_IMAGE.repository &&
+        globalImage.tag !== ImageInfo.BASE_IMAGE.tag
       if (hasBuiltImage || !resPorterApp.data.repo_name) {
         setWorkflowCheckPassed(true);
         setHasBuiltImage(true);
@@ -273,12 +275,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             }
           );
           setWorkflowCheckPassed(true);
+          setGithubWorkflowFilename(`porter_stack_${resPorterApp.data.name}.yml`);
         } catch (err) {
           // Handle unmerged PR
           if (err.response?.status === 404) {
             try {
               // Check for user-copied porter.yml as fallback
-              const resPorterYml = await api.getBranchContents(
+              await api.getBranchContents(
                 "<token>",
                 { dir: `./.github/workflows/porter.yml` },
                 {
@@ -291,6 +294,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                 }
               );
               setWorkflowCheckPassed(true);
+              setGithubWorkflowFilename(`porter.yml`);
             } catch (err) {
               setWorkflowCheckPassed(false);
             }
@@ -304,32 +308,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   };
 
-  const deletePorterApp = async () => {
-    setShowDeleteOverlay(false);
+  const deletePorterApp = async (deleteGHWorkflowFile?: boolean) => {
     setDeleting(true);
     const { appName } = props.match.params as any;
-    if (syncedEnvGroups) {
-      const removeApplicationToEnvGroupPromises = syncedEnvGroups?.map((envGroup: any) => {
-        return api.removeApplicationFromEnvGroup(
-          "<token>",
-          {
-            name: envGroup?.name,
-            app_name: appData.chart.name,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            namespace: "porter-env-group",
-          }
-        );
-      });
-
-      try {
-        await Promise.all(removeApplicationToEnvGroupPromises);
-      } catch (error) {
-        // TODO: Handle error
-      }
-    }
     try {
       await api.deletePorterApp(
         "<token>",
@@ -340,6 +321,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           name: appName,
         }
       );
+    } catch (err) {
+      // TODO: handle error
+    }
+    try {
       await api.deleteNamespace(
         "<token>",
         {},
@@ -349,80 +334,56 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           namespace: `porter-stack-${appName}`,
         }
       );
-      // intentionally do not await this promise
-      api.updateStackStep(
-        "<token>",
-        {
-          step: "stack-deletion",
-          stack_name: appName,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-      props.history.push("/apps");
     } catch (err) {
       // TODO: handle error
-    } finally {
-      setDeleting(false);
     }
-  };
 
-  const updatePorterApp = async (options: Partial<CreateUpdatePorterAppOptions>) => {
-    //setting the EnvGroups Config Maps
-    const filteredEnvGroups = deletedEnvGroups.filter((deletedEnvGroup) => {
-      return !syncedEnvGroups.some((syncedEnvGroup) => {
-        return syncedEnvGroup.name === deletedEnvGroup.name;
-      });
-    });
-    setDeleteEnvGroups(filteredEnvGroups);
-    if (deletedEnvGroups) {
-      const removeApplicationToEnvGroupPromises = deletedEnvGroups?.map((envGroup: any) => {
-        return api.removeApplicationFromEnvGroup(
-          "<token>",
-          {
-            name: envGroup?.name,
-            app_name: appData.chart.name,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            namespace: "porter-env-group",
-          }
-        );
-      });
+    let deleteWorkflowFile = false;
 
+    if (deleteGHWorkflowFile && githubWorkflowFilename !== "" && appData?.app != null) {
       try {
-        await Promise.all(removeApplicationToEnvGroupPromises);
-      } catch (error) {
-        setCurrentError(
-          "We couldn't remove the synced env group from the application, please try again."
-        );
-      }
-    }
-    const addApplicationToEnvGroupPromises = syncedEnvGroups?.map(
-      (envGroup: any) => {
-        return api.addApplicationToEnvGroup(
+        const res = await api.createSecretAndOpenGitHubPullRequest(
           "<token>",
           {
-            name: envGroup?.name,
-            app_name: appData.chart.name,
+            github_app_installation_id: appData.app.git_repo_id,
+            github_repo_owner: appData.app.repo_name.split("/")[0],
+            github_repo_name: appData.app.repo_name.split("/")[1],
+            branch: appData.app.git_branch,
+            delete_workflow_filename: githubWorkflowFilename,
           },
           {
             project_id: currentProject.id,
             cluster_id: currentCluster.id,
-            namespace: "porter-env-group",
+            stack_name: appData.app.name,
           }
         );
+        if (res.data?.url) {
+          window.open(res.data.url, "_blank", "noreferrer");
+        }
+        deleteWorkflowFile = true;
+      } catch (err) {
+        // TODO: handle error
+      }
+    }
+
+    // intentionally do not await this promise
+    api.updateStackStep(
+      "<token>",
+      {
+        step: "stack-deletion",
+        stack_name: appName,
+        delete_workflow_file: deleteWorkflowFile,
+      },
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
       }
     );
 
-    try {
-      await Promise.all(addApplicationToEnvGroupPromises);
-    } catch (error) {
-      // TODO: handle error
-    }
+    props.history.push("/apps");
+  };
+
+  const updatePorterApp = async (options: Partial<CreateUpdatePorterAppOptions>) => {
     try {
       setButtonStatus("loading");
       if (
@@ -449,7 +410,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           repo_name: tempPorterApp.repo_name,
           git_branch: tempPorterApp.git_branch,
           buildpacks: "",
-          env_groups: syncedEnvGroups?.map((env) => env.name),
+          environment_groups: syncedEnvGroups?.map((env) => env.name),
           user_update: true,
           ...options,
         }
@@ -490,6 +451,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         "An error occurred while deploying your app. Please try again.";
       setButtonStatus(<Error message={errMessage} />);
     }
+
+    // redirect to the default tab
+    history.push(`/apps/${appData.app.name}/${DEFAULT_TAB}`);
   };
 
   const fetchPorterYamlContent = async (
@@ -623,7 +587,18 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           chart={appData.chart}
           stackName={appData?.app?.name}
           appData={appData}
-          eventId={eventId}
+        />;
+      case "events":
+        if (eventId != null && eventId !== "") {
+          return <EventFocusView
+            eventId={eventId}
+            appData={appData}
+          />;
+        }
+        return <ActivityFeed
+          chart={appData.chart}
+          stackName={appData?.app?.name}
+          appData={appData}
         />;
       case "overview":
         return (
@@ -705,26 +680,18 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           />
         );
       case "settings":
-        return (
-          <>
-            <Text size={16}>Delete "{appData.app.name}"</Text>
-            <Spacer y={1} />
-            <Text color="helper">
-              Delete this application and all of its resources.
-            </Text>
-            <Spacer y={1} />
-            <Button
-              onClick={() => {
-                setShowDeleteOverlay(true);
-              }}
-              color="#b91133"
-            >
-              Delete
-            </Button>
-          </>
-        );
+        return <SettingsTab
+          appName={appData.app.name}
+          githubWorkflowFilename={githubWorkflowFilename}
+          deleteApplication={deletePorterApp}
+        />;
       case "logs":
-        return <LogSection currentChart={appData.chart} services={services} />;
+        return <LogSection
+          currentChart={appData.chart}
+          services={services.filter(svc => Service.isNonRelease(svc) && !Service.isJob(svc))}
+          appName={appData.app.name}
+          filterOpts={queryParamOpts}
+        />;
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
       case "debug":
@@ -752,27 +719,22 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           currentChart={appData.chart}
           updatePorterApp={updatePorterApp}
           buttonStatus={buttonStatus}
-        />
+        />;
+      case "job-history":
+        return <ExpandedJob
+          appName={appData.app.name}
+          jobName={queryParamOpts.service}
+          goBack={() => setExpandedJob(null)}
+        />;
       default:
         return <ActivityFeed
           chart={appData.chart}
           stackName={appData?.app?.name}
           appData={appData}
-          eventId={eventId}
         />;
     }
   };
 
-  if (expandedJob) {
-    return (
-      <ExpandedJob
-        appName={appData.app.name}
-        jobName={expandedJob}
-        goBack={() => setExpandedJob(null)}
-      />
-    );
-  }
-
   return (
     <>
       {isLoading && <Loading />}
@@ -913,7 +875,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               ) : (
                 <>
                   <DarkMatter />
-                  <RevisionSection
+                  <PorterAppRevisionSection
                     showRevisions={showRevisions}
                     toggleShowRevisions={() => {
                       setShowRevisions(!showRevisions);
@@ -927,7 +889,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                       appData.chart.latest_version !==
                       appData.chart.chart.metadata.version
                     }
+                    updatePorterApp={updatePorterApp}
                     latestVersion={appData.chart.latest_version}
+                    appName={appData.app.name}
                   />
                   <DarkMatter antiHeight="-18px" />
                 </>
@@ -989,17 +953,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           )}
         </StyledExpandedApp>
       )}
-      {showDeleteOverlay && (
-        <ConfirmOverlay
-          message={`Are you sure you want to delete "${appData.app.name}"?`}
-          onYes={() => {
-            deletePorterApp();
-          }}
-          onNo={() => {
-            setShowDeleteOverlay(false);
-          }}
-        />
-      )}
     </>
   );
 };
@@ -1011,10 +964,6 @@ const A = styled.a`
   align-items: center;
 `;
 
-const Underline = styled.div`
-  border-bottom: 1px solid #ffffff;
-`;
-
 const RefreshButton = styled.div`
   color: #ffffff;
   display: flex;

+ 548 - 0
dashboard/src/main/home/app-dashboard/expanded-app/PorterAppRevisionSection.tsx

@@ -0,0 +1,548 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType, CreateUpdatePorterAppOptions, StorageType } from "shared/types";
+
+import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
+import { readableDate } from "shared/string_utils";
+import { createPortal } from "react-dom";
+import yaml from "js-yaml";
+
+type PropsType = WithAuthProps & {
+    chart: ChartType;
+    refreshChart: () => void;
+    setRevision: (x: ChartType, isCurrent?: boolean) => void;
+    forceRefreshRevisions: boolean;
+    refreshRevisionsOff: () => void;
+    shouldUpdate: boolean;
+    upgradeVersion: (version: string, cb: () => void) => void;
+    latestVersion: string;
+    showRevisions?: boolean;
+    toggleShowRevisions?: () => void;
+    updatePorterApp: (options: Partial<CreateUpdatePorterAppOptions>) => Promise<void>;
+    appName: string;
+};
+
+type StateType = {
+    revisions: ChartType[];
+    rollbackRevision: number | null;
+    upgradeVersion: string;
+    loading: boolean;
+    maxVersion: number;
+    expandRevisions: boolean;
+};
+
+// TODO: refactor this component it's so gross
+class PorterAppRevisionSection extends Component<PropsType, StateType> {
+    state = {
+        revisions: [] as ChartType[],
+        rollbackRevision: null as number | null,
+        upgradeVersion: "",
+        loading: false,
+        maxVersion: 0, // Track most recent version even when previewing old revisions
+        expandRevisions: false,
+    };
+
+    ws: WebSocket | null = null;
+
+    refreshHistory = () => {
+        let { chart } = this.props;
+        let { currentCluster, currentProject } = this.context;
+
+        return api
+            .getRevisions(
+                "<token>",
+                {},
+                {
+                    id: currentProject.id,
+                    namespace: chart.namespace,
+                    cluster_id: currentCluster.id,
+                    name: chart.name,
+                }
+            )
+            .then((res) => {
+                res.data.sort((a: ChartType, b: ChartType) => {
+                    return -(a.version - b.version);
+                });
+                this.setState({
+                    revisions: res.data,
+                    maxVersion: res.data[0].version,
+                });
+            })
+            .catch(console.log);
+    };
+
+    componentDidMount() {
+        this.refreshHistory();
+        this.connectToLiveUpdates();
+    }
+
+    componentWillUnmount() {
+        if (this.ws) {
+            this.ws.close(); // Close the WebSocket connection
+        }
+    }
+
+    connectToLiveUpdates() {
+        let { chart } = this.props;
+        let { currentCluster, currentProject } = this.context;
+
+        const apiPath = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/helm_release?charts=${chart.name}`;
+        const protocol = window.location.protocol == "https:" ? "wss" : "ws";
+        const url = `${protocol}://${window.location.host}`;
+
+        this.ws = new WebSocket(`${url}${apiPath}`);
+
+        this.ws.onopen = () => {
+            console.log("connected to chart live updates websocket");
+        };
+
+        this.ws.onmessage = (evt: MessageEvent) => {
+            let event = JSON.parse(evt.data);
+
+            if (event.event_type == "UPDATE") {
+                let object = event.Object;
+
+                this.setState(
+                    (prevState) => {
+                        const { revisions: oldRevisions } = prevState;
+                        // Copy old array to clean up references
+                        const prevRevisions = [...oldRevisions];
+
+                        // Check if it's an update of a revision or if it's a new one
+                        const revisionIndex = prevRevisions.findIndex((rev) => {
+                            if (rev.version === object.version) {
+                                return true;
+                            }
+                        });
+
+                        // Place new one at top of the array or update the old one
+                        if (revisionIndex > -1) {
+                            prevRevisions.splice(revisionIndex, 1, object);
+                        } else {
+                            return { ...prevState, revisions: [object, ...prevRevisions] };
+                        }
+
+                        return { ...prevState, revisions: prevRevisions, maxVersion: Math.max(...prevRevisions.map(rev => rev.version)) };
+                    },
+                    () => {
+                        this.props.setRevision(this.state.revisions[0], true);
+                    }
+                );
+            }
+        };
+
+        this.ws.onclose = () => {
+            console.log("closing chart live updates websocket");
+        };
+
+        this.ws.onerror = (err: ErrorEvent) => {
+            console.log(err);
+            this.ws.close();
+        };
+    }
+
+    // Handle update of values.yaml
+    componentDidUpdate(prevProps: PropsType) {
+        if (this.props.forceRefreshRevisions) {
+            this.props.refreshRevisionsOff();
+
+            // Force refresh occurs on submit -> set current to newest
+            this.refreshHistory().then(() => {
+                this.props.setRevision(this.state.revisions[0], true);
+            });
+        } else if (this.props.chart !== prevProps.chart) {
+            this.refreshHistory();
+        }
+    }
+
+    handleRollback = async () => {
+        let { setCurrentError, currentCluster, currentProject } = this.context;
+
+        let revisionNumber = this.state.rollbackRevision;
+        if (revisionNumber == null) {
+            return;
+        }
+        this.setState({ loading: true, rollbackRevision: null });
+
+        try {
+            await api.rollbackPorterApp(
+                "<token>",
+                {
+                    revision: revisionNumber,
+                },
+                {
+                    project_id: currentProject.id,
+                    cluster_id: currentCluster.id,
+                    stack_name: this.props.appName,
+                }
+            );
+        } catch {
+            // TODO: handle error better
+            setCurrentError(err.response.data);
+        } finally {
+            this.setState({ loading: false });
+        }
+    };
+
+    handleClickRevision = (revision: ChartType) => {
+        this.props.setRevision(
+            revision,
+            revision.version === this.state.maxVersion
+        );
+    };
+
+    renderRevisionList = () => {
+        return this.state.revisions.map((revision: ChartType, i: number) => {
+            let isCurrent = revision.version === this.state.maxVersion;
+            const isGithubApp = !!this.props.chart.git_action_config;
+            const imageTag = revision.config?.image?.tag || revision.config?.global?.image?.tag;
+
+            const parsedImageTag = isGithubApp
+                ? String(imageTag).slice(0, 7)
+                : imageTag;
+
+            const isStack = !!this.props.chart.stack_id;
+
+            return (
+                <Tr
+                    key={i}
+                    onClick={() => this.handleClickRevision(revision)}
+                    selected={this.props.chart.version === revision.version}
+                >
+                    <Td>{revision.version}</Td>
+                    <Td>{readableDate(revision.info.last_deployed)}</Td>
+                    <Td>
+                        {!imageTag ? (
+                            "N/A"
+                        ) : isGithubApp && /^[0-9A-Fa-f]{7}$/g.test(imageTag) ? (
+                            <A
+                                href={`https://github.com/${this.props.chart.git_action_config?.git_repo}/commit/${imageTag}`}
+                                target="_blank"
+                                onClick={(e) => {
+                                    e.stopPropagation();
+                                }}
+                            >
+                                {parsedImageTag}
+                            </A>
+                        ) : (
+                            parsedImageTag
+                        )}
+                    </Td>
+                    <Td>v{revision.chart.metadata.version}</Td>
+                    <Td>
+                        <RollbackButton
+                            disabled={
+                                isCurrent ||
+                                !this.props.isAuthorized("application", "", [
+                                    "get",
+                                    "update",
+                                ]) ||
+                                isStack
+                            }
+                            onClick={(e) => {
+                                e.stopPropagation();
+                                this.setState({ rollbackRevision: revision.version })
+                            }
+                            }
+                        >
+                            {isCurrent ? "Current" : "Revert"}
+                        </RollbackButton>
+                    </Td>
+                </Tr >
+            );
+        });
+    };
+
+    renderExpanded = () => {
+        if (this.state.expandRevisions) {
+            return (
+                <TableWrapper>
+                    <RevisionsTable>
+                        <tbody>
+                            <Tr disableHover={true}>
+                                <Th>Revision no.</Th>
+                                <Th>Timestamp</Th>
+                                <Th>
+                                    {this.props.chart.git_action_config ? "Commit" : "Image Tag"}
+                                </Th>
+                                <Th>Template version</Th>
+                                <Th>Rollback</Th>
+                            </Tr>
+                            {this.renderRevisionList()}
+                        </tbody>
+                    </RevisionsTable>
+                </TableWrapper>
+            );
+        }
+    };
+
+    renderContents = () => {
+        if (this.state.loading) {
+            return (
+                <LoadingPlaceholder>
+                    <StatusWrapper>
+                        <LoadingGif src={loading} revision={false} /> Updating . . .
+                    </StatusWrapper>
+                </LoadingPlaceholder>
+            );
+        }
+
+        let isCurrent =
+            this.props.chart.version === this.state.maxVersion ||
+            this.state.maxVersion === 0;
+        return (
+            <div>
+                {this.state.upgradeVersion && (
+                    <Modal
+                        onRequestClose={() => this.setState({ upgradeVersion: "" })}
+                        width="500px"
+                        height="450px"
+                    >
+                        <UpgradeChartModal
+                            currentChart={this.props.chart}
+                            closeModal={() => {
+                                this.setState({ upgradeVersion: "" });
+                            }}
+                            onSubmit={() => {
+                                this.props.upgradeVersion(this.state.upgradeVersion, () => {
+                                    this.setState({ loading: false });
+                                });
+                                this.setState({ upgradeVersion: "", loading: true });
+                            }}
+                        />
+                    </Modal>
+                )}
+                <RevisionHeader
+                    showRevisions={this.props.showRevisions}
+                    isCurrent={isCurrent}
+                    onClick={() => {
+                        if (typeof this.props.toggleShowRevisions === "function") {
+                            this.props.toggleShowRevisions();
+                        }
+                        this.setState((prev) => ({
+                            ...prev,
+                            expandRevisions: !prev.expandRevisions,
+                        }));
+                    }}
+                >
+                    <RevisionPreview>
+                        <i className="material-icons">arrow_drop_down</i>
+                        {isCurrent
+                            ? `Current version`
+                            : `Previewing revision (not deployed)`}{" "}
+                        - <Revision>No. {this.props.chart.version}</Revision>
+                    </RevisionPreview>
+                </RevisionHeader>
+                <RevisionList>{this.renderExpanded()}</RevisionList>
+            </div>
+        );
+    };
+
+    render() {
+        return (
+            <StyledRevisionSection showRevisions={this.state.expandRevisions}>
+                {this.renderContents()}
+                {createPortal(
+                    <ConfirmOverlay
+                        show={this.state.rollbackRevision != null}
+                        message={`Are you sure you want to revert to version ${this.state.rollbackRevision}?`}
+                        onYes={this.handleRollback}
+                        onNo={() => this.setState({ rollbackRevision: null })}
+                    />,
+                    document.body
+                )}
+            </StyledRevisionSection>
+        );
+    }
+}
+
+PorterAppRevisionSection.contextType = Context;
+
+export default withAuth(PorterAppRevisionSection);
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: ${(props: { revision: boolean }) =>
+        props.revision ? "0px" : "9px"};
+  margin-left: ${(props: { revision: boolean }) =>
+        props.revision ? "10px" : "0px"};
+  margin-bottom: ${(props: { revision: boolean }) =>
+        props.revision ? "-2px" : "0px"};
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+const RevisionList = styled.div`
+  overflow-y: auto;
+  max-height: 215px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+        props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#405eddbb"};
+  }
+`;
+
+const Tr = styled.tr`
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+        props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+        props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+        props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+        props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  background: ${({ theme }) => theme.fg};
+  :hover {
+    background: ${(props) => props.showRevisions && props.theme.fg2};
+  }
+
+  > div > i {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+        props.showRevisions ? "" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
+  }
+`;
+
+const StyledRevisionSection = styled.div`
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) =>
+        props.showRevisions ? "255px" : "40px"};
+  margin: 20px 0px 18px;
+  overflow: hidden;
+  border-radius: 5px;
+  background: ${props => props.theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: ${(props: { showRevisions: boolean }) =>
+        props.showRevisions ? "expandRevisions 0.3s" : ""};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const RevisionUpdateMessage = styled.div`
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 5px;
+  margin-right: 10px;
+
+  :hover {
+    border: 1px solid white;
+    padding: 3px 9px;
+  }
+
+  > i {
+    margin-right: 6px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: none;
+  }
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 57 - 0
dashboard/src/main/home/app-dashboard/expanded-app/SettingsTab.tsx

@@ -0,0 +1,57 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import DeleteApplicationModal from "./DeleteApplicationModal";
+
+type Props = {
+    appName: string;
+    githubWorkflowFilename: string;
+    deleteApplication: (deleteWorkflowFile?: boolean) => void;
+};
+
+const SettingsTab: React.FC<Props> = ({
+    appName,
+    githubWorkflowFilename,
+    deleteApplication
+}) => {
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+    useEffect(() => {
+        // Do something
+    }, []);
+
+    return (
+        <StyledSettingsTab>
+            <Text size={16}>Delete "{appName}"</Text>
+            <Spacer y={1} />
+            <Text color="helper">
+                Delete this application and all of its resources.
+            </Text>
+            <Spacer y={1} />
+            <Button
+                onClick={() => {
+                    setIsDeleteModalOpen(true);
+                }}
+                color="#b91133"
+            >
+                Delete
+            </Button>
+            {isDeleteModalOpen &&
+                <DeleteApplicationModal
+                    closeModal={() => setIsDeleteModalOpen(false)}
+                    githubWorkflowFilename={githubWorkflowFilename}
+                    deleteApplication={deleteApplication}
+                />
+            }
+        </StyledSettingsTab>
+    );
+};
+
+export default SettingsTab;
+
+const StyledSettingsTab = styled.div`
+width: 100%;
+`;

+ 13 - 10
dashboard/src/main/home/app-dashboard/expanded-app/StatusFooter.tsx

@@ -19,6 +19,7 @@ import { timeFormat } from "d3-time-format";
 import AnimateHeight, { Height } from "react-animate-height";
 import { ControllerTabPodType } from "./status/ControllerTab";
 import _ from "lodash";
+import Link from "components/porter/Link";
 
 type Props = RouteComponentProps & {
   chart: any;
@@ -274,16 +275,18 @@ const StatusFooter: React.FC<Props> = ({
               Last run succeeded at 12:39 PM on 4/13/23
             </Text>
             */}
-            <Button
-              onClick={() => setExpandedJob(service.name)}
-              height="30px"
-              width="87px"
-              color="#ffffff11"
-              withBorder
-            >
-              <I className="material-icons">open_in_new</I>
-              History
-            </Button>
+            <Link to={`/apps/${chart.name}/job-history?service=${service.name}`}>
+              <Button
+                onClick={() => { }}
+                height="30px"
+                width="87px"
+                color="#ffffff11"
+                withBorder
+              >
+                <I className="material-icons">open_in_new</I>
+                History
+              </Button>
+            </Link>
           </Container>
         )}
       </StyledStatusFooter>

+ 59 - 31
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -19,17 +19,17 @@ import _ from "lodash";
 import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Container from "components/porter/Container";
-import EventFocusView from "./events/focus-views/EventFocusView";
-import { PorterAppEvent } from "shared/types";
+import { PorterAppEvent, PorterAppEventType } from "./events/types";
 
 type Props = {
   chart: any;
   stackName: string;
   appData: any;
-  eventId?: string;
 };
 
-const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) => {
+const EVENTS_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
   const [events, setEvents] = useState<PorterAppEvent[]>([]);
@@ -39,6 +39,7 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   const [numPages, setNumPages] = useState<number>(0);
   const [hasPorterAgent, setHasPorterAgent] = useState(false);
   const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+  const [shouldAnimate, setShouldAnimate] = useState(true);
 
   const getEvents = async () => {
     setLoading(true)
@@ -65,9 +66,41 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
       setError(err);
     } finally {
       setLoading(false);
+      setShouldAnimate(false);
+    }
+  };
+
+  const getLatestDeployEventIndex = () => {
+    const deployEvents = events.filter((event) => event.type === PorterAppEventType.DEPLOY);
+    if (deployEvents.length === 0) {
+      return -1;
     }
+    return events.indexOf(deployEvents[0]);
   };
 
+  const updateEvents = async () => {
+    if (!currentProject || !currentCluster) {
+      return;
+    }
+    try {
+      const res = await api.getFeedEvents(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          stack_name: stackName,
+          page,
+        }
+      );
+      setError(undefined)
+      setNumPages(res.data.num_pages);
+      setEvents(res.data.events?.map((event: any) => PorterAppEvent.toPorterAppEvent(event)) ?? []);
+    } catch (err) {
+      setError(err);
+    }
+  }
+
   useEffect(() => {
     const checkForAgent = async () => {
       const project_id = currentProject?.id;
@@ -92,10 +125,12 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
     if (!hasPorterAgent) {
       checkForAgent();
     } else {
+      const intervalId = setInterval(updateEvents, EVENTS_POLL_INTERVAL);
       getEvents();
+      return () => clearInterval(intervalId);
     }
 
-  }, [currentProject, currentCluster, hasPorterAgent, page, eventId]);
+  }, [currentProject, currentCluster, hasPorterAgent, page]);
 
   const installAgent = async () => {
     const project_id = currentProject?.id;
@@ -140,13 +175,6 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
     );
   }
 
-  if (eventId != null) {
-    return <EventFocusView
-      eventId={eventId}
-      appData={appData}
-    />;
-  }
-
   if (!loading && !hasPorterAgent) {
     return (
       <Fieldset>
@@ -178,18 +206,18 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   }
 
   return (
-    <StyledActivityFeed>
+    <StyledActivityFeed shouldAnimate={shouldAnimate}>
       {events.map((event, i) => {
         return (
           <EventWrapper isLast={i === events.length - 1} key={i}>
-            {i !== events.length - 1 && events.length > 1 && <Line />}
-            <Dot />
-            <Time>
+            {i !== events.length - 1 && events.length > 1 && <Line shouldAnimate={shouldAnimate} />}
+            <Dot shouldAnimate={shouldAnimate} />
+            <Time shouldAnimate={shouldAnimate}>
               <Text>{feedDate(event.created_at).split(", ")[0]}</Text>
               <Spacer x={0.5} />
               <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
             </Time>
-            <EventCard appData={appData} event={event} key={i} />
+            <EventCard appData={appData} event={event} key={i} isLatestDeployEvent={i === getLatestDeployEventIndex()} />
           </EventWrapper>
         );
       })}
@@ -224,26 +252,26 @@ const I = styled.i`
   margin-right: 5px;
 `;
 
-const Time = styled.div`
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+const Time = styled.div<{ shouldAnimate: boolean }>`
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
   width: 90px;
 `;
 
-const Line = styled.div`
+const Line = styled.div<{ shouldAnimate: boolean }>`
   width: 1px;
   height: calc(100% + 30px);
   background: #414141;
   position: absolute;
   left: 3px;
   top: 36px;
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
 `;
 
-const Dot = styled.div`
+const Dot = styled.div<{ shouldAnimate: boolean }>`
   width: 7px;
   height: 7px;
   background: #fff;
@@ -251,9 +279,9 @@ const Dot = styled.div`
   position: absolute;
   left: 0;
   top: 36px;
-  opacity: 0;
-  animation: fadeIn 0.3s 0.1s;
-  animation-fill-mode: forwards;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
 `;
 
 const EventWrapper = styled.div<{
@@ -266,9 +294,9 @@ const EventWrapper = styled.div<{
   margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
 `;
 
-const StyledActivityFeed = styled.div`
+const StyledActivityFeed = styled.div<{ shouldAnimate: boolean }>`
   width: 100%;
-  animation: fadeIn 0.3s 0s;
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0s;"}
   @keyframes fadeIn {
     from {
       opacity: 0;

+ 22 - 43
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx

@@ -7,15 +7,14 @@ import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
 
-import { PorterAppEvent } from "shared/types";
 import { StyledEventCard } from "./EventCard";
-import styled from "styled-components";
 import AppEventModal from "../../../status/AppEventModal";
 import { readableDate } from "shared/string_utils";
 import dayjs from "dayjs";
 import Anser from "anser";
 import api from "shared/api";
 import { Direction } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -45,23 +44,24 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
         }
       )
 
-      const updatedLogs = logResp.data.logs.map((l: { line: string; timestamp: string; }, index: number) => {
-        try {
-          return {
-            line: JSON.parse(l.line)?.log ?? Anser.ansiToJson(l.line),
-            lineNumber: index + 1,
-            timestamp: l.timestamp,
+      if (logResp.data?.logs != null) {
+        const updatedLogs = logResp.data.logs.map((l: { line: string; timestamp: string; }, index: number) => {
+          try {
+            return {
+              line: JSON.parse(l.line)?.log ?? Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
+          } catch (err) {
+            return {
+              line: Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
           }
-        } catch (err) {
-          return {
-            line: Anser.ansiToJson(l.line),
-            lineNumber: index + 1,
-            timestamp: l.timestamp,
-          }
-        }
-      });
-
-      setLogs(updatedLogs);
+        });
+        setLogs(updatedLogs);
+      }
     } catch (error) {
       console.log(error);
     }
@@ -72,17 +72,15 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
       <Container row spaced>
         <Container row>
           <Icon height="16px" src={app_event} />
-          <Spacer inline width="10px" />
+          <Spacer inline x={1} />
           <Text>{event.metadata.summary}</Text>
         </Container>
       </Container>
       <Spacer y={0.5} />
       <Container row spaced>
-        <TempWrapper>
-          <Link onClick={getAppLogs} hasunderline>
-            View details
-          </Link>
-        </TempWrapper>
+        <Link onClick={getAppLogs} hasunderline>
+          View details
+        </Link>
       </Container>
       {showModal && (
         <AppEventModal
@@ -99,22 +97,3 @@ const AppEventCard: React.FC<Props> = ({ event, appData }) => {
 
 export default AppEventCard;
 
-const TempWrapper = styled.div`
-  margin-top: -3px;
-`;
-
-const ViewDetailsButton = styled.div<{ width?: string }>`
-  border-radius: 5px;
-  height: 30px;
-  font-size: 13px;
-  color: white;
-  display: flex;
-  align-items: center;
-  padding: 0px 10px;
-  background: #ffffff11;
-  border: 1px solid #aaaabb33;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-`;

+ 11 - 8
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 
 import build from "assets/build.png";
@@ -11,11 +11,14 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
+import api from "shared/api";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
-import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
+import JSZip from "jszip";
+import Anser, { AnserJsonEntry } from "anser";
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -26,11 +29,11 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
   const renderStatusText = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":
-        return <Text color="#68BF8B">Build succeeded</Text>;
+        return <Text color={getStatusColor(event.status)}>Build succeeded</Text>;
       case "FAILED":
-        return <Text color="#FF6060">Build failed</Text>;
+        return <Text color={getStatusColor(event.status)}>Build failed</Text>;
       default:
-        return <Text color="#aaaabb66">Build in progress...</Text>;
+        return <Text color={getStatusColor(event.status)}>Build in progress...</Text>;
     }
   };
 
@@ -41,7 +44,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       case "FAILED":
         return (
           <Wrapper>
-            <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+            <Link to={`/apps/${appData.app.name}/events?event_id=${event.id}`} hasunderline>
               <Container row>
                 <Icon src={document} height="10px" />
                 <Spacer inline width="5px" />
@@ -91,7 +94,7 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           <Spacer inline x={1} />

+ 138 - 35
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx

@@ -1,43 +1,129 @@
-import React, { useEffect, useState } from "react";
-
-
+import React, { useState } from "react";
 import deploy from "assets/deploy.png";
-import refresh from "assets/refresh.png";
-
 import Text from "components/porter/Text";
 import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
-import Modal from "components/porter/Modal";
-import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon } from '../utils';
+import { getStatusColor, getStatusIcon } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import styled from "styled-components";
-import Button from "components/porter/Button";
-import api from "shared/api";
 import Link from "components/porter/Link";
 import ChangeLogModal from "../../../ChangeLogModal";
+import { PorterAppDeployEvent } from "../types";
+import AnimateHeight from "react-animate-height";
+import ServiceStatusDetail from "./ServiceStatusDetail";
 
 type Props = {
-  event: PorterAppEvent;
+  event: PorterAppDeployEvent;
   appData: any;
+  showServiceStatusDetail?: boolean;
 };
 
-const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
-  const [loading, setLoading] = useState<boolean>(false);
+const DeployEventCard: React.FC<Props> = ({ event, appData, showServiceStatusDetail = false }) => {
   const [diffModalVisible, setDiffModalVisible] = useState(false);
   const [revertModalVisible, setRevertModalVisible] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(showServiceStatusDetail);
 
-  const renderStatusText = (event: PorterAppEvent) => {
+  const renderStatusText = () => {
     switch (event.status) {
       case "SUCCESS":
-        return event?.metadata?.image_tag ? <Text color="#68BF8B">Deployed <Code>{event?.metadata?.image_tag}</Code></Text> : <Text color="#68BF8B">Deployment successful</Text>;
+        return event.metadata.image_tag != null ?
+          event.metadata.service_deployment_metadata != null ?
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deployed <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+            :
+            <Text color={getStatusColor(event.status)}>
+              Deployed <Code>{event.metadata.image_tag}</Code>
+            </Text>
+          :
+          <Text color={getStatusColor(event.status)}>
+            Deployment successful
+          </Text>;
       case "FAILED":
-        return <Text color="#FF6060">Deployment failed</Text>;
+        if (event.metadata.service_deployment_metadata != null) {
+          let failedServices = 0;
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "FAILED") {
+              failedServices++;
+            }
+          }
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Failed to deploy <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(failedServices, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deployment failed
+            </Text>
+          );
+        }
+      case "CANCELED":
+        if (event.metadata.service_deployment_metadata != null) {
+          let canceledServices = 0;
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "CANCELED") {
+              canceledServices++;
+            }
+          }
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Canceled deploy of <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(canceledServices, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deployment canceled
+            </Text>
+          );
+        }
       default:
-        return <Text color="#aaaabb66">Deployment in progress...</Text>;
+        if (event.metadata.service_deployment_metadata != null) {
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deploying <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deploying <Code>{event.metadata.image_tag}</Code>...
+            </Text>
+          );
+        }
     }
   };
+
+  const renderServiceDropdownCta = (numServices: number, color?: string) => {
+    return (
+      <ServiceStatusDropdownCtaContainer >
+        <Link color={color} onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
+          <ServiceStatusDropdownIcon className="material-icons" serviceStatusVisible={serviceStatusVisible}>arrow_drop_down</ServiceStatusDropdownIcon>
+          {numServices} service{numServices === 1 ? "" : "s"}
+        </Link>
+      </ServiceStatusDropdownCtaContainer>
+    )
+  }
+
   return (
     <StyledEventCard>
       <Container row spaced>
@@ -50,23 +136,23 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
-          {renderStatusText(event)}
-          {appData?.chart?.version !== event.metadata?.revision && (
+          {renderStatusText()}
+          {appData?.chart?.version !== event.metadata.revision && (
             <>
               <Spacer inline x={1} />
               <TempWrapper>
                 <Link hasunderline onClick={() => setRevertModalVisible(true)}>
-                  Revert to version {event?.metadata?.revision}
+                  Revert to version {event.metadata.revision}
                 </Link>
 
               </TempWrapper>
             </>
           )}
-          <Spacer inline width="15px" />
+          <Spacer inline x={1} />
           <TempWrapper>
-            {event?.metadata?.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
+            {event.metadata.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
               View changes
             </Link>)}
             {diffModalVisible && (
@@ -91,7 +177,16 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
           </TempWrapper>
         </Container>
       </Container>
-
+      {event.metadata.service_deployment_metadata != null &&
+        <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
+          <Spacer y={0.5} />
+          <ServiceStatusDetail
+            serviceDeploymentMetadata={event.metadata.service_deployment_metadata}
+            appName={appData.app.name}
+            revision={event.metadata.revision}
+          />
+        </AnimateHeight>
+      }
     </StyledEventCard>
   );
 };
@@ -107,20 +202,28 @@ const Code = styled.span`
   font-family: monospace;
 `;
 
-const RevertButton = styled.div<{ width?: string }>`
-  border-radius: 5px;
-  height: 30px;
-  font-size: 13px;
-  color: white;
+const ServiceStatusDropdownCtaContainer = styled.div`
   display: flex;
   justify-content: center;
-  align-items: center;
-  padding: 0px 10px;
-  background: #ffffff11;
-  border: 1px solid #aaaabb33;
   cursor: pointer;
+  padding: 3px 5px;
+  border-radius: 5px;
   :hover {
-    border: 1px solid #7a7b80;
+    background: #ffffff11;
   }
-  width: 92px;
+`;
+
+const ServiceStatusDropdownIcon = styled.i`
+  margin-left: -5px;
+  font-size: 20px;
+  border-radius: 20px;
+  transform: ${(props: { serviceStatusVisible: boolean }) =>
+    props.serviceStatusVisible ? "" : "rotate(-90deg)"};
+  transition: transform 0.1s ease;
+`
+
+const StatusTextContainer = styled.div`
+  display: flex;
+  align-items: center;
+  flex-direction: row;
 `;

+ 4 - 3
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx

@@ -1,18 +1,19 @@
 import React from "react";
 import styled from "styled-components";
 
-import { PorterAppEvent, PorterAppEventType } from "shared/types";
 import BuildEventCard from "./BuildEventCard";
 import PreDeployEventCard from "./PreDeployEventCard";
 import AppEventCard from "./AppEventCard";
 import DeployEventCard from "./DeployEventCard";
+import { PorterAppDeployEvent, PorterAppEvent, PorterAppEventType } from "../types";
 
 type Props = {
   event: PorterAppEvent;
   appData: any;
+  isLatestDeployEvent?: boolean;
 };
 
-const EventCard: React.FC<Props> = ({ event, appData }) => {
+const EventCard: React.FC<Props> = ({ event, appData, isLatestDeployEvent }) => {
   const renderEventCard = (event: PorterAppEvent) => {
     switch (event.type) {
       case PorterAppEventType.APP_EVENT:
@@ -20,7 +21,7 @@ const EventCard: React.FC<Props> = ({ event, appData }) => {
       case PorterAppEventType.BUILD:
         return <BuildEventCard event={event} appData={appData} />;
       case PorterAppEventType.DEPLOY:
-        return <DeployEventCard event={event} appData={appData} />;
+        return <DeployEventCard event={event as PorterAppDeployEvent} appData={appData} showServiceStatusDetail={isLatestDeployEvent} />;
       case PorterAppEventType.PRE_DEPLOY:
         return <PreDeployEventCard event={event} appData={appData} />;
       default:

+ 4 - 4
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -11,11 +11,11 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
 
-import { PorterAppEvent } from "shared/types";
 import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import Link from "components/porter/Link";
 import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -30,7 +30,7 @@ const PreDeployEventCard: React.FC<Props> = ({ event, appData }) => {
       case "FAILED":
         return <Text color="#FF6060">Pre-deploy failed</Text>;
       default:
-        return <Text color="#aaaabb66">Pre-deploy in progress...</Text>;
+        return <Text color="helper">Pre-deploy in progress...</Text>;
     }
   };
 
@@ -51,14 +51,14 @@ const PreDeployEventCard: React.FC<Props> = ({ event, appData }) => {
       <Spacer y={0.5} />
       <Container row spaced>
         <Container row>
-          <Icon height="16px" src={getStatusIcon(event.status)} />
+          <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText(event)}
           {(event.status !== "SUCCESS") &&
             <>
               <Spacer inline x={1} />
               <Wrapper>
-                <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+                <Link to={`/apps/${appData.app.name}/events?event_id=${event.id}`} hasunderline>
                   <Container row>
                     <Icon src={document} height="10px" />
                     <Spacer inline width="5px" />

+ 126 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/ServiceStatusDetail.tsx

@@ -0,0 +1,126 @@
+import Icon from 'components/porter/Icon';
+import Spacer from 'components/porter/Spacer';
+import Text from 'components/porter/Text';
+import React from 'react'
+import styled from 'styled-components';
+import { getStatusColor, getStatusIcon } from '../utils';
+import Link from 'components/porter/Link';
+import { PorterAppDeployEvent } from "../types";
+import { Service } from 'main/home/app-dashboard/new-app-flow/serviceTypes';
+
+type Props = {
+    serviceDeploymentMetadata: PorterAppDeployEvent["metadata"]["service_deployment_metadata"];
+    appName: string;
+    revision: number;
+}
+
+const ServiceStatusDetail: React.FC<Props> = ({
+    serviceDeploymentMetadata,
+    appName,
+    revision,
+}) => {
+    const convertEventStatusToCopy = (status: string) => {
+        switch (status) {
+            case "PROGRESSING":
+                return "DEPLOYING";
+            case "SUCCESS":
+                return "DEPLOYED";
+            case "FAILED":
+                return "FAILED";
+            case "CANCELED":
+                return "CANCELED";
+            default:
+                return "UNKNOWN";
+        }
+    };
+
+    return (
+        <ServiceStatusTable>
+            <tbody>
+                {Object.keys(serviceDeploymentMetadata).map((key) => {
+                    const deploymentMetadata = serviceDeploymentMetadata[key];
+                    return (
+                        <ServiceStatusTableRow key={key}>
+                            <ServiceStatusTableData width={"100px"}>
+                                <Text>{key}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData width={"120px"}>
+                                <Icon height="12px" src={getStatusIcon(deploymentMetadata.status)} />
+                                <Spacer inline x={0.5} />
+                                <Text color={getStatusColor(deploymentMetadata.status)}>{convertEventStatusToCopy(serviceDeploymentMetadata[key].status)}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData>
+                                {deploymentMetadata.type !== "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Logs
+                                        </Link>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Metrics
+                                        </Link>
+                                    </>
+                                }
+                                {deploymentMetadata.type === "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/job-history?service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            History
+                                        </Link>
+                                    </>
+                                }
+                                {deploymentMetadata.external_uri !== "" &&
+                                    <>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={Service.prefixSubdomain(deploymentMetadata.external_uri)}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                            target={"_blank"}
+                                        >
+                                            External Link
+                                        </Link>
+                                    </>
+                                }
+                            </ServiceStatusTableData>
+                        </ServiceStatusTableRow>
+                    );
+                })}
+            </tbody>
+        </ServiceStatusTable>
+    )
+}
+
+export default ServiceStatusDetail;
+
+const ServiceStatusTable = styled.table`
+  border-collapse: collapse;
+  width: 100%;
+`;
+
+const ServiceStatusTableRow = styled.tr`
+  display: flex;
+  align-items: center;  
+`;
+
+const ServiceStatusTableData = styled.td`
+  padding: 8px;
+  display: flex;
+  align-items: center;
+  ${(props) => props.width && `width: ${props.width};`}
+
+  &:not(:last-child) {
+    border-right: 2px solid #ffffff11;
+  }
+`;

+ 3 - 3
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx

@@ -6,12 +6,12 @@ import styled from "styled-components";
 import Anser, { AnserJsonEntry } from "anser";
 import JSZip from "jszip";
 import dayjs from "dayjs";
-import { PorterAppEvent } from "shared/types";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { getDuration } from "../utils";
 import Link from "components/porter/Link";
 import { PorterLog } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
 
 type Props = {
     event: PorterAppEvent;
@@ -24,7 +24,7 @@ const BuildFailureEventFocusView: React.FC<Props> = ({
 }) => {
     const [logs, setLogs] = useState<PorterLog[]>([]);
     const [isLoading, setIsLoading] = useState<boolean>(true);
-    const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+    const scrollToBottomRef = useRef<HTMLDivElement>(null);
 
     useEffect(() => {
         if (!isLoading && scrollToBottomRef.current) {
@@ -118,7 +118,7 @@ const BuildFailureEventFocusView: React.FC<Props> = ({
 
     useEffect(() => {
         getBuildLogs();
-    }, [event]);
+    }, []);
 
     return (
         <>

+ 71 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/DeployEventFocusView.tsx

@@ -0,0 +1,71 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppDeployEvent } from "../types";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    event: PorterAppDeployEvent;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts
+};
+
+const DeployEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+    filterOpts,
+}) => {
+    const renderHeaderText = () => {
+        switch (event.status) {
+            case "SUCCESS":
+                return <Text color="#68BF8B" size={16}>Deploy succeeded</Text>;
+            case "FAILED":
+                return <Text color="#FF6060" size={16}>Deploy failed</Text>;
+            case "CANCELED":
+                return <Text color="#FFBF00" size={16}>Deploy canceled</Text>;
+            default:
+                return (
+                    <Container row>
+                        <Icon height="16px" src={loading} />
+                        <Spacer inline width="10px" />
+                        <Text size={16}>Deploy in progress...</Text>
+                    </Container>
+                );
+        }
+    };
+
+    const renderDurationText = () => {
+        switch (event.status) {
+            case "PROGRESSING":
+                return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+            default:
+                return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+        }
+    }
+
+    return (
+        <>
+            <AppearingView>
+                {renderHeaderText()}
+            </AppearingView>
+            <Spacer y={0.5} />
+            {renderDurationText()}
+            <Spacer y={0.5} />
+            <LogSection
+                currentChart={appData.chart}
+                appName={appData.app.name}
+                filterOpts={filterOpts}
+            />
+        </>
+    );
+};
+
+export default DeployEventFocusView;

+ 12 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx

@@ -4,22 +4,26 @@ import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
-import { PorterAppEvent } from "shared/types";
 import Link from "components/porter/Link";
 import BuildFailureEventFocusView from "./BuildFailureEventFocusView";
 import PreDeployEventFocusView from "./PredeployEventFocusView";
 import _ from "lodash";
+import { PorterAppDeployEvent, PorterAppEvent } from "../types";
+import DeployEventFocusView from "./DeployEventFocusView";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
 
 type Props = {
     eventId: string;
     appData: any;
+    filterOpts?: LogFilterQueryParamOpts;
 };
 
-const EVENT_POLL_INTERVAL = 15000; // poll every 15 seconds
+const EVENT_POLL_INTERVAL = 5000; // poll every 5 seconds
 
 const EventFocusView: React.FC<Props> = ({
     eventId,
     appData,
+    filterOpts,
 }) => {
     const { currentProject, currentCluster } = useContext(Context);
     const [event, setEvent] = useState<PorterAppEvent | null>(null);
@@ -59,6 +63,12 @@ const EventFocusView: React.FC<Props> = ({
                 return <BuildFailureEventFocusView event={event} appData={appData} />
             case "PRE_DEPLOY":
                 return <PreDeployEventFocusView event={event} appData={appData} />
+            case "DEPLOY":
+                return <DeployEventFocusView
+                    event={event as PorterAppDeployEvent}
+                    appData={appData}
+                    filterOpts={filterOpts}
+                />
             default:
                 return null
         }

+ 4 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/PredeployEventFocusView.tsx

@@ -1,7 +1,6 @@
 import Spacer from "components/porter/Spacer";
 import React from "react";
 import dayjs from "dayjs";
-import { PorterAppEvent } from "shared/types";
 import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 import { getDuration } from "../utils";
@@ -10,6 +9,7 @@ import { AppearingView } from "./EventFocusView";
 import Icon from "components/porter/Icon";
 import loading from "assets/loading.gif";
 import Container from "components/porter/Container";
+import { PorterAppEvent } from "../types";
 
 type Props = {
   event: PorterAppEvent;
@@ -30,7 +30,8 @@ const PreDeployEventFocusView: React.FC<Props> = ({
         return (
           <Container row>
             <Icon height="16px" src={loading} />
-            <Spacer inline width="10px" /><Text size={16}>Pre-deploy in progress...</Text>
+            <Spacer inline width="10px" />
+            <Text size={16}>Pre-deploy in progress...</Text>
           </Container>
         );
     }
@@ -60,6 +61,7 @@ const PreDeployEventFocusView: React.FC<Props> = ({
           endTime: event.metadata.end_time != null ? dayjs(event.metadata.end_time).add(1, 'minute') : undefined,
         }}
         showFilter={false}
+        appName={appData.app.name}
       />
     </>
   );

+ 44 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/types.ts

@@ -0,0 +1,44 @@
+export enum PorterAppEventType {
+    BUILD = "BUILD",
+    DEPLOY = "DEPLOY",
+    APP_EVENT = "APP_EVENT",
+    PRE_DEPLOY = "PRE_DEPLOY",
+}
+export interface PorterAppEvent {
+    created_at: string;
+    updated_at: string;
+    id: string;
+    status: string;
+    type: PorterAppEventType;
+    type_source: string;
+    porter_app_id: number;
+    metadata: any;
+}
+export const PorterAppEvent = {
+    toPorterAppEvent: (data: any): PorterAppEvent => {
+        return {
+            created_at: data.created_at ?? "",
+            updated_at: data.updated_at ?? "",
+            id: data.id ?? "",
+            status: data.status ?? "",
+            type: data.type ?? "",
+            type_source: data.type_source ?? "",
+            porter_app_id: data.porter_app_id ?? "",
+            metadata: data.metadata ?? {},
+        };
+    }
+}
+
+interface PorterAppServiceDeploymentMetadata {
+    status: string;
+    external_uri: string;
+    type: string;
+}
+export interface PorterAppDeployEvent extends PorterAppEvent {
+    type: PorterAppEventType.DEPLOY;
+    metadata: {
+        image_tag: string;
+        revision: number;
+        service_deployment_metadata: Record<string, PorterAppServiceDeploymentMetadata>;
+    };
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio