Feroze Mohideen před 2 roky
rodič
revize
76f0462af6
42 změnil soubory, kde provedl 1238 přidání a 487 odebrání
  1. 32 20
      api/server/handlers/porter_app/create.go
  2. 102 36
      api/server/handlers/porter_app/parse.go
  3. 11 0
      api/server/handlers/project/delete.go
  4. 18 7
      api/server/handlers/project_integration/create_aws.go
  5. 60 0
      api/server/handlers/user/update_onboarding_step.go
  6. 10 4
      api/server/shared/apitest/notifier.go
  7. 1 0
      api/server/shared/config/env/envconfs.go
  8. 1 0
      api/server/shared/config/loader/loader.go
  9. 1 0
      api/types/porter_app.go
  10. 8 2
      api/types/user.go
  11. 5 5
      cli/cmd/stack/apply.go
  12. 3 3
      cli/cmd/stack/preDeploy.go
  13. 39 22
      dashboard/package-lock.json
  14. 1 1
      dashboard/package.json
  15. 142 4
      dashboard/src/components/AzureProvisionerSettings.tsx
  16. 221 123
      dashboard/src/components/CloudFormationForm.tsx
  17. 2 2
      dashboard/src/components/porter/Error.tsx
  18. 0 11
      dashboard/src/hosted.index.html
  19. 2 2
      dashboard/src/main/home/app-dashboard/build-settings/BuildSettingsTab.tsx
  20. 115 77
      dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogComponent.tsx
  21. 3 0
      dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogModal.tsx
  22. 1 1
      dashboard/src/main/home/app-dashboard/expanded-app/DiffViewModal.tsx
  23. 28 98
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  24. 84 0
      dashboard/src/main/home/app-dashboard/expanded-app/HelmValuesTab.tsx
  25. 0 1
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx
  26. 5 5
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx
  27. 3 2
      dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts
  28. 10 3
      dashboard/src/main/home/app-dashboard/expanded-app/status/ControllerTab.tsx
  29. 23 2
      dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx
  30. 12 9
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  31. 1 1
      dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx
  32. 6 11
      dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts
  33. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  34. 97 0
      dashboard/src/main/home/project-settings/ProjectDeleteConsent.tsx
  35. 10 19
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  36. 7 2
      dashboard/src/shared/api.tsx
  37. 2 1
      dashboard/src/shared/types.tsx
  38. 11 6
      internal/analytics/track_events.go
  39. 111 0
      internal/analytics/tracks.go
  40. 5 5
      internal/kubernetes/prometheus/metrics.go
  41. 33 0
      internal/notifier/sendgrid/user_notifier.go
  42. 10 0
      internal/notifier/user_notifier.go

+ 32 - 20
api/server/handlers/porter_app/create.go

@@ -107,9 +107,18 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 		// this is required because when the front-end sends an update request with overrideRelease=true, it is unable to
 		// get the image info from the release. unless it is explicitly provided in the request, we avoid overwriting it
-		// by attempting to get the image info from the release
+		// by attempting to get the image info from the release or the provided helm values
 		if helmRelease != nil && (imageInfo.Repository == "" || imageInfo.Tag == "") {
-			imageInfo = attemptToGetImageInfoFromRelease(helmRelease.Config)
+			if request.FullHelmValues != "" {
+				imageInfo, err = attemptToGetImageInfoFromFullHelmValues(request.FullHelmValues)
+				if err != nil {
+					err = telemetry.Error(ctx, span, err, "error getting image info from full helm values")
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+					return
+				}
+			} else {
+				imageInfo = attemptToGetImageInfoFromRelease(helmRelease.Config)
+			}
 		}
 	} else {
 		releaseValues = helmRelease.Config
@@ -139,27 +148,30 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		cloneEnvGroup(c, w, r, k8sAgent, request.EnvGroups, namespace)
 	}
 	chart, values, preDeployJobValues, serviceNames, err := parse(
-		porterYaml,
-		imageInfo,
-		c.Config(),
-		cluster.ProjectID,
-		request.UserUpdate,
-		request.EnvGroups,
-		namespace,
-		releaseValues,
-		releaseDependencies,
-		SubdomainCreateOpts{
-			k8sAgent:       k8sAgent,
-			dnsRepo:        c.Repo().DNSRecord(),
-			powerDnsClient: c.Config().PowerDNSClient,
-			appRootDomain:  c.Config().ServerConf.AppRootDomain,
-			stackName:      stackName,
+		ParseConf{
+			PorterYaml:                porterYaml,
+			ImageInfo:                 imageInfo,
+			ServerConfig:              c.Config(),
+			ProjectID:                 cluster.ProjectID,
+			UserUpdate:                request.UserUpdate,
+			EnvGroups:                 request.EnvGroups,
+			Namespace:                 namespace,
+			ExistingHelmValues:        releaseValues,
+			ExistingChartDependencies: releaseDependencies,
+			SubdomainCreateOpts: SubdomainCreateOpts{
+				k8sAgent:       k8sAgent,
+				dnsRepo:        c.Repo().DNSRecord(),
+				powerDnsClient: c.Config().PowerDNSClient,
+				appRootDomain:  c.Config().ServerConf.AppRootDomain,
+				stackName:      stackName,
+			},
+			InjectLauncherToStartCommand: injectLauncher,
+			ShouldValidateHelmValues:     shouldCreate,
+			FullHelmValues:               request.FullHelmValues,
 		},
-		injectLauncher,
-		shouldCreate,
 	)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error parsing porter yaml into chart and values")
+		err = telemetry.Error(ctx, span, err, "parse error")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}

+ 102 - 36
api/server/handlers/porter_app/parse.go

@@ -61,30 +61,56 @@ type SyncedEnvSectionKey struct {
 	Secret bool   `json:"secret" yaml:"secret"`
 }
 
-func parse(
-	porterYaml []byte,
-	imageInfo types.ImageInfo,
-	config *config.Config,
-	projectID uint,
-	userUpdate bool,
-	envGroups []string,
-	namespace string,
-	existingValues map[string]interface{},
-	existingDependencies []*chart.Dependency,
-	opts SubdomainCreateOpts,
-	injectLauncher bool,
-	shouldCreate bool,
-) (*chart.Chart, map[string]interface{}, map[string]interface{}, []string, error) {
+type ParseConf struct {
+	// 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
+	// is stored in the 'global' key of the values, which is not part of the porter yaml
+	ImageInfo types.ImageInfo
+	// ServerConfig is the server conf, used to find the default helm repo
+	ServerConfig *config.Config
+	// ProjectID
+	ProjectID uint
+	// UserUpdate used for synced env groups
+	UserUpdate bool
+	// EnvGroups used for synced env groups
+	EnvGroups []string
+	// Namespace used for synced env groups
+	Namespace string
+	// ExistingHelmValues is the existing values for the helm release, if it exists
+	ExistingHelmValues map[string]interface{}
+	// ExistingChartDependencies is the existing dependencies for the helm release, if it exists
+	ExistingChartDependencies []*chart.Dependency
+	// SubdomainCreateOpts contains the necessary information to create a subdomain if necessary
+	SubdomainCreateOpts SubdomainCreateOpts
+	// InjectLauncherToStartCommand is a flag to determine whether to prepend the launcher to the start command
+	InjectLauncherToStartCommand bool
+	// ShouldValidateHelmValues is a flag to determine whether to validate helm values
+	ShouldValidateHelmValues bool
+	// FullHelmValues if provided, override anything specified in porter.yaml. Used as an escape hatch for support
+	FullHelmValues string
+}
+
+func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]interface{}, []string, error) {
 	parsed := &PorterStackYAML{}
 
-	err := yaml.Unmarshal(porterYaml, parsed)
-	if err != nil {
-		return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
+	if conf.FullHelmValues != "" {
+		parsedHelmValues, err := convertHelmValuesToPorterYaml(conf.FullHelmValues)
+		if err != nil {
+			return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error parsing raw helm values", err)
+		}
+		parsed = parsedHelmValues
+	} else {
+		err := yaml.Unmarshal(conf.PorterYaml, parsed)
+		if err != nil {
+			return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
+		}
 	}
+
 	synced_env := make([]*SyncedEnvSection, 0)
 
-	for i := range envGroups {
-		cm, _, err := opts.k8sAgent.GetLatestVersionedConfigMap(envGroups[i], namespace)
+	for i := range conf.EnvGroups {
+		cm, _, err := conf.SubdomainCreateOpts.k8sAgent.GetLatestVersionedConfigMap(conf.EnvGroups[i], conf.Namespace)
 		if err != nil {
 			return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 		}
@@ -101,7 +127,7 @@ func parse(
 		version := uint(versionInt)
 
 		newSection := &SyncedEnvSection{
-			Name:    envGroups[i],
+			Name:    conf.EnvGroups[i],
 			Version: version,
 		}
 
@@ -120,21 +146,21 @@ func parse(
 
 	parsed.SyncedEnv = synced_env
 
-	values, serviceNames, err := buildUmbrellaChartValues(parsed, imageInfo, existingValues, opts, injectLauncher, shouldCreate, userUpdate)
+	values, serviceNames, err := buildUmbrellaChartValues(parsed, conf.ImageInfo, conf.ExistingHelmValues, conf.SubdomainCreateOpts, conf.InjectLauncherToStartCommand, conf.ShouldValidateHelmValues, conf.UserUpdate)
 	if err != nil {
 		return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 	}
 	convertedValues := convertMap(values).(map[string]interface{})
 
-	chart, err := buildUmbrellaChart(parsed, config, projectID, existingDependencies)
+	chart, err := buildUmbrellaChart(parsed, conf.ServerConfig, conf.ProjectID, conf.ExistingChartDependencies)
 	if err != nil {
-		return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
+		return nil, nil, nil, nil, fmt.Errorf("%s: %w", "error building chart", err)
 	}
 
 	// return the parsed release values for the release job chart, if they exist
 	var preDeployJobValues map[string]interface{}
 	if parsed.Release != nil && parsed.Release.Run != nil {
-		preDeployJobValues = buildPreDeployJobChartValues(parsed.Release, parsed.Env, parsed.SyncedEnv, imageInfo, injectLauncher, existingValues, strings.TrimSuffix(strings.TrimPrefix(namespace, "porter-stack-"), "")+"-r", userUpdate)
+		preDeployJobValues = buildPreDeployJobChartValues(parsed.Release, parsed.Env, parsed.SyncedEnv, conf.ImageInfo, conf.InjectLauncherToStartCommand, conf.ExistingHelmValues, strings.TrimSuffix(strings.TrimPrefix(conf.Namespace, "porter-stack-"), "")+"-r", conf.UserUpdate)
 	}
 
 	return chart, convertedValues, preDeployJobValues, serviceNames, nil
@@ -146,14 +172,14 @@ func buildUmbrellaChartValues(
 	existingValues map[string]interface{},
 	opts SubdomainCreateOpts,
 	injectLauncher bool,
-	shouldCreate bool,
+	shouldValidateHelmValues bool,
 	userUpdate bool,
 ) (map[string]interface{}, []string, error) {
 	values := make(map[string]interface{})
 
 	if parsed.Apps == nil {
 		if existingValues == nil {
-			return nil, nil, fmt.Errorf("porter.yaml must contain at least one app, or release must exist and have values")
+			return nil, nil, fmt.Errorf("porter.yaml must contain at least one app, or pre-deploy must exist and have values")
 		}
 	}
 
@@ -176,7 +202,7 @@ func buildUmbrellaChartValues(
 			}
 		}
 
-		validateErr := validateHelmValues(helm_values, shouldCreate, appType)
+		validateErr := validateHelmValues(helm_values, shouldValidateHelmValues, appType)
 		if validateErr != "" {
 			return nil, nil, fmt.Errorf("error validating service \"%s\": %s", name, validateErr)
 		}
@@ -233,9 +259,8 @@ func buildUmbrellaChartValues(
 }
 
 // we can add to this function up later or use an alternative
-func validateHelmValues(values map[string]interface{}, shouldCreate bool, appType string) string {
-	// currently, we only validate port on initial app create, because this will break any updates to existing apps with lower port numbers
-	if shouldCreate {
+func validateHelmValues(values map[string]interface{}, shouldValidateHelmValues bool, appType string) string {
+	if shouldValidateHelmValues {
 		// validate port for web services
 		if appType == "web" {
 			containerMap, err := getNestedMap(values, "container")
@@ -288,12 +313,6 @@ func buildPreDeployJobChartValues(release *App, env map[string]string, synced_en
 	return helm_values
 }
 
-// func populateSyncedEnvGroups(release *App, opts SubdomainCreateOpts) {
-// 	// TODO
-// 	cm, _, err := opts.k8sAgent.GetLatestVersionedConfigMap()
-// 	fmt.Println("This is the config map:" ,cm)
-// }
-
 func getType(name string, app *App) string {
 	if app.Type != nil {
 		return *app.Type
@@ -642,6 +661,17 @@ func getChartTypeFromHelmName(name string) string {
 	return ""
 }
 
+func getServiceNameAndTypeFromHelmName(name string) (string, string) {
+	if strings.HasSuffix(name, "-web") {
+		return strings.TrimSuffix(name, "-web"), "web"
+	} else if strings.HasSuffix(name, "-wkr") {
+		return strings.TrimSuffix(name, "-wkr"), "worker"
+	} else if strings.HasSuffix(name, "-job") {
+		return strings.TrimSuffix(name, "-job"), "job"
+	}
+	return "", ""
+}
+
 func attemptToGetImageInfoFromRelease(values map[string]interface{}) types.ImageInfo {
 	imageInfo := types.ImageInfo{}
 
@@ -664,6 +694,17 @@ func attemptToGetImageInfoFromRelease(values map[string]interface{}) types.Image
 	return imageInfo
 }
 
+func attemptToGetImageInfoFromFullHelmValues(fullHelmValues string) (types.ImageInfo, error) {
+	imageInfo := types.ImageInfo{}
+	var values map[string]interface{}
+	err := yaml.Unmarshal([]byte(fullHelmValues), &values)
+	if err != nil {
+		return imageInfo, fmt.Errorf("error unmarshaling full helm values to read image info: %w", err)
+	}
+	convertedValues := convertMap(values).(map[string]interface{})
+	return attemptToGetImageInfoFromRelease(convertedValues), nil
+}
+
 func getStacksNestedMap(obj map[string]interface{}, fields ...string) ([]map[string]interface{}, error) {
 	var res map[string]interface{}
 	curr := obj
@@ -701,3 +742,28 @@ func getStacksNestedMap(obj map[string]interface{}, fields ...string) ([]map[str
 	}
 	return result, nil
 }
+
+func convertHelmValuesToPorterYaml(helmValues string) (*PorterStackYAML, error) {
+	var values map[string]interface{}
+	err := yaml.Unmarshal([]byte(helmValues), &values)
+	if err != nil {
+		return nil, err
+	}
+	apps := make(map[string]*App)
+	for k, v := range values {
+		if k == "global" {
+			continue
+		}
+		serviceName, serviceType := getServiceNameAndTypeFromHelmName(k)
+		if serviceName == "" {
+			return nil, fmt.Errorf("invalid service key: %s. make sure that service key ends in either -web, -wkr, or -job", k)
+		}
+		apps[serviceName] = &App{
+			Config: convertMap(v).(map[string]interface{}),
+			Type:   &serviceType,
+		}
+	}
+	return &PorterStackYAML{
+		Apps: apps,
+	}, nil
+}

+ 11 - 0
api/server/handlers/project/delete.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
 )
 
 type ProjectDeleteHandler struct {
@@ -69,6 +70,16 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			}
 		}
 	}
+	err := p.Config().UserNotifier.SendProjectDeleteEmail(
+		&notifier.SendProjectDeleteEmailOpts{
+			Email:   user.Email,
+			Project: proj.Name,
+		},
+	)
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 
 	deletedProject, err := p.Repo().Project().DeleteProject(proj)
 	if err != nil {

+ 18 - 7
api/server/handlers/project_integration/create_aws.go

@@ -1,7 +1,6 @@
 package project_integration
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/bufbuild/connect-go"
@@ -13,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type CreateAWSHandler struct {
@@ -30,12 +30,16 @@ func NewCreateAWSHandler(
 }
 
 func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-aws-integration")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	request := &types.CreateAWSRequest{}
 	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
@@ -43,7 +47,8 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	aws, err := p.Repo().AWSIntegration().CreateAWSIntegration(aws)
 	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "error creating aws integration")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
@@ -60,13 +65,19 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			TargetArn:       request.TargetArn,
 			ExternalId:      request.ExternalID,
 		}
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "target-arn", Value: request.TargetArn},
+			telemetry.AttributeKV{Key: "external-id", Value: request.ExternalID},
+			telemetry.AttributeKV{Key: "target-access-id", Value: request.AWSAccessKeyID},
+		)
 		credResp, err := p.Config().ClusterControlPlaneClient.CreateAssumeRoleChain(ctx, connect.NewRequest(&credReq))
 		if err != nil {
-			e := fmt.Errorf("unable to create CAPI required credential: %w", err)
-			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
+			err = telemetry.Error(ctx, span, err, "error creating CAPI required credential")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed, err.Error()))
 			return
 		}
 		res.CloudProviderCredentialIdentifier = credResp.Msg.TargetArn
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cloud-provider-credential-identifier", Value: credResp.Msg.TargetArn})
 	}
 
 	p.WriteResult(w, r, res)

+ 60 - 0
api/server/handlers/user/update_onboarding_step.go

@@ -55,6 +55,66 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		}))
 	}
 
+	if request.Step == "aws-account-id-complete" {
+		v.Config().AnalyticsClient.Track(analytics.AWSInputTrack(&analytics.AWSInputTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			AccountId:           request.AccountId,
+		}))
+	}
+
+	if request.Step == "aws-login-redirect-success" {
+		v.Config().AnalyticsClient.Track(analytics.AWSLoginRedirectSuccess(&analytics.AWSRedirectOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			AccountId:           request.AccountId,
+			LoginURL:            request.LoginURL,
+		}))
+	}
+
+	if request.Step == "aws-cloudformation-redirect-success" {
+		v.Config().AnalyticsClient.Track(analytics.AWSCloudformationRedirectSuccess(&analytics.AWSRedirectOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			AccountId:           request.AccountId,
+			CloudformationURL:   request.CloudformationURL,
+			ExternalId:          request.ExternalId,
+		}))
+	}
+
+	if request.Step == "aws-create-integration-success" {
+		v.Config().AnalyticsClient.Track(analytics.AWSCreateIntegrationSucceeded(&analytics.AWSCreateIntegrationOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			AccountId:           request.AccountId,
+		}))
+	}
+
+	if request.Step == "aws-create-integration-failure" {
+		v.Config().AnalyticsClient.Track(analytics.AWSCreateIntegrationFailed(&analytics.AWSCreateIntegrationOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			AccountId:           request.AccountId,
+			ErrorMessage:        request.ErrorMessage,
+			ExternalId:          request.ExternalId,
+		}))
+	}
+
 	if request.Step == "credential-step-complete" {
 		v.Config().AnalyticsClient.Track(analytics.CredentialStepTrack(&analytics.CredentialStepTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),

+ 10 - 4
api/server/shared/apitest/notifier.go

@@ -7,10 +7,11 @@ import (
 // FakeUserNotifier just stores data about a single notification,
 // without sending the data anywhere
 type FakeUserNotifier struct {
-	lastPWResetOpts  *notifier.SendPasswordResetEmailOpts
-	lastGHResetOpts  *notifier.SendGithubRelinkEmailOpts
-	lastEmailVerOpts *notifier.SendEmailVerificationOpts
-	lastProjInvOpts  *notifier.SendProjectInviteEmailOpts
+	lastPWResetOpts       *notifier.SendPasswordResetEmailOpts
+	lastGHResetOpts       *notifier.SendGithubRelinkEmailOpts
+	lastEmailVerOpts      *notifier.SendEmailVerificationOpts
+	lastProjInvOpts       *notifier.SendProjectInviteEmailOpts
+	lastDeleteProjectOpts *notifier.SendProjectDeleteEmailOpts
 }
 
 func NewFakeUserNotifier() notifier.UserNotifier {
@@ -52,3 +53,8 @@ func (f *FakeUserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInvi
 func (f *FakeUserNotifier) GetSendProjectInviteEmailLastOpts() *notifier.SendProjectInviteEmailOpts {
 	return f.lastProjInvOpts
 }
+
+func (f *FakeUserNotifier) SendProjectDeleteEmail(opts *notifier.SendProjectDeleteEmailOpts) error {
+	f.lastDeleteProjectOpts = opts
+	return nil
+}

+ 1 - 0
api/server/shared/config/env/envconfs.go

@@ -62,6 +62,7 @@ type ServerConf struct {
 	SendgridProjectInviteTemplateID    string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
 	SendgridIncidentAlertTemplateID    string `env:"SENDGRID_INCIDENT_ALERT_TEMPLATE_ID"`
 	SendgridIncidentResolvedTemplateID string `env:"SENDGRID_INCIDENT_RESOLVED_TEMPLATE_ID"`
+	SendgridDeleteProjectTemplateID    string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"`
 	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
 	SlackClientID     string `env:"SLACK_CLIENT_ID"`

+ 1 - 0
api/server/shared/config/loader/loader.go

@@ -141,6 +141,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 			PWGHTemplateID:          envConf.ServerConf.SendgridPWGHTemplateID,
 			VerifyEmailTemplateID:   envConf.ServerConf.SendgridVerifyEmailTemplateID,
 			ProjectInviteTemplateID: envConf.ServerConf.SendgridProjectInviteTemplateID,
+			DeleteProjectTemplateID: envConf.ServerConf.SendgridDeleteProjectTemplateID,
 		})
 		res.Logger.Info().Msg("Created new user notifier")
 	}

+ 1 - 0
api/types/porter_app.go

@@ -49,6 +49,7 @@ type CreatePorterAppRequest struct {
 	OverrideRelease  bool      `json:"override_release"`
 	EnvGroups        []string  `json:"env_groups"`
 	UserUpdate       bool      `json:"user_update"`
+	FullHelmValues   string    `json:"full_helm_values"`
 }
 
 type UpdatePorterAppRequest struct {

+ 8 - 2
api/types/user.go

@@ -82,6 +82,12 @@ type UpdateUserInfoRequest struct {
 }
 
 type UpdateOnboardingStepRequest struct {
-	Step     string `json:"step" form:"required,max=255"`
-	Provider string `json:"provider"`
+	Step              string `json:"step" form:"required,max=255"`
+	Provider          string `json:"provider"`
+	AccountId         string `json:"account_id"`
+	CloudformationURL string `json:"cloudformation_url"`
+	ErrorMessage      string `json:"error_message"`
+	LoginURL          string `json:"login_url"`
+	// used as a 'password' for the aws assume role chain to porter-manager role
+	ExternalId string `json:"external_id"`
 }

+ 5 - 5
cli/cmd/stack/apply.go

@@ -59,7 +59,7 @@ func CreateV1BuildResources(client *api.Client, raw []byte, stackName string, pr
 
 	v1File.Resources = append(v1File.Resources, bi, pi)
 
-	release, cmd, err := createReleaseResource(client,
+	preDeploy, cmd, err := createPreDeployResource(client,
 		stackConf.parsed.Release,
 		stackConf.stackName,
 		bi.Name,
@@ -72,11 +72,11 @@ func CreateV1BuildResources(client *api.Client, raw []byte, stackName string, pr
 		return nil, "", err
 	}
 
-	if release != nil {
-		color.New(color.FgYellow).Printf("Found release command to run before deploying apps: %s \n", cmd)
-		v1File.Resources = append(v1File.Resources, release)
+	if preDeploy != nil {
+		color.New(color.FgYellow).Printf("Found pre-deploy command to run before deploying apps: %s \n", cmd)
+		v1File.Resources = append(v1File.Resources, preDeploy)
 	} else {
-		color.New(color.FgYellow).Printf("No release command found in porter.yaml or helm. \n")
+		color.New(color.FgYellow).Printf("No pre-deploy command found in porter.yaml or helm. \n")
 	}
 
 	return v1File, builder, nil

+ 3 - 3
cli/cmd/stack/release.go → cli/cmd/stack/preDeploy.go

@@ -12,12 +12,12 @@ import (
 	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
 )
 
-func createReleaseResource(client *api.Client, release *App, stackName, buildResourceName, pushResourceName string, projectID, clusterID uint, env map[string]string) (*switchboardTypes.Resource, string, error) {
+func createPreDeployResource(client *api.Client, release *App, stackName, buildResourceName, pushResourceName string, projectID, clusterID uint, env map[string]string) (*switchboardTypes.Resource, string, error) {
 	var finalCmd string
 	if release != nil && release.Run != nil {
 		finalCmd = *release.Run
 	} else {
-		finalCmd = getReleaseCommandFromRelease(client, stackName, projectID, clusterID)
+		finalCmd = getPredeployStartCommandFromRelease(client, stackName, projectID, clusterID)
 		if finalCmd == "" {
 			return nil, "", nil
 		}
@@ -64,7 +64,7 @@ func createReleaseResource(client *api.Client, release *App, stackName, buildRes
 	}, finalCmd, nil
 }
 
-func getReleaseCommandFromRelease(client *api.Client, stackName string, projectID uint, clusterID uint) string {
+func getPredeployStartCommandFromRelease(client *api.Client, stackName string, projectID uint, clusterID uint) string {
 	namespace := fmt.Sprintf("porter-stack-%s", stackName)
 	releaseName := fmt.Sprintf("%s-r", stackName)
 	release, err := client.GetRelease(

+ 39 - 22
dashboard/package-lock.json

@@ -37,7 +37,6 @@
         "core-js": "^3.16.1",
         "cron-parser": "^4.3.0",
         "cron-validator": "^1.3.1",
-        "cronstrue": "^2.2.0",
         "d3-array": "^2.11.0",
         "d3-time-format": "^3.0.0",
         "dayjs": "^1.11.5",
@@ -117,6 +116,7 @@
         "babel-loader": "^8.2.2",
         "babel-plugin-lodash": "^3.3.4",
         "babel-plugin-styled-components": "^1.13.3",
+        "cronstrue": "^2.28.0",
         "css-loader": "^5.2.6",
         "file-loader": "^6.1.0",
         "html-webpack-plugin": "^4.5.0",
@@ -5636,9 +5636,10 @@
       "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A=="
     },
     "node_modules/cronstrue": {
-      "version": "2.23.0",
-      "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.23.0.tgz",
-      "integrity": "sha512-iPoWUQbCwUmrBf1w9W+9YQs8FowWp/teC2XGz3zAmt0Aja+HWGjyjUkWASWcsdzxSuL0EIIdvlfGEVBljvTbSQ==",
+      "version": "2.28.0",
+      "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.28.0.tgz",
+      "integrity": "sha512-yByAR5on9i/p26djiSve4l29/AJkjCFDgopq+3gUVrQh2xgb3KKScofwkpf5XcRhuWAX0u0EuEK2nltB5hV1jQ==",
+      "dev": true,
       "bin": {
         "cronstrue": "bin/cli.js"
       }
@@ -16425,7 +16426,8 @@
     "@icons/material": {
       "version": "0.2.4",
       "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
-      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw=="
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
+      "requires": {}
     },
     "@ironplans/api": {
       "version": "0.4.1",
@@ -16605,7 +16607,8 @@
     "@material-ui/types": {
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz",
-      "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A=="
+      "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==",
+      "requires": {}
     },
     "@material-ui/utils": {
       "version": "4.11.3",
@@ -16920,7 +16923,8 @@
       "version": "7.2.1",
       "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-7.2.1.tgz",
       "integrity": "sha512-oZ0Ib5I4Z2pUEcoo95cT1cr6slco9WY7yiPpG+RGNkj8YcYgJnM7pXmYmorNOReh8MIGcKSqXyeGjxnr8YiZbA==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "@types/body-parser": {
       "version": "1.19.2",
@@ -18137,13 +18141,15 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
       "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "ajv-keywords": {
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
       "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "anser": {
       "version": "2.1.1",
@@ -19439,9 +19445,10 @@
       "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A=="
     },
     "cronstrue": {
-      "version": "2.23.0",
-      "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.23.0.tgz",
-      "integrity": "sha512-iPoWUQbCwUmrBf1w9W+9YQs8FowWp/teC2XGz3zAmt0Aja+HWGjyjUkWASWcsdzxSuL0EIIdvlfGEVBljvTbSQ=="
+      "version": "2.28.0",
+      "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.28.0.tgz",
+      "integrity": "sha512-yByAR5on9i/p26djiSve4l29/AJkjCFDgopq+3gUVrQh2xgb3KKScofwkpf5XcRhuWAX0u0EuEK2nltB5hV1jQ==",
+      "dev": true
     },
     "cross-spawn": {
       "version": "6.0.5",
@@ -21079,7 +21086,8 @@
     "goober": {
       "version": "2.1.12",
       "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz",
-      "integrity": "sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q=="
+      "integrity": "sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==",
+      "requires": {}
     },
     "good-listener": {
       "version": "1.2.2",
@@ -21465,7 +21473,8 @@
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
       "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "ieee754": {
       "version": "1.2.1",
@@ -22342,7 +22351,8 @@
     "markdown-to-jsx": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz",
-      "integrity": "sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg=="
+      "integrity": "sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg==",
+      "requires": {}
     },
     "material-colors": {
       "version": "1.2.6",
@@ -23260,7 +23270,8 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
       "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "postcss-modules-local-by-default": {
       "version": "4.0.0",
@@ -23557,7 +23568,8 @@
     "react-animate-height": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.1.1.tgz",
-      "integrity": "sha512-UkC6+V3ZlCneBRaSM7aUctDJ+PRP6ztcGtxvU7MTeoMMWPhz8BQNaX7QWaZrkzp1ih1G8uZZ+DI9nfLvtD6OdQ=="
+      "integrity": "sha512-UkC6+V3ZlCneBRaSM7aUctDJ+PRP6ztcGtxvU7MTeoMMWPhz8BQNaX7QWaZrkzp1ih1G8uZZ+DI9nfLvtD6OdQ==",
+      "requires": {}
     },
     "react-beautiful-dnd": {
       "version": "13.1.1",
@@ -23676,7 +23688,8 @@
     "react-onclickoutside": {
       "version": "6.12.2",
       "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz",
-      "integrity": "sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA=="
+      "integrity": "sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA==",
+      "requires": {}
     },
     "react-popper": {
       "version": "2.3.0",
@@ -23748,7 +23761,8 @@
     "react-table": {
       "version": "7.8.0",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz",
-      "integrity": "sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA=="
+      "integrity": "sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==",
+      "requires": {}
     },
     "react-transition-group": {
       "version": "4.4.5",
@@ -25530,12 +25544,14 @@
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz",
       "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "use-sync-external-store": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
-      "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="
+      "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
+      "requires": {}
     },
     "util": {
       "version": "0.11.1",
@@ -26829,7 +26845,8 @@
       "version": "7.5.9",
       "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
       "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
-      "dev": true
+      "dev": true,
+      "requires": {}
     },
     "xtend": {
       "version": "4.0.2",

+ 1 - 1
dashboard/package.json

@@ -32,7 +32,6 @@
     "core-js": "^3.16.1",
     "cron-parser": "^4.3.0",
     "cron-validator": "^1.3.1",
-    "cronstrue": "^2.2.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dayjs": "^1.11.5",
@@ -118,6 +117,7 @@
     "babel-loader": "^8.2.2",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-styled-components": "^1.13.3",
+    "cronstrue": "^2.28.0",
     "css-loader": "^5.2.6",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",

+ 142 - 4
dashboard/src/components/AzureProvisionerSettings.tsx

@@ -71,6 +71,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
   const [clusterVersion, setClusterVersion] = useState("v1.24.9");
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [errorMessage, setErrorMessage] = useState<string>("");
+  const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
 
   const markStepStarted = async (step: string) => {
@@ -84,10 +85,16 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
   const getStatus = () => {
     if (isReadOnly && props.provisionerError == "") {
       return "Provisioning is still in progress...";
-    } else if (errorMessage) {
+    } else if (errorMessage !== "") {
       return (
         <Error
-          message={errorMessage}
+          message={errorDetails !== "" ? errorMessage + " (" + errorDetails + ")" : errorMessage}
+          ctaText={
+            errorMessage !== DEFAULT_ERROR_MESSAGE
+                ? "Troubleshooting steps"
+                : null
+          }
+          errorModalContents={errorMessageToModal(errorMessage)}
         />
       );
     }
@@ -128,6 +135,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     const err = validateInputs();
     if (err !== "") {
       setErrorMessage(err)
+      setErrorDetails("")
       return;
     }
 
@@ -180,6 +188,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     try {
       setIsReadOnly(true);
       setErrorMessage("");
+      setErrorDetails("")
 
       if (!props.clusterId) {
         markStepStarted("provisioning-started");
@@ -210,12 +219,22 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
           console.error(err);
         });
       // }
-      setErrorMessage(undefined);
+      setErrorMessage("");
+      setErrorDetails("")
     } catch (err) {
       const errMessage = err.response.data.error.replace("unknown: ", "");
       // hacky, need to standardize error contract with backend
       setIsClicked(false);
-      setErrorMessage(DEFAULT_ERROR_MESSAGE);
+      if (errMessage.includes("resource provider")) {
+        setErrorMessage(AZURE_MISSING_RESOURCE_PROVIDER_MESSAGE);
+        setErrorDetails(errMessage)
+      } else if (errMessage.includes("quota")) {
+        setErrorMessage(AZURE_CORE_QUOTA_ERROR_MESSAGE)
+        setErrorDetails(errMessage)
+      } else {
+        setErrorMessage(DEFAULT_ERROR_MESSAGE);
+        setErrorDetails("")
+      }
     } finally {
       setIsReadOnly(false);
       setIsClicked(false);
@@ -409,3 +428,122 @@ const StyledForm = styled.div`
 
 const DEFAULT_ERROR_MESSAGE =
   "An error occurred while provisioning your infrastructure. Please try again.";
+const AZURE_CORE_QUOTA_ERROR_MESSAGE =
+    "Your Azure subscription has reached a vCPU core quota in the location";
+const AZURE_MISSING_RESOURCE_PROVIDER_MESSAGE =
+    "Your Azure subscription is missing required resource providers";
+
+const errorMessageToModal = (errorMessage: string) => {
+  switch (errorMessage) {
+    case AZURE_CORE_QUOTA_ERROR_MESSAGE:
+      return (
+          <>
+            <Text size={16} weight={500}>
+              Requesting more cores
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              You will need to request a quota increase for vCPUs in your region.
+            </Text>
+            <Spacer y={1} />
+            <Step number={1}>
+              Log into
+              <Spacer inline width="5px" />
+              <Link
+                  to="https://login.microsoftonline.com/"
+                  target="_blank"
+              >
+                your Azure account
+              </Link>
+              .
+            </Step>
+            <Spacer y={1} />
+            <Step number={2}>
+              Navigate to
+              <Spacer inline width="5px" />
+              <Link
+                  to="https://portal.azure.com/#view/Microsoft_Azure_Billing/SubscriptionsBlade"
+                  target="_blank"
+              >
+                the Subscriptions page
+              </Link>
+              <Spacer inline width="5px" />
+              and select the subscription you are using to provision Porter.
+            </Step>
+            <Spacer y={1} />
+            <Step number={3}>
+              Select "Usage + Quotas" under "Settings" from the left panel.
+            </Step>
+            <Spacer y={1} />
+            <Step number={4}>
+              Select "Compute" and search for the quotas that have reached usage limits in your region. Request an increase by clicking the pencil icon on the far right.
+            </Step>
+            <Spacer y={1} />
+            <Text color="helper">
+              We recommend an initial quota of 30 vCPUs for both Total Regional Cores and Standard Av2 Family.
+            </Text>
+            <Spacer y={1} />
+            <Step number={5}>
+              Once the request has been approved, return to Porter and retry the
+              provision.
+            </Step>
+            <Spacer y={1} />
+            <Text color="helper">
+              Quota increases can take several minutes to process. If Azure is unable to automatically increase the quota, create a support request as prompted by Azure. Requests are usually fulfilled in a few hours.
+            </Text>
+          </>
+      );
+    case AZURE_MISSING_RESOURCE_PROVIDER_MESSAGE:
+      return (
+          <>
+            <Text size={16} weight={500}>
+              Registering required resource providers
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              You will need to register all of the following resource providers to your Azure subscription before provisioning: Capacity, Compute, ContainerRegistry, ContainerService, ManagedIdentity, Network, OperationalInsights, OperationsManagement, ResourceGraph, Resources, Storage
+            </Text>
+            <Spacer y={1} />
+            <Step number={1}>
+              Log into
+              <Spacer inline width="5px" />
+              <Link
+                  to="https://login.microsoftonline.com/"
+                  target="_blank"
+              >
+                your Azure account
+              </Link>
+              .
+            </Step>
+            <Spacer y={1} />
+            <Step number={2}>
+              Navigate to
+              <Spacer inline width="5px" />
+              <Link
+                  to="https://portal.azure.com/#view/Microsoft_Azure_Billing/SubscriptionsBlade"
+                  target="_blank"
+              >
+                the Subscriptions page
+              </Link>
+              <Spacer inline width="5px" />
+               and select the subscription you are using to provision Porter.
+            </Step>
+            <Spacer y={1} />
+            <Step number={3}>
+              Select "Resource Providers" under "Settings" from the left panel.
+            </Step>
+            <Spacer y={1} />
+            <Step number={4}>
+              Search for each required resource provider and select "Register" from the top menu bar if it is not already registered.
+            </Step>
+            <Spacer y={1} />
+            <Step number={5}>
+              After confirming that all providers are registered, return to Porter and retry the
+              provision.
+            </Step>
+          </>
+      );
+    default:
+      return null;
+  }
+};

+ 221 - 123
dashboard/src/components/CloudFormationForm.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useContext, useMemo } from "react";
+import React, { useState, useContext } from "react";
 import styled from "styled-components";
 import { v4 as uuidv4 } from 'uuid';
 
@@ -9,16 +9,13 @@ import { Context } from "shared/Context";
 
 import Text from "./porter/Text";
 import Spacer from "./porter/Spacer";
-import InputRow from "./form-components/InputRow";
-import SaveButton from "./SaveButton";
-import Fieldset from "./porter/Fieldset";
 import Input from "./porter/Input";
 import Button from "./porter/Button";
-import DocsHelper from "./DocsHelper";
 import Error from "./porter/Error";
 import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Container from "./porter/Container";
+import VerticalSteps from "./porter/VerticalSteps";
 
 type Props = {
   goBack: () => void;
@@ -31,15 +28,75 @@ const CloudFormationForm: React.FC<Props> = ({
   proceed,
   switchToCredentialFlow
 }) => {
-  const [grantPermissionsError, setGrantPermissionsError] = useState("");
+  const [hasSentAWSNotif, setHasSentAWSNotif] = useState(false);
   const [roleStatus, setRoleStatus] = useState("");
-  const [errorMessage, setErrorMessage] = useState(undefined);
+  const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
   const [AWSAccountID, setAWSAccountID] = useState("");
+  const [AWSAccountIDInputError, setAWSAccountIDInputError] = useState<string | undefined>(undefined);
+  const [currentStep, setCurrentStep] = useState<number>(0);
+
   const { currentProject } = useContext(Context);
+  const markStepStarted = async (
+    {
+      step,
+      account_id = "",
+      cloudformation_url = "",
+      error_message = "",
+      login_url = "",
+      external_id = "",
+    }:
+      {
+        step: string;
+        account_id?: string;
+        cloudformation_url?: string;
+        error_message?: string;
+        login_url?: string;
+        external_id?: string;
+      }
+  ) => {
+    try {
+      await api.updateOnboardingStep("<token>", { step, account_id, cloudformation_url, error_message, login_url, external_id }, {});
+    } catch (err) {
+      // console.log(err);
+    }
+  };
+
+  const getAccountIdInputError = (accountId: string) => {
+    const regex = /^\d{12}$/;
+    if (accountId === "") {
+      return undefined;
+    } else if (!regex.test(accountId)) {
+      return 'A valid AWS Account ID must be a 12-digit number.';
+    }
+    return undefined;
+  };
+
+  const handleAWSAccountIDChange = (accountId: string) => {
+    setAWSAccountID(accountId);
+    if (accountId === "open-sesame") {
+      switchToCredentialFlow();
+    }
+    // handle case where user resets the input to empty
+    if (accountId.trim().length === 0) {
+      setCurrentStep(0);
+      setAWSAccountIDInputError(undefined);
+      return;
+    }
+    const accountIdInputError = getAccountIdInputError(accountId);
+    if (accountIdInputError == null) {
+      setCurrentStep(1);
+      if (!hasSentAWSNotif) {
+        setHasSentAWSNotif(true);
+        markStepStarted({ step: "aws-account-id-complete", account_id: accountId });
+      }
+    } else {
+      setCurrentStep(0);
+    }
+    setAWSAccountIDInputError(accountIdInputError);
+  };
 
   const getExternalId = () => {
     let externalId = localStorage.getItem(AWSAccountID)
-    console.log(externalId)
     if (!externalId) {
       externalId = uuidv4()
       localStorage.setItem(AWSAccountID, externalId);
@@ -55,6 +112,10 @@ const CloudFormationForm: React.FC<Props> = ({
     setRoleStatus("loading");
     setErrorMessage(undefined)
     try {
+      if (currentProject == null) {
+        setErrorMessage("Could not find current project.")
+        return;
+      };
       await api
         .createAWSIntegration(
           "<token>",
@@ -67,135 +128,170 @@ const CloudFormationForm: React.FC<Props> = ({
           }
         );
       setRoleStatus("successful")
+      markStepStarted({ step: "aws-create-integration-success", account_id: AWSAccountID })
       proceed(targetARN);
     } catch (err) {
-      console.log(err);
       setRoleStatus("");
       setErrorMessage("Porter could not access your AWS account. Please make sure you have granted permissions and try again.")
+      markStepStarted({
+        step: "aws-create-integration-failure",
+        account_id: AWSAccountID,
+        error_message: err?.response?.data?.error ??
+          err?.toString() ?? "unable to determine error - check honeycomb",
+        external_id: externalId,
+      })
     }
   };
 
-  const directToCloudFormation = () => {
-    let externalId = getExternalId();
+  const directToAWSLoginAndProceedStep = () => {
+    const login_url = `https://${AWSAccountID}.signin.aws.amazon.com/console`;
+    markStepStarted({ step: "aws-login-redirect-success", account_id: AWSAccountID, login_url })
+    setCurrentStep(2);
+    window.open(login_url, "_blank")
+  }
+
+  const directToCloudFormationAndProceedStep = () => {
+    const externalId = getExternalId();
     let trustArn = process.env.TRUST_ARN ? process.env.TRUST_ARN : "arn:aws:iam::108458755588:role/CAPIManagement";
-    window.open(
-      `https://console.aws.amazon.com/cloudformation/home?
-      #/stacks/create/review?templateURL=https://porter-role.s3.us-east-2.amazonaws.com/cloudformation-policy.json&stackName=PorterRole&param_ExternalIdParameter=${externalId}&param_TrustArnParameter=${trustArn}`
-    )
+    const cloudformation_url = `https://console.aws.amazon.com/cloudformation/home?#/stacks/create/review?templateURL=https://porter-role.s3.us-east-2.amazonaws.com/cloudformation-policy.json&stackName=PorterRole&param_ExternalIdParameter=${externalId}&param_TrustArnParameter=${trustArn}`
+    markStepStarted({ step: "aws-cloudformation-redirect-success", account_id: AWSAccountID, cloudformation_url, external_id: externalId })
+    setCurrentStep(3);
+    window.open(cloudformation_url, "_blank")
   }
 
   const renderContent = () => {
     return (
       <>
+        <Text>Grant Porter permissions to create infrastructure in your AWS account by following 4 simple steps.</Text>
         <Spacer y={1} />
-        <Fieldset>
-          <Text size={16}>
-            Log in to AWS and "Create stack"
-          </Text>
-          <Spacer height="15px" />
-          <Text color="helper">
-            Provide your AWS account ID to log in and grant Porter access to AWS by clicking 'Grant permissions' below. You will need to select "Create stack" after being redirected to the AWS console.
-          </Text>
-          <Spacer y={1} />
-          <Input
-            label={
-              <Flex>
-                👤 AWS account ID
-                <i
-                  className="material-icons"
-                  onClick={() => {
-                    window.open("https://console.aws.amazon.com/billing/home?region=us-east-1#/account", "_blank")
-                  }}
-                >
-                  help_outline
-                </i>
-              </Flex>
-            }
-            value={AWSAccountID}
-            setValue={(e) => {
-              if (e === "open-sesame") {
-                switchToCredentialFlow();
-              }
-              setGrantPermissionsError("");
-              setAWSAccountID(e.trim());
-            }}
-            placeholder="ex: 915037676314"
-          />
-          <Spacer y={1} />
-          <Button
-            onClick={() => {
-              if (AWSAccountID.length === 12 && !isNaN(Number(AWSAccountID))) {
-                directToCloudFormation();
-              } else {
-                setGrantPermissionsError("Invalid AWS account ID");
-              }
-            }}
-            status={
-              grantPermissionsError && (
-                <Error message={grantPermissionsError} />
-              )
-            }
-            color="#1E2631"
-            withBorder
-          >
-            <ButtonImg src={aws} /> Grant permissions
-          </Button>
-          <Spacer y={1} />
-          <Text color="helper">
-            Make sure that the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE" before clicking Continue below.
-          </Text>
-        </Fieldset>
-        <Spacer y={1} />
-        <Button
-          onClick={() => {
-            checkIfRoleExists()
-          }}
-          status={
-            errorMessage ? (
-              <Error
-                message={errorMessage}
-                ctaText="Troubleshooting steps"
-                errorModalContents={
+        <VerticalSteps
+          currentStep={currentStep}
+          steps={
+            [
+              <>
+                <Text size={16}>1. Provide your AWS Account ID.</Text>
+                <Spacer y={0.5} />
+                <Input
+                  label={
+                    <Flex>
+                      👤 AWS account ID
+                      <i
+                        className="material-icons"
+                        onClick={() => {
+                          window.open("https://docs.aws.amazon.com/IAM/latest/UserGuide/FindingYourAWSId.html", "_blank")
+                        }}
+                      >
+                        help_outline
+                      </i>
+                    </Flex>
+                  }
+                  value={AWSAccountID}
+                  setValue={handleAWSAccountIDChange}
+                  placeholder="ex: 915037676314"
+                  error={AWSAccountIDInputError}
+                />
+              </>,
+              <>
+                <Text size={16}>2. Log in to your AWS Account.</Text>
+                <Spacer y={0.25} />
+                <Text color="helper">Return to Porter after successful log-in.</Text>
+                <Spacer y={0.5} />
+                <AWSButtonContainer>
+                  <ButtonImg src={aws} />
+                  <Button
+                    width={"170px"}
+                    onClick={directToAWSLoginAndProceedStep}
+                    color="#1E2631"
+                    withBorder
+                  >
+                    Log in
+                  </Button>
+                </AWSButtonContainer>
+                {/* escape hatch for dev use only */}
+                {process.env.TRUST_ARN != null && process.env.TRUST_ARN !== "arn:aws:iam::108458755588:role/CAPIManagement" &&
                   <>
-                    <Text size={16}>Granting Porter access to AWS</Text>
-                    <Spacer y={1} />
-                    <Text color="helper">
-                      Porter needs access to your AWS account in order to create infrastructure. You can grant Porter access to AWS by following these steps:
-                    </Text>
-                    <Spacer y={1} />
-                    <Step number={1}>
-                      <Link to="https://aws.amazon.com/resources/create-account/" target="_blank">
-                        Create an AWS account
-                      </Link>
-                      <Spacer inline width="5px" />
-                      if you don't already have one.
-                    </Step>
-                    <Spacer y={1} />
-                    <Step number={2}>
-                      Once you are logged in to your AWS account,
-                      <Spacer inline width="5px" />
-                      <Link to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account" target="_blank">
-                        copy your account ID
-                      </Link>.
-                    </Step>
-                    <Spacer y={1} />
-                    <Step number={3}>Fill in your account ID on Porter and select "Grant permissions".</Step>
-                    <Spacer y={1} />
-                    <Step number={4}>After being redirected to AWS, select "Create stack" on the AWS console.</Step>
-                    <Spacer y={1} />
-                    <Step number={5}>Wait until the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE".</Step>
-                    <Spacer y={1} />
-                    <Step number={6}>Return to Porter and select "Continue".</Step>
+                    <Spacer y={0.5} />
+                    <Link onClick={() => setCurrentStep(4)} hasunderline>Skip this step</Link>
                   </>
                 }
-              />
-            ) : (
-              roleStatus
-            )
+              </>,
+              <>
+                <Text size={16}>3. Create an AWS Cloudformation Stack.</Text>
+                <Spacer y={0.25} />
+                <Text color="helper">This grants Porter permissions to create infrastructure.</Text>
+                <Spacer y={0.25} />
+                <Text color="helper">
+                  Return to Porter once the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE".
+                </Text>
+                <Spacer y={0.5} />
+                <AWSButtonContainer>
+                  <ButtonImg src={aws} />
+                  <Button
+                    width={"170px"}
+                    onClick={directToCloudFormationAndProceedStep}
+                    color="#1E2631"
+                    withBorder
+                  >
+                    Grant permissions
+                  </Button>
+                </AWSButtonContainer>
+              </>,
+              <>
+                <Text size={16}>4. Continue to the provision step.</Text>
+                <Spacer y={0.5} />
+                <Button
+                  width={"200px"}
+                  onClick={checkIfRoleExists}
+                  status={
+                    errorMessage ? (
+                      <Error
+                        message={errorMessage}
+                        ctaText="Troubleshooting steps"
+                        errorModalContents={
+                          <>
+                            <Text size={16}>Granting Porter access to AWS</Text>
+                            <Spacer y={1} />
+                            <Text color="helper">
+                              Porter needs access to your AWS account in order to create infrastructure. You can grant Porter access to AWS by following these steps:
+                            </Text>
+                            <Spacer y={1} />
+                            <Step number={1}>
+                              <Link to="https://aws.amazon.com/resources/create-account/" target="_blank">
+                                Create an AWS account
+                              </Link>
+                              <Spacer inline width="5px" />
+                              if you don't already have one.
+                            </Step>
+                            <Spacer y={1} />
+                            <Step number={2}>
+                              Once you are logged in to your AWS account,
+                              <Spacer inline width="5px" />
+                              <Link to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account" target="_blank">
+                                copy your account ID
+                              </Link>.
+                            </Step>
+                            <Spacer y={1} />
+                            <Step number={3}>Fill in your account ID on Porter and select "Grant permissions".</Step>
+                            <Spacer y={1} />
+                            <Step number={4}>After being redirected to AWS, select "Create stack" on the AWS console.</Step>
+                            <Spacer y={1} />
+                            <Step number={5}>Wait until the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE".</Step>
+                            <Spacer y={1} />
+                            <Step number={6}>Return to Porter and select "Continue".</Step>
+                          </>
+                        }
+                      />
+                    ) : (
+                      roleStatus
+                    )
+                  }
+                >
+                  Continue
+                </Button>
+              </>
+            ].filter(step => step != null)
           }
-        >
-          Continue
-        </Button>
+        />
       </>
     );
   }
@@ -214,9 +310,6 @@ const CloudFormationForm: React.FC<Props> = ({
         </Text>
       </Container>
       <Spacer y={1} />
-      <Text color="helper">
-        Grant Porter permissions to create infrastructure in your AWS account.
-      </Text>
       {renderContent()}
     </>
   );
@@ -269,4 +362,9 @@ const BackButton = styled.div`
     margin-right: 6px;
     margin-left: -2px;
   }
-`;
+`;
+
+const AWSButtonContainer = styled.div`
+  display: flex;
+  align-items: center;
+  `;

+ 2 - 2
dashboard/src/components/porter/Error.tsx

@@ -18,7 +18,7 @@ const Error: React.FC<Props> = ({
   errorModalContents,
 }) => {
   const [errorModalOpen, setErrorModalOpen] = useState(false);
-  
+
   return (
     <>
       <StyledError>
@@ -26,7 +26,7 @@ const Error: React.FC<Props> = ({
         <Block>
         <Bold>Error:</Bold>
         <Text>{message}</Text>
-        {ctaText && (
+        {ctaText && (errorModalContents != null || ctaOnClick != null) && (
           <Cta onClick={() => {
             errorModalContents ? setErrorModalOpen(true) : ctaOnClick();
           }}>

+ 0 - 11
dashboard/src/hosted.index.html

@@ -10,17 +10,6 @@
       };
     </script>
 
-    <script>
-      (function(h,o,t,j,a,r){
-          h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
-          h._hjSettings={hjid:"<%= htmlWebpackPlugin.options.hotjarId %>",hjsv:6};
-          a=o.getElementsByTagName('head')[0];
-          r=o.createElement('script');r.async=1;
-          r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
-          a.appendChild(r);
-      })(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
-    </script>
-
     <script>
       // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
       (function () {

+ 2 - 2
dashboard/src/main/home/app-dashboard/build-settings/BuildSettingsTab.tsx

@@ -4,7 +4,7 @@ import React, {
 } from "react";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
-import { PorterAppOptions } from "shared/types";
+import { CreateUpdatePorterAppOptions } from "shared/types";
 import { Context } from "shared/Context";
 
 import api from "shared/api";
@@ -18,7 +18,7 @@ import _ from "lodash";
 type Props = {
   porterApp: PorterApp;
   setTempPorterApp: (app: PorterApp) => void;
-  updatePorterApp: (options: Partial<PorterAppOptions>) => Promise<void>;
+  updatePorterApp: (options: Partial<CreateUpdatePorterAppOptions>) => Promise<void>;
   clearStatus: () => void;
   buildView: BuildMethod;
   setBuildView: (buildView: BuildMethod) => void;

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

@@ -1,6 +1,9 @@
 import React, { FC } from 'react';
 import * as Diff from "deep-diff";
 import styled from 'styled-components';
+import Text from 'components/porter/Text';
+import { flatMapDepth } from 'lodash';
+import Link from 'components/porter/Link';
 
 const createCompareLink = (repoId: string, oldTag: string, newTag: string) => {
   const baseUrl = 'https://github.com';
@@ -17,116 +20,151 @@ const getTagsFromChange = (changeString: string) => {
   return null;
 }
 
-const ChangeBoxComponent: FC<BoxProps> = ({ type, children }) => {
-
-  return (
-    <ChangeBox type={type}>
-      {children}
-    </ChangeBox>
-  );
-
-};
-
 type Props = {
   oldYaml: any;
   newYaml: any;
+  appData: any;
 };
 
-const ChangeLogComponent: FC<Props> = ({ oldYaml, newYaml }) => {
+const ChangeLogComponent: FC<Props> = ({ oldYaml, newYaml, appData }) => {
   const diff = Diff.diff(oldYaml, newYaml);
   const changes: JSX.Element[] = [];
+  // Define the regex pattern to match service creation
   const servicePattern = /^[a-zA-Z0-9\-]*-[a-zA-Z0-9]*[^\.]$/;
-
   diff?.forEach((difference: any) => {
-    let path = difference.path?.join(".");
-    // Extract the base path and check if it includes forbidden paths
-
-    const syncedPaths = ["synced"];
-    const isSyncedPath = syncedPaths.some(subPath => path?.includes(subPath));
-
-    // Restructure the path when synced is included
-    if (isSyncedPath) {
-      const parts = path?.split(".");
-      const syncedIndex = parts?.indexOf("synced");
-      path = `${parts[0]}.${parts[syncedIndex]}.${parts[parts?.length - 1]}`;
-    }
-
-    // Extract the base path and check if it includes forbidden paths
-    const basePath = path?.split('.').slice(0, -1).join('.');
-    const forbiddenPaths = ["container", "env", "keys", "name"];
-    const isForbiddenPath = forbiddenPaths.some(subPath => basePath?.includes(subPath));
-
-    if (difference.kind === "E" && isForbiddenPath && !isSyncedPath) {
-      return;  // Skip if it's a forbidden path
-    }
-
-    console.log("Filtered Difference: ", difference);
-    console.log("Filtered Path: ", path);
-
-    // rest of th
-
+    let path = difference.path?.join(" ");
     switch (difference.kind) {
-      case "E":
-        const tags = getTagsFromChange(path);
-        if (tags) {
-          const repoId = "your-repo-id-here"; // replace with your repoId
-          const link = createCompareLink(repoId, tags.oldTag, tags.newTag);
+      case "N":
+        // Check if the added item is a service by testing the path against the regex pattern
+        if (path?.includes('container env normal')) {
+          const appName = path.split(' ')[0];
+          const keyName = path.split(' ')[4];
           changes.push(
-            <ChangeBoxComponent type="E">
-              Image tag changed: {tags.oldTag} -{'>'} {tags.newTag}
-            </ChangeBoxComponent>
+            <ChangeBox type="N">{`${appName} added env var ${keyName} = ${difference.rhs}`}</ChangeBox>
           );
+        } else if (servicePattern.test(path)) {
+          changes.push(<ChangeBox type="N">{`${path} created`}</ChangeBox>);
         } else {
+          // If not, display the full message
           changes.push(
-            <ChangeBoxComponent type="E">
-              {`${path}: ${JSON.stringify(difference.lhs)} -> ${JSON.stringify(difference.rhs)}`}
-            </ChangeBoxComponent>
+            <ChangeBox type="N">{`${path} added: ${JSON.stringify(
+              difference.rhs
+            )}`}</ChangeBox>
           );
         }
         break;
-      case "N":
+      case "D":
         if (servicePattern.test(path)) {
-          changes.push(
-            <ChangeBoxComponent type="N">{`${path} created`}</ChangeBoxComponent>
-          );
+          // If so, display a simplified message
+          changes.push(<ChangeBox type="D">
+            {`${path} deleted`}
+          </ChangeBox>);
         } else {
-          changes.push(
-            <ChangeBoxComponent type="N">{`${path} added: ${JSON.stringify(difference.rhs)}`}</ChangeBoxComponent>
-          );
+
+          changes.push(<ChangeBox type="D">
+            {`${path} removed`}
+          </ChangeBox>);
         }
         break;
-      case "D":
-        if (servicePattern.test(path)) {
-          changes.push(
-            <ChangeBoxComponent type="D">{`${path} deleted`}</ChangeBoxComponent>
-          );
+      case "E":
+        if (path === "global image tag") {
+          const oldCommit = difference.lhs;
+          const newCommit = difference.rhs;
+          if (appData?.app?.repo_name) {
+            const commitDiffLink = `https://github.com/${appData.app.repo_name}/compare/${oldCommit}...${newCommit}`;
+            changes.push(
+              <ChangeBox type="E">
+                {`Tag upated: ${oldCommit} -> ${newCommit}.   `}
+
+                <Link
+                  target="_blank"
+                  hasunderline
+                  to={commitDiffLink}
+                >
+                  View commit diff
+                </Link>
+              </ChangeBox>
+            );
+          } else {
+            <ChangeBox type="E">
+              {`Tag upated: ${oldCommit} -> ${newCommit}.   `}
+            </ChangeBox>
+          }
         } else {
           changes.push(
-            <ChangeBoxComponent type="D">{`${path} removed`}</ChangeBoxComponent>
+            <ChangeBox type="E">
+              {`${path}: ${JSON.stringify(difference.lhs)} -> ${JSON.stringify(difference.rhs)}`}
+            </ChangeBox>
           );
         }
         break;
       case "A":
-        path = `${path}[${difference.index}]`;
+        path = path + `[${difference.index}]`;
         if (difference.item.kind === "N") {
+          if (path.includes('container env synced')) {
+            const appName = path.split(' ')[0];
+            if (path.includes('keys')) {
+              // This is an addition of a key in an existing env group
+              const keyName = difference.item.rhs?.name;
+              changes.push(
+                <ChangeBox type="N">{`${appName} synced env-group key ${keyName} added`}</ChangeBox>
+              );
+            } else {
+              // This is an addition of a whole new env group
+              const groupName = difference.item.rhs?.name;
+              changes.push(
+                <ChangeBox type="N">{`${appName} synced env-group ${groupName} added`}</ChangeBox>
+              );
+            }
+          } else {
+            changes.push(
+              <ChangeBox type="N">{`${path} added: ${JSON.stringify(difference.item.rhs)}`}</ChangeBox>
+            );
+          }
+        }
+        if (difference.item.kind === "D") {
+          if (path.includes('container env synced')) {
+            const appName = path.split(' ')[0];
+            if (path.includes('keys')) {
+              // This is a deletion of a key in an existing env group
+              const keyName = difference.item.lhs?.name;
+              changes.push(
+                <ChangeBox type="D">{`${appName} synced env-group key ${keyName} removed`}</ChangeBox>
+              );
+            } else {
+              // This is a deletion of a whole env group
+              const groupName = difference.item.lhs?.name;
+              changes.push(
+                <ChangeBox type="D">{`${appName} synced env-group ${groupName} removed`}</ChangeBox>
+              );
+            }
+          } else {
+            changes.push(
+              <ChangeBox type="D">{`${path} removed: ${JSON.stringify(difference.item.lhs)}`}</ChangeBox>
+            );
+          }
+        }
+        if (difference.item.kind === "E")
           changes.push(
-            <ChangeBoxComponent type="N">{`${path} added`}</ChangeBoxComponent>
-          );
-        } else if (difference.item.kind === "D") {
-          changes.push(
-            <ChangeBoxComponent type="D">{`${path} deleted`}</ChangeBoxComponent>
+            <ChangeBox type="E">
+              {`${path} updated: ${JSON.stringify(
+                difference.item.lhs
+              )} -> ${JSON.stringify(difference.item.rhs)}`}
+            </ChangeBox>
           );
-        }
-        break;
-      default:
         break;
     }
-
-    if (changes.length === 0) {
-      changes.push(<ChangeBoxComponent type="E">No changes detected</ChangeBoxComponent>);
-    }
   });
-  return <ChangeLog>{changes}</ChangeLog>;
+  if (changes.length === 0) {
+    changes.push(
+      <ChangeBox type="E">
+        {`No changes detected`}
+      </ChangeBox>
+    )
+  }
+
+  return <ChangeLog>{changes}</ChangeLog>
+
 };
 
 export default ChangeLogComponent;

+ 3 - 0
dashboard/src/main/home/app-dashboard/expanded-app/ChangeLogModal.tsx

@@ -183,11 +183,14 @@ const ChangeLogModal: React.FC<Props> = ({
                     <ChangeLogComponent
                       oldYaml={currentChart.config}
                       newYaml={chartEvent?.config}
+                      appData={appData}
                     />
                     : <ChangeLogComponent
                       oldYaml={prevChartEvent?.config}
                       newYaml={chartEvent?.config}
+                      appData={appData}
                     />
+
                   }
                 </div>
               )}

+ 1 - 1
dashboard/src/main/home/app-dashboard/expanded-app/DiffViewModal.tsx

@@ -16,7 +16,7 @@ import Spacer from "components/porter/Spacer";
 import Checkbox from "components/porter/Checkbox";
 import { NavLink } from "react-router-dom";
 import SidebarLink from "main/home/sidebar/SidebarLink";
-import { EnvVariablesTab } from "./EnvVariablesTab";
+import { EnvVariablesTab } from "./env-vars/EnvVariablesTab";
 type Props = {
   modalVisible: boolean;
   setModalVisible: (x: boolean) => void;

+ 28 - 98
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useContext, useCallback } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import { RouteComponentProps, useParams, withRouter } from "react-router";
 import styled from "styled-components";
 import yaml from "js-yaml";
@@ -13,7 +13,6 @@ import refresh from "assets/refresh.png";
 import save from "assets/save-01.svg";
 
 import api from "shared/api";
-import JSZip from "jszip";
 import { Context } from "shared/Context";
 import Error from "components/porter/Error";
 
@@ -26,7 +25,7 @@ import Link from "components/porter/Link";
 import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
 import Icon from "components/porter/Icon";
-import { ChartType, PorterAppOptions } from "shared/types";
+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";
@@ -37,20 +36,19 @@ import Fieldset from "components/porter/Fieldset";
 import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { PorterYamlSchema } from "../new-app-flow/schema";
-import { EnvVariablesTab } from "./EnvVariablesTab";
+import { EnvVariablesTab } from "./env-vars/EnvVariablesTab";
 import GHABanner from "./GHABanner";
 import LogSection from "./logs/LogSection";
 import ActivityFeed from "./activity-feed/ActivityFeed";
 import MetricsSection from "./MetricsSection";
 import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
-import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
-import Anser, { AnserJsonEntry } from "anser";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
 import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
 import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
+import HelmValuesTab from "./HelmValuesTab";
 
 type Props = RouteComponentProps & {};
 
@@ -72,6 +70,7 @@ const validTabs = [
   "build-settings",
   "settings",
   "events",
+  "helm-values",
 ] as const;
 const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
@@ -85,7 +84,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     currentCluster,
     currentProject,
     setCurrentError,
-    featurePreview,
+    user,
   } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [deleting, setDeleting] = useState(false);
@@ -95,7 +94,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   );
   const [hasBuiltImage, setHasBuiltImage] = useState<boolean>(false);
 
-  const [error, setError] = useState(null);
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
     false
   );
@@ -112,8 +110,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [showUnsavedChangesBanner, setShowUnsavedChangesBanner] = useState<boolean>(false);
 
   const [expandedJob, setExpandedJob] = useState(null);
-  const [logs, setLogs] = useState<Log[]>([]);
-
   const [services, setServices] = useState<Service[]>([]);
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
@@ -128,13 +124,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const { eventId, tab } = useParams<Params>();
   const selectedTab: ValidTab = tab != null && validTabs.includes(tab) ? tab : DEFAULT_TAB;
 
-  useEffect(() => {
-    setBannerLoading(true);
-    getBuildLogs().then(() => {
-      setBannerLoading(false);
-    });
-  }, [appData]);
-
   useEffect(() => {
     if (!_.isEqual(_.omitBy(porterApp, _.isEmpty), _.omitBy(tempPorterApp, _.isEmpty))) {
       setButtonStatus("");
@@ -197,7 +186,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         );
       } catch (err) {
-        setError(err)
+        // that's ok if there's an error, just means there is no pre-deploy chart
       }
 
       // update apps and release
@@ -313,7 +302,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         }
       }
     } catch (err) {
-      setError(err);
+      // TODO: handle error
     } finally {
       setIsLoading(false);
     }
@@ -342,7 +331,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       try {
         await Promise.all(removeApplicationToEnvGroupPromises);
       } catch (error) {
-        setError(error);
+        // TODO: Handle error
       }
     }
     try {
@@ -378,13 +367,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       );
       props.history.push("/apps");
     } catch (err) {
-      setError(err);
+      // TODO: handle error
     } finally {
       setDeleting(false);
     }
   };
 
-  const updatePorterApp = async (options: Partial<PorterAppOptions>) => {
+  const updatePorterApp = async (options: Partial<CreateUpdatePorterAppOptions>) => {
     //setting the EnvGroups Config Maps
     const filteredEnvGroups = deletedEnvGroups.filter((deletedEnvGroup) => {
       return !syncedEnvGroups.some((syncedEnvGroup) => {
@@ -436,7 +425,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     try {
       await Promise.all(addApplicationToEnvGroupPromises);
     } catch (error) {
-      setError(error);
+      // TODO: handle error
     }
     try {
       setButtonStatus("loading");
@@ -464,9 +453,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           repo_name: tempPorterApp.repo_name,
           git_branch: tempPorterApp.git_branch,
           buildpacks: "",
-          ...options,
           env_groups: syncedEnvGroups?.map((env) => env.name),
           user_update: true,
+          ...options,
         }
         if (buildView === "docker") {
           updatedPorterApp.dockerfile = tempPorterApp.dockerfile;
@@ -481,7 +470,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         await api.createPorterApp(
           "<token>",
           updatedPorterApp,
-
           {
             cluster_id: currentCluster.id,
             project_id: currentProject.id,
@@ -494,13 +482,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         setPorterApp(tempPorterApp);
         setButtonStatus("success");
         setShowUnsavedChangesBanner(false);
+        getPorterApp({ revision: 0 });
       } else {
         setButtonStatus(<Error message="Unable to update app" />);
       }
     } catch (err) {
       // TODO: better error handling
-
-      console.log(err);
       const errMessage =
         err?.response?.data?.error ??
         err?.toString() ??
@@ -509,70 +496,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   };
 
-  const getBuildLogs = async () => {
-    try {
-      const res = await api.getGHWorkflowLogs(
-        "",
-        {},
-        {
-          project_id: appData.app.project_id,
-          cluster_id: appData.app.cluster_id,
-          git_installation_id: appData.app.git_repo_id,
-          owner: appData.app.repo_name?.split("/")[0],
-          name: appData.app.repo_name?.split("/")[1],
-          filename: "porter_stack_" + appData.chart.name + ".yml",
-        }
-      );
-      let logs: Log[] = [];
-      if (res.data != null) {
-        // Fetch the logs
-        const logsResponse = await fetch(res.data);
-
-        // Ensure that the response body is only read once
-        const logsBlob = await logsResponse.blob();
-
-        if (logsResponse.headers.get("Content-Type") === "application/zip") {
-          const zip = await JSZip.loadAsync(logsBlob);
-
-          zip.forEach(async function (relativePath, zipEntry) {
-            const fileData = await zip.file(relativePath)?.async("string");
-
-            if (
-              fileData &&
-              fileData.includes("Run porter-dev/porter-cli-action@v0.1.0")
-            ) {
-              const lines = fileData.split("\n");
-              const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
-
-              lines.forEach((line, index) => {
-                const lineWithoutTimestamp = line
-                  .replace(timestampPattern, "")
-                  .trimStart();
-                const anserLine: AnserJsonEntry[] = Anser.ansiToJson(
-                  lineWithoutTimestamp
-                );
-                if (lineWithoutTimestamp.toLowerCase().includes("error")) {
-                  anserLine[0].fg = "238,75,43";
-                }
-
-                const log: Log = {
-                  line: anserLine,
-                  lineNumber: index + 1,
-                  timestamp: line.match(timestampPattern)?.[0],
-                };
-
-                logs.push(log);
-              });
-            }
-          });
-          setLogs(logs);
-        }
-      }
-    } catch (error) {
-      setError(error);
-    }
-  };
-
   const fetchPorterYamlContent = async (
     porterYaml: string,
     appData: any
@@ -668,7 +591,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   };
 
   const setRevision = (chart: ChartType, isCurrent?: boolean) => {
-    getPorterApp({ revision: chart.version });
+    getPorterApp({ revision: isCurrent ? 0 : chart.version });
   };
 
   const getReadableDate = (s: string) => {
@@ -699,6 +622,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
   const renderTabContents = () => {
     switch (selectedTab) {
+      case "activity":
+        return <ActivityFeed
+          chart={appData.chart}
+          stackName={appData?.app?.name}
+          appData={appData}
+        />;
       case "overview":
         return (
           <>
@@ -797,12 +726,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             </Button>
           </>
         );
-      case "activity":
-        return <ActivityFeed
-          chart={appData.chart}
-          stackName={appData?.app?.name}
-          appData={appData}
-        />;
       case "events":
         if (eventId != null) {
           return <EventFocusView
@@ -840,6 +763,12 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             setDeletedEnvGroups={setDeleteEnvGroups}
           />
         );
+      case "helm-values":
+        return <HelmValuesTab
+          currentChart={appData.chart}
+          updatePorterApp={updatePorterApp}
+          buttonStatus={buttonStatus}
+        />
       default:
         return <ActivityFeed
           chart={appData.chart}
@@ -1058,6 +987,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     value: "build-settings",
                   },
                   { label: "Settings", value: "settings" },
+                  user.email.endsWith("porter.run") && { label: "Helm values", value: "helm-values" },
                 ].filter((x) => x)}
                 currentTab={selectedTab}
                 setCurrentTab={(tab: string) => {

+ 84 - 0
dashboard/src/main/home/app-dashboard/expanded-app/HelmValuesTab.tsx

@@ -0,0 +1,84 @@
+import React from "react";
+import styled from "styled-components";
+import yaml from "js-yaml";
+import _ from "lodash";
+
+import { ChartType, CreateUpdatePorterAppOptions } from "shared/types";
+
+import YamlEditor from "components/YamlEditor";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+type Props = {
+    currentChart: ChartType;
+    updatePorterApp: (options: Partial<CreateUpdatePorterAppOptions>) => Promise<void>;
+    buttonStatus: any;
+};
+
+const HelmValuesTab: React.FC<Props> = ({
+    currentChart,
+    updatePorterApp,
+    buttonStatus,
+}) => {
+    const [values, setValues] = React.useState<string>(yaml.dump(currentChart.config));
+
+    const handleSaveValues = async () => {
+        await updatePorterApp({ full_helm_values: values })
+    };
+
+
+    return (
+        <StyledValuesYaml>
+            <Wrapper>
+                <YamlEditor
+                    value={values}
+                    onChange={setValues}
+                    height="calc(100vh - 412px)"
+                />
+            </Wrapper>
+            <Spacer y={0.5} />
+            <Text color="helper">Note: any unsaved service changes from the Overview tab will be lost.</Text>
+            <Spacer y={0.5} />
+            <Button
+                onClick={handleSaveValues}
+                status={buttonStatus}
+                loadingText={"Updating..."}
+            >
+                Update values
+            </Button>
+        </StyledValuesYaml>
+    );
+
+}
+
+export default HelmValuesTab;
+
+const Wrapper = styled.div`
+  overflow: auto;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+`;
+
+const StyledValuesYaml = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: calc(100vh - 350px);
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

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

@@ -8,7 +8,6 @@ import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
 
 import { StyledEventCard } from "./EventCard";
-import styled from "styled-components";
 import AppEventModal from "../../../status/AppEventModal";
 import { readableDate } from "shared/string_utils";
 import dayjs from "dayjs";

+ 5 - 5
dashboard/src/main/home/app-dashboard/expanded-app/EnvVariablesTab.tsx → dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx

@@ -6,12 +6,12 @@ import styled, { keyframes } from "styled-components";
 import Text from "components/porter/Text";
 import Error from "components/porter/Error";
 import sliders from "assets/sliders.svg";
-import EnvGroupModal from "./env-vars/EnvGroupModal";
-import ExpandableEnvGroup from "./env-vars/ExpandableEnvGroup";
-import { PopulatedEnvGroup, PartialEnvGroup } from "../../../../components/porter-form/types";
+import EnvGroupModal from "./EnvGroupModal";
+import ExpandableEnvGroup from "./ExpandableEnvGroup";
+import { PopulatedEnvGroup, PartialEnvGroup } from "../../../../../components/porter-form/types";
 import _, { isObject, differenceBy, omit } from "lodash";
-import api from "../../../../shared/api";
-import { Context } from "../../../../shared/Context";
+import api from "../../../../../shared/api";
+import { Context } from "../../../../../shared/Context";
 
 interface EnvVariablesTabProps {
   envVars: any;

+ 3 - 2
dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts

@@ -297,7 +297,6 @@ export const useLogs = (
     setLoading(true);
     setLogs([]);
     flushLogsBuffer(true);
-    const websocketKey = `${currentPod}-${namespace}-websocket`;
     const endDate = timeRange?.endTime != null ? timeRange.endTime : dayjs(setDate);
     const oneDayAgo = timeRange?.startTime != null ? timeRange.startTime : endDate.subtract(1, "day");
 
@@ -320,7 +319,9 @@ export const useLogs = (
       );
     }
 
-    closeWebsocket(websocketKey);
+    closeAllWebsockets();
+    const suffix = Math.random().toString(36).substring(2, 15);
+    const websocketKey = `${currentPod}-${namespace}-websocket-${suffix}`;
 
     setLoading(false);
 

+ 10 - 3
dashboard/src/main/home/app-dashboard/expanded-app/status/ControllerTab.tsx

@@ -89,6 +89,10 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
     () => closeAllWebsockets();
   }, [currentSelectors, controller, currentCluster, currentProject]);
 
+  useEffect(() => {
+    return () => closeAllWebsockets();
+  }, [])
+
   const updatePods = async () => {
     try {
       const res = await api.getMatchingPods(
@@ -322,9 +326,12 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
       }
 
       if (event.Kind != "pod") {
-        let [available, total] = getAvailability(object.metadata.kind, object);
-        setAvailable(available);
-        setTotal(total);
+        const availability = getAvailability(object.metadata.kind, object);
+        if (availability != null) {
+          let [available, total] = availability;
+          setAvailable(available);
+          setTotal(total);
+        }
         return;
       }
       updatePods();

+ 23 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx

@@ -6,6 +6,8 @@ import TabSelector from "components/TabSelector";
 import Checkbox from "components/porter/Checkbox";
 import { JobService } from "./serviceTypes";
 import { Height } from "react-animate-height";
+import cronstrue from 'cronstrue';
+import Link from "components/porter/Link";
 
 interface Props {
   service: JobService;
@@ -20,6 +22,23 @@ const JobTabs: React.FC<Props> = ({
 }) => {
   const [currentTab, setCurrentTab] = React.useState<string>('main');
 
+  const getScheduleDescription = () => {
+    try {
+      return <Text color="helper">This job runs: {cronstrue.toString(service.cronSchedule.value)}</Text>;
+    } catch (err) {
+      return <Text color="helper">
+        Invalid cron schedule.{" "}
+        <Link
+          to={"https://crontab.cronhub.io/"}
+          hasunderline
+          target="_blank"
+        >
+          Need help?
+        </Link>
+      </Text>;
+    }
+  }
+
   const renderMain = () => {
     return (
       <>
@@ -35,7 +54,7 @@ const JobTabs: React.FC<Props> = ({
         />
         <Spacer y={1} />
         <Input
-          label="Cron schedule (leave blank to run manually)"
+          label="Cron schedule"
           placeholder="ex: */5 * * * *"
           value={service.cronSchedule.value}
           disabled={service.cronSchedule.readOnly}
@@ -43,6 +62,8 @@ const JobTabs: React.FC<Props> = ({
           setValue={(e) => { editService({ ...service, cronSchedule: { readOnly: false, value: e } }) }}
           disabledTooltip={"You may only edit this field in your porter.yaml."}
         />
+        <Spacer y={0.5} />
+        {getScheduleDescription()}
       </>
     )
   };
@@ -101,7 +122,7 @@ const JobTabs: React.FC<Props> = ({
         currentTab={currentTab}
         setCurrentTab={(value: string) => {
           if (value === 'main') {
-            setHeight(244);
+            setHeight(276);
           } else if (value === 'resources') {
             setHeight(244);
           } else if (value === 'advanced') {

+ 12 - 9
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -167,6 +167,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
 
   useEffect(() => {
     setFormState({ ...formState, serviceList: [] });
+    setDetected(undefined);
   }, [porterApp.git_branch]);
 
   const handleSetAccessData = (data: GithubAppAccessData) => {
@@ -321,16 +322,10 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
 
       const yamlString = yaml.dump(finalPorterYaml);
       const base64Encoded = btoa(yamlString);
-      let imageInfo = {
+      const imageInfo = {
         repository: "",
         tag: "",
       };
-      if (porterApp.image_repo_uri && imageTag) {
-        imageInfo = {
-          repository: porterApp.image_repo_uri,
-          tag: imageTag,
-        };
-      }
 
       const porterAppRequest = {
         porter_yaml: base64Encoded,
@@ -348,7 +343,15 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         env_groups: syncedEnvGroups?.map((env: PopulatedEnvGroup) => env.name),
         user_update: true,
       }
-      if (buildView === "docker") {
+      if (porterApp.image_repo_uri && imageTag) {
+        porterAppRequest.image_info = {
+          repository: porterApp.image_repo_uri,
+          tag: imageTag,
+        };
+        porterAppRequest.repo_name = "";
+        porterAppRequest.git_branch = "";
+        porterAppRequest.git_repo_id = 0;
+      } else if (buildView === "docker") {
         porterAppRequest.dockerfile = porterApp.dockerfile;
       } else {
         porterAppRequest.builder = porterApp.builder;
@@ -365,7 +368,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         }
       );
 
-      if (porterApp.repo_name === "") {
+      if (porterAppRequest.repo_name === "") {
         props.history.push(`/apps/${porterApp.name}`);
       }
 

+ 1 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -135,7 +135,7 @@ const Services: React.FC<ServicesProps> = ({
               options={[
                 { label: "Web", value: "web" },
                 { label: "Worker", value: "worker" },
-                { label: "Job", value: "job" },
+                { label: "Cron Job", value: "job" },
               ]}
             />
           </Container>

+ 6 - 11
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -202,9 +202,6 @@ const WebService = {
                 command: service.startCommand.value,
                 port: service.port.value,
             },
-            service: {
-                port: service.port.value,
-            },
             autoscaling: {
                 enabled: service.autoscaling.enabled.value,
                 minReplicas: service.autoscaling.minReplicas.value,
@@ -300,16 +297,10 @@ const JobService = {
         startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'job',
         jobsExecuteConcurrently: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.allowConcurrent),
-        cronSchedule: ServiceField.string('', porterJson?.apps?.[name]?.config?.schedule?.value),
+        cronSchedule: ServiceField.string('*/10 * * * *', porterJson?.apps?.[name]?.config?.schedule?.value),
         canDelete: porterJson?.apps?.[name] == null,
     }),
     serialize: (service: JobService) => {
-        const schedule = service.cronSchedule.value ? {
-            schedule: {
-                enabled: true,
-                value: service.cronSchedule.value,
-            }
-        } : {};
         return {
             allowConcurrent: service.jobsExecuteConcurrently.value,
             container: {
@@ -321,7 +312,11 @@ const JobService = {
                     memory: service.ram.value + 'Mi',
                 }
             },
-            ...schedule,
+            schedule: {
+                enabled: service.cronSchedule.value ? true : false,
+                value: service.cronSchedule.value,
+            },
+            paused: true,
         }
     },
     deserialize: (name: string, values: any, porterJson?: PorterJson): JobService => {

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

@@ -90,7 +90,7 @@ class RevisionSection extends Component<PropsType, StateType> {
     const ws = new WebSocket(`${url}${apiPath}`);
 
     ws.onopen = () => {
-      // console.log("connected to chart live updates websocket");
+      console.log("connected to chart live updates websocket");
     };
 
     ws.onmessage = (evt: MessageEvent) => {
@@ -119,7 +119,7 @@ class RevisionSection extends Component<PropsType, StateType> {
               return { ...prevState, revisions: [object, ...prevRevisions] };
             }
 
-            return { ...prevState, revisions: prevRevisions };
+            return { ...prevState, revisions: prevRevisions, maxVersion: Math.max(...prevRevisions.map(rev => rev.version)) };
           },
           () => {
             this.props.setRevision(this.state.revisions[0], true);

+ 97 - 0
dashboard/src/main/home/project-settings/ProjectDeleteConsent.tsx

@@ -0,0 +1,97 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Modal from "components/porter/Modal";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Fieldset from "components/porter/Fieldset";
+import Button from "components/porter/Button";
+import ExpandableSection from "components/porter/ExpandableSection";
+import Input from "components/porter/Input";
+import Link from "components/porter/Link";
+
+type Props = {
+  setShowCostConfirmModal: (show: boolean) => void;
+  show: boolean
+};
+
+const ProjectDeleteConsent: React.FC<Props> = ({
+  setShowCostConfirmModal,
+  show,
+}) => {
+  const [confirmDelete, setDeleteCost] = useState("");
+  const {
+    currentProject, setCurrentModal
+  } = useContext(Context);
+  return show ? (
+    <>
+      <Modal
+        closeModal={() => {
+          setDeleteCost("");
+          setShowCostConfirmModal(false);
+        }}
+      >
+        <Text size={16}>Delete {currentProject.name}?</Text>
+        <Spacer y={1} />
+
+        <Text size={14} color="red">
+          Attention:
+        </Text>
+        <Spacer y={.1} />
+        <Text>
+          Destruction of resources sometimes results in dangling resources. To
+          ensure that everything has been properly destroyed, please visit
+          your cloud provider's console.
+        </Text>
+        <Link
+
+          target="_blank"
+          hasunderline
+          to="https://docs.porter.run/other/deleting-dangling-resources"
+        >
+          Deletion instructions
+        </Link>
+        <Spacer y={1} />
+
+        <Text color="helper">
+          To acknowledge, enter the project name in the text input field.
+        </Text>
+        <Input
+          placeholder={currentProject.name}
+          value={confirmDelete}
+          setValue={setDeleteCost}
+          width="100%"
+          height="40px"
+        />
+        <Spacer y={1} />
+
+        <Button
+          disabled={confirmDelete !== currentProject?.name}
+          onClick={() => {
+            setShowCostConfirmModal(false);
+            setCurrentModal("UpdateProjectModal", {
+              currentProject: currentProject,
+            });
+          }}
+          status={confirmDelete == currentProject?.name ? "This action cannot be undone" : ""}
+        >
+          Continue
+        </Button>
+      </Modal>
+    </>) : null
+};
+
+export default ProjectDeleteConsent;
+
+const Cost = styled.div`
+  font-weight: 600;
+  font-size: 20px;
+`;
+
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;

+ 10 - 19
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -16,6 +16,7 @@ import APITokensSection from "./APITokensSection";
 import _ from "lodash";
 import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
+import ProjectDeleteConsent from "./ProjectDeleteConsent";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 
@@ -23,6 +24,7 @@ type StateType = {
   projectName: string;
   currentTab: string;
   tabOptions: { value: string; label: string }[];
+  showCostConfirmModal: boolean;
 };
 
 class ProjectSettings extends Component<PropsType, StateType> {
@@ -30,6 +32,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
     projectName: "",
     currentTab: "manage-access",
     tabOptions: [] as { value: string; label: string }[],
+    showCostConfirmModal: false,
   };
 
   componentDidUpdate(prevProps: PropsType) {
@@ -145,6 +148,8 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return (
         <>
           <Heading isAtTop={true}>Delete project</Heading>
+          <Helper>
+          </Helper>
           <Helper>
             Permanently delete this project. This will destroy all clusters tied
             to this project that have been provisioned by Porter. Note that this
@@ -152,31 +157,17 @@ class ProjectSettings extends Component<PropsType, StateType> {
             delete the registries, please do so manually in your cloud console.
           </Helper>
 
-          <Helper>
-            Destruction of resources sometimes results in dangling resources. To
-            ensure that everything has been properly destroyed, please visit
-            your cloud provider's console.
-            <Spacer inline width="5px" />
-            <Link
-              target="_blank"
-              hasunderline
-              to="https://docs.porter.run/other/deleting-dangling-resources"
-            >
-              Deletion instructions
-            </Link>
-          </Helper>
-
-          <Warning highlight={true}>This action cannot be undone.</Warning>
-
           <DeleteButton
             onClick={() => {
-              this.context.setCurrentModal("UpdateProjectModal", {
-                currentProject: this.context.currentProject,
-              });
+              this.setState({ showCostConfirmModal: true });
             }}
           >
             Delete project
           </DeleteButton>
+          <ProjectDeleteConsent
+            setShowCostConfirmModal={(show: boolean) => this.setState({ showCostConfirmModal: show })}
+            show={this.state.showCostConfirmModal}  // <-- Pass these props
+          />
         </>
       );
     }

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

@@ -2,7 +2,7 @@ import { PolicyDocType } from "./auth/types";
 import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
 import { baseApi } from "./baseApi";
 
-import { BuildConfig, FullActionConfigType, PorterAppOptions } from "./types";
+import { BuildConfig, FullActionConfigType, CreateUpdatePorterAppOptions } from "./types";
 import {
   CreateStackBody,
   SourceConfig,
@@ -200,7 +200,7 @@ const getPorterAppEvent = baseApi<
 });
 
 const createPorterApp = baseApi<
-  PorterAppOptions,
+  CreateUpdatePorterAppOptions,
   {
     project_id: number;
     cluster_id: number;
@@ -2448,6 +2448,11 @@ const updateOnboardingStep = baseApi<
   {
     step: string;
     provider?: string;
+    account_id?: string;
+    cloudformation_url?: string;
+    error_message?: string;
+    login_url?: string;
+    external_id?: string;
   },
   {}
 >("POST", (pathParams) => {

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

@@ -645,7 +645,7 @@ export type BuildConfig = {
   };
 };
 
-export interface PorterAppOptions {
+export interface CreateUpdatePorterAppOptions {
   porter_yaml: string;
   porter_yaml_path?: string;
   repo_name?: string;
@@ -661,6 +661,7 @@ export interface PorterAppOptions {
     tag: string;
   };
   override_release?: boolean;
+  full_helm_values?: string;
 }
 
 

+ 11 - 6
internal/analytics/track_events.go

@@ -8,11 +8,16 @@ const (
 	UserVerifyEmail SegmentEvent = "User Verified Email"
 	ProjectCreate   SegmentEvent = "New Project Event"
 
-	CostConsentOpened      SegmentEvent = "Cost Consent Opened"
-	CostConsentComplete    SegmentEvent = "Cost Consent Complete"
-	CredentialStepComplete SegmentEvent = "Credential Step Complete"
-	PreProvisionCheck      SegmentEvent = "Pre Provision Check Started"
-	ProvisioningAttempted  SegmentEvent = "Provisioning Attempted"
+	CostConsentOpened           SegmentEvent = "Cost Consent Opened"
+	CostConsentComplete         SegmentEvent = "Cost Consent Complete"
+	CredentialStepComplete      SegmentEvent = "Credential Step Complete"
+	PreProvisionCheck           SegmentEvent = "Pre Provision Check Started"
+	AWSInputted                 SegmentEvent = "AWS Account ID Inputted"
+	AWSCloudformationRedirect   SegmentEvent = "AWS Cloudformation Redirect"
+	AWSLoginRedirect            SegmentEvent = "AWS Login Redirect"
+	AWSCreateIntegrationSuccess SegmentEvent = "AWS Create Integration Success"
+	AWSCreateIntegrationFailure SegmentEvent = "AWS Create Integration Failure"
+	ProvisioningAttempted       SegmentEvent = "Provisioning Attempted"
 
 	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"
 	ClusterProvisioningError   SegmentEvent = "Cluster Provisioning Error"
@@ -41,7 +46,7 @@ const (
 	ClusterDestroyingStart   SegmentEvent = "Cluster Destroying Start"
 	ClusterDestroyingSuccess SegmentEvent = "Cluster Destroying Success"
 
-	// stacks
+	// porter apps
 	StackLaunchStart    SegmentEvent = "Stack Launch Started"
 	StackLaunchComplete SegmentEvent = "Stack Launch Complete"
 	StackLaunchSuccess  SegmentEvent = "Stack Launch Success"

+ 111 - 0
internal/analytics/tracks.go

@@ -180,6 +180,117 @@ func CostConsentCompletedTrack(opts *CostConsentCompletedTrackOpts) segmentTrack
 	)
 }
 
+// AWSInputTrackOpts are the options for creating a track when a user inputs a complete AWS account ID
+type AWSInputTrackOpts struct {
+	*UserScopedTrackOpts
+
+	Email       string
+	FirstName   string
+	LastName    string
+	CompanyName string
+	AccountId   string
+}
+
+// AWSInputTrack returns a track for when a user inputs a complete AWS account ID
+func AWSInputTrack(opts *AWSInputTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+	additionalProps["account_id"] = opts.AccountId
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, AWSInputted),
+	)
+}
+
+type AWSRedirectOpts struct {
+	*UserScopedTrackOpts
+
+	Email             string
+	FirstName         string
+	LastName          string
+	CompanyName       string
+	AccountId         string
+	CloudformationURL string
+	LoginURL          string
+	ExternalId        string
+}
+
+// AWSCloudformationRedirectSuccess returns a track for when a user clicks 'grant permissions' and gets redirected to cloudformation
+func AWSCloudformationRedirectSuccess(opts *AWSRedirectOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+	additionalProps["account_id"] = opts.AccountId
+	additionalProps["cloudformation_url"] = opts.CloudformationURL
+	additionalProps["external_id"] = opts.ExternalId
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, AWSCloudformationRedirect),
+	)
+}
+
+// AWSLoginRedirectSuccess returns a track for when a user is prompted to login to AWS
+func AWSLoginRedirectSuccess(opts *AWSRedirectOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+	additionalProps["account_id"] = opts.AccountId
+	additionalProps["login_url"] = opts.LoginURL
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, AWSLoginRedirect),
+	)
+}
+
+type AWSCreateIntegrationOpts struct {
+	*UserScopedTrackOpts
+
+	Email        string
+	FirstName    string
+	LastName     string
+	CompanyName  string
+	AccountId    string
+	ExternalId   string
+	ErrorMessage string
+}
+
+// AWSCreateIntegrationSucceeded returns a track for when a user succeeds in creating an aws integration
+func AWSCreateIntegrationSucceeded(opts *AWSCreateIntegrationOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+	additionalProps["account_id"] = opts.AccountId
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, AWSCreateIntegrationSuccess),
+	)
+}
+
+// AWSCreateIntegrationSucceeded returns a track for when a user succeeds in creating an aws integration
+func AWSCreateIntegrationFailed(opts *AWSCreateIntegrationOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+	additionalProps["account_id"] = opts.AccountId
+	additionalProps["error_message"] = opts.ErrorMessage
+	additionalProps["external_id"] = opts.ExternalId
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, AWSCreateIntegrationFailure),
+	)
+}
+
 // CredentialStepTrackOpts are the options for creating a track when a user completes the credential step
 type CredentialStepTrackOpts struct {
 	*UserScopedTrackOpts

+ 5 - 5
internal/kubernetes/prometheus/metrics.go

@@ -142,15 +142,15 @@ func QueryPrometheus(
 		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s"`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
 	} else if opts.Metric == "nginx:errors" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",exported_namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{exported_namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, selectionRegex)
+		num := fmt.Sprintf(`(sum(rate(nginx_ingress_controller_requests{status=~"5.*",exported_namespace="%s",ingress=~"%s"}[5m]) OR sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m])) OR on() vector(0))`, opts.Namespace, selectionRegex, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`(sum(rate(nginx_ingress_controller_requests{exported_namespace="%s",ingress=~"%s"}[5m]) OR sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m])) > 0)`, opts.Namespace, selectionRegex, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf(`%s / %s * 100 OR on() vector(0)`, num, denom)
 	} else if opts.Metric == "nginx:latency" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_sum{exported_namespace=~"%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_count{exported_namespace=~"%s",ingress=~"%s"}[5m]))`, opts.Namespace, selectionRegex)
+		num := fmt.Sprintf(`(sum(rate(nginx_ingress_controller_request_duration_seconds_sum{exported_namespace=~"%s",ingress=~"%s"}[5m]) OR sum(rate(nginx_ingress_controller_request_duration_seconds_sum{namespace=~"%s",ingress=~"%s"}[5m])) OR on() vector(0))`, opts.Namespace, selectionRegex, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`(sum(rate(nginx_ingress_controller_request_duration_seconds_count{exported_namespace=~"%s",ingress=~"%s"}[5m])) OR sum(rate(nginx_ingress_controller_request_duration_seconds_count{namespace=~"%s",ingress=~"%s"}[5m])))`, opts.Namespace, selectionRegex, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf(`%s / %s OR on() vector(0)`, num, denom)
 	} else if opts.Metric == "nginx:latency-histogram" {
-		query = fmt.Sprintf(`histogram_quantile(%f, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",exported_namespace=~"%s",ingress=~"%s"}[5m])) by (le, ingress))`, opts.Percentile, opts.Namespace, selectionRegex)
+		query = fmt.Sprintf(`histogram_quantile(%f, (sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",exported_namespace=~"%s",ingress=~"%s"}[5m])) OR sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",namespace=~"%s",ingress=~"%s"}[5m]))) by (le, ingress))`, opts.Percentile, opts.Namespace, selectionRegex, opts.Namespace, selectionRegex)
 	} else if opts.Metric == "cpu_hpa_threshold" {
 		// get the name of the kube hpa metric
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")

+ 33 - 0
internal/notifier/sendgrid/user_notifier.go

@@ -16,6 +16,7 @@ type UserNotifierOpts struct {
 	PWGHTemplateID          string
 	VerifyEmailTemplateID   string
 	ProjectInviteTemplateID string
+	DeleteProjectTemplateID string
 }
 
 func NewUserNotifier(opts *UserNotifierOpts) notifier.UserNotifier {
@@ -150,3 +151,35 @@ func (s *UserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInviteEm
 
 	return err
 }
+
+func (s *UserNotifier) SendProjectDeleteEmail(opts *notifier.SendProjectDeleteEmailOpts) error {
+	request := sendgrid.GetRequest(s.opts.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: opts.Email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"email":   opts.Email,
+					"project": opts.Project,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: s.opts.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: s.opts.DeleteProjectTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}

+ 10 - 0
internal/notifier/user_notifier.go

@@ -22,11 +22,17 @@ type SendProjectInviteEmailOpts struct {
 	ProjectOwnerEmail string
 }
 
+type SendProjectDeleteEmailOpts struct {
+	Project string
+	Email   string
+}
+
 type UserNotifier interface {
 	SendPasswordResetEmail(opts *SendPasswordResetEmailOpts) error
 	SendGithubRelinkEmail(opts *SendGithubRelinkEmailOpts) error
 	SendEmailVerification(opts *SendEmailVerificationOpts) error
 	SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error
+	SendProjectDeleteEmail(opts *SendProjectDeleteEmailOpts) error
 }
 
 type EmptyUserNotifier struct{}
@@ -46,3 +52,7 @@ func (e *EmptyUserNotifier) SendEmailVerification(opts *SendEmailVerificationOpt
 func (e *EmptyUserNotifier) SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error {
 	return nil
 }
+
+func (e *EmptyUserNotifier) SendProjectDeleteEmail(opts *SendProjectDeleteEmailOpts) error {
+	return nil
+}