Browse Source

attempt to fix merge conflicts

Alexander Belanger 4 years ago
parent
commit
b5d78b2705
100 changed files with 4578 additions and 1524 deletions
  1. 1 1
      .github/workflows/production.yaml
  2. 1 1
      .github/workflows/release.yaml
  3. 4 0
      .gitignore
  4. 66 0
      api/client/api.go
  5. 2 0
      api/server/shared/config/env/envconfs.go
  6. 134 3
      cli/cmd/deploy.go
  7. 17 5
      cli/cmd/deploy/deploy.go
  8. 1 2
      cli/cmd/login/server.go
  9. 128 46
      cli/cmd/run.go
  10. 15 0
      cmd/app/main.go
  11. 6 2
      dashboard/babel.config.json
  12. 15 9
      dashboard/package-lock.json
  13. 2 1
      dashboard/package.json
  14. 1 1
      dashboard/src/assets/GoogleIcon.tsx
  15. 4 0
      dashboard/src/assets/Iconly/Bulk/Info Square.svg
  16. 56 0
      dashboard/src/components/Banner.tsx
  17. 34 0
      dashboard/src/components/Placeholder.tsx
  18. 5 5
      dashboard/src/components/form-components/CheckboxList.tsx
  19. 1 1
      dashboard/src/components/porter-form/PorterForm.tsx
  20. 16 7
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  21. 57 20
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  22. 9 5
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  23. 22 19
      dashboard/src/components/porter-form/field-components/Input.tsx
  24. 12 8
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  25. 8 7
      dashboard/src/components/porter-form/field-components/Select.tsx
  26. 5 1
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  27. 2 6
      dashboard/src/components/porter-form/types.ts
  28. 5 0
      dashboard/src/components/porter-form/utils.ts
  29. 2 15
      dashboard/src/index.html
  30. 3 2
      dashboard/src/main/auth/Login.tsx
  31. 18 1
      dashboard/src/main/home/Home.tsx
  32. 305 0
      dashboard/src/main/home/WelcomeForm.tsx
  33. 26 12
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  34. 61 0
      dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx
  35. 1 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  36. 42 42
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  37. 206 70
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  38. 3 3
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  39. 25 12
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  40. 104 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentType.tsx
  41. 50 71
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  42. 15 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  43. 164 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  44. 79 56
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  45. 23 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  46. 30 28
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  47. 120 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx
  48. 94 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx
  49. 269 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  50. 13 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  51. 4 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  52. 3 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  53. 5 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts
  54. 5 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  55. 1 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  56. 2 0
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  57. 1 1
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  58. 1 1
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  59. 1 1
      dashboard/src/main/home/modals/UpgradeChartModal.tsx
  60. 12 0
      dashboard/src/main/home/navbar/Navbar.tsx
  61. 6 0
      dashboard/src/main/home/new-project/NewProject.tsx
  62. 337 312
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  63. 196 151
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  64. 2 0
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  65. 253 206
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  66. 188 156
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  67. 6 1
      dashboard/src/shared/Context.tsx
  68. 59 5
      dashboard/src/shared/api.tsx
  69. 83 0
      dashboard/src/shared/baseApi.ts
  70. 11 0
      dashboard/src/shared/types.tsx
  71. 0 3
      dashboard/webpack.config.js
  72. 3 1
      docker/Dockerfile
  73. 14 1
      docs/deploy/applications/deploying-from-the-cli.md
  74. 71 18
      docs/developing/analytics.md
  75. 0 1
      go.sum
  76. 4 4
      internal/analytics/identifiers.go
  77. 27 3
      internal/analytics/track_events.go
  78. 167 0
      internal/analytics/track_scopes.go
  79. 397 62
      internal/analytics/tracks.go
  80. 1 1
      internal/forms/git_action.go
  81. 35 2
      internal/helm/agent.go
  82. 24 1
      internal/helm/loader/loader.go
  83. 33 17
      internal/integrations/ci/actions/actions.go
  84. 16 6
      internal/integrations/ci/actions/steps.go
  85. 2 1
      internal/kubernetes/agent.go
  86. 0 2
      internal/kubernetes/prometheus/metrics.go
  87. 76 6
      internal/kubernetes/provisioner/global_stream.go
  88. 51 0
      internal/models/event.go
  89. 3 0
      internal/models/infra.go
  90. 1 0
      internal/models/release.go
  91. 28 11
      internal/oauth/config.go
  92. 12 0
      internal/repository/event.go
  93. 63 0
      internal/repository/gorm/event.go
  94. 2 0
      internal/repository/gorm/migrate.go
  95. 1 1
      internal/repository/gorm/notification.go
  96. 32 0
      internal/repository/gorm/repository.go
  97. 1 0
      internal/repository/repository.go
  98. 33 19
      server/api/api.go
  99. 1 0
      server/api/capability_handler.go
  100. 22 34
      server/api/cluster_handler.go

+ 1 - 1
.github/workflows/production.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile --build-arg version=production
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:latest

+ 1 - 1
.github/workflows/release.yaml

@@ -34,7 +34,7 @@ jobs:
           cat ./dashboard/.env
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile --build-arg version=${{steps.tag_name.outputs.tag}}
       - name: Push
         run: |
           docker push porter1/porter:${{steps.tag_name.outputs.tag}}

+ 4 - 0
.gitignore

@@ -58,4 +58,8 @@ override.tf.json
 .terraformrc
 terraform.rc
 
+
+# Ignore editor files
+.vscode
+
 tmp

+ 66 - 0
api/client/api.go

@@ -1,6 +1,8 @@
 package client
 
 import (
+	"bytes"
+	"context"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
@@ -28,6 +30,32 @@ type HTTPError struct {
 	Errors []string `json:"errors"`
 }
 
+type EventStatus int64
+
+const (
+	EventStatusSuccess    EventStatus = 1
+	EventStatusInProgress             = 2
+	EventStatusFailed                 = 3
+)
+
+// Event represents an event that happens during
+type Event struct {
+	ID     string      `json:"event_id"` // events with the same id wil be treated the same, and the highest index one is retained
+	Name   string      `json:"name"`
+	Index  int64       `json:"index"` // priority of the event, used for sorting
+	Status EventStatus `json:"status"`
+	Info   string      `json:"info"` // extra information (can be error or success)
+}
+
+// StreamEventForm is used to send event data to the api
+type StreamEventForm struct {
+	Event     `json:"event"`
+	Token     string `json:"token"`
+	ClusterID uint   `json:"cluster_id"`
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
 // NewClient constructs a new client based on a set of options
 func NewClient(baseURL string, cookieFileName string) *Client {
 	home := homedir.HomeDir()
@@ -121,6 +149,44 @@ func (c *Client) saveCookie(cookie *http.Cookie) error {
 	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
 }
 
+// StreamEvent sends an event from deployment to the api
+func (c *Client) StreamEvent(event Event, projID uint, clusterID uint, name string, namespace string) error {
+	form := StreamEventForm{
+		Event:     event,
+		ClusterID: clusterID,
+		Name:      name,
+		Namespace: namespace,
+	}
+
+	body := new(bytes.Buffer)
+	err := json.NewEncoder(body).Encode(form)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/releases/%s/steps", c.BaseURL, projID, name),
+		body,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(context.Background())
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+		return err
+	}
+
+	return nil
+}
+
 // retrieves single cookie from file
 func (c *Client) getCookie() (*http.Cookie, error) {
 	data, err := ioutil.ReadFile(c.CookieFilePath)

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

@@ -60,6 +60,8 @@ type ServerConf struct {
 	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
 	IngressCluster     string `env:"INGRESS_CLUSTER"`
 	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
+
+	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 134 - 3
cli/cmd/deploy.go

@@ -202,6 +202,7 @@ var localPath string
 var tag string
 var dockerfile string
 var method string
+var stream bool
 
 func init() {
 	rootCmd.AddCommand(updateCmd)
@@ -267,6 +268,13 @@ func init() {
 		"the build method to use (\"docker\" or \"pack\")",
 	)
 
+	updateCmd.PersistentFlags().BoolVar(
+		&stream,
+		"stream",
+		false,
+		"stream update logs to porter dashboard",
+	)
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -392,9 +400,29 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	// build the deployment
 	color.New(color.FgGreen).Println("Building docker image for", app)
 
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "build",
+			Name:   "Build",
+			Index:  100,
+			Status: api.EventStatusInProgress,
+			Info:   "",
+		})
+	}
+
 	buildEnv, err := updateAgent.GetBuildEnv()
 
 	if err != nil {
+		if stream {
+			// another concern: is it safe to ignore the error here?
+			updateAgent.StreamEvent(api.Event{
+				ID:     "build",
+				Name:   "Build",
+				Index:  110,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
@@ -402,36 +430,139 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	err = updateAgent.SetBuildEnv(buildEnv)
 
 	if err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "build",
+				Name:   "Build",
+				Index:  120,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
+		return err
+	}
+
+	if err := updateAgent.Build(); err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "build",
+				Name:   "Build",
+				Index:  130,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
-	return updateAgent.Build()
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "build",
+			Name:   "Build",
+			Index:  140,
+			Status: api.EventStatusSuccess,
+			Info:   "",
+		})
+	}
+
+	return nil
 }
 
 func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
 	color.New(color.FgGreen).Println("Pushing new image for", app)
 
-	return updateAgent.Push()
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "push",
+			Name:   "Push",
+			Index:  200,
+			Status: api.EventStatusInProgress,
+			Info:   "",
+		})
+	}
+
+	if err := updateAgent.Push(); err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "push",
+				Name:   "Push",
+				Index:  210,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
+		return err
+	}
+
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "push",
+			Name:   "Push",
+			Index:  220,
+			Status: api.EventStatusSuccess,
+			Info:   "",
+		})
+	}
+
+	return nil
 }
 
 func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
-	color.New(color.FgGreen).Println("Calling webhook for", app)
+	color.New(color.FgGreen).Println("Upgrading configuration for", app)
+
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "upgrade",
+			Name:   "Upgrade",
+			Index:  300,
+			Status: api.EventStatusInProgress,
+			Info:   "",
+		})
+	}
 
 	// read the values if necessary
 	valuesObj, err := readValuesFile()
 
 	if err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "upgrade",
+				Name:   "Upgrade",
+				Index:  310,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 
 	if err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "upgrade",
+				Name:   "Upgrade",
+				Index:  320,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "upgrade",
+			Name:   "Upgrade",
+			Index:  330,
+			Status: api.EventStatusSuccess,
+			Info:   "",
+		})
+	}
+
 	color.New(color.FgGreen).Println("Successfully updated", app)
 
 	return nil

+ 17 - 5
cli/cmd/deploy/deploy.go

@@ -3,7 +3,6 @@ package deploy
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -110,7 +109,7 @@ func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAg
 			deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
 		}
 
-		if deployAgent.opts.LocalDockerfile == "" {
+		if deployAgent.dockerfilePath == "" && deployAgent.opts.LocalDockerfile == "" {
 			deployAgent.dockerfilePath = "./Dockerfile"
 		}
 	}
@@ -279,6 +278,11 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
+	// if this is a job chart, set "paused" to false so that the job doesn't run
+	if d.release.Chart.Name() == "job" {
+		mergedValues["paused"] = true
+	}
+
 	// if the current image section is hello-porter, the image must be overriden
 	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
 		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
@@ -323,10 +327,8 @@ func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error)
 	envConfig, err := getNestedMap(config, "container", "env", "normal")
 
 	// if the field is not found, set envConfig to an empty map; this release has no env set
-	if e := (&NestedMapFieldNotFoundError{}); errors.As(err, &e) {
+	if err != nil {
 		envConfig = make(map[string]interface{})
-	} else if err != nil {
-		return nil, fmt.Errorf("could not get environment variables from release: %s", err.Error())
 	}
 
 	mapEnvConfig := make(map[string]string)
@@ -395,6 +397,12 @@ func (d *DeployAgent) pullCurrentReleaseImage() error {
 		return fmt.Errorf("could not cast image.tag field to string")
 	}
 
+	// if image repo is a hello-porter image, skip
+	if d.imageRepo == "public.ecr.aws/o1j4x7p4/hello-porter" ||
+		d.imageRepo == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
+		return nil
+	}
+
 	fmt.Printf("attempting to pull image: %s\n", fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
 
 	return d.agent.PullImage(fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
@@ -439,6 +447,10 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	return res, nil
 }
 
+func (d *DeployAgent) StreamEvent(event api.Event) error {
+	return d.client.StreamEvent(event, d.opts.ProjectID, d.opts.ClusterID, d.release.Name, d.release.Namespace)
+}
+
 type NestedMapFieldNotFoundError struct {
 	Field string
 }

+ 1 - 2
cli/cmd/login/server.go

@@ -127,8 +127,7 @@ const successScreen = `
     <meta charset='UTF-8'>
     <title>Porter | Login</title>
     <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
-    <link href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600" rel="stylesheet">
-    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600" rel="stylesheet">
     <link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
     <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@600&display=swap" rel="stylesheet">
     <style>

+ 128 - 46
cli/cmd/run.go

@@ -2,6 +2,7 @@ package cmd
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -14,6 +15,8 @@ import (
 	"github.com/spf13/cobra"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/fields"
+	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/kubectl/pkg/util/term"
 
 	"k8s.io/apimachinery/pkg/runtime"
@@ -254,8 +257,10 @@ func executeRun(config *PorterRunSharedConfig, namespace, name, container string
 		Out: os.Stdout,
 		Raw: true,
 	}
+	size := t.GetSize()
+	sizeQueue := t.MonitorSize(size)
 
-	fn := func() error {
+	return t.Safe(func() error {
 		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
 
 		if err != nil {
@@ -267,14 +272,10 @@ func executeRun(config *PorterRunSharedConfig, namespace, name, container string
 			Stdout: os.Stdout,
 			Stderr: os.Stderr,
 			Tty:    true,
-		})
-	}
-
-	if err := t.Safe(fn); err != nil {
-		return err
-	}
 
-	return nil
+			TerminalSizeQueue: sizeQueue,
+		})
+	})
 }
 
 func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, container string, args []string) error {
@@ -285,80 +286,161 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 	}
 
 	newPod, err := createPodFromExisting(config, existing, args)
+	podName := newPod.ObjectMeta.Name
 
-	if err != nil {
-		return err
+	// delete the ephemeral pod no matter what
+	defer deletePod(config, podName, namespace)
+
+	color.New(color.FgYellow).Printf("Waiting for pod %s to be ready...", podName)
+	if err = waitForPod(config, newPod); err != nil {
+		color.New(color.FgRed).Println("failed")
+		return handlePodAttachError(err, config, namespace, podName, container)
 	}
 
-	podName := newPod.ObjectMeta.Name
+	// refresh pod info for latest status
+	newPod, err = config.Clientset.CoreV1().
+		Pods(newPod.Namespace).
+		Get(context.Background(), newPod.Name, metav1.GetOptions{})
+
+	// pod exited while we were waiting.  maybe an error maybe not.
+	// we dont know if the user wanted an interactive shell or not.
+	// if it was an error the logs hopefully say so.
+	if isPodExited(newPod) {
+		color.New(color.FgGreen).Println("complete!")
+		var writtenBytes int64
+		writtenBytes, _ = pipePodLogsToStdout(config, namespace, podName, container, false)
+
+		if verbose || writtenBytes == 0 {
+			color.New(color.FgYellow).Println("Could not get logs. Pod events:\n")
+			pipeEventsToStdout(config, namespace, podName, container, false)
+		}
+		return nil
+	}
+	color.New(color.FgGreen).Println("ready!")
+
+	color.New(color.FgYellow).Println("Attempting connection to the container. If you don't see a command prompt, try pressing enter.")
+	req := config.RestClient.Post().
+		Resource("pods").
+		Name(podName).
+		Namespace("default").
+		SubResource("attach")
+
+	req.Param("stdin", "true")
+	req.Param("stdout", "true")
+	req.Param("tty", "true")
+	req.Param("container", container)
 
 	t := term.TTY{
 		In:  os.Stdin,
 		Out: os.Stdout,
 		Raw: true,
 	}
+	size := t.GetSize()
+	sizeQueue := t.MonitorSize(size)
 
-	fn := func() error {
-		req := config.RestClient.Post().
-			Resource("pods").
-			Name(podName).
-			Namespace("default").
-			SubResource("attach")
-
-		req.Param("stdin", "true")
-		req.Param("stdout", "true")
-		req.Param("tty", "true")
-		req.Param("container", container)
-
+	if err = t.Safe(func() error {
 		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
-
 		if err != nil {
 			return err
 		}
-
 		return exec.Stream(remotecommand.StreamOptions{
 			Stdin:  os.Stdin,
 			Stdout: os.Stdout,
 			Stderr: os.Stderr,
 			Tty:    true,
+
+			TerminalSizeQueue: sizeQueue,
 		})
+	}); err != nil {
+		// ugly way to catch no TTY errors, such as when running command "echo \"hello\""
+		return handlePodAttachError(err, config, namespace, podName, container)
+	}
+
+	if verbose {
+		color.New(color.FgYellow).Println("Pod events:\n")
+		pipeEventsToStdout(config, namespace, podName, container, false)
 	}
 
-	color.New(color.FgYellow).Println("Attempting connection to the container, this may take up to 10 seconds. If you don't see a command prompt, try pressing enter.")
+	return err
+}
 
-	for i := 0; i < 5; i++ {
-		err = t.Safe(fn)
+func waitForPod(config *PorterRunSharedConfig, pod *v1.Pod) error {
+	var (
+		w   watch.Interface
+		err error
+		ok  bool
+	)
+	// immediately after creating a pod, the API may return a 404. heuristically 1
+	// second seems to be plenty.
+	watchRetries := 3
+	for i := 0; i < watchRetries; i++ {
+		selector := fields.OneTermEqualSelector("metadata.name", pod.Name).String()
+		w, err = config.Clientset.CoreV1().
+			Pods(pod.Namespace).
+			Watch(context.Background(), metav1.ListOptions{FieldSelector: selector})
 
 		if err == nil {
 			break
 		}
-
-		time.Sleep(2 * time.Second)
-
+		time.Sleep(time.Second)
 	}
-
-	// ugly way to catch no TTY errors, such as when running command "echo \"hello\""
 	if err != nil {
-		color.New(color.FgYellow).Println("Could not open a shell to this container. Container logs:")
+		return err
+	}
+	defer w.Stop()
+	for {
+		select {
+		case <-time.Tick(time.Second):
+			// poll every second in case we already missed the ready event while
+			// creating the listener.
+			pod, err = config.Clientset.CoreV1().
+				Pods(pod.Namespace).
+				Get(context.Background(), pod.Name, metav1.GetOptions{})
+			if isPodReady(pod) || isPodExited(pod) {
+				return nil
+			}
+		case evt := <-w.ResultChan():
+			pod, ok = evt.Object.(*v1.Pod)
+			if !ok {
+				return fmt.Errorf("unexpected object type: %T", evt.Object)
+			}
+			if isPodReady(pod) || isPodExited(pod) {
+				return nil
+			}
+		case <-time.After(time.Second * 10):
+			return errors.New("timed out waiting for pod")
+		}
+	}
+}
 
-		var writtenBytes int64
+func isPodReady(pod *v1.Pod) bool {
+	ready := false
+	conditions := pod.Status.Conditions
+	for i := range conditions {
+		if conditions[i].Type == v1.PodReady {
+			ready = pod.Status.Conditions[i].Status == v1.ConditionTrue
+		}
+	}
+	return ready
+}
 
-		writtenBytes, err = pipePodLogsToStdout(config, namespace, podName, container, false)
+func isPodExited(pod *v1.Pod) bool {
+	return pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed
+}
 
-		if verbose || writtenBytes == 0 {
-			color.New(color.FgYellow).Println("Could not get logs. Pod events:")
+func handlePodAttachError(err error, config *PorterRunSharedConfig, namespace, podName, container string) error {
+	if verbose {
+		color.New(color.FgYellow).Printf("Error: %s\n", err)
+	}
+	color.New(color.FgYellow).Println("Could not open a shell to this container. Container logs:\n")
 
-			err = pipeEventsToStdout(config, namespace, podName, container, false)
-		}
-	} else if verbose {
-		color.New(color.FgYellow).Println("Pod events:")
+	var writtenBytes int64
+	writtenBytes, _ = pipePodLogsToStdout(config, namespace, podName, container, false)
 
+	if verbose || writtenBytes == 0 {
+		color.New(color.FgYellow).Println("Could not get logs. Pod events:\n")
 		pipeEventsToStdout(config, namespace, podName, container, false)
 	}
-
-	// delete the ephemeral pod
-	deletePod(config, podName, namespace)
-
 	return err
 }
 

+ 15 - 0
cmd/app/main.go

@@ -33,6 +33,21 @@ func main() {
 		log.Fatal("Config loading failed: ", err)
 	}
 
+	if config.RedisConf.Enabled {
+		redis, err := adapter.NewRedisClient(&appConf.Redis)
+
+		if err != nil {
+			logger.Fatal().Err(err).Msg("")
+			return
+		}
+
+		prov.InitGlobalStream(redis)
+
+		errorChan := make(chan error)
+
+		go prov.GlobalStreamListener(redis, *repo, a.AnalyticsClient, errorChan)
+	}
+
 	appRouter := router.NewAPIRouter(config)
 
 	address := fmt.Sprintf(":%d", config.ServerConf.Port)

+ 6 - 2
dashboard/babel.config.json

@@ -1,4 +1,8 @@
 {
   "plugins": ["lodash"],
-  "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
-}
+  "presets": [
+    "@babel/preset-env",
+    "@babel/preset-react",
+    "@babel/preset-typescript"
+  ]
+}

+ 15 - 9
dashboard/package-lock.json

@@ -3090,11 +3090,12 @@
       "dev": true
     },
     "@types/react": {
-      "version": "16.14.2",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.2.tgz",
-      "integrity": "sha512-BzzcAlyDxXl2nANlabtT4thtvbbnhee8hMmH/CcJrISDBVcJS1iOsP1f0OAgSdGE0MsY9tqcrb9YoZcOFv9dbQ==",
+      "version": "16.14.14",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.14.tgz",
+      "integrity": "sha512-uwIWDYW8LznHzEMJl7ag9St1RsK0gw/xaFZ5+uI1ZM1HndwUgmPH3/wQkSb87GkOVg7shUxnpNW8DcN0AzvG5Q==",
       "requires": {
         "@types/prop-types": "*",
+        "@types/scheduler": "*",
         "csstype": "^3.0.2"
       }
     },
@@ -3162,6 +3163,11 @@
         "@types/react": "*"
       }
     },
+    "@types/scheduler": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
+      "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
+    },
     "@types/semver": {
       "version": "7.3.5",
       "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.5.tgz",
@@ -5638,9 +5644,9 @@
       }
     },
     "dom-helpers": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz",
-      "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
       "requires": {
         "@babel/runtime": "^7.8.7",
         "csstype": "^3.0.2"
@@ -9306,9 +9312,9 @@
       "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
     },
     "react-transition-group": {
-      "version": "4.4.1",
-      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
-      "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==",
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
+      "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
       "requires": {
         "@babel/runtime": "^7.5.5",
         "dom-helpers": "^5.0.1",

+ 2 - 1
dashboard/package.json

@@ -38,6 +38,7 @@
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-table": "^7.7.0",
+    "react-transition-group": "^4.4.2",
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "styled-components": "^5.2.0"
@@ -68,7 +69,7 @@
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
-    "@types/react": "^16.9.49",
+    "@types/react": "^16.14.14",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",

+ 1 - 1
dashboard/src/assets/GoogleIcon.tsx

@@ -8,7 +8,7 @@ type StateType = {};
 export default class GHIcon extends Component<PropsType, StateType> {
   render() {
     return (
-      <Svg width="46px" height="46px" viewBox="0 0 46 46">
+      <Svg width="46px" height="46px" viewBox="0 0 46 46" {...this.props}>
         <title>btn_google_light_normal_ios</title>
         <desc>Created with Sketch.</desc>
         <defs>

+ 4 - 0
dashboard/src/assets/Iconly/Bulk/Info Square.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.34 1.9998H7.67C4.28 1.9998 2 4.3798 2 7.9198V16.0898C2 19.6198 4.28 21.9998 7.67 21.9998H16.34C19.73 21.9998 22 19.6198 22 16.0898V7.9198C22 4.3798 19.73 1.9998 16.34 1.9998Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1247 8.1893C11.1247 8.6713 11.5157 9.0643 11.9947 9.0643C12.4877 9.0643 12.8797 8.6713 12.8797 8.1893C12.8797 7.7073 12.4877 7.3143 12.0047 7.3143C11.5197 7.3143 11.1247 7.7073 11.1247 8.1893ZM12.8697 11.3621C12.8697 10.8801 12.4767 10.4871 11.9947 10.4871C11.5127 10.4871 11.1197 10.8801 11.1197 11.3621V15.7821C11.1197 16.2641 11.5127 16.6571 11.9947 16.6571C12.4767 16.6571 12.8697 16.2641 12.8697 15.7821V11.3621Z" fill="white"/>
+</svg>

+ 56 - 0
dashboard/src/components/Banner.tsx

@@ -0,0 +1,56 @@
+import React from "react";
+import styled from "styled-components";
+
+import info from "assets/info.svg";
+import warning from "assets/warning.png";
+
+interface Props {
+  type?: string;
+  children: React.ReactNode;
+}
+
+const Banner: React.FC<Props> = ({ type, children }) => {
+  const renderIcon = () => {
+    if (type === "error" || type === "warning") {
+      return <i className="material-icons-round">warning</i>;
+    }
+    return <img src={info} />;
+  };
+
+  return (
+    <StyledBanner
+      color={type === "error" ? "#ff385d" : type === "warning" && "#f5cb42"}
+    >
+      {renderIcon()}
+      {children}
+    </StyledBanner>
+  );
+};
+
+export default Banner;
+
+const StyledBanner = styled.div<{ color?: string }>`
+  height: 40px;
+  width: 100%;
+  margin: 5px 0 10px;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  display: flex;
+  border: 1px solid ${(props) => props.color || "#ffffff00"};
+  border-radius: 8px;
+  padding-left: 14px;
+  color: ${(props) => props.color || "#ffffff"};
+  align-items: center;
+  background: #ffffff11;
+  > img {
+    margin-right: 10px;
+    width: 20px;
+  }
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  > a {
+    color: ${(props) => props.color || "#ffffff"};
+  }
+`;

+ 34 - 0
dashboard/src/components/Placeholder.tsx

@@ -0,0 +1,34 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  height?: string;
+  minHeight?: string;
+  children: React.ReactNode;
+}
+
+const Placeholder: React.FC<Props> = ({ height, minHeight, children }) => {
+  return (
+    <StyledPlaceholder height={height} minHeight={minHeight}>
+      {children}
+    </StyledPlaceholder>
+  );
+};
+
+export default Placeholder;
+
+const StyledPlaceholder = styled.div<{
+  height: string;
+  minHeight: string;
+}>`
+  width: 100%;
+  height: ${(props) => props.height || "100px"};
+  minheight: ${(props) => props.minHeight || ""};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+  border-radius: 5px;
+  background: #ffffff11;
+`;

+ 5 - 5
dashboard/src/components/form-components/CheckboxList.tsx

@@ -10,12 +10,12 @@ type PropsType = {
 
 const CheckboxList = ({ label, options, selected, setSelected }: PropsType) => {
   let onSelectOption = (option: { value: string; label: string }) => {
-    if (!selected.includes(option)) {
-      selected.push(option);
-      setSelected(selected);
+    const tmp = [...selected];
+    if (!tmp.includes(option)) {
+      setSelected([...tmp, option]);
     } else {
-      selected.splice(selected.indexOf(option), 1);
-      setSelected(selected);
+      tmp.splice(tmp.indexOf(option), 1);
+      setSelected(tmp);
     }
   };
 

+ 1 - 1
dashboard/src/components/porter-form/PorterForm.tsx

@@ -8,7 +8,7 @@ import {
   ResourceListField,
   Section,
   SelectField,
-  ServiceIPListField
+  ServiceIPListField,
 } from "./types";
 import TabRegion, { TabOption } from "../TabRegion";
 import Heading from "../form-components/Heading";

+ 16 - 7
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -220,23 +220,25 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
   };
 
   /*
-    Takes in old form data and changes it to use newer fields
+    Takes in old form data and changes it to use newer fields and assigns ids
     For example, number-input becomes input with a setting that makes it
     a number input
    */
   const restructureToNewFields = (data: PorterFormData) => {
     return {
       ...data,
-      tabs: data?.tabs?.map((tab) => {
+      tabs: data?.tabs?.map((tab, i) => {
         return {
           ...tab,
-          sections: tab.sections?.map((section) => {
+          sections: tab.sections?.map((section, j) => {
             return {
               ...section,
               contents: section.contents
-                ?.map((field: any) => {
+                ?.map((field: any, k) => {
+                  const id = `${i}-${j}-${k}`;
                   if (field?.type == "number-input") {
                     return {
+                      id,
                       ...field,
                       type: "input",
                       settings: {
@@ -247,6 +249,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   if (field?.type == "string-input") {
                     return {
+                      id,
                       ...field,
                       type: "input",
                       settings: {
@@ -257,6 +260,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   if (field?.type == "string-input-password") {
                     return {
+                      id,
                       ...field,
                       type: "input",
                       settings: {
@@ -267,6 +271,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   if (field?.type == "provider-select") {
                     return {
+                      id,
                       ...field,
                       type: "select",
                       settings: {
@@ -277,6 +282,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   if (field?.type == "env-key-value-array") {
                     return {
+                      id,
                       ...field,
                       type: "key-value-array",
                       secretOption: true,
@@ -288,7 +294,10 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                     };
                   }
                   if (field?.type == "variable") return null;
-                  return field;
+                  return {
+                    id,
+                    ...field,
+                  };
                 })
                 .filter((x) => x != null),
             };
@@ -321,7 +330,6 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                 contents: section.contents?.map((field, k) => {
                   return {
                     ...field,
-                    id: `${i}-${j}-${k}`,
                   };
                 }),
               };
@@ -412,7 +420,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     data?.tabs?.map((tab) =>
       tab.sections?.map((section) =>
         section.contents?.map((field) => {
-          if (finalFunctions[field?.type])
+          if (finalFunctions[field?.type]) {
             varList.push(
               finalFunctions[field?.type](
                 state.variables,
@@ -421,6 +429,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                 context
               )
             );
+          }
         })
       )
     );

+ 57 - 20
dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -1,17 +1,33 @@
 import React from "react";
 import styled from "styled-components";
-import { ArrayInputField, ArrayInputFieldState, GetFinalVariablesFunction } from "../types";
+import {
+  ArrayInputField,
+  ArrayInputFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
 import useFormField from "../hooks/useFormField";
+import { hasSetValue } from "../utils";
+
+// this is used to set validation for the below form component in case
+// input validation needs to get more complicated in the future
+const validateArray = (arr: any[]) => {
+  return arr.some((x) => x);
+};
 
 const ArrayInput: React.FC<ArrayInputField> = (props) => {
-  const { state, variables, setVars } = useFormField<ArrayInputFieldState>(
-    props.id,
-    {
-      initVars: {
-        [props.variable]: props.value && props.value[0] ? props.value[0] : [],
-      },
-    }
-  );
+  const {
+    state,
+    variables,
+    setVars,
+    setValidation,
+  } = useFormField<ArrayInputFieldState>(props.id, {
+    initVars: {
+      [props.variable]: hasSetValue(props) ? props.value[0] : [],
+    },
+    initValidation: {
+      validated: validateArray(hasSetValue(props) ? props.value[0] : []),
+    },
+  });
 
   if (state == undefined) return <></>;
 
@@ -21,10 +37,17 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
         <DeleteButton
           onClick={() => {
             setVars((prev) => {
+              const val = prev[props.variable]
+                .slice(0, i)
+                .concat(prev[props.variable].slice(i + 1));
+              setValidation((prev) => {
+                return {
+                  ...prev,
+                  validated: validateArray(val),
+                };
+              });
               return {
-                [props.variable]: prev[props.variable]
-                  .slice(0, i)
-                  .concat(prev[props.variable].slice(i + 1)),
+                [props.variable]: val,
               };
             });
           }}
@@ -48,12 +71,19 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
                 onChange={(e: any) => {
                   e.persist();
                   setVars((prev) => {
+                    const val = prev[props.variable]?.map(
+                      (t: string, j: number) => {
+                        return i == j ? e.target.value : t;
+                      }
+                    );
+                    setValidation((prev) => {
+                      return {
+                        ...prev,
+                        validated: validateArray(val),
+                      };
+                    });
                     return {
-                      [props.variable]: prev[props.variable]?.map(
-                        (t: string, j: number) => {
-                          return i == j ? e.target.value : t;
-                        }
-                      ),
+                      [props.variable]: val,
                     };
                   });
                 }}
@@ -69,7 +99,10 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
 
   return (
     <StyledInputArray>
-      <Label>{props.label}</Label>
+      <Label>
+        {props.label}
+        {props.required && <Required>{" *"}</Required>}
+      </Label>
       {variables[props.variable] === 0 ? (
         <></>
       ) : (
@@ -96,10 +129,10 @@ export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
   vars,
   props: ArrayInputField
 ) => {
-  return vars[props.variable]
+  return vars[props.variable] != undefined && vars[props.variable] != null
     ? {}
     : {
-        [props.variable]: props.value ? props.value[0] : [],
+        [props.variable]: hasSetValue(props) ? props.value[0] : [],
       };
 };
 
@@ -181,3 +214,7 @@ const StyledInputArray = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
 `;
+
+const Required = styled.span`
+  color: #fc4976;
+`;

+ 9 - 5
dashboard/src/components/porter-form/field-components/Checkbox.tsx

@@ -1,5 +1,9 @@
 import React from "react";
-import { CheckboxField, CheckboxFieldState, GetFinalVariablesFunction } from "../types";
+import {
+  CheckboxField,
+  CheckboxFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
 import CheckboxRow from "../../form-components/CheckboxRow";
 import useFormField from "../hooks/useFormField";
 
@@ -22,7 +26,7 @@ const Checkbox: React.FC<Props> = ({
       validated: !required,
     },
     initVars: {
-      [variable]: value ? value[0] : !!settings?.default,
+      [variable]: value ? value[0] : false,
     },
   });
 
@@ -56,9 +60,9 @@ export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
 ) => {
   // Read from revision values if unrendered (and therefore not in form state)
   if (vars[props.variable] === null || vars[props.variable] === undefined) {
-    if (props.value[0] === false) {
+    if (props.value && props.value[0] === false) {
       return { [props.variable]: false };
-    } else if (props.value[0] === true) {
+    } else if (props.value && props.value[0] === true) {
       return { [props.variable]: true };
     }
   }
@@ -71,6 +75,6 @@ export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
   }
 
   return {
-    [props.variable]: props.value ? props.value[0] : !!props.settings?.default,
+    [props.variable]: props.value ? props.value[0] : false,
   };
 };

+ 22 - 19
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -6,6 +6,7 @@ import {
   InputField,
   StringInputFieldState,
 } from "../types";
+import { hasSetValue } from "../utils";
 
 const clipOffUnit = (unit: string, x: string) => {
   if (typeof x === "string" && unit) {
@@ -16,17 +17,19 @@ const clipOffUnit = (unit: string, x: string) => {
   return x;
 };
 
-const Input: React.FC<InputField> = ({
-  id,
-  variable,
-  label,
-  required,
-  placeholder,
-  info,
-  settings,
-  isReadOnly,
-  value,
-}) => {
+const Input: React.FC<InputField> = (props) => {
+  const {
+    id,
+    variable,
+    label,
+    required,
+    placeholder,
+    info,
+    settings,
+    isReadOnly,
+    value,
+  } = props;
+
   const {
     state,
     variables,
@@ -34,14 +37,12 @@ const Input: React.FC<InputField> = ({
     setValidation,
   } = useFormField<StringInputFieldState>(id, {
     initValidation: {
-      validated: value
-        ? value[0] !== undefined && value[0] !== "" && value[0] != null
-        : settings?.default != undefined,
+      validated: hasSetValue(props),
     },
     initVars: {
-      [variable]: value
+      [variable]: hasSetValue(props)
         ? clipOffUnit(settings?.unit, value[0])
-        : settings?.default,
+        : undefined,
     },
   });
 
@@ -93,10 +94,12 @@ export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
   props: InputField
 ) => {
   const val =
-    vars[props.variable] ||
-    (props.value
+    vars[props.variable] != undefined && vars[props.variable] != null
+      ? vars[props.variable]
+      : hasSetValue(props)
       ? clipOffUnit(props.settings?.unit, props.value[0])
-      : props.settings?.default);
+      : undefined;
+
   return {
     [props.variable]:
       props.settings?.unit && !props.settings.omitUnitFromValue

+ 12 - 8
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -1,5 +1,9 @@
 import React from "react";
-import { GetFinalVariablesFunction, KeyValueArrayField, KeyValueArrayFieldState } from "../types";
+import {
+  GetFinalVariablesFunction,
+  KeyValueArrayField,
+  KeyValueArrayFieldState,
+} from "../types";
 import sliders from "../../../assets/sliders.svg";
 import upload from "../../../assets/upload.svg";
 import styled from "styled-components";
@@ -7,6 +11,7 @@ import useFormField from "../hooks/useFormField";
 import Modal from "../../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
+import { hasSetValue } from "../utils";
 
 interface Props extends KeyValueArrayField {
   id: string;
@@ -17,12 +22,11 @@ const KeyValueArray: React.FC<Props> = (props) => {
     props.id,
     {
       initState: {
-        values:
-          props.value && props.value[0]
-            ? (Object.entries(props.value[0])?.map(([k, v]) => {
-                return { key: k, value: v };
-              }) as any[])
-            : [],
+        values: hasSetValue(props)
+          ? (Object.entries(props.value[0])?.map(([k, v]) => {
+              return { key: k, value: v };
+            }) as any[])
+          : [],
         showEnvModal: false,
         showEditorModal: false,
       },
@@ -347,7 +351,7 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
 ) => {
   if (!state) {
     return {
-      [props.variable]: props.value ? props.value[0] : [],
+      [props.variable]: hasSetValue(props) ? props.value[0] : [],
     };
   }
 

+ 8 - 7
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -1,18 +1,21 @@
 import React, { useContext } from "react";
-import { GetFinalVariablesFunction, SelectField, SelectFieldState } from "../types";
+import {
+  GetFinalVariablesFunction,
+  SelectField,
+  SelectFieldState,
+} from "../types";
 import Selector from "../../Selector";
 import styled from "styled-components";
 import useFormField from "../hooks/useFormField";
 import { Context } from "../../../shared/Context";
+import { hasSetValue } from "../utils";
 
 const Select: React.FC<SelectField> = (props) => {
   const { currentCluster } = useContext(Context);
   const { variables, setVars } = useFormField<SelectFieldState>(props.id, {
     initVars: {
-      [props.variable]: props.value
+      [props.variable]: hasSetValue(props)
         ? props.value[0]
-        : props.settings.default
-        ? props.settings.default
         : props.settings.type == "provider"
         ? ({
             gke: "gcp",
@@ -68,10 +71,8 @@ export const getFinalVariablesForSelect: GetFinalVariablesFunction = (
   return vars[props.variable]
     ? {}
     : {
-        [props.variable]: props.value
+        [props.variable]: hasSetValue(props)
           ? props.value[0]
-          : props.settings.default
-          ? props.settings.default
           : props.settings.type == "provider"
           ? ({
               gke: "gcp",

+ 5 - 1
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -1,6 +1,10 @@
 import { useContext, useEffect } from "react";
 import { PorterFormContext } from "../PorterFormContextProvider";
-import { PorterFormFieldFieldState, PorterFormFieldValidationState, PorterFormVariableList } from "../types";
+import {
+  PorterFormFieldFieldState,
+  PorterFormFieldValidationState,
+  PorterFormVariableList,
+} from "../types";
 
 interface FormFieldData<T> {
   state: T;

+ 2 - 6
dashboard/src/components/porter-form/types.ts

@@ -18,7 +18,7 @@ export interface GenericInputField extends GenericField {
   settings?: any;
 
   // Read in value from Helm for existing revisions
-  value?: any[];
+  value?: [any] | [];
 }
 
 export interface HeadingField extends GenericField {
@@ -61,9 +61,7 @@ export interface InputField extends GenericInputField {
 export interface CheckboxField extends GenericInputField {
   type: "checkbox";
   label?: string;
-  settings?: {
-    default: boolean;
-  };
+  settings?: {};
 }
 
 export interface KeyValueArrayField extends GenericInputField {
@@ -88,11 +86,9 @@ export interface SelectField extends GenericInputField {
     | {
         type: "normal";
         options: { value: string; label: string }[];
-        default?: string;
       }
     | {
         type: "provider";
-        default?: string;
       };
   width: string;
   label?: string;

+ 5 - 0
dashboard/src/components/porter-form/utils.ts

@@ -0,0 +1,5 @@
+import { GenericInputField } from "./types";
+
+export const hasSetValue = (field: GenericInputField) => {
+  return field.value && field.value.length != 0 && field.value[0] != null;
+};

+ 2 - 15
dashboard/src/index.html

@@ -81,17 +81,8 @@
       content="Kubernetes powered PaaS that runs in your own cloud."
     />
     <meta property="og:url" content="https://porter.run" />
-
-    <link
-      href="https://fonts.googleapis.com/icon?family=Material+Icons"
-      rel="stylesheet"
-    />
-    <link
-      href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600"
-      rel="stylesheet"
-    />
     <link
-      href="https://fonts.googleapis.com/icon?family=Material+Icons"
+      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
       rel="stylesheet"
     />
     <link
@@ -99,11 +90,7 @@
       rel="stylesheet"
     />
     <link
-      href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined"
-      rel="stylesheet"
-    />
-    <link
-      href="https://fonts.googleapis.com/icon?family=Roboto+Mono"
+      href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
       rel="stylesheet"
     />
   </head>

+ 3 - 2
dashboard/src/main/auth/Login.tsx

@@ -306,7 +306,7 @@ const IconWrapper = styled.div`
 
 const Icon = styled.img`
   height: 18px;
-  margin: 14px;
+  margin: 0 10px;
 `;
 
 const StyledGoogleIcon = styled(GoogleIcon)`
@@ -314,9 +314,10 @@ const StyledGoogleIcon = styled(GoogleIcon)`
   height: 38px;
 `;
 
-const OAuthButton = styled.div`
+const OAuthButton = styled.button`
   width: 200px;
   height: 30px;
+  border: 0;
   display: flex;
   background: #ffffff;
   align-items: center;

+ 18 - 1
dashboard/src/main/home/Home.tsx

@@ -12,6 +12,7 @@ import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import ClusterDashboard from "./cluster-dashboard/ClusterDashboard";
 import Dashboard from "./dashboard/Dashboard";
+import WelcomeForm from "./WelcomeForm";
 import Integrations from "./integrations/Integrations";
 import Templates from "./launch/Launch";
 import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
@@ -65,6 +66,7 @@ type StateType = {
 
   // Track last project id for refreshing clusters on project change
   prevProjectId: number | null;
+  showWelcomeForm: boolean;
 };
 
 // TODO: Handle cluster connected but with some failed infras (no successful set)
@@ -78,6 +80,7 @@ class Home extends Component<PropsType, StateType> {
     sidebarReady: false,
     handleDO: false,
     ghRedirect: false,
+    showWelcomeForm: true,
   };
 
   // TODO: Refactor and prevent flash + multiple reload
@@ -385,6 +388,20 @@ class Home extends Component<PropsType, StateType> {
             <Icon src={discordLogo} />
             Join Our Discord
           </DiscordButton>
+          {(this.context?.capabilities?.version === "production" ||
+            this.context?.capabilities?.version === "staging") &&
+            this.state.showWelcomeForm &&
+            localStorage.getItem("welcomed") != "true" && (
+              <>
+                <WelcomeForm
+                  closeForm={() => this.setState({ showWelcomeForm: false })}
+                />
+                <Navbar
+                  logOut={this.props.logOut}
+                  currentView={this.props.currentRoute} // For form feedback
+                />
+              </>
+            )}
         </>
       );
     }
@@ -619,7 +636,7 @@ const StyledHome = styled.div`
 
 const DiscordButton = styled.a`
   position: absolute;
-  z-index: 100;
+  z-index: 1;
   text-decoration: none;
   bottom: 17px;
   display: flex;

+ 305 - 0
dashboard/src/main/home/WelcomeForm.tsx

@@ -0,0 +1,305 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import { CSSTransition } from "react-transition-group";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+
+type Props = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  active: boolean;
+};
+
+const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
+  const context = useContext(Context);
+  const [active, setActive] = useState(true);
+  const [isCompany, setIsCompany] = useState(false);
+  const [role, setRole] = useState("unspecified");
+  const [company, setCompany] = useState("");
+
+  const submitForm = () => {
+    api
+      .getWelcome(
+        "<token>",
+        {
+          email: context.user && context.user.email,
+          isCompany,
+          company,
+          role,
+        },
+        {}
+      )
+      .then(() => {
+        localStorage.setItem("welcomed", "true");
+        setActive(false);
+      })
+      .catch((err) => console.log(err));
+  };
+
+  const renderContents = () => {
+    if (isCompany) {
+      return (
+        <FadeWrapper>
+          <Title>Welcome to Porter</Title>
+          <Subtitle>Just two things before getting started.</Subtitle>
+          <SubtitleAlt>
+            <Num>1</Num> What is your company name? *
+          </SubtitleAlt>
+          <Input
+            placeholder="ex: Acme"
+            value={company}
+            onChange={(e: any) => setCompany(e.target.value)}
+          />
+          <SubtitleAlt>
+            <Num>2</Num> What is your role? *
+          </SubtitleAlt>
+          <RadioButton
+            onClick={() => setRole("founder")}
+            selected={role === "founder"}
+          >
+            <i className="material-icons-round">
+              {role === "founder" ? "check_box" : "check_box_outline_blank"}
+            </i>{" "}
+            Founder
+          </RadioButton>
+          <RadioButton
+            onClick={() => setRole("developer")}
+            selected={role === "developer"}
+          >
+            <i className="material-icons-round">
+              {role === "developer" ? "check_box" : "check_box_outline_blank"}
+            </i>{" "}
+            Developer
+          </RadioButton>
+          <RadioButton
+            onClick={() => setRole("devops")}
+            selected={role === "devops"}
+          >
+            <i className="material-icons-round">
+              {role === "devops" ? "check_box" : "check_box_outline_blank"}
+            </i>{" "}
+            DevOps
+          </RadioButton>
+
+          <Submit
+            isDisabled={!company || role === "unspecified"}
+            onClick={() => company && role !== "unspecified" && submitForm()}
+          >
+            <i className="material-icons-round">check</i> Done
+          </Submit>
+        </FadeWrapper>
+      );
+    }
+    return (
+      <>
+        <Title>Welcome to Porter</Title>
+        <Subtitle delay="0.7s">I am interested in using Porter as:</Subtitle>
+        <Option onClick={() => setIsCompany(true)}>
+          <i className="material-icons-round">people</i> A Company
+        </Option>
+        <Option onClick={() => submitForm()}>
+          <i className="material-icons-round">person</i> An Individual
+        </Option>
+      </>
+    );
+  };
+
+  return (
+    <CSSTransition
+      in={active}
+      timeout={500}
+      classNames="alert"
+      unmountOnExit
+      onEnter={() => setActive(true)}
+      onExited={() => setActive(false)}
+    >
+      <StyledWelcomeForm>
+        <div>
+          {renderContents()}
+          <br />
+          <br />
+        </div>
+      </StyledWelcomeForm>
+    </CSSTransition>
+  );
+};
+
+export default WelcomeForm;
+
+const Circle = styled.div`
+  width: 13px;
+  height: 13px;
+  border-radius: 20px;
+  background: #ffffff11;
+  margin-right: 12px;
+  border: 1px solid #aaaabb;
+`;
+
+const FadeWrapper = styled.div`
+  background: #202227;
+  opacity: 0;
+  animation: fadeIn 0.7s 0s;
+  animation-fill-mode: forwards;
+`;
+
+const Num = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  width: 30px;
+  height: 30px;
+  border: 1px solid #ffffff;
+`;
+
+const Option = styled.div`
+  width: 500px;
+  max-width: 80vw;
+  height: 50px;
+  background: #ffffff22;
+  display: flex;
+  align-items: center;
+  margin-top: 15px;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  padding-left: 15px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff44;
+  }
+
+  > i {
+    font-size: 20px;
+    margin-right: 12px;
+    color: #aaaabb;
+  }
+
+  opacity: 0;
+  animation: slideIn 0.7s 1.3s;
+  animation-fill-mode: forwards;
+
+  @keyframes slideIn {
+    from {
+      opacity: 0;
+      transform: translateX(-30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateX(0);
+    }
+  }
+`;
+
+const Submit = styled(Option)<{ isDisabled: boolean }>`
+  border: 0;
+  opacity: 0;
+  animation: fadeIn 0.7s 0.5s;
+  animation-fill-mode: forwards;
+  margin-top: 35px;
+  cursor: ${(props) => (props.isDisabled ? "not-allowed" : "pointer")};
+  background: ${(props) => (props.isDisabled ? "#aaaabb" : "#616FEEcc")};
+  :hover {
+    filter: ${(props) => (props.isDisabled ? "" : "brightness(130%)")};
+    background: ${(props) => (props.isDisabled ? "#aaaabb" : "#616FEEcc")};
+  }
+
+  > i {
+    color: #ffffff;
+  }
+`;
+
+const RadioButton = styled(Option)<{ selected: boolean }>`
+  opacity: 0;
+  background: ${(props) => (props.selected ? "#ffffff44" : "#ffffff22")};
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+
+  > div {
+    background: ${(props) => (props.selected ? "#ffffff44" : "")};
+  }
+`;
+
+const Input = styled.input`
+  width: 500px;
+  max-width: 80vw;
+  height: 50px;
+  background: #ffffff22;
+  font-size: 18px;
+  display: flex;
+  align-items: center;
+  margin-top: 0px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  padding-left: 15px;
+  margin-bottom: 40px;
+
+  opacity: 0;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+`;
+
+const Subtitle = styled.div<{ delay?: string }>`
+  margin: 20px 0 30px;
+  color: #aaaabb;
+
+  opacity: 0;
+  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
+  animation-fill-mode: forwards;
+`;
+
+const SubtitleAlt = styled(Subtitle)`
+  margin: -5px 0 30px;
+  color: white;
+  display: flex;
+  align-items: center;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+`;
+
+const Title = styled.div`
+  color: white;
+
+  font-size: 26px;
+  margin-bottom: 5px;
+  display: flex;
+  align-items: center;
+
+  opacity: 0;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledWelcomeForm = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  background: #202227;
+
+  &.alert-exit {
+    opacity: 1;
+  }
+  &.alert-exit-active {
+    opacity: 0;
+    transform: translateY(-100px);
+    transition: opacity 500ms, transform 1000ms;
+  }
+`;

+ 26 - 12
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -5,7 +5,11 @@ import monoweb from "assets/monoweb.png";
 import { Route, Switch } from "react-router-dom";
 
 import { Context } from "shared/Context";
+<<<<<<< HEAD
 import { ChartType, ClusterType } from "shared/types";
+=======
+import { ChartType, ClusterType, JobStatusType } from "shared/types";
+>>>>>>> master
 import {
   getQueryParam,
   PorterUrl,
@@ -25,6 +29,7 @@ import api from "shared/api";
 import DashboardRoutes from "./dashboard/Routes";
 import GuardedRoute from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import LastRunStatusSelector from "./LastRunStatusSelector";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -36,6 +41,7 @@ type PropsType = RouteComponentProps &
 type StateType = {
   namespace: string;
   sortType: string;
+  lastRunStatus: JobStatusType | null;
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
 };
@@ -47,6 +53,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     sortType: localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       : "Newest",
+    lastRunStatus: null as null,
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
   };
@@ -129,7 +136,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
     return (
       <>
-        <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+        <ControlRow>
           {isAuthorizedToAdd && (
             <Button
               onClick={() =>
@@ -140,10 +147,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </Button>
           )}
           <SortFilterWrapper>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-            />
+            {currentView === "jobs" && (
+              <LastRunStatusSelector
+                lastRunStatus={this.state.lastRunStatus}
+                setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                  this.setState({ lastRunStatus });
+                }}
+              />
+            )}
             <NamespaceSelector
               setNamespace={(namespace) =>
                 this.setState({ namespace }, () => {
@@ -154,12 +165,17 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               }
               namespace={this.state.namespace}
             />
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
           </SortFilterWrapper>
         </ControlRow>
 
         <ChartList
           currentView={currentView}
           currentCluster={currentCluster}
+          lastRunStatus={this.state.lastRunStatus}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
         />
@@ -238,12 +254,8 @@ const Br = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
-    if (props.hasMultipleChilds) {
-      return "space-between";
-    }
-    return "flex-end";
-  }};
+  margin-left: auto;
+  justify-content: space-between;
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;
@@ -388,7 +400,9 @@ const Img = styled.img`
 `;
 
 const SortFilterWrapper = styled.div`
-  width: 468px;
   display: flex;
   justify-content: space-between;
+  > div:not(:first-child) {
+    margin-left: 30px;
+  }
 `;

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

@@ -0,0 +1,61 @@
+import React from "react";
+import styled from "styled-components";
+
+import Selector from "components/Selector";
+import { JobStatusType } from "shared/types";
+
+type PropsType = {
+  lastRunStatus: JobStatusType;
+  setLastRunStatus: (lastRunStatus: JobStatusType) => void;
+};
+
+const LastRunStatusSelector = (props: PropsType) => {
+  const options = [
+    {
+      label: "All",
+      value: null,
+    },
+  ].concat(
+    Object.entries(JobStatusType).map((status) => ({
+      label: status[0],
+      value: status[1],
+    }))
+  );
+
+  return (
+    <StyledLastRunStatusSelector>
+      <Label>
+        <i className="material-icons">filter_alt</i>
+        Last Run Status
+      </Label>
+      <Selector
+        activeValue={props.lastRunStatus}
+        setActiveValue={props.setLastRunStatus}
+        options={options}
+        dropdownLabel="Last Run Status"
+        width="150px"
+        dropdownWidth="230px"
+        closeOverlay={true}
+      />
+    </StyledLastRunStatusSelector>
+  );
+};
+
+export default LastRunStatusSelector;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledLastRunStatusSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -101,7 +101,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
     return (
       <StyledNamespaceSelector>
         <Label>
-          <i className="material-icons">filter_alt</i> Filter
+          <i className="material-icons">filter_alt</i> Namespace
         </Label>
         <Selector
           activeValue={this.props.namespace}

+ 42 - 42
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -2,46 +2,35 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import { useHistory, useLocation, useRouteMatch } from "react-router";
 
-import { ChartType, StorageType } from "shared/types";
+import {
+  ChartType,
+  JobStatusType,
+  JobStatusWithTimeType,
+  StorageType,
+} from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
-import { useWebsockets } from "shared/hooks/useWebsockets";
 import api from "shared/api";
 
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
-  isJob: boolean;
-  release: any;
-};
-
-type JobStatusType = {
-  status: "succeeded" | "running" | "failed";
-  start_time: string;
+  jobStatus: JobStatusWithTimeType;
 };
 
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
-  isJob,
-  release,
+  jobStatus,
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
-  const [jobStatus, setJobStatus] = useState<JobStatusType>(null);
   const context = useContext(Context);
   const location = useLocation();
   const history = useHistory();
   const match = useRouteMatch();
 
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeAllWebsockets,
-    closeWebsocket,
-  } = useWebsockets();
-
   const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
@@ -78,6 +67,7 @@ const Chart: React.FunctionComponent<Props> = ({
     getControllerForChart(chart);
   }, [chart]);
 
+<<<<<<< HEAD
   const setupWebsocket = (kind: string) => {
     const { currentProject, currentCluster } = context;
 
@@ -131,6 +121,8 @@ const Chart: React.FunctionComponent<Props> = ({
     return () => closeAllWebsockets();
   }, [isJob]);
 
+=======
+>>>>>>> master
   const readableDate = (s: string) => {
     const ts = new Date(s);
     const date = ts.toLocaleDateString();
@@ -161,7 +153,9 @@ const Chart: React.FunctionComponent<Props> = ({
         let urlParams = new URLSearchParams(location.search);
         let cluster = urlParams.get("cluster");
         let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
-        pushFiltered({ location, history }, route, ["project_id"]);
+        pushFiltered({ location, history }, route, ["project_id"], {
+          chart_revision: chart.version,
+        });
       }}
     >
       <Title>
@@ -177,9 +171,23 @@ const Chart: React.FunctionComponent<Props> = ({
             margin_left={"17px"}
           />
           <LastDeployed>
-            <Dot>•</Dot> Last deployed{" "}
-            {readableDate(
-              release?.info?.last_deployed || chart.info.last_deployed
+            {jobStatus?.status ? (
+              <>
+                <Dot>•</Dot>
+                <JobStatus status={jobStatus.status}>
+                  {jobStatus.status === JobStatusType.Running
+                    ? "Started running"
+                    : `Last run ${jobStatus.status}`}{" "}
+                  at {readableDate(jobStatus.start_time)}
+                </JobStatus>
+              </>
+            ) : (
+              <>
+                <Dot>•</Dot>
+                <JobStatus>
+                  Last deployed {readableDate(chart.info.last_deployed)}
+                </JobStatus>
+              </>
             )}
           </LastDeployed>
         </InfoWrapper>
@@ -191,16 +199,7 @@ const Chart: React.FunctionComponent<Props> = ({
       </BottomWrapper>
 
       <TopRightContainer>
-        {isJob && jobStatus?.status && (
-          <>
-            <JobStatus status={jobStatus.status}>
-              Last run {jobStatus.status.toUpperCase()} at{" "}
-              {readableDate(jobStatus.start_time)}
-            </JobStatus>
-            <StatusDot>•</StatusDot>
-          </>
-        )}
-        <span>v{release?.version || chart.version}</span>
+        <span>v{chart.version}</span>
       </TopRightContainer>
     </StyledChart>
   );
@@ -329,17 +328,18 @@ const Title = styled.div`
   }
 `;
 
-const JobStatus = styled.span`
-  font-weight: bold;
-  ${(props: { status: string }) => `
+const JobStatus = styled.span<{ status?: JobStatusType }>`
+  font-size: 13px;
+  font-weight: ${(props) =>
+    props.status && props.status !== JobStatusType.Running ? "500" : ""};
+  ${(props) => `
   color: ${
-    props.status === "succeeded"
+    props.status === JobStatusType.Succeeded
       ? "rgb(56, 168, 138)"
-      : props.status === "failed"
-      ? "rgb(204, 61, 66)"
-      : "#aaaabb"
-  }
-`}
+      : props.status === JobStatusType.Failed
+      ? "#ff385d"
+      : "#aaaabb66"
+  }`}
 `;
 
 const StyledChart = styled.div`

+ 206 - 70
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -1,9 +1,16 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
+import _ from "lodash";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ChartType, ClusterType, StorageType } from "shared/types";
+import {
+  ChartType,
+  ClusterType,
+  JobStatusType,
+  JobStatusWithTimeType,
+  StorageType,
+} from "shared/types";
 import { PorterUrl } from "shared/routing";
 
 import Chart from "./Chart";
@@ -12,13 +19,19 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 
 type Props = {
   currentCluster: ClusterType;
+  lastRunStatus?: JobStatusType | null;
   namespace: string;
   // TODO Convert to enum
   sortType: string;
   currentView: PorterUrl;
 };
 
+interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
+  resource_version: number;
+}
+
 const ChartList: React.FunctionComponent<Props> = ({
+  lastRunStatus,
   namespace,
   sortType,
   currentView,
@@ -33,12 +46,17 @@ const ChartList: React.FunctionComponent<Props> = ({
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
-  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [jobStatus, setJobStatus] = useState<
+    Record<string, JobStatusWithTimeAndVersion>
+  >({});
   const [isLoading, setIsLoading] = useState(false);
   const [isError, setIsError] = useState(false);
 
   const context = useContext(Context);
 
+  const getChartKey = (name: string, namespace: string) =>
+    `${namespace}-${name}`;
+
   const updateCharts = async () => {
     try {
       const { currentCluster, currentProject } = context;
@@ -67,37 +85,8 @@ const ChartList: React.FunctionComponent<Props> = ({
         }
       );
       const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
       setIsError(false);
-      return sortedCharts;
+      return charts;
     } catch (error) {
       console.log(error);
       context.setCurrentError(JSON.stringify(error));
@@ -105,54 +94,73 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
   };
 
+<<<<<<< HEAD
   const setupHelmReleasesWebsocket = () => {
     const apiPath = `/api/projects/${context.currentProject.id}/clusters/${context.currentCluster.id}/helm_release`;
+=======
+  const setupHelmReleasesWebsocket = (
+    websocketID: string,
+    namespace: string
+  ) => {
+    let apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+    if (namespace) {
+      apiPath += `&namespace=${namespace}`;
+    }
+>>>>>>> master
 
     const wsConfig = {
       onopen: () => {
-        console.log("connected to chart live updates websocket");
+        console.log(`connected to websocket: ${websocketID}`);
       },
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
-        const object = event.Object;
-        setReleases((oldReleases) => {
-          const currentRelease = oldReleases[object?.name];
-          const currentReleaseVersion = Number(currentRelease?.version);
-          const newReleaseVersion = Number(object?.version);
-          if (currentReleaseVersion > newReleaseVersion) {
-            return {
-              ...oldReleases,
-            };
+        const newChart: ChartType = event.Object;
+        const isSameChart = (chart: ChartType) =>
+          getChartKey(chart.name, chart.namespace) ===
+          getChartKey(newChart.name, newChart.namespace);
+        setCharts((currentCharts) => {
+          switch (event.event_type) {
+            case "ADD":
+              if (currentCharts.find(isSameChart)) {
+                return currentCharts;
+              }
+              return currentCharts.concat(newChart);
+            case "UPDATE":
+              return currentCharts.map((chart) => {
+                if (isSameChart(chart) && newChart.version >= chart.version) {
+                  return newChart;
+                }
+                return chart;
+              });
+            case "DELETE":
+              return currentCharts.filter((chart) => !isSameChart(chart));
+            default:
+              return currentCharts;
           }
-
-          return {
-            ...oldReleases,
-            [object.name]: object,
-          };
         });
       },
 
       onclose: () => {
-        console.log("closing chart live updates websocket");
+        console.log(`closing websocket: ${websocketID}`);
       },
 
       onerror: (err: ErrorEvent) => {
         console.log(err);
-        closeWebsocket("helm_releases");
+        closeWebsocket(websocketID);
       },
     };
 
-    newWebsocket("helm_releases", apiPath, wsConfig);
-    openWebsocket("helm_releases");
+    newWebsocket(websocketID, apiPath, wsConfig);
+    openWebsocket(websocketID);
   };
 
-  const setupWebsocket = (kind: string) => {
+  const setupControllerWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = context;
     const apiPath = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/${kind}/status`;
 
     const wsConfig = {
       onopen: () => {
-        console.log("connected to websocket");
+        console.log(`connected to websocket: ${kind}`);
       },
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
@@ -165,7 +173,7 @@ const ChartList: React.FunctionComponent<Props> = ({
         }));
       },
       onclose: () => {
-        console.log("closing websocket");
+        console.log(`closing websocket: ${kind}`);
       },
       onerror: (err: ErrorEvent) => {
         console.log(err);
@@ -178,27 +186,108 @@ const ChartList: React.FunctionComponent<Props> = ({
     openWebsocket(kind);
   };
 
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
+  const setupControllerWebsockets = (controllers: string[]) => {
+    controllers.map((kind) => setupControllerWebsocket(kind));
+  };
+
+  const setupJobWebsocket = (websocketID: string) => {
+    const kind = "job";
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log(`connected to websocket: ${websocketID}`);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+
+        if (_.get(object.metadata, ["annotations", "helm.sh/hook"])) {
+          return;
+        }
+
+        setJobStatus((currentStatus) => {
+          let nextStatus: JobStatusType = null;
+          for (const status of Object.values(JobStatusType)) {
+            if (_.get(object.status, status, 0) > 0) {
+              nextStatus = status;
+              break;
+            }
+          }
+
+          const chartName =
+            object.metadata.labels["app.kubernetes.io/instance"];
+          const chartNamespace = object.metadata.namespace;
+          const key = getChartKey(chartName, chartNamespace);
+
+          const existingValue: JobStatusWithTimeAndVersion = _.get(
+            currentStatus,
+            key,
+            null
+          );
+          const newValue: JobStatusWithTimeAndVersion = {
+            status: nextStatus,
+            start_time: object.status.startTime,
+            resource_version: object.metadata.resourceVersion,
+          };
+
+          if (
+            !existingValue ||
+            newValue.resource_version > existingValue.resource_version
+          ) {
+            return {
+              ...currentStatus,
+              [key]: newValue,
+            };
+          }
+
+          return currentStatus;
+        });
+      },
+      onclose: () => {
+        console.log(`closing websocket: ${websocketID}`);
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketID);
+      },
+    };
+
+    newWebsocket(websocketID, apiPath, wsConfig);
+
+    openWebsocket(websocketID);
   };
 
   // Setup basic websockets on start
   useEffect(() => {
-    setControllerWebsockets([
+    const controllers = [
       "deployment",
       "statefulset",
       "daemonset",
       "replicaset",
-    ]);
-    setupHelmReleasesWebsocket();
+    ];
+    setupControllerWebsockets(controllers);
+
+    const jobWebsocketID = "job";
+    setupJobWebsocket(jobWebsocketID);
 
     return () => {
-      closeAllWebsockets();
+      controllers.map((controller) => closeWebsocket(controller));
+      closeWebsocket(jobWebsocketID);
     };
   }, []);
 
+  useEffect(() => {
+    const websocketID = "helm_releases";
+
+    setupHelmReleasesWebsocket(websocketID, namespace);
+
+    return () => {
+      closeWebsocket(websocketID);
+    };
+  }, [namespace]);
+
   useEffect(() => {
     let isSubscribed = true;
 
@@ -213,6 +302,50 @@ const ChartList: React.FunctionComponent<Props> = ({
     return () => (isSubscribed = false);
   }, [namespace, currentView]);
 
+  const filteredCharts = useMemo(() => {
+    const result = charts
+      .filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      })
+      .filter((chart: ChartType) => {
+        if (currentView !== "jobs") {
+          return true;
+        }
+        if (lastRunStatus === null) {
+          return true;
+        }
+        const status: JobStatusWithTimeAndVersion = _.get(
+          jobStatus,
+          getChartKey(chart.name, chart.namespace),
+          { status: null } as any
+        );
+        return status.status === lastRunStatus;
+      });
+
+    if (sortType == "Newest") {
+      result.sort((a: any, b: any) =>
+        Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+          ? -1
+          : 1
+      );
+    } else if (sortType == "Oldest") {
+      result.sort((a: any, b: any) =>
+        Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+          ? 1
+          : -1
+      );
+    } else if (sortType == "Alphabetical") {
+      result.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+    }
+
+    return result;
+  }, [charts, sortType, jobStatus, lastRunStatus]);
+
   const renderChartList = () => {
     if (isLoading || (!namespace && namespace !== "")) {
       return (
@@ -226,24 +359,27 @@ const ChartList: React.FunctionComponent<Props> = ({
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
       );
-    } else if (charts.length === 0) {
+    } else if (filteredCharts.length === 0) {
       return (
         <Placeholder>
           <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
+          {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
+          filters.
         </Placeholder>
       );
     }
 
-    return charts.map((chart: ChartType, i: number) => {
+    return filteredCharts.map((chart: ChartType, i: number) => {
       return (
         <Chart
-          key={`${chart.namespace}-${chart.name}`}
+          key={getChartKey(chart.name, chart.namespace)}
           chart={chart}
           controllers={controllers || {}}
-          isJob={currentView === "jobs"}
-          release={releases[chart.name] || {}}
+          jobStatus={_.get(
+            jobStatus,
+            getChartKey(chart.name, chart.namespace),
+            null
+          )}
         />
       );
     });

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -120,7 +120,7 @@ const ClusterSettings: React.FC = () => {
 
   return (
     <div>
-      <StyledSettingsSection showSource={false}>
+      <StyledSettingsSection>
         {keyRotationSection}
         <DarkMatter />
         <Heading>Delete Cluster</Heading>
@@ -143,7 +143,7 @@ const DarkMatter = styled.div`
   margin-top: -15px;
 `;
 
-const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+const StyledSettingsSection = styled.div`
   margin-top: 35px;
   width: 100%;
   background: #ffffff11;
@@ -152,7 +152,7 @@ const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   position: relative;
   border-radius: 8px;
   overflow: auto;
-  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+  height: 100%;
 `;
 
 const Button = styled.button`

+ 25 - 12
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -1,11 +1,11 @@
 import React, { useContext, useState, useEffect } from "react";
-import { Context } from "../../../../shared/Context";
-import api from "../../../../shared/api";
+import { Context } from "shared/Context";
+import api from "shared/api";
 import styled from "styled-components";
-import Loading from "../../../../components/Loading";
-import settings from "../../../../assets/settings.svg";
-import TabSelector from "../../../../components/TabSelector";
-import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import Loading from "components/Loading";
+import settings from "assets/settings.svg";
+import TabSelector from "components/TabSelector";
+import Placeholder from "components/Placeholder";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import AreaChart from "../expanded-chart/metrics/AreaChart";
 import {
@@ -257,10 +257,15 @@ const Metrics: React.FC = () => {
       <Loading />
     </LoadingWrapper>
   ) : !detected ? (
-    <p>
-      This message displays when either there's no ingress controller or nginx
-      is not installed
-    </p>
+    <>
+      <br />
+      <br />
+      <Placeholder height="calc(50vh - 50px)" minHeight="400px">
+        Cluster metrics unavailable. Make sure nginx-ingress and Prometheus are
+        installed.
+        <A href="/launch">Go to Launch</A>
+      </Placeholder>
+    </>
   ) : (
     <StyledMetricsSection>
       <Header>
@@ -331,8 +336,13 @@ const Metrics: React.FC = () => {
 
 export default Metrics;
 
+const A = styled.a`
+  margin-left: 5px;
+`;
+
 const LoadingWrapper = styled.div`
-  padding: 30px 0px;
+  padding: 100px 0px;
+  width: 100%;
   display: flex;
   align-items: center;
   font-size: 13px;
@@ -516,7 +526,7 @@ const StyledMetricsSection = styled.div`
   animation: floatIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  margin-top: 20px;
+  margin-top: 34px;
   @keyframes floatIn {
     from {
       opacity: 0;
@@ -531,6 +541,9 @@ const StyledMetricsSection = styled.div`
 
 const Header = styled.div`
   font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #aaaabb;
   font-size: 16px;
   margin-bottom: 15px;

+ 104 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentType.tsx

@@ -0,0 +1,104 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import { integrationList } from "shared/common";
+import { ChartType } from "shared/types";
+
+type Props = {
+  currentChart: ChartType;
+};
+
+const DeploymentType: React.FC<Props> = ({ currentChart }) => {
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+
+  const githubRepository = currentChart?.git_action_config?.git_repo;
+  const icon = githubRepository
+    ? integrationList.repo.icon
+    : integrationList.registry.icon;
+
+  const repository =
+    githubRepository ||
+    currentChart?.image_repo_uri ||
+    currentChart?.config?.image?.repository;
+
+  if (repository?.includes("hello-porter")) {
+    return null;
+  }
+
+  return (
+    <DeploymentImageContainer>
+      <DeploymentTypeIcon src={icon} />
+      <RepositoryName
+        onMouseOver={() => {
+          setShowRepoTooltip(true);
+        }}
+        onMouseOut={() => {
+          setShowRepoTooltip(false);
+        }}
+      >
+        {repository}
+      </RepositoryName>
+      {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+    </DeploymentImageContainer>
+  );
+};
+
+export default DeploymentType;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;
+
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: -40px;
+  top: 28px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 50 - 71
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -28,10 +28,13 @@ import MetricsSection from "./metrics/MetricsSection";
 import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
+import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import { integrationList } from "shared/common";
+import DeploymentType from "./DeploymentType";
+import EventsTab from "./events/EventsTab";
 
 type Props = {
   namespace: string;
@@ -66,9 +69,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [rightTabOptions, setRightTabOptions] = useState<any[]>([]);
   const [leftTabOptions, setLeftTabOptions] = useState<any[]>([]);
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
-  const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
-    false
-  );
+  const [forceRefreshRevisions, setForceRefreshRevisions] =
+    useState<boolean>(false);
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
@@ -80,19 +82,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
 
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeAllWebsockets,
-    closeWebsocket,
-  } = useWebsockets();
+  const { newWebsocket, openWebsocket, closeAllWebsockets, closeWebsocket } =
+    useWebsockets();
 
-  const {
-    currentCluster,
-    currentProject,
-    setCurrentError,
-    setCurrentOverlay,
-  } = useContext(Context);
+  const { currentCluster, currentProject, setCurrentError, setCurrentOverlay } =
+    useContext(Context);
 
   // Retrieve full chart data (includes form and values)
   const getChartData = async (chart: ChartType) => {
@@ -350,15 +344,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
+      case "events":
+        return <EventsTab currentChart={chart} />;
       case "status":
         if (isLoadingChartData) {
           return (
             <Placeholder>
-              <TextWrap>
-                <Header>
-                  <Spinner src={loadingSrc} />
-                </Header>
-              </TextWrap>
+              <Loading />
             </Placeholder>
           );
         }
@@ -370,8 +362,17 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   <Spinner src={loadingSrc} /> This application is currently
                   being deployed
                 </Header>
-                Navigate to the "Actions" tab of your GitHub repo to view live
-                build logs.
+                Navigate to the{" "}
+                <A
+                  href={
+                    props.currentChart.git_action_config &&
+                    `https://github.com/${props.currentChart.git_action_config?.git_repo}/actions`
+                  }
+                  target={"_blank"}
+                >
+                  Actions
+                </A>{" "}
+                tab of your GitHub repo to view live build logs.
               </TextWrap>
             </Placeholder>
           );
@@ -432,6 +433,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
+    leftTabOptions.push({ label: "Events", value: "events" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });
@@ -453,7 +455,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     // Filter tabs if previewing an old revision or updating the chart version
     if (isPreview) {
-      let liveTabs = ["status", "settings", "deploy", "metrics"];
+      let liveTabs = ["status", "events", "settings", "deploy", "metrics"];
       rightTabOptions = rightTabOptions.filter(
         (tab: any) => !liveTabs.includes(tab.value)
       );
@@ -612,7 +614,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     localStorage.setItem("devOpsMode", devOpsMode.toString());
   }, [devOpsMode, currentChart?.form, isPreview]);
 
-  useEffect(() => {
+  useEffect((): any => {
     let isSubscribed = true;
 
     const ingressComponent = components?.find((c) => c.Kind === "Ingress");
@@ -653,46 +655,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
     return () => (isSubscribed = false);
   }, [components, currentCluster, currentProject, currentChart]);
 
-  const renderDeploymentType = () => {
-    const githubRepository = currentChart?.git_action_config?.git_repo;
-    const icon = githubRepository
-      ? integrationList.repo.icon
-      : integrationList.registry.icon;
-
-    const isWebOrWorkerDeployment = ["web", "worker"].includes(
-      currentChart?.chart?.metadata?.name
-    );
-    if (!isWebOrWorkerDeployment) {
-      return null;
-    }
-
-    const repository =
-      githubRepository ||
-      currentChart?.image_repo_uri ||
-      currentChart?.config?.image?.repository;
-
-    if (repository?.includes("hello-porter")) {
-      return null;
-    }
-
-    return (
-      <DeploymentImageContainer>
-        <DeploymentTypeIcon src={icon} />
-        <RepositoryName
-          onMouseOver={() => {
-            setShowRepoTooltip(true);
-          }}
-          onMouseOut={() => {
-            setShowRepoTooltip(false);
-          }}
-        >
-          {repository}
-        </RepositoryName>
-        {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-      </DeploymentImageContainer>
-    );
-  };
-
   return (
     <>
       <StyledExpandedChart>
@@ -705,7 +667,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
             iconWidth="33px"
           >
             {currentChart.name}
-            {renderDeploymentType()}
+            <DeploymentType currentChart={currentChart} />
             <TagWrapper>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
             </TagWrapper>
@@ -834,6 +796,14 @@ const Tooltip = styled.div`
 
 const TextWrap = styled.div``;
 
+const LoadingWrapper = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;
@@ -882,16 +852,19 @@ const Header = styled.div`
 `;
 
 const Placeholder = styled.div`
-  min-height: 400px;
-  height: 50vh;
-  padding: 30px;
-  padding-bottom: 90px;
-  font-size: 13px;
-  color: #ffffff44;
   width: 100%;
+  min-height: 300px;
+  height: 40vh;
   display: flex;
   align-items: center;
   justify-content: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+  }
 `;
 
 const Spinner = styled.img`
@@ -1067,3 +1040,9 @@ const DeploymentTypeIcon = styled(Icon)`
   width: 20px;
   margin-right: 10px;
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

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

@@ -5,7 +5,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 import { ChartType, StorageType } from "shared/types";
 import api from "shared/api";
-import { pushFiltered } from "shared/routing";
+import { getQueryParam, pushFiltered } from "shared/routing";
 import ExpandedJobChart from "./ExpandedJobChart";
 import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";
@@ -34,11 +34,14 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
     let { currentProject, currentCluster } = this.context;
     if (currentProject && currentCluster) {
       // TODO: add query for retrieving max revision #
+      const lastCheckedRevision = getQueryParam(this.props, "chart_revision");
+
       api
-        .getRevisions(
+        .getChart(
           "<token>",
           {
           },
+<<<<<<< HEAD
           { id: currentProject.id, namespace: namespace, cluster_id: currentCluster.id ,name: chartName }
         )
         .then((res) => {
@@ -66,6 +69,16 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
               console.log("err", err.response.data);
               this.setState({ loading: false });
             });
+=======
+          {
+            name: chartName,
+            revision: Number(lastCheckedRevision),
+            id: currentProject.id,
+          }
+        )
+        .then((res) => {
+          this.setState({ currentChart: res.data, loading: false });
+>>>>>>> master
         })
         .catch((err) => {
           console.log("err", err.response.data);

+ 164 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -17,14 +17,20 @@ import SettingsSection from "./SettingsSection";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import ValuesYaml from "./ValuesYaml";
-
-type PropsType = WithAuthProps & {
-  namespace: string;
-  currentChart: ChartType;
-  currentCluster: ClusterType;
-  closeChart: () => void;
-  setSidebar: (x: boolean) => void;
-};
+import DeploymentType from "./DeploymentType";
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
+import { pushFiltered } from "../../../../shared/routing";
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = WithAuthProps &
+  RouteComponentProps & {
+    namespace: string;
+    currentChart: ChartType;
+    currentCluster: ClusterType;
+    closeChart: () => void;
+    setSidebar: (x: boolean) => void;
+  };
 
 type StateType = {
   currentChart: ChartType;
@@ -41,6 +47,7 @@ type StateType = {
   saveValuesStatus: string | null;
   formData: any;
   devOpsMode: boolean;
+  upgradeVersion: string;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -58,6 +65,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     formData: {} as any,
+    upgradeVersion: "",
     devOpsMode: localStorage.getItem("devOpsMode") === "true",
   };
 
@@ -99,6 +107,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
             },
             () => {
               this.updateTabs();
+              this.updateURL();
             }
           );
         } else {
@@ -110,6 +119,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
             },
             () => {
               this.updateTabs();
+              this.updateURL();
             }
           );
         }
@@ -117,6 +127,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
+  updateURL = () => {
+    // updates the url to use the correct revision to ensure refreshes work correctly
+    pushFiltered(
+      { location: this.props.location, history: this.props.history },
+      this.props.match.url,
+      ["project_id"],
+      {
+        chart_revision: this.state.currentChart.version,
+      }
+    );
+  };
+
   refreshChart = (revision: number) =>
     this.getChartData(this.state.currentChart, revision);
 
@@ -432,8 +454,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 <Header>
                   <Spinner src={loading} /> This job is currently being deployed
                 </Header>
-                Navigate to the "Actions" tab of your GitHub repo to view live
-                build logs.
+                Navigate to the
+                <A
+                  href={`https://github.com/${this.props.currentChart?.git_action_config?.git_repo}/actions`}
+                  target={"_blank"}
+                >
+                  Actions tab
+                </A>{" "}
+                of your GitHub repo to view live build logs.
               </TextWrap>
             </Placeholder>
           );
@@ -461,7 +489,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         return (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
             <SettingsSection
-              showSource={true}
               currentChart={this.state.currentChart}
               refreshChart={() => this.refreshChart(0)}
               setShowDeleteOverlay={(x: boolean) => {
@@ -565,13 +592,91 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
+  handleUpgradeVersion = async (version: string, cb: () => void) => {
+    // convert current values to yaml
+    let values = this.state.currentChart.config;
+
+    let valuesYaml = yaml.dump({
+      ...(this.state.currentChart.config as Object),
+      ...values,
+    });
+
+    _.set(values, "paused", true);
+
+    const { currentChart } = this.state;
+    this.setState({ saveValuesStatus: "loading" });
+    this.getChartData(currentChart, currentChart.version);
+
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          storage: StorageType.Secret,
+          values: valuesYaml,
+          version: version,
+        },
+        {
+          id: this.context.currentProject.id,
+          name: currentChart.name,
+          cluster_id: this.context.currentCluster.id,
+        }
+      );
+      this.setState({ saveValuesStatus: "successful" });
+
+      window.analytics.track("Chart Upgraded", {
+        chart: currentChart.name,
+        values: valuesYaml,
+      });
+
+      cb && cb();
+    } catch (err) {
+      let parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+      this.setState({ saveValuesStatus: err });
+      this.context.setCurrentError(parsedErr);
+
+      window.analytics.track("Failed to Upgrade Chart", {
+        chart: currentChart.name,
+        values: valuesYaml,
+        error: err,
+      });
+    }
+  };
+
   render() {
     let { closeChart } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
-
+    const displayUpdateButton =
+      chart.latest_version &&
+      chart.latest_version !== chart.chart.metadata.version;
     return (
       <>
+        {this.state.upgradeVersion && (
+          <Modal
+            onRequestClose={() => this.setState({ upgradeVersion: "" })}
+            width="500px"
+            height="450px"
+          >
+            <UpgradeChartModal
+              currentChart={chart}
+              closeModal={() => {
+                this.setState({ upgradeVersion: "" });
+              }}
+              onSubmit={() => {
+                this.handleUpgradeVersion(this.state.upgradeVersion, () => {
+                  this.setState({ loading: false });
+                });
+                this.setState({ upgradeVersion: "", loading: true });
+              }}
+            />
+          </Modal>
+        )}
         <StyledExpandedChart>
           <HeaderWrapper>
             <BackButton onClick={closeChart}>
@@ -582,6 +687,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               iconWidth="33px"
             >
               {chart.name}
+              <DeploymentType currentChart={currentChart} />
               <TagWrapper>
                 Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
               </TagWrapper>
@@ -594,6 +700,19 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 {" " + this.readableDate(chart.info.last_deployed)}
               </LastDeployed>
             </InfoWrapper>
+            {displayUpdateButton && (
+              <RevisionUpdateMessage
+                onClick={(e) => {
+                  e.stopPropagation();
+                  this.setState({
+                    upgradeVersion: currentChart.latest_version,
+                  });
+                }}
+              >
+                <i className="material-icons">notification_important</i>
+                Template Update Available
+              </RevisionUpdateMessage>
+            )}
           </HeaderWrapper>
 
           {this.state.deleting ? (
@@ -653,7 +772,32 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
 ExpandedJobChart.contextType = Context;
 
-export default withAuth(ExpandedJobChart);
+export default withRouter(withAuth(ExpandedJobChart));
+
+const RevisionUpdateMessage = styled.button`
+  background: none;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 5px;
+  border: none;
+  margin-bottom: 14px;
+
+  :hover {
+    border: 1px solid white;
+    padding: 3px 9px;
+    cursor: pointer;
+  }
+
+  > i {
+    margin-right: 6px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: none;
+  }
+`;
 
 const LineBreak = styled.div`
   width: calc(100% - 0px);
@@ -861,3 +1005,10 @@ const TabButton = styled.div`
     margin-right: 9px;
   }
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;

+ 79 - 56
dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx

@@ -1,12 +1,14 @@
 import React, { useContext, useState, useEffect } from "react";
-import Heading from "../../../../components/form-components/Heading";
-import CheckboxRow from "../../../../components/form-components/CheckboxRow";
-import Helper from "../../../../components/form-components/Helper";
-import SaveButton from "../../../../components/SaveButton";
-import api from "../../../../shared/api";
-import { Context } from "../../../../shared/Context";
-import { ChartType } from "../../../../shared/types";
-import Loading from "../../../../components/Loading";
+import Heading from "components/form-components/Heading";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import Helper from "components/form-components/Helper";
+import SaveButton from "components/SaveButton";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import Loading from "components/Loading";
+import Banner from "components/Banner";
+import styled from "styled-components";
 
 const NOTIF_CATEGORIES = ["success", "fail"];
 
@@ -105,62 +107,72 @@ const NotificationSettingsSection: React.FC<Props> = (props) => {
   return (
     <>
       <Heading>Notification Settings</Heading>
+      <Helper>Configure notification settings for this application.</Helper>
       {initLoading ? (
         <Loading />
       ) : !hasRelease ? (
-        <Heading>
-          This message appears when the release isn't in the database, so Porter
-          can't laod in notifications for it
-        </Heading>
+        <Banner type="error">
+          Notifications unavailable. Porter could not find this application in
+          the database.
+        </Banner>
       ) : (
         <>
-          {hasNotifications != null && !hasNotifications && (
-            <Helper>
-              This message appears when there are no notification integrations
-              for the project
-            </Helper>
-          )}
-          <CheckboxRow
-            label={"Notifications Enabled"}
-            checked={notificationsOn}
-            toggle={() => setNotificationsOn(!notificationsOn)}
-            disabled={props.disabled}
-          />
-          {notificationsOn && (
+          {hasNotifications != null && !hasNotifications ? (
+            <Banner type="warning">
+              No integration has been set up for notifications.{" "}
+              <A href="http://localhost:8080/integrations/slack">
+                Connect to Slack
+              </A>
+            </Banner>
+          ) : (
             <>
-              <Helper>Send notifications on:</Helper>
-              {Object.entries(categories).map(([k, v]: [string, boolean]) => {
-                return (
-                  <React.Fragment key={k}>
-                    <CheckboxRow
-                      label={k}
-                      checked={v}
-                      toggle={() =>
-                        setCategories((prev) => {
-                          return {
-                            ...prev,
-                            [k]: !v,
-                          };
-                        })
-                      }
-                      disabled={props.disabled}
-                    />
-                  </React.Fragment>
-                );
-              })}
+              <CheckboxRow
+                label={"Enable notifications"}
+                checked={notificationsOn}
+                toggle={() => setNotificationsOn(!notificationsOn)}
+                disabled={props.disabled}
+              />
+              {notificationsOn && (
+                <>
+                  <Helper>Send notifications on:</Helper>
+                  {Object.entries(categories).map(
+                    ([k, v]: [string, boolean]) => {
+                      return (
+                        <React.Fragment key={k}>
+                          <CheckboxRow
+                            label={`Deploy ${k}`}
+                            checked={v}
+                            toggle={() =>
+                              setCategories((prev) => {
+                                return {
+                                  ...prev,
+                                  [k]: !v,
+                                };
+                              })
+                            }
+                            disabled={props.disabled}
+                          />
+                        </React.Fragment>
+                      );
+                    }
+                  )}
+                </>
+              )}
+              <br />
+              <SaveButton
+                onClick={() => saveChanges()}
+                text="Save Notification Settings"
+                clearPosition={true}
+                statusPosition={"right"}
+                disabled={props.disabled || initLoading || saveLoading}
+                status={
+                  saveLoading ? "loading" : numSaves > 0 ? "successful" : null
+                }
+                saveText={"Saving . . ."}
+              />
+              <Br />
             </>
           )}
-          <SaveButton
-            onClick={() => saveChanges()}
-            text={"Save Changes"}
-            clearPosition={true}
-            statusPosition={"right"}
-            disabled={props.disabled || initLoading || saveLoading}
-            status={
-              saveLoading ? "loading" : numSaves > 0 ? "successful" : null
-            }
-            saveText={"Saving . . ."}
-          />
         </>
       )}
     </>
@@ -168,3 +180,14 @@ const NotificationSettingsSection: React.FC<Props> = (props) => {
 };
 
 export default NotificationSettingsSection;
+
+const A = styled.a`
+  text-decoration: underline;
+  cursor: pointer;
+  margin-left: 5px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;

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

@@ -231,7 +231,23 @@ class RevisionSection extends Component<PropsType, StateType> {
         >
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
-          <Td>{parsedImageTag || "N/A"}</Td>
+          <Td>
+            {!imageTag ? (
+              "N/A"
+            ) : isGithubApp && /^[0-9A-Fa-f]{7}$/g.test(imageTag) ? (
+              <A
+                href={`https://github.com/${this.props.chart.git_action_config?.git_repo}/commit/${imageTag}`}
+                target="_blank"
+                onClick={(e) => {
+                  e.stopPropagation();
+                }}
+              >
+                {parsedImageTag}
+              </A>
+            ) : (
+              parsedImageTag
+            )}
+          </Td>
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
@@ -533,3 +549,9 @@ const RevisionUpdateMessage = styled.div`
     transform: none;
   }
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 30 - 28
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -20,7 +20,6 @@ type PropsType = {
   currentChart: ChartType;
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
-  showSource?: boolean;
   saveButtonText?: string | null;
 };
 
@@ -28,7 +27,6 @@ const SettingsSection: React.FC<PropsType> = ({
   currentChart,
   refreshChart,
   setShowDeleteOverlay,
-  showSource,
   saveButtonText,
 }) => {
   const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
@@ -201,21 +199,33 @@ const SettingsSection: React.FC<PropsType> = ({
 
     return (
       <>
-        {showSource && (
-          <>
-            <Heading>Source Settings</Heading>
-            <Helper>Specify an image tag to use.</Helper>
-            <ImageSelector
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
-              setSelectedTag={(x: string) => setSelectedTag(x)}
-              forceExpanded={true}
-              disableImageSelect={true}
-            />
-            <Br />
-          </>
-        )}
+        <>
+          <Heading>Source Settings</Heading>
+          <Helper>Specify an image tag to use.</Helper>
+          <ImageSelector
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+            setSelectedTag={(x: string) => setSelectedTag(x)}
+            forceExpanded={true}
+            disableImageSelect={true}
+          />
+          {!loadingWebhookToken && (
+            <>
+              <Br />
+              <Br />
+              <Br />
+              <SaveButton
+                clearPosition={true}
+                statusPosition="right"
+                text="Save Source Settings"
+                status={saveValuesStatus}
+                onClick={handleSubmit}
+              />
+            </>
+          )}
+          <Br />
+        </>
 
         <>
           <Heading>Redeploy Webhook</Heading>
@@ -257,7 +267,7 @@ const SettingsSection: React.FC<PropsType> = ({
   return (
     <Wrapper>
       {!loadingWebhookToken ? (
-        <StyledSettingsSection showSource={showSource}>
+        <StyledSettingsSection>
           {renderWebhookSection()}
           <NotificationSettingsSection currentChart={currentChart} />
           <Heading>Additional Settings</Heading>
@@ -268,14 +278,6 @@ const SettingsSection: React.FC<PropsType> = ({
       ) : (
         <Loading />
       )}
-      {!loadingWebhookToken && showSource && (
-        <SaveButton
-          text={saveButtonText || "Save Config"}
-          status={saveValuesStatus}
-          onClick={handleSubmit}
-          makeFlush={true}
-        />
-      )}
     </Wrapper>
   );
 };
@@ -372,7 +374,7 @@ const Wrapper = styled.div`
   height: 100%;
 `;
 
-const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+const StyledSettingsSection = styled.div`
   width: 100%;
   background: #ffffff11;
   padding: 0 35px;
@@ -380,7 +382,7 @@ const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   position: relative;
   border-radius: 8px;
   overflow: auto;
-  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+  height: calc(100% - 55px);
 `;
 
 const Holder = styled.div`

+ 120 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx

@@ -0,0 +1,120 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { Event } from "./EventsTab";
+import Loading from "../../../../../components/Loading";
+
+type CardProps = {
+  event: Event;
+  selectEvent?: () => void;
+  overrideName?: string;
+};
+
+export const getReadableDate = (s: number) => {
+  let ts = new Date(s * 1000);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+// Rename to Event Card
+const EventCard: React.FunctionComponent<CardProps> = ({
+  event,
+  selectEvent,
+  overrideName,
+}) => {
+  return (
+    <StyledCard onClick={() => selectEvent && selectEvent()}>
+      {event.status == 1 && (
+        <Icon status="normal" className="material-icons-outlined">
+          check
+        </Icon>
+      )}
+      {event.status == 2 && (
+        <Icon className="material-icons-outlined">autorenew</Icon>
+      )}
+      {event.status == 3 && (
+        <Icon status="critical" className="material-icons-outlined">
+          error
+        </Icon>
+      )}
+
+      <InfoWrapper>
+        <EventName>
+          {overrideName ? overrideName : event.name}
+          {event.status == 1 && " successful"}
+          {event.status == 2 && " in progress"}
+          {event.status == 3 && ` failed: ${event.info}`}
+        </EventName>
+        <TimestampContainer>
+          <i className="material-icons-outlined">access_time</i>
+          {getReadableDate(event.time)}
+        </TimestampContainer>
+      </InfoWrapper>
+    </StyledCard>
+  );
+};
+
+export default EventCard;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  margin-bottom: 10px;
+  border-radius: 10px;
+  padding-left: 20px;
+  overflow: hidden;
+  height: 80px;
+  cursor: pointer;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff66;
+  }
+`;
+
+const Icon = styled.span<{ status?: "critical" | "normal" }>`
+  font-size: 22px;
+  margin-right: 18px;
+  color: ${({ status }) =>
+    status ? (status === "critical" ? "#cc3d42" : "#38a88a") : "#efefef"};
+  animation: ${({ status }) => !status && "rotating 3s linear infinite"};
+  @keyframes rotating {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+const EventName = styled.div`
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff55;
+  font-size: 13px;
+  margin-top: 8px;
+
+  > i {
+    margin-right: 5px;
+    font-size: 18px;
+    margin-left: -1px;
+  }
+`;

+ 94 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx

@@ -0,0 +1,94 @@
+import React, { Fragment } from "react";
+import { EventContainer } from "./EventsTab";
+import TitleSection from "components/TitleSection";
+import EventCard, { getReadableDate } from "./EventCard";
+import styled from "styled-components";
+
+interface Props {
+  container: EventContainer;
+  resetSelection: () => {};
+}
+
+const EventDetail: React.FC<Props> = (props) => {
+  return (
+    <>
+      <Flex>
+        <TitleSection handleNavBack={props.resetSelection}>
+          {props.container.name}
+        </TitleSection>
+        <P>
+          <i className="material-icons-outlined">access_time</i>
+          {getReadableDate(props.container.started_at)}
+        </P>
+      </Flex>
+      <EventsGrid>
+        {props.container.events
+          .slice(0)
+          .reverse()
+          .map((event) => {
+            return (
+              <React.Fragment key={event.index}>
+                <EventCard event={event} />
+              </React.Fragment>
+            );
+          })}
+      </EventsGrid>
+    </>
+  );
+};
+
+export default EventDetail;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const P = styled.p`
+  display: flex;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 13px;
+  margin-left: 20px;
+  margin-top: 0px;
+
+  > i {
+    margin-right: 5px;
+    font-size: 18px;
+    margin-left: -1px;
+  }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 16px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;

+ 269 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -0,0 +1,269 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import loadingSrc from "assets/loading.gif";
+import { Context } from "shared/Context";
+import { ChartType } from "../../../../../shared/types";
+import api from "../../../../../shared/api";
+import EventCard from "./EventCard";
+import Loading from "components/Loading";
+import EventDetail from "./EventDetail";
+
+export type Event = {
+  event_id: string;
+  index: number;
+  info: string;
+  name: string;
+  status: number;
+  time: number;
+};
+
+export type EventContainer = {
+  events: Event[];
+  name: string;
+  started_at: number;
+};
+
+type Props = {
+  currentChart: ChartType;
+};
+
+const REFRESH_TIME = 15000;
+
+const EventsTab: React.FunctionComponent<Props> = (props) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [isError, setIsError] = useState(false);
+  const [shouldRequest, setShouldRequest] = useState(true);
+  const [eventData, setEventData] = useState<EventContainer[]>([]); // most recent event is last
+  const [selectedEvent, setSelectedEvent] = useState<number | null>(null);
+
+  // sort by time, ensure sequences are monotonically increasing by time, collapse by id
+  const filterData = (data: Event[]) => {
+    data = data.sort((a, b) => a.time - b.time);
+
+    if (data.length == 0) return;
+
+    let seq: Event[][] = [];
+    let cur: Event[] = [data[0]];
+
+    for (let i = 1; i < data.length; ++i) {
+      if (data[i].index < data[i - 1].index) {
+        seq.push(cur);
+        cur = [];
+      }
+      cur.push(data[i]);
+    }
+    if (cur) seq.push(cur);
+
+    let ret: EventContainer[] = [];
+    seq.forEach((j) => {
+      j.push({
+        event_id: "",
+        index: 0,
+        info: "",
+        name: "",
+        status: 0,
+        time: 0,
+      });
+
+      let fin: EventContainer = {
+        events: [],
+        name: "Deployment",
+        started_at: j[0].time,
+      };
+      for (let i = 0; i < j.length - 1; ++i) {
+        if (j[i].event_id != j[i + 1].event_id) {
+          fin.events.push(j[i]);
+        }
+      }
+      ret.push(fin);
+    });
+
+    setEventData(ret);
+  };
+
+  useEffect(() => {
+    const getData = () => {
+      if (!shouldRequest) return;
+      setShouldRequest(false);
+      api
+          .getReleaseSteps(
+              "<token>",
+              {
+                cluster_id: currentCluster.id,
+                namespace: props.currentChart.namespace,
+              },
+              {
+                id: currentProject.id,
+                name: props.currentChart.name,
+              }
+          )
+          .then((data) => {
+            setIsLoading(false);
+            filterData(data.data);
+          })
+          .catch((err) => {
+            setIsError(true);
+          })
+          .finally(() => {
+            setShouldRequest(true);
+          });
+    };
+
+    getData();
+    const id = window.setInterval(getData, REFRESH_TIME);
+
+    return () => {
+      setIsLoading(true);
+      window.clearInterval(id);
+    };
+  }, [currentProject, currentCluster, props.currentChart]);
+
+  if (isError) {
+    return (
+        <Placeholder>
+          Error loading events.
+        </Placeholder>
+    )
+  }
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (eventData.length === 0) {
+    return (
+      <Placeholder>
+        <i className="material-icons">category</i>
+        No application events found.
+      </Placeholder>
+    );
+  }
+
+  if (selectedEvent !== null) {
+    return (
+      <EventDetail
+        container={eventData[selectedEvent]}
+        resetSelection={() => {
+          setSelectedEvent(null);
+          return null;
+        }}
+      />
+    );
+  }
+
+  return (
+    <EventsGrid>
+      {eventData
+        .slice(0)
+        .reverse()
+        .map((dat, i) => {
+          console.log(dat.started_at);
+          return (
+            <React.Fragment key={dat.started_at}>
+              <EventCard
+                event={dat.events[dat.events.length - 1]}
+                selectEvent={() => {
+                  setSelectedEvent(eventData.length - i - 1);
+                }}
+                overrideName={"Deployment"}
+              />
+            </React.Fragment>
+          );
+        })}
+    </EventsGrid>
+  );
+};
+
+export default EventsTab;
+
+const EventsPageWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  min-height: 300px;
+  height: 40vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;

+ 13 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -39,7 +39,17 @@ class JobList extends Component<PropsType, StateType> {
               <JobResource
                 key={job?.metadata?.name}
                 job={job}
-                handleDelete={() => this.setState({ deletionCandidate: job })}
+                handleDelete={() => {
+                  this.setState({ deletionCandidate: job });
+                  this.context.setCurrentOverlay({
+                    message: `Are you sure you want to delete this job run?`,
+                    onYes: this.deleteJob,
+                    onNo: () => {
+                      this.setState({ deletionCandidate: null });
+                      this.context.setCurrentOverlay(null);
+                    },
+                  });
+                }}
                 deleting={
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
                 }
@@ -61,6 +71,7 @@ class JobList extends Component<PropsType, StateType> {
   deleteJob = () => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let job = this.state.deletionCandidate;
+    this.context.setCurrentOverlay(null);
 
     api
       .deleteJob(
@@ -91,17 +102,7 @@ class JobList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return (
-      <>
-        <ConfirmOverlay
-          show={this.state.deletionCandidate}
-          message={`Are you sure you want to delete this job run?`}
-          onYes={this.deleteJob}
-          onNo={() => this.setState({ deletionCandidate: null })}
-        />
-        <JobListWrapper>{this.renderJobList()}</JobListWrapper>
-      </>
-    );
+    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
   }
 }
 

+ 4 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -140,7 +140,10 @@ export default class JobResource extends Component<PropsType, StateType> {
     let envObject = {} as any;
     envArray &&
       envArray.forEach((env: any, i: number) => {
-        envObject[env.name] = env.value;
+        const secretName = _.get(env, "valueFrom.secretKeyRef.name");
+        envObject[env.name] = secretName
+          ? `PORTERSECRET_${secretName}`
+          : env.value;
       });
 
     // Handle no config to show

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

@@ -24,7 +24,9 @@ const TempJobList: React.FC<Props> = (props) => {
   let saveButton = (
     <ButtonWrapper>
       <SaveButton
-        onClick={() => props.handleSaveValues(getSubmitValues(), true)}
+        onClick={() => {
+          props.handleSaveValues(getSubmitValues(), true);
+        }}
         status={props.saveValuesStatus}
         makeFlush={true}
         clearPosition={true}

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts

@@ -5,7 +5,7 @@ import {
   MetricsNetworkDataResponse,
   MetricsNGINXErrorsDataResponse,
   AvailableMetrics,
-  MetricsHpaReplicasDataResponse, 
+  MetricsHpaReplicasDataResponse,
   MetricsNGINXLatencyDataResponse,
   NormalizedMetricsData,
 } from "./types";
@@ -40,7 +40,10 @@ export class MetricNormalizer {
     if (this.kind.includes("nginx:errors")) {
       return this.parseNGINXErrorsMetrics(this.metric_results);
     }
-    if (this.kind.includes("nginx:latency") || this.kind.includes("nginx:latency-histogram")) {
+    if (
+      this.kind.includes("nginx:latency") ||
+      this.kind.includes("nginx:latency-histogram")
+    ) {
       return this.parseNGINXLatencyMetrics(this.metric_results);
     }
     if (this.kind.includes("hpa_replicas")) {

+ 5 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -3,7 +3,11 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ClusterType, DetailedClusterType, DetailedIngressError } from "shared/types";
+import {
+  ClusterType,
+  DetailedClusterType,
+  DetailedIngressError,
+} from "shared/types";
 import Helper from "components/form-components/Helper";
 import { pushFiltered } from "shared/routing";
 

+ 1 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -326,6 +326,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       return (
         <WorkflowPage
           name={templateName}
+          namespace={"default"}
           fullActionConfig={fullActionConfig}
           shouldCreateWorkflow={shouldCreateWorkflow}
           setShouldCreateWorkflow={setShouldCreateWorkflow}

+ 2 - 0
dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx

@@ -12,6 +12,7 @@ import SaveButton from "../../../../components/SaveButton";
 
 type PropsType = {
   name: string;
+  namespace: string;
   fullActionConfig: FullActionConfigType;
   shouldCreateWorkflow: boolean;
   setShouldCreateWorkflow: (x: (prevState: boolean) => boolean) => void;
@@ -31,6 +32,7 @@ const WorkflowPage: React.FC<PropsType> = (props) => {
     api
       .generateGHAWorkflow("<token>", props.fullActionConfig, {
         name: props.name,
+        namespace: props.namespace,
         cluster_id: currentCluster.id,
         project_id: currentProject.id,
       })

+ 1 - 1
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -140,7 +140,7 @@ const User = styled.div`
 
 const ListWrapper = styled.div`
   width: 100%;
-  height: 200px;
+  height: 250px;
   background: #ffffff11;
   display: flex;
   align-items: center;

+ 1 - 1
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -133,7 +133,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: "Work Sans", sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/UpgradeChartModal.tsx

@@ -33,7 +33,7 @@ export default class UpgradeChartModal extends Component<PropsType, StateType> {
       .toLowerCase()
       .trim();
 
-    if (chartName == "web" || chartName == "worker") {
+    if (chartName == "web" || chartName == "worker" || chartName === "job") {
       repoURL = process.env.APPLICATION_CHART_REPO_URL;
     }
 

+ 12 - 0
dashboard/src/main/home/navbar/Navbar.tsx

@@ -24,6 +24,7 @@ class Navbar extends Component<PropsType, StateType> {
 
   renderSettingsDropdown = () => {
     if (this.state.showDropdown) {
+      let version = this.context?.capabilities?.version;
       return (
         <>
           <CloseOverlay
@@ -45,6 +46,7 @@ class Navbar extends Component<PropsType, StateType> {
             </UserDropdownButton>
             <UserDropdownButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
+              {version !== "production" && <VersionTag>{version}</VersionTag>}
             </UserDropdownButton>
           </Dropdown>
         </>
@@ -80,6 +82,14 @@ Navbar.contextType = Context;
 
 export default withAuth(Navbar);
 
+const VersionTag = styled.div`
+  position: absolute;
+  right: 10px;
+  top: 15px;
+  color: #ffffff22;
+  font-weight: 400;
+`;
+
 const SettingsIcon = styled.div`
   > i {
     background: none;
@@ -119,6 +129,7 @@ const CloseOverlay = styled.div`
 
 const UserDropdownButton = styled.button`
   padding: 13px;
+  position: relative;
   height: 40px;
   font-size: 13px;
   font-weight: 500;
@@ -230,6 +241,7 @@ const StyledNavbar = styled.div`
   align-items: center;
   padding-right: 5px;
   justify-content: flex-end;
+  z-index: 1;
 `;
 
 const NavButton = styled.a`

+ 6 - 0
dashboard/src/main/home/new-project/NewProject.tsx

@@ -23,6 +23,12 @@ export default class NewProject extends Component<PropsType, StateType> {
     selectedProvider: null as string | null,
   };
 
+  componentDidMount() {
+    window.analytics.track("provision_new-project", {
+      userId: this.context.user?.id,
+    });
+  }
+
   render() {
     let { capabilities } = this.context;
     let { projectName } = this.state;

+ 337 - 312
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import close from "assets/close.png";
@@ -15,25 +15,15 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
-import { RouteComponentProps, withRouter } from "react-router";
+import { useHistory, useLocation } from "react-router";
 
-type PropsType = RouteComponentProps & {
+type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   projectName: string;
   infras: InfraType[];
-};
-
-type StateType = {
-  awsRegion: string;
-  awsMachineType: string;
-  awsAccessId: string;
-  awsSecretKey: string;
-  clusterName: string;
-  clusterNameSet: boolean;
-  selectedInfras: { value: string; label: string }[];
-  buttonStatus: string;
-  provisionConfirmed: boolean;
+  highlightCosts?: boolean;
+  trackOnSave: () => void;
 };
 
 const provisionOptions = [
@@ -73,29 +63,49 @@ const machineTypeOptions = [
   { value: "t3.2xlarge", label: "t3.2xlarge" },
 ];
 
-// TODO: Consolidate across forms w/ HOC
-class AWSFormSection extends Component<PropsType, StateType> {
-  state = {
-    awsRegion: "us-east-1",
-    awsMachineType: "t2.medium",
-    awsAccessId: "",
-    awsSecretKey: "",
-    clusterName: "",
-    clusterNameSet: false,
-    selectedInfras: [...provisionOptions],
-    buttonStatus: "",
-    provisionConfirmed: false,
-  };
+const costMapping: Record<string, number> = {
+  "t2.medium": 35,
+  "t2.xlarge": 135,
+  "t2.2xlarge": 270,
+  "t3.medium": 30,
+  "t3.xlarge": 120,
+  "t3.2xlarge": 240,
+};
+
+const AWSFormSectionFC: React.FC<PropsType> = (props) => {
+  const [awsRegion, setAwsRegion] = useState("us-east-1");
+  const [awsMachineType, setAwsMachineType] = useState("t2.medium");
+  const [awsAccessId, setAwsAccessId] = useState("");
+  const [awsSecretKey, setAwsSecretKey] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  const context = useContext(Context);
+
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
 
-  componentDidMount = () => {
-    let { infras } = this.props;
-    let { selectedInfras } = this.state;
-    this.setClusterNameIfNotSet();
+    window.analytics?.track("provision_form-dirty", {
+      provider: "aws",
+    });
+  }, [isFormDirty]);
 
-    if (infras) {
+  useEffect(() => {
+    if (props.infras) {
       // From the dashboard, only uncheck and disable if "creating" or "created"
       let filtered = selectedInfras;
-      infras.forEach((infra: InfraType, i: number) => {
+      props.infras.forEach((infra: InfraType, i: number) => {
         let { kind, status } = infra;
         if (status === "creating" || status === "created") {
           filtered = filtered.filter((item: any) => {
@@ -103,45 +113,30 @@ class AWSFormSection extends Component<PropsType, StateType> {
           });
         }
       });
-      this.setState({ selectedInfras: filtered });
+      setSelectedInfras(filtered);
     }
-  };
+  }, [props.infras]);
 
-  componentDidUpdate = (prevProps: PropsType, prevState: StateType) => {
-    if (prevProps.projectName != this.props.projectName) {
-      this.setClusterNameIfNotSet();
-    }
-  };
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
 
-  setClusterNameIfNotSet = () => {
-    let projectName =
-      this.props.projectName || this.context.currentProject?.name;
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
 
-    if (
-      !this.state.clusterNameSet &&
-      !this.state.clusterName.includes(`${projectName}-cluster`)
-    ) {
-      this.setState({
-        clusterName: `${projectName}-cluster-${Math.random()
-          .toString(36)
-          .substring(2, 8)}`,
-      });
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
     }
   };
 
-  checkFormDisabled = () => {
-    if (!this.state.provisionConfirmed) {
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
       return true;
     }
 
-    let {
-      awsRegion,
-      awsAccessId,
-      awsSecretKey,
-      selectedInfras,
-      clusterName,
-    } = this.state;
-    let { projectName } = this.props;
+    const { projectName } = props;
     if (projectName || projectName === "") {
       return (
         !isAlphanumeric(projectName) ||
@@ -165,172 +160,144 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  catchError = (err: any) => {
+  const catchError = (err: any) => {
     console.log(err);
-    this.props.handleError();
+    props.handleError();
   };
 
   // Step 1: Create a project
   // TODO: promisify this function
-  createProject = (callback?: any) => {
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
-
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then((res) => {
-        let proj = res.data;
-        // Need to set project list for dropdown
-        // TODO: consolidate into ProjectSection (case on exists in list on set)
-        api
-          .getProjects(
-            "<token>",
-            {},
-            {
-              id: user.userId,
-            }
-          )
-          .then((res) => {
-            setProjects(res.data);
-            setCurrentProject(proj, () => {
-              callback && callback();
-            });
-          })
-          .catch(this.catchError);
-      })
-      .catch(this.catchError);
+  const createProject = async () => {
+    const { projectName } = props;
+    const { user, setProjects, setCurrentProject } = context;
+    try {
+      const project = await api
+        .createProject("<token>", { name: projectName }, {})
+        .then((res) => res.data);
+
+      // Need to set project list for dropdown
+      // TODO: consolidate into ProjectSection (case on exists in list on set)
+      const projectList = await api
+        .getProjects(
+          "<token>",
+          {},
+          {
+            id: user.userId,
+          }
+        )
+        .then((res) => res.data);
+      setProjects(projectList);
+      setCurrentProject(project);
+    } catch (error) {
+      catchError(error);
+    }
   };
 
-  provisionECR = () => {
-    console.log("Provisioning ECR");
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-    let { currentProject } = this.context;
-
-    return api
-      .createAWSIntegration(
+  const getAwsIntegrationId = async () => {
+    const { currentProject } = context;
+    try {
+      const res = await api.createAWSIntegration(
         "<token>",
         {
           aws_region: awsRegion,
           aws_access_key_id: awsAccessId,
           aws_secret_access_key: awsSecretKey,
+          aws_cluster_id: clusterName,
         },
         { id: currentProject.id }
-      )
-      .then((res) =>
-        api.provisionECR(
-          "<token>",
-          {
-            aws_integration_id: res.data.id,
-            ecr_name: `${currentProject.name}-registry`,
-          },
-          { id: currentProject.id }
-        )
-      )
-      .catch(this.catchError);
+      );
+      return res.data;
+    } catch (error) {
+      catchError(error);
+    }
   };
 
-  provisionEKS = () => {
-    let {
-      awsAccessId,
-      awsSecretKey,
-      awsRegion,
-      awsMachineType,
-      clusterName,
-    } = this.state;
-    let { currentProject } = this.context;
-
-    api
-      .createAWSIntegration(
+  const provisionECR = async (awsIntegrationId: string) => {
+    console.log("Started provision ECR");
+    const { currentProject } = context;
+    try {
+      await api.provisionECR(
         "<token>",
         {
-          aws_region: awsRegion,
-          aws_access_key_id: awsAccessId,
-          aws_secret_access_key: awsSecretKey,
-          aws_cluster_id: clusterName,
+          aws_integration_id: awsIntegrationId,
+          ecr_name: `${currentProject.name}-registry`,
         },
         { id: currentProject.id }
-      )
-      .then((res) =>
-        api.provisionEKS(
-          "<token>",
-          {
-            aws_integration_id: res.data.id,
-            eks_name: clusterName,
-            machine_type: awsMachineType,
-          },
-          { id: currentProject.id }
-        )
-      )
-      .then(() =>
-        pushFiltered(this.props, "/dashboard", ["project_id"], {
-          tab: "provisioner",
-        })
-      )
-      .catch(this.catchError);
+      );
+    } catch (error) {
+      catchError(error);
+    }
+  };
+
+  const provisionEKS = async (awsIntegrationId: string) => {
+    const { currentProject } = context;
+    try {
+      await api.provisionEKS(
+        "<token>",
+        {
+          aws_integration_id: awsIntegrationId,
+          eks_name: clusterName,
+          machine_type: awsMachineType,
+        },
+        { id: currentProject.id }
+      );
+    } catch (error) {
+      catchError(error);
+    }
   };
 
   // TODO: handle generically (with > 2 steps)
-  onCreateAWS = () => {
-    this.setState({ buttonStatus: "loading" });
-    let { projectName } = this.props;
-    let { selectedInfras } = this.state;
-
-    if (!projectName) {
-      if (selectedInfras.length === 2) {
-        // Case: project exists, provision ECR + EKS
-        this.provisionECR().then(this.provisionEKS);
-      } else if (selectedInfras[0].value === "ecr") {
-        // Case: project exists, only provision ECR
-        this.provisionECR().then(() =>
-          pushFiltered(this.props, "/dashboard", ["project_id"], {
-            tab: "provisioner",
-          })
-        );
-      } else {
-        // Case: project exists, only provision EKS
-        this.provisionEKS();
-      }
-    } else {
-      if (selectedInfras.length === 2) {
-        // Case: project DNE, provision ECR + EKS
-        this.createProject(() => this.provisionECR().then(this.provisionEKS));
-      } else if (selectedInfras[0].value === "ecr") {
-        // Case: project DNE, only provision ECR
-        this.createProject(() =>
-          this.provisionECR().then(() =>
-            pushFiltered(this.props, "/dashboard", ["project_id"], {
-              tab: "provisioner",
-            })
-          )
-        );
-      } else {
-        // Case: project DNE, only provision EKS
-        this.createProject(this.provisionEKS);
-      }
+  const onCreateAWS = async () => {
+    // Track to segment the intent of provision cluster
+    props?.trackOnSave();
+    setButtonStatus("loading");
+    const { projectName } = props;
+
+    if (projectName) {
+      await createProject();
     }
+
+    const awsIntegrationId = await getAwsIntegrationId();
+
+    const filterNonAWSInfras = (infra: any) =>
+      ["ecr", "eks"].includes(infra.value);
+
+    const infraCreationRequests = selectedInfras
+      // Check that we don't include any other key into the infra creation than ecr and eks
+      .filter(filterNonAWSInfras)
+      .map((infra) => {
+        if (infra.value === "ecr") {
+          return provisionECR(awsIntegrationId?.id);
+        }
+        return provisionEKS(awsIntegrationId?.id);
+      });
+    // Wait for all promises to be completed (could be just one)
+    await Promise.all(infraCreationRequests);
+
+    pushFiltered({ history, location }, "/dashboard", ["project_id"], {
+      tab: "provisioner",
+    });
   };
 
-  getButtonStatus = () => {
-    if (this.props.projectName) {
-      if (!isAlphanumeric(this.props.projectName)) {
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
         return "Project name contains illegal characters";
       }
     }
     if (
-      !this.state.awsAccessId ||
-      !this.state.awsSecretKey ||
-      !this.state.provisionConfirmed ||
-      !this.state.clusterName ||
-      this.props.projectName === ""
+      !awsAccessId ||
+      !awsSecretKey ||
+      !provisionConfirmed ||
+      !clusterName ||
+      props.projectName === ""
     ) {
       return "Required fields missing";
     }
-    return this.state.buttonStatus;
+    return buttonStatus;
   };
 
-  renderClusterNameSection = () => {
-    let { selectedInfras, clusterName } = this.state;
-
+  const renderClusterNameSection = () => {
     if (
       selectedInfras.length == 2 ||
       (selectedInfras.length == 1 && selectedInfras[0].value === "eks")
@@ -339,9 +306,11 @@ class AWSFormSection extends Component<PropsType, StateType> {
         <InputRow
           type="text"
           value={clusterName}
-          setValue={(x: string) =>
-            this.setState({ clusterName: x, clusterNameSet: true })
-          }
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setClusterName(x);
+            setClusterNameSet(true);
+          }}
           label="Cluster Name"
           placeholder="ex: porter-cluster"
           width="100%"
@@ -351,121 +320,155 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { setSelectedProvisioner } = this.props;
-    let {
-      awsRegion,
-      awsMachineType,
-      awsAccessId,
-      awsSecretKey,
-      selectedInfras,
-    } = this.state;
-
-    return (
-      <StyledAWSFormSection>
-        <FormSection>
-          <CloseButton onClick={() => setSelectedProvisioner(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Heading isAtTop={true}>
-            AWS Credentials
-            <GuideButton
-              href="https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-              target="_blank"
-            >
-              <i className="material-icons-outlined">help</i>
-              Guide
-            </GuideButton>
-          </Heading>
-          <SelectRow
-            options={regionOptions}
-            width="100%"
-            value={awsRegion}
-            dropdownMaxHeight="240px"
-            setActiveValue={(x: string) => this.setState({ awsRegion: x })}
-            label="📍 AWS Region"
-          />
-          <SelectRow
-            options={machineTypeOptions}
-            width="100%"
-            value={awsMachineType}
-            dropdownMaxHeight="240px"
-            setActiveValue={(x: string) => this.setState({ awsMachineType: x })}
-            label="⚙️ AWS Machine Type"
-          />
-          <InputRow
-            type="text"
-            value={awsAccessId}
-            setValue={(x: string) => this.setState({ awsAccessId: x })}
-            label="👤 AWS Access ID"
-            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
-            width="100%"
-            isRequired={true}
-          />
-          <InputRow
-            type="password"
-            value={awsSecretKey}
-            setValue={(x: string) => this.setState({ awsSecretKey: x })}
-            label="🔒 AWS Secret Key"
-            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
-            width="100%"
-            isRequired={true}
-          />
-          <Br />
-          <Heading>AWS Resources</Heading>
-          <Helper>
-            Porter will provision the following AWS resources in your own cloud.
-          </Helper>
-          <CheckboxList
-            options={provisionOptions}
-            selected={selectedInfras}
-            setSelected={(x: { value: string; label: string }[]) => {
-              this.setState({ selectedInfras: x });
-            }}
-          />
-          {this.renderClusterNameSection()}
-          <Helper>
-            By default, Porter creates a cluster with three t2.medium instances
-            (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
-            resources. Learn more about EKS pricing
-            <Highlight
-              href="https://aws.amazon.com/eks/pricing/"
-              target="_blank"
-            >
-              here
-            </Highlight>
-            .
-          </Helper>
-          <CheckboxRow
-            isRequired={true}
-            checked={this.state.provisionConfirmed}
-            toggle={() =>
-              this.setState({
-                provisionConfirmed: !this.state.provisionConfirmed,
-              })
-            }
-            label="I understand and wish to proceed"
-          />
-        </FormSection>
-        {this.props.children ? this.props.children : <Padding />}
-        <SaveButton
-          text="Submit"
-          disabled={
-            this.checkFormDisabled() || this.state.buttonStatus === "loading"
-          }
-          onClick={this.onCreateAWS}
-          makeFlush={true}
-          status={this.getButtonStatus()}
-          helper="Note: Provisioning can take up to 15 minutes"
-        />
-      </StyledAWSFormSection>
+  const goToGuide = () => {
+    window?.analytics?.track("provision_go-to-guide", {
+      hosting: "aws",
+    });
+
+    window.open(
+      "https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
     );
-  }
-}
+  };
 
-AWSFormSection.contextType = Context;
+  return (
+    <StyledAWSFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>
+          AWS Credentials
+          <GuideButton onClick={() => goToGuide()}>
+            <i className="material-icons-outlined">help</i>
+            Guide
+          </GuideButton>
+        </Heading>
+        <SelectRow
+          options={regionOptions}
+          width="100%"
+          value={awsRegion}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsRegion(x);
+          }}
+          label="📍 AWS Region"
+        />
+        <SelectRow
+          options={machineTypeOptions}
+          width="100%"
+          value={awsMachineType}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsMachineType(x);
+          }}
+          label="⚙️ AWS Machine Type"
+        />
+        {/*
+        <Helper>
+          Estimated Cost:{" "}
+          <CostHighlight highlight={this.props.highlightCosts}>
+            {`\$${
+              70 + 3 * costMapping[this.state.awsMachineType] + 30
+            }/Month`}
+          </CostHighlight>
+          <Tooltip
+            title={
+              <div
+                style={{
+                  fontFamily: "Work Sans, sans-serif",
+                  fontSize: "12px",
+                  fontWeight: "normal",
+                  padding: "5px 6px",
+                }}
+              >
+                EKS cost: ~$70/month <br />
+                Machine (x3) cost: ~$
+                {`${3 * costMapping[this.state.awsMachineType]}`}/month <br />
+                Networking cost: ~$30/month
+              </div>
+            }
+            placement="top"
+          >
+            <StyledInfoTooltip>
+              <i className="material-icons">help_outline</i>
+            </StyledInfoTooltip>
+          </Tooltip>
+        </Helper>
+        */}
+        <InputRow
+          type="text"
+          value={awsAccessId}
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsAccessId(x);
+          }}
+          label="👤 AWS Access ID"
+          placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+          width="100%"
+          isRequired={true}
+        />
+        <InputRow
+          type="password"
+          value={awsSecretKey}
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setAwsSecretKey(x);
+          }}
+          label="🔒 AWS Secret Key"
+          placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+          width="100%"
+          isRequired={true}
+        />
+        <Br />
+        <Heading>AWS Resources</Heading>
+        <Helper>
+          Porter will provision the following AWS resources in your own cloud.
+        </Helper>
+        <CheckboxList
+          options={provisionOptions}
+          selected={selectedInfras}
+          setSelected={(x: { value: string; label: string }[]) => {
+            setIsFormDirty(true);
+            console.log(x);
+            setSelectedInfras(x);
+          }}
+        />
+        {renderClusterNameSection()}
+        <Helper>
+          By default, Porter creates a cluster with three t2.medium instances
+          (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
+          resources. Learn more about EKS pricing
+          <Highlight href="https://aws.amazon.com/eks/pricing/" target="_blank">
+            here
+          </Highlight>
+          .
+        </Helper>
+        <CheckboxRow
+          isRequired={true}
+          checked={provisionConfirmed}
+          toggle={() => {
+            setIsFormDirty(true);
+            setProvisionConfirmed(!provisionConfirmed);
+          }}
+          label="I understand and wish to proceed"
+        />
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        disabled={checkFormDisabled() || buttonStatus === "loading"}
+        onClick={onCreateAWS}
+        makeFlush={true}
+        status={getButtonStatus()}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledAWSFormSection>
+  );
+};
 
-export default withRouter(AWSFormSection);
+export default AWSFormSectionFC;
 
 const Highlight = styled.a`
   color: #8590ff;
@@ -518,7 +521,7 @@ const CloseButton = styled.div`
   }
 `;
 
-const GuideButton = styled.a`
+const GuideButton = styled.div`
   display: flex;
   align-items: center;
   margin-left: 20px;
@@ -527,7 +530,7 @@ const GuideButton = styled.a`
   margin-bottom: -1px;
   border: 1px solid #aaaabb;
   padding: 5px 10px;
-  padding-left: 6px;
+  padding-left: 8px;
   border-radius: 5px;
   cursor: pointer;
   :hover {
@@ -543,7 +546,7 @@ const GuideButton = styled.a`
   > i {
     color: #aaaabb;
     font-size: 16px;
-    margin-right: 6px;
+    margin-right: 7px;
   }
 `;
 
@@ -551,3 +554,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   margin: 0 auto;
 `;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 196 - 151
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import close from "assets/close.png";
@@ -19,16 +19,9 @@ type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   projectName: string;
+  highlightCosts?: boolean;
   infras: InfraType[];
-};
-
-type StateType = {
-  selectedInfras: { value: string; label: string }[];
-  subscriptionTier: string;
-  doRegion: string;
-  clusterName: string;
-  clusterNameSet: boolean;
-  provisionConfirmed: boolean;
+  trackOnSave: () => void;
 };
 
 const provisionOptions = [
@@ -55,25 +48,35 @@ const regionOptions = [
 ];
 
 // TODO: Consolidate across forms w/ HOC
-export default class DOFormSection extends Component<PropsType, StateType> {
-  state = {
-    selectedInfras: [...provisionOptions],
-    subscriptionTier: "basic",
-    doRegion: "nyc1",
-    clusterName: "",
-    clusterNameSet: false,
-    provisionConfirmed: false,
-  };
+const DOFormSectionFC: React.FC<PropsType> = (props) => {
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [subscriptionTier, setSubscriptionTier] = useState("basic");
+  const [doRegion, setDoRegion] = useState("nyc1");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+
+  const context = useContext(Context);
+
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
 
-  componentDidMount = () => {
-    let { infras } = this.props;
-    let { selectedInfras } = this.state;
-    this.setClusterNameIfNotSet();
+    window.analytics?.track("provision_form-dirty", {
+      provider: "do",
+    });
+  }, [isFormDirty]);
 
-    if (infras) {
+  useEffect(() => {
+    if (props.infras) {
       // From the dashboard, only uncheck and disable if "creating" or "created"
       let filtered = selectedInfras;
-      infras.forEach((infra: InfraType, i: number) => {
+      props.infras.forEach((infra: InfraType, i: number) => {
         let { kind, status } = infra;
         if (status === "creating" || status === "created") {
           filtered = filtered.filter((item: any) => {
@@ -81,39 +84,30 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           });
         }
       });
-      this.setState({ selectedInfras: filtered });
+      setSelectedInfras(filtered);
     }
-  };
+  }, [props.infras]);
 
-  componentDidUpdate = (prevProps: PropsType, prevState: StateType) => {
-    if (prevProps.projectName != this.props.projectName) {
-      this.setClusterNameIfNotSet();
-    }
-  };
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
 
-  setClusterNameIfNotSet = () => {
-    let projectName =
-      this.props.projectName || this.context.currentProject?.name;
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
 
-    if (
-      !this.state.clusterNameSet &&
-      !this.state.clusterName.includes(`${projectName}-cluster`)
-    ) {
-      this.setState({
-        clusterName: `${projectName}-cluster-${Math.random()
-          .toString(36)
-          .substring(2, 8)}`,
-      });
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
     }
   };
 
-  checkFormDisabled = () => {
-    if (!this.state.provisionConfirmed) {
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
       return true;
     }
 
-    let { selectedInfras, clusterName } = this.state;
-    let { projectName } = this.props;
+    let { projectName } = props;
     if (projectName || projectName === "") {
       return (
         !isAlphanumeric(projectName) ||
@@ -125,16 +119,16 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  catchError = (err: any) => {
+  const catchError = (err: any) => {
     console.log(err);
-    this.props.handleError();
+    props.handleError();
     return;
   };
 
   // Step 1: Create a project
-  createProject = (callback?: any) => {
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
+  const createProject = (callback?: any) => {
+    let { projectName } = props;
+    let { user, setProjects, setCurrentProject } = context;
 
     api
       .createProject("<token>", { name: projectName }, {})
@@ -153,9 +147,10 @@ export default class DOFormSection extends Component<PropsType, StateType> {
         setProjects(res_1.data);
         setCurrentProject(proj, () => callback && callback(proj.id));
       })
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
+<<<<<<< HEAD
   doRedirect = (projectId: number) => {
     let {
       subscriptionTier,
@@ -164,6 +159,10 @@ export default class DOFormSection extends Component<PropsType, StateType> {
       clusterName,
     } = this.state;
     let redirectUrl = `/api/projects/${projectId}/oauth/digitalocean?project_id=${projectId}&provision=do`;
+=======
+  const doRedirect = (projectId: number) => {
+    let redirectUrl = `/api/oauth/projects/${projectId}/digitalocean?project_id=${projectId}&provision=do`;
+>>>>>>> master
     redirectUrl += `&tier=${subscriptionTier}&region=${doRegion}&cluster_name=${clusterName}`;
     selectedInfras.forEach((option: { value: string; label: string }) => {
       redirectUrl += `&infras=${option.value}`;
@@ -173,36 +172,30 @@ export default class DOFormSection extends Component<PropsType, StateType> {
   };
 
   // TODO: handle generically (with > 2 steps)
-  onCreateDO = () => {
-    let { projectName } = this.props;
-    let { selectedInfras } = this.state;
-    let { currentProject } = this.context;
+  const onCreateDO = () => {
+    props?.trackOnSave();
+    let { projectName } = props;
+    let { currentProject } = context;
 
     if (!projectName) {
-      this.doRedirect(currentProject.id);
+      doRedirect(currentProject.id);
     } else {
-      this.createProject((projectId: number) => this.doRedirect(projectId));
+      createProject((projectId: number) => doRedirect(projectId));
     }
   };
 
-  getButtonStatus = () => {
-    if (this.props.projectName) {
-      if (!isAlphanumeric(this.props.projectName)) {
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
         return "Project name contains illegal characters";
       }
     }
-    if (
-      !this.state.provisionConfirmed ||
-      this.props.projectName === "" ||
-      !this.state.clusterName
-    ) {
+    if (!provisionConfirmed || props.projectName === "" || !clusterName) {
       return "Required fields missing";
     }
   };
 
-  renderClusterNameSection = () => {
-    let { selectedInfras, clusterName } = this.state;
-
+  const renderClusterNameSection = () => {
     if (
       selectedInfras.length == 2 ||
       (selectedInfras.length == 1 && selectedInfras[0].value === "doks")
@@ -211,9 +204,11 @@ export default class DOFormSection extends Component<PropsType, StateType> {
         <InputRow
           type="text"
           value={clusterName}
-          setValue={(x: string) =>
-            this.setState({ clusterName: x, clusterNameSet: true })
-          }
+          setValue={(x: string) => {
+            setClusterName(x);
+            setClusterNameSet(true);
+            setIsFormDirty(true);
+          }}
           label="Cluster Name"
           placeholder="ex: porter-cluster"
           width="100%"
@@ -223,86 +218,114 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { setSelectedProvisioner } = this.props;
-    let { selectedInfras, subscriptionTier, doRegion } = this.state;
-
-    return (
-      <StyledAWSFormSection>
-        <FormSection>
-          <CloseButton onClick={() => setSelectedProvisioner(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Heading isAtTop={true}>DigitalOcean Settings</Heading>
-          <SelectRow
-            options={tierOptions}
-            width="100%"
-            value={subscriptionTier}
-            setActiveValue={(x: string) =>
-              this.setState({ subscriptionTier: x })
-            }
-            label="💰 Subscription Tier"
-          />
-          <SelectRow
-            options={regionOptions}
-            width="100%"
-            dropdownMaxHeight="240px"
-            value={doRegion}
-            setActiveValue={(x: string) => this.setState({ doRegion: x })}
-            label="📍 DigitalOcean Region"
-          />
-          <Br />
-          <Heading>DigitalOcean Resources</Heading>
-          <Helper>
-            Porter will provision the following DigitalOcean resources in your
-            own cloud.
-          </Helper>
-          <CheckboxList
-            options={provisionOptions}
-            selected={selectedInfras}
-            setSelected={(x: { value: string; label: string }[]) => {
-              this.setState({ selectedInfras: x });
-            }}
-          />
-          {this.renderClusterNameSection()}
-          <Helper>
-            By default, Porter creates a cluster with three Standard (2vCPUs /
-            2GB RAM) droplets. DigitalOcean will bill you for any provisioned
-            resources. Learn more about DOKS pricing
-            <Highlight
-              href="https://www.digitalocean.com/products/kubernetes/"
-              target="_blank"
-            >
-              here
-            </Highlight>
-            .
-          </Helper>
-          <CheckboxRow
-            isRequired={true}
-            checked={this.state.provisionConfirmed}
-            toggle={() =>
-              this.setState({
-                provisionConfirmed: !this.state.provisionConfirmed,
-              })
+  return (
+    <StyledAWSFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>DigitalOcean Settings</Heading>
+        <SelectRow
+          options={tierOptions}
+          width="100%"
+          value={subscriptionTier}
+          setActiveValue={(x: string) => {
+            setSubscriptionTier(x);
+            setIsFormDirty(true);
+          }}
+          label="💰 Subscription Tier"
+        />
+        <SelectRow
+          options={regionOptions}
+          width="100%"
+          dropdownMaxHeight="240px"
+          value={doRegion}
+          setActiveValue={(x: string) => {
+            setDoRegion(x);
+            setIsFormDirty(true);
+          }}
+          label="📍 DigitalOcean Region"
+        />
+        <Br />
+        <Heading>DigitalOcean Resources</Heading>
+        <Helper>
+          Porter will provision the following DigitalOcean resources in your own
+          cloud.
+        </Helper>
+        <CheckboxList
+          options={provisionOptions}
+          selected={selectedInfras}
+          setSelected={(x: { value: string; label: string }[]) => {
+            setSelectedInfras(x);
+            setIsFormDirty(true);
+          }}
+        />
+        {renderClusterNameSection()}
+        <Helper>
+          By default, Porter creates a cluster with three Standard (2vCPUs / 2GB
+          RAM) droplets. DigitalOcean will bill you for any provisioned
+          resources. Learn more about DOKS pricing
+          <Highlight
+            href="https://www.digitalocean.com/products/kubernetes/"
+            target="_blank"
+          >
+            here
+          </Highlight>
+          .
+        </Helper>
+        {/*
+        <Helper>
+          Estimated Cost:{" "}
+          <CostHighlight highlight={this.props.highlightCosts}>
+            $90/Month
+          </CostHighlight>
+          <Tooltip
+            title={
+              <div
+                style={{
+                  fontFamily: "Work Sans, sans-serif",
+                  fontSize: "12px",
+                  fontWeight: "normal",
+                  padding: "5px 6px",
+                }}
+              >
+                Cluster cost: ~$10/month <br />
+                Machine (x3) cost: ~$60/month <br />
+                Networking cost: ~$20/month
+              </div>
             }
-            label="I understand and wish to proceed"
-          />
-        </FormSection>
-        {this.props.children ? this.props.children : <Padding />}
-        <SaveButton
-          text="Submit"
-          disabled={this.checkFormDisabled()}
-          onClick={this.onCreateDO}
-          makeFlush={true}
-          status={this.getButtonStatus()}
-          helper="Note: Provisioning can take up to 15 minutes"
+            placement="top"
+          >
+            <StyledInfoTooltip>
+              <i className="material-icons">help_outline</i>
+            </StyledInfoTooltip>
+          </Tooltip>
+        </Helper>
+        */}
+        <CheckboxRow
+          isRequired={true}
+          checked={provisionConfirmed}
+          toggle={() => {
+            setIsFormDirty(true);
+            setProvisionConfirmed(!provisionConfirmed);
+          }}
+          label="I understand and wish to proceed"
         />
-      </StyledAWSFormSection>
-    );
-  }
-}
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        disabled={checkFormDisabled()}
+        onClick={onCreateDO}
+        makeFlush={true}
+        status={getButtonStatus()}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledAWSFormSection>
+  );
+};
 
-DOFormSection.contextType = Context;
+export default DOFormSectionFC;
 
 const Highlight = styled.a`
   color: #8590ff;
@@ -388,3 +411,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   margin: 0 auto;
 `;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 2 - 0
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -12,6 +12,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 type PropsType = RouteComponentProps & {
   projectName: string;
+  trackOnSave: () => void;
 };
 
 type StateType = {
@@ -24,6 +25,7 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
   };
 
   onCreateProject = () => {
+    this.props?.trackOnSave();
     let { projectName } = this.props;
     let { user, setProjects, setCurrentProject } = this.context;
 

+ 253 - 206
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import close from "assets/close.png";
@@ -16,24 +16,15 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
-import { RouteComponentProps, withRouter } from "react-router";
+import { useHistory, useLocation } from "react-router";
 
-type PropsType = RouteComponentProps & {
+type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   projectName: string;
+  highlightCosts?: boolean;
   infras: InfraType[];
-};
-
-type StateType = {
-  gcpRegion: string;
-  gcpProjectId: string;
-  gcpKeyData: any;
-  clusterName: string;
-  clusterNameSet: boolean;
-  selectedInfras: { value: string; label: string }[];
-  buttonStatus: string;
-  provisionConfirmed: boolean;
+  trackOnSave: () => void;
 };
 
 const provisionOptions = [
@@ -68,27 +59,38 @@ const regionOptions = [
   { value: "us-west4", label: "us-west4" },
 ];
 
-class GCPFormSection extends Component<PropsType, StateType> {
-  state = {
-    gcpRegion: "us-east1",
-    gcpProjectId: "",
-    gcpKeyData: "",
-    clusterName: "",
-    clusterNameSet: false,
-    selectedInfras: [...provisionOptions],
-    buttonStatus: "",
-    provisionConfirmed: false,
-  };
+const GCPFormSectionFC: React.FC<PropsType> = (props) => {
+  const [gcpRegion, setGcpRegion] = useState("us-east1");
+  const [gcpProjectId, setGcpProjectId] = useState("");
+  const [gcpKeyData, setGcpKeyData] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  const context = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
 
-  componentDidMount = () => {
-    let { infras } = this.props;
-    let { selectedInfras } = this.state;
-    this.setClusterNameIfNotSet();
+    window.analytics?.track("provision_form-dirty", {
+      provider: "gcp",
+    });
+  }, [isFormDirty]);
 
-    if (infras) {
+  useEffect(() => {
+    if (props.infras) {
       // From the dashboard, only uncheck and disable if "creating" or "created"
       let filtered = selectedInfras;
-      infras.forEach((infra: InfraType, i: number) => {
+      props.infras.forEach((infra: InfraType, i: number) => {
         let { kind, status } = infra;
         if (status === "creating" || status === "created") {
           filtered = filtered.filter((item: any) => {
@@ -96,45 +98,30 @@ class GCPFormSection extends Component<PropsType, StateType> {
           });
         }
       });
-      this.setState({ selectedInfras: filtered });
+      setSelectedInfras(filtered);
     }
-  };
+  }, [props.infras]);
 
-  componentDidUpdate = (prevProps: PropsType, prevState: StateType) => {
-    if (prevProps.projectName != this.props.projectName) {
-      this.setClusterNameIfNotSet();
-    }
-  };
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
 
-  setClusterNameIfNotSet = () => {
-    let projectName =
-      this.props.projectName || this.context.currentProject?.name;
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
 
-    if (
-      !this.state.clusterNameSet &&
-      !this.state.clusterName.includes(`${projectName}-cluster`)
-    ) {
-      this.setState({
-        clusterName: `${projectName}-cluster-${Math.random()
-          .toString(36)
-          .substring(2, 8)}`,
-      });
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
     }
   };
 
-  checkFormDisabled = () => {
-    if (!this.state.provisionConfirmed) {
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
       return true;
     }
 
-    let {
-      gcpRegion,
-      gcpProjectId,
-      gcpKeyData,
-      selectedInfras,
-      clusterName,
-    } = this.state;
-    let { projectName } = this.props;
+    let { projectName } = props;
     if (projectName || projectName === "") {
       return (
         !isAlphanumeric(projectName) ||
@@ -158,15 +145,15 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  catchError = (err: any) => {
+  const catchError = (err: any) => {
     console.log(err);
-    this.props.handleError();
+    props.handleError();
   };
 
   // Step 1: Create a project
-  createProject = (callback?: any) => {
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
+  const createProject = (callback?: any) => {
+    let { projectName } = props;
+    let { user, setProjects, setCurrentProject } = context;
 
     api
       .createProject("<token>", { name: projectName }, {})
@@ -187,14 +174,14 @@ class GCPFormSection extends Component<PropsType, StateType> {
             setProjects(res.data);
             setCurrentProject(proj, () => callback && callback());
           })
-          .catch(this.catchError);
+          .catch(catchError);
       })
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  provisionGCR = (id: number, callback?: any) => {
+  const provisionGCR = (id: number, callback?: any) => {
     console.log("Provisioning GCR");
-    let { currentProject } = this.context;
+    let { currentProject } = context;
 
     return api
       .createGCR(
@@ -204,34 +191,32 @@ class GCPFormSection extends Component<PropsType, StateType> {
         },
         { project_id: currentProject.id }
       )
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  provisionGKE = (id: number) => {
+  const provisionGKE = (id: number) => {
     console.log("Provisioning GKE");
-    let { handleError } = this.props;
-    let { currentProject } = this.context;
+    let { currentProject } = context;
 
     api
       .createGKE(
         "<token>",
         {
-          gke_name: this.state.clusterName,
+          gke_name: clusterName,
           gcp_integration_id: id,
         },
         { project_id: currentProject.id }
       )
       .then((res) =>
-        pushFiltered(this.props, "/dashboard", ["project_id"], {
+        pushFiltered({ history, location }, "/dashboard", ["project_id"], {
           tab: "provisioner",
         })
       )
-      .catch(this.catchError);
+      .catch(catchError);
   };
 
-  handleCreateFlow = () => {
-    let { selectedInfras, gcpKeyData, gcpProjectId, gcpRegion } = this.state;
-    let { currentProject } = this.context;
+  const handleCreateFlow = () => {
+    let { currentProject } = context;
     api
       .createGCPIntegration(
         "<token>",
@@ -248,56 +233,59 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
           if (selectedInfras.length === 2) {
             // Case: project exists, provision GCR + GKE
-            this.provisionGCR(id).then(() => this.provisionGKE(id));
+            provisionGCR(id).then(() => provisionGKE(id));
           } else if (selectedInfras[0].value === "gcr") {
             // Case: project exists, only provision GCR
-            this.provisionGCR(id).then(() =>
-              pushFiltered(this.props, "/dashboard", ["project_id"], {
-                tab: "provisioner",
-              })
+            provisionGCR(id).then(() =>
+              pushFiltered(
+                { location, history },
+                "/dashboard",
+                ["project_id"],
+                {
+                  tab: "provisioner",
+                }
+              )
             );
           } else {
             // Case: project exists, only provision GKE
-            this.provisionGKE(id);
+            provisionGKE(id);
           }
         }
       })
       .catch(console.log);
   };
 
-  // TODO: handle generically (with > 2 steps)
-  onCreateGCP = () => {
-    this.setState({ buttonStatus: "loading" });
-    let { projectName } = this.props;
+  const onCreateGCP = () => {
+    props?.trackOnSave();
+    setButtonStatus("loading");
+    let { projectName } = props;
 
     if (!projectName) {
-      this.handleCreateFlow();
+      handleCreateFlow();
     } else {
-      this.createProject(this.handleCreateFlow);
+      createProject(handleCreateFlow);
     }
   };
 
-  getButtonStatus = () => {
-    if (this.props.projectName) {
-      if (!isAlphanumeric(this.props.projectName)) {
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
         return "Project name contains illegal characters";
       }
     }
     if (
-      !this.state.gcpProjectId ||
-      !this.state.gcpKeyData ||
-      !this.state.provisionConfirmed ||
-      !this.state.clusterName ||
-      this.props.projectName === ""
+      !gcpProjectId ||
+      !gcpKeyData ||
+      !provisionConfirmed ||
+      !clusterName ||
+      props.projectName === ""
     ) {
       return "Required fields missing";
     }
-    return this.state.buttonStatus;
+    return buttonStatus;
   };
 
-  renderClusterNameSection = () => {
-    let { selectedInfras, clusterName } = this.state;
-
+  const renderClusterNameSection = () => {
     if (
       selectedInfras.length == 2 ||
       (selectedInfras.length == 1 && selectedInfras[0].value === "gke")
@@ -306,9 +294,11 @@ class GCPFormSection extends Component<PropsType, StateType> {
         <InputRow
           type="text"
           value={clusterName}
-          setValue={(x: string) =>
-            this.setState({ clusterName: x, clusterNameSet: true })
-          }
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setClusterName(x);
+            setClusterNameSet(true);
+          }}
           label="Cluster Name"
           placeholder="ex: porter-cluster"
           width="100%"
@@ -318,106 +308,141 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { setSelectedProvisioner } = this.props;
-    let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
-    return (
-      <StyledGCPFormSection>
-        <FormSection>
-          <CloseButton onClick={() => setSelectedProvisioner(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Heading isAtTop={true}>
-            GCP Credentials
-            <GuideButton
-              href="https://docs.getporter.dev/docs/getting-started-on-gcp"
-              target="_blank"
-            >
-              <i className="material-icons-outlined">help</i>
-              Guide
-            </GuideButton>
-          </Heading>
-          <SelectRow
-            options={regionOptions}
-            width="100%"
-            value={gcpRegion}
-            dropdownMaxHeight="240px"
-            setActiveValue={(x: string) => this.setState({ gcpRegion: x })}
-            label="📍 GCP Region"
-          />
-          <InputRow
-            type="text"
-            value={gcpProjectId}
-            setValue={(x: string) => this.setState({ gcpProjectId: x })}
-            label="🏷️ GCP Project ID"
-            placeholder="ex: blindfold-ceiling-24601"
-            width="100%"
-            isRequired={true}
-          />
-          <UploadArea
-            setValue={(x: any) => this.setState({ gcpKeyData: x })}
-            label="🔒 GCP Key Data (JSON)"
-            placeholder="Choose a file or drag it here."
-            width="100%"
-            height="100%"
-            isRequired={true}
-          />
-
-          <Br />
-          <Heading>GCP Resources</Heading>
-          <Helper>
-            Porter will provision the following GCP resources in your own cloud.
-          </Helper>
-          <CheckboxList
-            options={provisionOptions}
-            selected={selectedInfras}
-            setSelected={(x: { value: string; label: string }[]) => {
-              this.setState({ selectedInfras: x });
-            }}
-          />
-          {this.renderClusterNameSection()}
-          <Helper>
-            By default, Porter creates a cluster with three e2-medium instances
-            (2vCPUs and 4GB RAM each). Google Cloud will bill you for any
-            provisioned resources. Learn more about GKE pricing
-            <Highlight
-              href="https://cloud.google.com/kubernetes-engine/pricing"
-              target="_blank"
-            >
-              here
-            </Highlight>
-            .
-          </Helper>
-          <CheckboxRow
-            isRequired={true}
-            checked={this.state.provisionConfirmed}
-            toggle={() =>
-              this.setState({
-                provisionConfirmed: !this.state.provisionConfirmed,
-              })
-            }
-            label="I understand and wish to proceed"
-          />
-        </FormSection>
-        {this.props.children ? this.props.children : <Padding />}
-        <SaveButton
-          text="Submit"
-          disabled={
-            this.checkFormDisabled() || this.state.buttonStatus === "loading"
-          }
-          onClick={this.onCreateGCP}
-          makeFlush={true}
-          status={this.getButtonStatus()}
-          helper="Note: Provisioning can take up to 15 minutes"
+  const goToGuide = () => {
+    window?.analytics?.track("provision_go-to-guide", {
+      hosting: "gcp",
+    });
+
+    window.open("https://docs.getporter.dev/docs/getting-started-on-gcp");
+  };
+
+  return (
+    <StyledGCPFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>
+          GCP Credentials
+          <GuideButton onClick={() => goToGuide()}>
+            <i className="material-icons-outlined">help</i>
+            Guide
+          </GuideButton>
+        </Heading>
+        <SelectRow
+          options={regionOptions}
+          width="100%"
+          value={gcpRegion}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setIsFormDirty(true);
+            setGcpRegion(x);
+          }}
+          label="📍 GCP Region"
+        />
+        <InputRow
+          type="text"
+          value={gcpProjectId}
+          setValue={(x: string) => {
+            setIsFormDirty(true);
+            setGcpProjectId(x);
+          }}
+          label="🏷️ GCP Project ID"
+          placeholder="ex: blindfold-ceiling-24601"
+          width="100%"
+          isRequired={true}
+        />
+        <UploadArea
+          setValue={(x: any) => {
+            setIsFormDirty(true);
+            setGcpKeyData(x);
+          }}
+          label="🔒 GCP Key Data (JSON)"
+          placeholder="Choose a file or drag it here."
+          width="100%"
+          height="100%"
+          isRequired={true}
         />
-      </StyledGCPFormSection>
-    );
-  }
-}
 
-GCPFormSection.contextType = Context;
+        <Br />
+        <Heading>GCP Resources</Heading>
+        <Helper>
+          Porter will provision the following GCP resources in your own cloud.
+        </Helper>
+        <CheckboxList
+          options={provisionOptions}
+          selected={selectedInfras}
+          setSelected={(x: { value: string; label: string }[]) => {
+            setIsFormDirty(true);
+            setSelectedInfras(x);
+          }}
+        />
+        {renderClusterNameSection()}
+        <Helper>
+          By default, Porter creates a cluster with three custom-2-4096
+          instances (2 CPU, 4 GB RAM each). Google Cloud will bill you for any
+          provisioned resources. Learn more about GKE pricing
+          <Highlight
+            href="https://cloud.google.com/kubernetes-engine/pricing"
+            target="_blank"
+          >
+            here
+          </Highlight>
+          .
+        </Helper>
+        {/*
+        <Helper>
+          Estimated Cost:{" "}
+          <CostHighlight highlight={this.props.highlightCosts}>
+            $250/Month
+          </CostHighlight>
+          <Tooltip
+            title={
+              <div
+                style={{
+                  fontFamily: "Work Sans, sans-serif",
+                  fontSize: "12px",
+                  fontWeight: "normal",
+                  padding: "5px 6px",
+                }}
+              >
+                GKE cost: ~$70/month <br />
+                Machine (x3) cost: ~$150/month <br />
+                Networking cost: ~$30/month
+              </div>
+            }
+            placement="top"
+          >
+            <StyledInfoTooltip>
+              <i className="material-icons">help_outline</i>
+            </StyledInfoTooltip>
+          </Tooltip>
+        </Helper>
+        */}
+        <CheckboxRow
+          isRequired={true}
+          checked={provisionConfirmed}
+          toggle={() => {
+            setIsFormDirty(true);
+            setProvisionConfirmed(!provisionConfirmed);
+          }}
+          label="I understand and wish to proceed"
+        />
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        disabled={checkFormDisabled() || buttonStatus === "loading"}
+        onClick={onCreateGCP}
+        makeFlush={true}
+        status={getButtonStatus()}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledGCPFormSection>
+  );
+};
 
-export default withRouter(GCPFormSection);
+export default GCPFormSectionFC;
 
 const Highlight = styled.a`
   color: #8590ff;
@@ -470,7 +495,7 @@ const CloseButton = styled.div`
   }
 `;
 
-const GuideButton = styled.a`
+const GuideButton = styled.div`
   display: flex;
   align-items: center;
   margin-left: 20px;
@@ -479,7 +504,7 @@ const GuideButton = styled.a`
   margin-bottom: -1px;
   border: 1px solid #aaaabb;
   padding: 5px 10px;
-  padding-left: 6px;
+  padding-left: 8px;
   border-radius: 5px;
   cursor: pointer;
   :hover {
@@ -495,7 +520,7 @@ const GuideButton = styled.a`
   > i {
     color: #aaaabb;
     font-size: 16px;
-    margin-right: 6px;
+    margin-right: 7px;
   }
 `;
 
@@ -503,3 +528,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   margin: 0 auto;
 `;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 188 - 156
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -11,134 +11,171 @@ import GCPFormSection from "./GCPFormSection";
 import DOFormSection from "./DOFormSection";
 import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
-import { RouteComponentProps, withRouter } from "react-router";
+import { useHistory, useLocation } from "react-router";
 import { pushFiltered } from "shared/routing";
 
-type PropsType = RouteComponentProps & {
+type Props = {
   isInNewProject?: boolean;
   projectName?: string;
   infras?: InfraType[];
   provisioner?: boolean;
 };
 
-type StateType = {
-  selectedProvider: string | null;
-  infras: InfraType[];
-};
-
 const providers = ["aws", "gcp", "do"];
 
-class NewProject extends Component<PropsType, StateType> {
-  state = {
-    selectedProvider: null as string | null,
-    infras: [] as InfraType[],
+const ProvisionerSettings: React.FC<Props> = ({
+  provisioner,
+  projectName,
+  infras,
+  isInNewProject,
+}) => {
+  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
+  const [highlightCosts, setHighlightCosts] = useState(true);
+
+  const { setCurrentError } = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    if (!provisioner) {
+      handleSelectProvider("skipped");
+    }
+  }, [provisioner]);
+
+  const handleSelectProvider = (newSelectedProvider: string) => {
+    if (!isInNewProject) {
+      setSelectedProvider(newSelectedProvider);
+      return;
+    }
+    if (newSelectedProvider === selectedProvider) {
+      return;
+    }
+
+    if (selectedProvider && !newSelectedProvider) {
+      window?.analytics?.track("provision_unselect-provider", {
+        unselectedProvider: selectedProvider,
+      });
+      setSelectedProvider(newSelectedProvider);
+      return;
+    }
+
+    window?.analytics?.track("provision_select-provider", {
+      selectedProvider: newSelectedProvider,
+    });
+    setSelectedProvider(newSelectedProvider);
   };
 
-  // Handle any submission (pre-status) error
-  handleError = () => {
-    let { setCurrentError } = this.context;
-    this.setState({ selectedProvider: null });
+  const handleError = () => {
+    handleSelectProvider(null);
+
     setCurrentError(
       "Provisioning failed. Check your credentials and try again."
     );
-    pushFiltered(this.props, "/dashboard", ["project_id"], { tab: "overview" });
+    pushFiltered({ location, history }, "/dashboard", ["project_id"], {
+      tab: "overview",
+    });
   };
 
-  renderSelectedProvider = (override?: string) => {
-    let { selectedProvider } = this.state;
-    let { projectName, infras } = this.props;
+  const trackOnSave = (provider: string) => {
+    window?.analytics?.track("provision_created-project", {
+      choosenProvider: provider,
+    });
+  };
 
-    if (override) {
-      selectedProvider = override;
+  const renderSkipHelper = () => {
+    if (!provisioner) {
+      return;
     }
 
-    let renderSkipHelper = () => {
-      if (!this.props.provisioner) {
-        return;
-      }
-
-      return (
-        <>
-          {selectedProvider === "skipped" ? (
+    return (
+      <>
+        {selectedProvider === "skipped" ? (
+          <Helper>
+            Don't have a Kubernetes cluster?
+            <Highlight onClick={() => handleSelectProvider(null)}>
+              Provision through Porter
+            </Highlight>
+          </Helper>
+        ) : (
+          <PositionWrapper selectedProvider={selectedProvider}>
             <Helper>
-              Don't have a Kubernetes cluster?
-              <Highlight
-                onClick={() => this.setState({ selectedProvider: null })}
-              >
-                Provision through Porter
+              Already have a Kubernetes cluster?
+              <Highlight onClick={() => handleSelectProvider("skipped")}>
+                Skip
               </Highlight>
             </Helper>
-          ) : (
-            <PositionWrapper selectedProvider={selectedProvider}>
-              <Helper>
-                Already have a Kubernetes cluster?
-                <Highlight
-                  onClick={() =>
-                    this.setState({
-                      selectedProvider: "skipped",
-                    })
-                  }
-                >
-                  Skip
-                </Highlight>
-              </Helper>
-            </PositionWrapper>
-          )}
-        </>
+          </PositionWrapper>
+        )}
+      </>
+    );
+  };
+
+  const renderSelectedProvider = (override?: string) => {
+    let currentSelectedProvider = selectedProvider;
+    if (override) {
+      currentSelectedProvider = override;
+    }
+
+    if (selectedProvider === "aws") {
+      return (
+        <AWSFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        >
+          {renderSkipHelper()}
+        </AWSFormSection>
+      );
+    }
+
+    if (selectedProvider === "gcp") {
+      return (
+        <GCPFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        >
+          {renderSkipHelper()}
+        </GCPFormSection>
       );
-    };
-
-    switch (selectedProvider) {
-      case "aws":
-        return (
-          <AWSFormSection
-            handleError={this.handleError}
-            projectName={projectName}
-            infras={infras}
-            setSelectedProvisioner={(x: string | null) => {
-              this.setState({ selectedProvider: x });
-            }}
-          >
-            {renderSkipHelper()}
-          </AWSFormSection>
-        );
-      case "gcp":
-        return (
-          <GCPFormSection
-            handleError={this.handleError}
-            projectName={projectName}
-            infras={infras}
-            setSelectedProvisioner={(x: string | null) => {
-              this.setState({ selectedProvider: x });
-            }}
-          >
-            {renderSkipHelper()}
-          </GCPFormSection>
-        );
-      case "do":
-        return (
-          <DOFormSection
-            handleError={this.handleError}
-            projectName={projectName}
-            infras={infras}
-            setSelectedProvisioner={(x: string | null) => {
-              this.setState({ selectedProvider: x });
-            }}
-          />
-        );
-      default:
-        return (
-          <ExistingClusterSection projectName={projectName}>
-            {renderSkipHelper()}
-          </ExistingClusterSection>
-        );
     }
+
+    if (selectedProvider === "do") {
+      return (
+        <DOFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        />
+      );
+    }
+
+    return (
+      <ExistingClusterSection
+        projectName={projectName}
+        trackOnSave={() => trackOnSave(selectedProvider)}
+      >
+        {renderSkipHelper()}
+      </ExistingClusterSection>
+    );
   };
 
-  renderFooter = () => {
-    let { selectedProvider } = this.state;
-    let { isInNewProject } = this.props;
-    let { provisioner } = this.props;
+  const renderFooter = () => {
     let helper = provisioner
       ? "Note: Provisioning can take up to 15 minutes"
       : "";
@@ -148,9 +185,7 @@ class NewProject extends Component<PropsType, StateType> {
         <>
           <Helper>
             Already have a Kubernetes cluster?
-            <Highlight
-              onClick={() => this.setState({ selectedProvider: "skipped" })}
-            >
+            <Highlight onClick={() => handleSelectProvider("skipped")}>
               Skip
             </Highlight>
           </Helper>
@@ -167,24 +202,7 @@ class NewProject extends Component<PropsType, StateType> {
     }
   };
 
-  componentDidMount() {
-    let { provisioner } = this.props;
-
-    if (!provisioner) {
-      this.setState({ selectedProvider: "skipped" });
-    }
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.provisioner !== this.props.provisioner) {
-      if (!this.props.provisioner) {
-        this.setState({ selectedProvider: "skipped" });
-      }
-    }
-  }
-
-  renderHelperText = () => {
-    let { isInNewProject, provisioner } = this.props;
+  const renderHelperText = () => {
     if (!provisioner) {
       return;
     }
@@ -200,42 +218,51 @@ class NewProject extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
-    let { selectedProvider } = this.state;
-
-    return (
-      <StyledProvisionerSettings>
-        <Helper>{this.renderHelperText()}</Helper>
-        {!selectedProvider ? (
-          <BlockList>
-            {providers.map((provider: string, i: number) => {
-              let providerInfo = integrationList[provider];
-              return (
-                <Block
-                  key={i}
-                  onClick={() => {
-                    this.setState({ selectedProvider: provider });
+  return (
+    <StyledProvisionerSettings>
+      <Helper>{renderHelperText()}</Helper>
+      {!selectedProvider ? (
+        <BlockList>
+          {providers.map((provider: string, i: number) => {
+            let providerInfo = integrationList[provider];
+            return (
+              <Block
+                key={i}
+                onClick={() => {
+                  handleSelectProvider(provider);
+                  setHighlightCosts(false);
+                }}
+              >
+                <Icon src={providerInfo.icon} />
+                <BlockTitle>{providerInfo.label}</BlockTitle>
+                <CostSection
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    handleSelectProvider(provider);
+                    setHighlightCosts(true);
                   }}
                 >
-                  <Icon src={providerInfo.icon} />
-                  <BlockTitle>{providerInfo.label}</BlockTitle>
-                  <BlockDescription>Hosted in your own cloud.</BlockDescription>
-                </Block>
-              );
-            })}
-          </BlockList>
-        ) : (
-          <>{this.renderSelectedProvider()}</>
-        )}
-        {this.renderFooter()}
-      </StyledProvisionerSettings>
-    );
-  }
-}
-
-NewProject.contextType = Context;
+                  {/*
+                  {provider == "aws" && "$205/Month"}
+                  {provider == "gcp" && "$250/Month"}
+                  {provider == "do" && "$90/Month"}
+                  <InfoTooltip text={""} />
+                  */}
+                </CostSection>
+                <BlockDescription>Hosted in your own cloud.</BlockDescription>
+              </Block>
+            );
+          })}
+        </BlockList>
+      ) : (
+        <>{renderSelectedProvider()}</>
+      )}
+      {renderFooter()}
+    </StyledProvisionerSettings>
+  );
+};
 
-export default withRouter(NewProject);
+export default ProvisionerSettings;
 
 const Br = styled.div`
   width: 100%;
@@ -335,3 +362,8 @@ const Block = styled.div<{ disabled?: boolean }>`
     }
   }
 `;
+
+const CostSection = styled.p`
+  position: absolute;
+  left: 0;
+`;

+ 6 - 1
dashboard/src/shared/Context.tsx

@@ -1,6 +1,11 @@
 import React, { Component } from "react";
 
-import { CapabilityType, ClusterType, ContextProps, ProjectType } from "shared/types";
+import {
+  CapabilityType,
+  ClusterType,
+  ContextProps,
+  ProjectType,
+} from "shared/types";
 
 import { pushQueryParams } from "shared/routing";
 

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

@@ -280,11 +280,12 @@ const generateGHAWorkflow = baseApi<
     cluster_id: number;
     project_id: number;
     name: string;
+    namespace: string;
   }
 >("POST", (pathParams) => {
-  const { name, cluster_id, project_id } = pathParams;
+  const { name, namespace, cluster_id, project_id } = pathParams;
 
-  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
+  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}&namespace=${namespace}`;
 });
 
 const deployTemplate = baseApi<
@@ -341,7 +342,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -357,7 +362,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -373,7 +382,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -672,7 +685,30 @@ const getReleaseToken = baseApi<
 >("GET", (pathParams) => {
   let { id, cluster_id, namespace, name } = pathParams;
 
+<<<<<<< HEAD
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/webhook`;
+=======
+const getReleaseSteps = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  { name: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/steps`;
+});
+
+const destroyEKS = baseApi<
+  {
+    eks_name: string;
+  },
+  {
+    project_id: number;
+    infra_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
+>>>>>>> master
 });
 
 const destroyInfra = baseApi<
@@ -739,6 +775,18 @@ const getMetadata = baseApi<{}, {}>("GET", () => {
   return `/api/metadata`;
 });
 
+const getWelcome = baseApi<
+  {
+    email: string;
+    isCompany: boolean;
+    company: string;
+    role: string;
+  },
+  {}
+>("GET", () => {
+  return `/api/welcome`;
+});
+
 const linkGithubProject = baseApi<
   {},
   {
@@ -1048,7 +1096,12 @@ export default {
   detectBuildpack,
   getBranchContents,
   getBranches,
+<<<<<<< HEAD
   getMetadata,
+=======
+  getCapabilities,
+  getWelcome,
+>>>>>>> master
   getChart,
   getCharts,
   getChartComponents,
@@ -1084,6 +1137,7 @@ export default {
   getPrometheusIsInstalled,
   getRegistryIntegrations,
   getReleaseToken,
+  getReleaseSteps,
   getRepoIntegrations,
   getSlackIntegrations,
   getRepos,

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

@@ -0,0 +1,83 @@
+import axios, { AxiosPromise, AxiosRequestConfig, Method } from "axios";
+import qs from "qs";
+
+type EndpointParam<PathParamsType> =
+  | string
+  | ((pathParams: PathParamsType) => string);
+
+type BuildAxiosConfigFunction = (
+  method: Method,
+  endpoint: EndpointParam<unknown>,
+  token: string,
+  params: unknown,
+  pathParams: unknown
+) => AxiosRequestConfig;
+
+const buildAxiosConfig: BuildAxiosConfigFunction = (
+  method,
+  endpoint,
+  token,
+  params,
+  pathParams
+) => {
+  const config: AxiosRequestConfig = {
+    method,
+    url: typeof endpoint === "function" ? endpoint(pathParams) : endpoint,
+  };
+
+  const AuthHeaders = {
+    Authorization: `Bearer ${token}`,
+  };
+
+  if (method.toUpperCase() === "POST") {
+    return {
+      ...config,
+      data: params,
+      headers: AuthHeaders,
+    };
+  }
+
+  if (method.toUpperCase() === "PUT") {
+    return {
+      ...config,
+      data: params,
+      headers: AuthHeaders,
+    };
+  }
+
+  if (method.toUpperCase() === "DELETE") {
+    const queryParams = qs.stringify(params, {
+      arrayFormat: "repeat",
+    });
+    return {
+      ...config,
+      url: `${config.url}?${queryParams}`,
+    };
+  }
+
+  if (method.toUpperCase() === "GET") {
+    return {
+      ...config,
+      params: params,
+      paramsSerializer: (params) =>
+        qs.stringify(params, { arrayFormat: "repeat" }),
+    };
+  }
+
+  return config;
+};
+
+const apiQueryBuilder = <ParamsType extends {}, PathParamsType = {}>(
+  method: Method = "GET",
+  endpoint: EndpointParam<PathParamsType>
+) => <ResponseType = any>(
+  token: string,
+  params: ParamsType,
+  pathParams: PathParamsType
+) =>
+  axios(
+    buildAxiosConfig(method, endpoint, token, params, pathParams)
+  ) as AxiosPromise<ResponseType>;
+
+export { apiQueryBuilder as baseApi };
+export default apiQueryBuilder;

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

@@ -297,3 +297,14 @@ export interface ContextProps {
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
 }
+
+export enum JobStatusType {
+  Succeeded = "succeeded",
+  Running = "active",
+  Failed = "failed",
+}
+
+export interface JobStatusWithTimeType {
+  status: JobStatusType;
+  start_time: string;
+}

+ 0 - 3
dashboard/webpack.config.js

@@ -78,7 +78,6 @@ module.exports = () => {
       publicPath: "/",
     },
     devServer: {
-      port: env["PORT"],
       historyApiFallback: true,
       disableHostCheck: true,
       host: "0.0.0.0",
@@ -134,9 +133,7 @@ module.exports = () => {
   if (env.ENABLE_ANALYZER) {
     config.plugins.push(new BundleAnalyzerPlugin());
   }
-  console.log(env);
   if (env.ENABLE_PROXY) {
-    console.log("WORKED!");
     if (!env.API_SERVER) {
       throw new Error(
         "API_SERVER is not present on .env! Please setup the api server url if you want the proxy to work! API_SERVER example: http://localhost:8080"

+ 3 - 1
docker/Dockerfile

@@ -20,9 +20,11 @@ RUN --mount=type=cache,target=$GOPATH/pkg/mod \
 # --------------------
 FROM base AS build-go
 
+ARG version=production
+
 RUN --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=cache,target=$GOPATH/pkg/mod \
-    go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
+    go build -ldflags="-w -s -X 'main.Version=${version}'" -a -o ./bin/app ./cmd/app && \
     go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate && \
     go build -ldflags '-w -s' -a -o ./bin/ready ./cmd/ready
 

+ 14 - 1
docs/deploy/applications/deploying-from-the-cli.md

@@ -118,6 +118,19 @@ If you would only like to update the configuration for your application via a `v
 porter update config --app [app-name] --values [values-file]
 ```
 
+For example, to update the app `web-test`, and to programmatically set the environment variables for that application, create a file called `web-test-values.yaml` with the following structure:
+
+```yaml
+container:
+  env:
+    normal:
+      TESTING: test-from-cli
+```
+
+If I then run `porter update config --app web-test --values web-test-values.yaml`, I will now see the new values in the application:
+
+![CLI env vars](https://files.readme.io/1c30b1c-Screen_Shot_2021-08-20_at_11.51.41_AM.png "Screen Shot 2021-08-20 at 11.51.41 AM.png")
+
 # Common Configuration Options
 
 ## Container Port
@@ -150,7 +163,7 @@ This configuration only applies to `web` applications.
 ```yaml
 ingress:
   custom_domain: true
-  custom_paths:
+  hosts:
   - my-app.example.com
 ```
 

+ 71 - 18
docs/developing/analytics.md

@@ -1,10 +1,10 @@
-# How the analytics package works
+# Adding Analytics
 
-The analytics package is entirely dependant over segment, to use it you should add
-a config key SEGMENT_CLIENT_KEY on `docker/.env` file.
-To find the segment client key check [this link](https://segment.com/docs/connections/find-writekey/).
+## Package Overview
 
-This package is divided in four files:
+The [analytics package](https://github.com/porter-dev/porter/tree/master/internal/analytics) is currently dependent on Segment, so to use it you need to add your segment key via an environment variables `SEGMENT_CLIENT_KEY` in the `docker/.env` file. See [this link](https://segment.com/docs/connections/find-writekey/) for information on how to retrieve your key.
+
+This package is divided in five files:
 
 - segment.go
 
@@ -14,6 +14,10 @@ This package is divided in four files:
 
   _tracks.go_ will export an interface `SegmentTrack` that all the tracks should follow, this helps when trying to standardize the analytics package. The idea behind this is to always use a constructor for the track that we're trying to use instead of having different implementations all over the app.
 
+- track_scopes.go
+
+  _track_scopes.go_ contains a set of "scopes" that a certain `SegmentTrack` will use. API operations can be user-scoped, project-scoped, cluster-scoped, etc. These scopes will populate certain fields in the track, like `project_id` and `cluster_id`. Most tracks will be project- or cluster-scoped, so when adding a new track, you can likely use an existing scope. If adding a scope is required, it should be straightforward to use the existing structure contained in this file.
+
 - track_events.go
 
   Enum of events that can be used on tracks, those will be implemented on the tracks.go so they shouldn't appear in any other part of the application.
@@ -22,9 +26,69 @@ This package is divided in four files:
 
   Similar as the tracks.go, although this is more specialized as it should only be used on user register/login/update parts of the application.
 
-## How to add new analytics to the app
+## Adding New Analytics
+
+### Adding New Events to Track
+
+To add a new event to track, you should follow two steps (see example below):
+
+1. Add the event in `track_events.go`, in the form `[Noun][Verb][Subverb]` (for example, `ClusterProvisioningSuccess`).
+
+2. You should then add the track in `tracks.go` by adding the following methods:
+
+```go
+// [Noun][Verb][Subverb]TrackOpts
+type [Noun][Verb][Subverb]TrackOpts  struct {
+	// *Optional Scope
+
+  // Additional fields
+}
+
+// [Noun][Verb][Subverb]Track
+func [Noun][Verb][Subverb]Track(opts *[Noun][Verb][Subverb]TrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+  // add additional fields to the Segment properties
+
+  // return an object which implements segmentTrack -- you can most likely use a scope helper here
+}
+```
 
-### Adding new segment spec objects
+So for example, to implement the track `ClusterProvisioningSuccess`, the following gets written:
+
+1. In `track_events.go`:
+
+```go
+  ClusterProvisioningSuccess SegmentEvent = "Cluster Provisioning Success"
+```
+
+2. In `tracks.go`:
+
+```go
+// ClusterProvisioningSuccessTrackOpts are the options for creating a track when a cluster
+// has successfully provisioned
+type ClusterProvisioningSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+// ClusterProvisioningSuccessTrack returns a new track for when a cluster
+// has successfully provisioned
+func ClusterProvisioningSuccessTrack(opts *ClusterProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningSuccess),
+	)
+}
+```
+
+### Adding New Segment [Specs](https://segment.com/docs/connections/spec/)
 
 The current implementation only uses [Tracks](https://segment.com/docs/connections/spec/track/) and [Identifiers](https://segment.com/docs/connections/spec/identify/) specs from the segment package, in order to add a new spec you should follow this steps:
 
@@ -32,14 +96,3 @@ The current implementation only uses [Tracks](https://segment.com/docs/connectio
 - Create a new file on the same `internal/analytics` folder with the name on plural of the spec you want to add.
 - In this spec file, you should declare the interface that the analyticsClient spec function will receive, and after that the correspondant structs that will refer to the different metrics you want to add. For more examples on how to implement this you can use as reference the `internal/analytics/tracks.go` file.
 - Update this file with the correspondant documentation about the implementation
-
-### Adding new objects to current implemented specs
-
-In order to add new metrics to the current implementation the process should be simple:
-
-- Look for the segment spec file in `internal/analytics` folder that you want to use
-- Add a new struct that accomplish the interface defined at the start of the file with the data that you need for that metric
-- Write a constructor for the struct.
-- You're done to use!
-
-For any doubts about this document or how to improve the analytics you can reach us on discord!

+ 0 - 1
go.sum

@@ -552,7 +552,6 @@ github.com/google/go-github/v29 v29.0.3/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD
 github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
 github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM=
 github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg=
-github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=

+ 4 - 4
internal/analytics/identifiers.go

@@ -18,14 +18,14 @@ type segmentIdentifyNewUser struct {
 	isGithub  bool
 }
 
-// Creates a segment Identifier struct for new users. As we handle registration with github, it also accepts a param
-// to check if the new user has registered with github or not.
-func CreateSegmentIdentifyNewUser(user *models.User, registeredViaGithub bool) *segmentIdentifyNewUser {
+// CreateSegmentIdentifyUser creates an identifier for users
+func CreateSegmentIdentifyUser(user *models.User) *segmentIdentifyNewUser {
 	userId := fmt.Sprintf("%v", user.ID)
+
 	return &segmentIdentifyNewUser{
 		userId:    userId,
 		userEmail: user.Email,
-		isGithub:  registeredViaGithub,
+		isGithub:  user.GithubUserID != 0,
 	}
 }
 

+ 27 - 3
internal/analytics/track_events.go

@@ -3,7 +3,31 @@ package analytics
 type SegmentEvent string
 
 const (
-	NewUser            SegmentEvent = "New User"
-	RedeployViaWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
-	NewClusterEvent    SegmentEvent = "New Cluster Event"
+	// onboarding flow
+	UserCreate      SegmentEvent = "New User"
+	UserVerifyEmail SegmentEvent = "User Verified Email"
+	ProjectCreate   SegmentEvent = "New Project Event"
+
+	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"
+	ClusterProvisioningError   SegmentEvent = "Cluster Provisioning Error"
+	ClusterProvisioningSuccess SegmentEvent = "Cluster Provisioning Success"
+
+	RegistryProvisioningStart   SegmentEvent = "Registry Provisioning Started"
+	RegistryProvisioningError   SegmentEvent = "Registry Provisioning Error"
+	RegistryProvisioningSuccess SegmentEvent = "Registry Provisioning Success"
+
+	ClusterConnectionStart   SegmentEvent = "Cluster Connection Started"
+	ClusterConnectionSuccess SegmentEvent = "Cluster Connection Success"
+
+	RegistryConnectionStart   SegmentEvent = "Registry Connection Started"
+	RegistryConnectionSuccess SegmentEvent = "Registry Connection Success"
+
+	GithubConnectionStart   SegmentEvent = "Github Connection Started"
+	GithubConnectionSuccess SegmentEvent = "Github Connection Success"
+
+	// launch flow
+	ApplicationLaunchStart   SegmentEvent = "Application Launch Started"
+	ApplicationLaunchSuccess SegmentEvent = "Application Launch Success"
+
+	ApplicationDeploymentWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
 )

+ 167 - 0
internal/analytics/track_scopes.go

@@ -0,0 +1,167 @@
+package analytics
+
+import (
+	"fmt"
+)
+
+// UserScopedTrack is a track that automatically adds a user id to the tracking
+type UserScopedTrack struct {
+	*defaultSegmentTrack
+
+	userID uint
+}
+
+// UserScopedTrackOpts are the options for created a new user-scoped track
+type UserScopedTrackOpts struct {
+	*defaultTrackOpts
+
+	UserID uint
+}
+
+// GetUserScopedTrackOpts is a helper method to getting the UserScopedTrackOpts
+func GetUserScopedTrackOpts(userID uint) *UserScopedTrackOpts {
+	return &UserScopedTrackOpts{
+		UserID: userID,
+	}
+}
+
+func (u *UserScopedTrack) getUserId() string {
+	return fmt.Sprintf("%d", u.userID)
+}
+
+func getSegmentUserTrack(opts *UserScopedTrackOpts, track *defaultSegmentTrack) *UserScopedTrack {
+	return &UserScopedTrack{
+		defaultSegmentTrack: track,
+		userID:              opts.UserID,
+	}
+}
+
+// ProjectScopedTrack is a track that automatically adds a project id to the track
+type ProjectScopedTrack struct {
+	*UserScopedTrack
+
+	projectID uint
+}
+
+// ProjectScopedTrackOpts are the options for created a new project-scoped track
+type ProjectScopedTrackOpts struct {
+	*UserScopedTrackOpts
+
+	ProjectID uint
+}
+
+// GetProjectScopedTrackOpts is a helper method to getting the ProjectScopedTrackOpts
+func GetProjectScopedTrackOpts(userID, projID uint) *ProjectScopedTrackOpts {
+	return &ProjectScopedTrackOpts{
+		UserScopedTrackOpts: GetUserScopedTrackOpts(userID),
+		ProjectID:           projID,
+	}
+}
+
+func getSegmentProjectTrack(opts *ProjectScopedTrackOpts, track *defaultSegmentTrack) *ProjectScopedTrack {
+	track.properties.addProjectProperties(opts)
+
+	return &ProjectScopedTrack{
+		UserScopedTrack: getSegmentUserTrack(opts.UserScopedTrackOpts, track),
+		projectID:       opts.ProjectID,
+	}
+}
+
+// RegistryScopedTrack is a track that automatically adds a registry id to the track
+type RegistryScopedTrack struct {
+	*ProjectScopedTrack
+
+	registryID uint
+}
+
+// RegistryScopedTrackOpts are the options for created a new registry-scoped track
+type RegistryScopedTrackOpts struct {
+	*ProjectScopedTrackOpts
+
+	RegistryID uint
+}
+
+// GetRegistryScopedTrackOpts is a helper method to getting the RegistryScopedTrackOpts
+func GetRegistryScopedTrackOpts(userID, projID, regID uint) *RegistryScopedTrackOpts {
+	return &RegistryScopedTrackOpts{
+		ProjectScopedTrackOpts: GetProjectScopedTrackOpts(userID, projID),
+		RegistryID:             regID,
+	}
+}
+
+func getSegmentRegistryTrack(opts *RegistryScopedTrackOpts, track *defaultSegmentTrack) *RegistryScopedTrack {
+	track.properties.addRegistryProperties(opts)
+
+	return &RegistryScopedTrack{
+		ProjectScopedTrack: getSegmentProjectTrack(opts.ProjectScopedTrackOpts, track),
+		registryID:         opts.RegistryID,
+	}
+}
+
+// ClusterScopedTrack is a track that automatically adds a cluster id to the track
+type ClusterScopedTrack struct {
+	*ProjectScopedTrack
+
+	clusterID uint
+}
+
+// ClusterScopedTrackOpts are the options for created a new cluster-scoped track
+type ClusterScopedTrackOpts struct {
+	*ProjectScopedTrackOpts
+
+	ClusterID uint
+}
+
+// GetClusterScopedTrackOpts is a helper method to getting the ClusterScopedTrackOpts
+func GetClusterScopedTrackOpts(userID, projID, clusterID uint) *ClusterScopedTrackOpts {
+	return &ClusterScopedTrackOpts{
+		ProjectScopedTrackOpts: GetProjectScopedTrackOpts(userID, projID),
+		ClusterID:              clusterID,
+	}
+}
+
+func getSegmentClusterTrack(opts *ClusterScopedTrackOpts, track *defaultSegmentTrack) *ClusterScopedTrack {
+	track.properties.addClusterProperties(opts)
+
+	return &ClusterScopedTrack{
+		ProjectScopedTrack: getSegmentProjectTrack(opts.ProjectScopedTrackOpts, track),
+		clusterID:          opts.ClusterID,
+	}
+}
+
+// ApplicationScopedTrack is a track that automatically adds an app name and namespace to the track
+type ApplicationScopedTrack struct {
+	*ClusterScopedTrack
+
+	name      string
+	namespace string
+}
+
+// ApplicationScopedTrackOpts are the options for created a new app-scoped track
+type ApplicationScopedTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	Name      string
+	Namespace string
+	ChartName string
+}
+
+// GetApplicationScopedTrackOpts is a helper method to getting the ApplicationScopedTrackOpts
+func GetApplicationScopedTrackOpts(userID, projID, clusterID uint, name, namespace, chartName string) *ApplicationScopedTrackOpts {
+	return &ApplicationScopedTrackOpts{
+		ClusterScopedTrackOpts: GetClusterScopedTrackOpts(userID, projID, clusterID),
+		Name:                   name,
+		Namespace:              namespace,
+		ChartName:              chartName,
+	}
+}
+
+func getSegmentApplicationTrack(opts *ApplicationScopedTrackOpts, track *defaultSegmentTrack) *ApplicationScopedTrack {
+	track.properties.addApplicationProperties(opts)
+
+	return &ApplicationScopedTrack{
+		ClusterScopedTrack: getSegmentClusterTrack(opts.ClusterScopedTrackOpts, track),
+		name:               opts.Name,
+		namespace:          opts.Namespace,
+	}
+}

+ 397 - 62
internal/analytics/tracks.go

@@ -1,8 +1,6 @@
 package analytics
 
 import (
-	"fmt"
-
 	"github.com/porter-dev/porter/internal/models"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 )
@@ -13,97 +11,434 @@ type segmentTrack interface {
 	getProperties() segment.Properties
 }
 
-type segmentNewUserTrack struct {
-	userId    string
-	userEmail string
+type defaultTrackOpts struct {
+	AdditionalProps map[string]interface{}
+}
+
+type defaultSegmentTrack struct {
+	event      SegmentEvent
+	properties segmentProperties
 }
 
-// CreateSegmentNewUserTrack creates a track of type "New User", which
-// tracks when a user has registered
-func CreateSegmentNewUserTrack(user *models.User) *segmentNewUserTrack {
-	userId := fmt.Sprintf("%v", user.ID)
+func getDefaultSegmentTrack(additionalProps map[string]interface{}, event SegmentEvent) *defaultSegmentTrack {
+	props := newSegmentProperties()
+	props.addAdditionalProperties(additionalProps)
 
-	return &segmentNewUserTrack{
-		userId:    userId,
-		userEmail: user.Email,
+	return &defaultSegmentTrack{
+		event:      event,
+		properties: props,
 	}
 }
 
-func (t *segmentNewUserTrack) getUserId() string {
-	return t.userId
+func (t *defaultSegmentTrack) getEvent() SegmentEvent {
+	return t.event
+}
+
+func (t *defaultSegmentTrack) getProperties() segment.Properties {
+	props := segment.NewProperties()
+
+	for key, val := range t.properties {
+		props = props.Set(key, val)
+	}
+
+	return props
+}
+
+type segmentProperties map[string]interface{}
+
+func newSegmentProperties() segmentProperties {
+	props := make(map[string]interface{})
+
+	return props
+}
+
+func (p segmentProperties) addProjectProperties(opts *ProjectScopedTrackOpts) {
+	p["proj_id"] = opts.ProjectID
 }
 
-func (t *segmentNewUserTrack) getEvent() SegmentEvent {
-	return NewUser
+func (p segmentProperties) addClusterProperties(opts *ClusterScopedTrackOpts) {
+	p["cluster_id"] = opts.ClusterID
 }
 
-func (t *segmentNewUserTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("email", t.userEmail)
+func (p segmentProperties) addRegistryProperties(opts *RegistryScopedTrackOpts) {
+	p["registry_id"] = opts.RegistryID
 }
 
-type segmentRedeployViaWebhookTrack struct {
-	userId     string
-	repository string
+func (p segmentProperties) addApplicationProperties(opts *ApplicationScopedTrackOpts) {
+	p["app_name"] = opts.Name
+	p["app_namespace"] = opts.Namespace
+	p["chart_name"] = opts.ChartName
 }
 
-// CreateSegmentRedeployViaWebhookTrack creates a track of type "Triggered Re-deploy via Webhook", which
-// tracks whenever a repository is redeployed via webhook call
-func CreateSegmentRedeployViaWebhookTrack(userId string, repository string) *segmentRedeployViaWebhookTrack {
-	return &segmentRedeployViaWebhookTrack{
-		userId:     userId,
-		repository: repository,
+func (p segmentProperties) addAdditionalProperties(props map[string]interface{}) {
+	for key, val := range props {
+		p[key] = val
 	}
 }
 
-func (t *segmentRedeployViaWebhookTrack) getUserId() string {
-	return t.userId
+// UserCreateTrackOpts are the options for creating a track when a user is created
+type UserCreateTrackOpts struct {
+	*UserScopedTrackOpts
+
+	Email string
 }
 
-func (t *segmentRedeployViaWebhookTrack) getEvent() SegmentEvent {
-	return RedeployViaWebhook
+// UserCreateTrack returns a track for when a user is created
+func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserCreate),
+	)
 }
 
-func (t *segmentRedeployViaWebhookTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("repository", t.repository)
+// UserCreateTrackOpts are the options for creating a track when a user's email is verified
+type UserVerifyEmailTrackOpts struct {
+	*UserScopedTrackOpts
+
+	Email string
 }
 
-type segmentNewClusterEventTrack struct {
-	userId      string
-	projId      string
-	clusterName string
-	clusterType string // EKS, DOKS, or GKE
-	eventType   string // connected, provisioned, or destroyed
+// UserVerifyEmailTrack returns a track for when a user's email is verified
+func UserVerifyEmailTrack(opts *UserVerifyEmailTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserVerifyEmail),
+	)
 }
 
-// NewClusterEventOpts are the parameters for creating a "New Cluster Event" track
-type NewClusterEventOpts struct {
-	UserId      string
-	ProjId      string
-	ClusterName string
-	ClusterType string // EKS, DOKS, or GKE
-	EventType   string // connected, provisioned, or destroyed
+// ProjectCreateTrackOpts are the options for creating a track when a project is created
+type ProjectCreateTrackOpts struct {
+	*ProjectScopedTrackOpts
 }
 
-// CreateSegmentNewClusterEvent creates a track of type "New Cluster Event", which
-// tracks whenever a cluster is newly provisioned, connected, or destroyed.
-func CreateSegmentNewClusterEvent(opts *NewClusterEventOpts) *segmentNewClusterEventTrack {
-	return &segmentNewClusterEventTrack{
-		userId:      opts.UserId,
-		projId:      opts.ProjId,
-		clusterName: opts.ClusterName,
-		clusterType: opts.ClusterType,
-		eventType:   opts.EventType,
-	}
+// ProjectCreateTrack returns a track for when a project is created
+func ProjectCreateTrack(opts *ProjectCreateTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ProjectCreate),
+	)
+}
+
+// ClusterProvisioningStartTrackOpts are the options for creating a track when a cluster
+// has started provisioning
+type ClusterProvisioningStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+// ClusterProvisioningStartTrack returns a track for when a cluster
+// has started provisioning
+func ClusterProvisioningStartTrack(opts *ClusterProvisioningStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningStart),
+	)
+}
+
+// ClusterProvisioningErrorTrackOpts are the options for creating a track when a cluster
+// has experienced a provisioning error
+type ClusterProvisioningErrorTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+// ClusterProvisioningErrorTrack returns a track for when a cluster
+// has experienced a provisioning error
+func ClusterProvisioningErrorTrack(opts *ClusterProvisioningErrorTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningError),
+	)
+}
+
+// ClusterProvisioningSuccessTrackOpts are the options for creating a track when a cluster
+// has successfully provisioned
+type ClusterProvisioningSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterType models.InfraKind
+	InfraID     uint
+}
+
+// ClusterProvisioningSuccessTrack returns a new track for when a cluster
+// has successfully provisioned
+func ClusterProvisioningSuccessTrack(opts *ClusterProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cluster_type"] = opts.ClusterType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterProvisioningSuccess),
+	)
+}
+
+// ClusterConnectionStartTrackOpts are the options for creating a track when a cluster
+// connection has started
+type ClusterConnectionStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	ClusterCandidateID uint
+}
+
+// ClusterConnectionStartTrack returns a new track for when a cluster
+// connection has started
+func ClusterConnectionStartTrack(opts *ClusterConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cc_id"] = opts.ClusterCandidateID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterConnectionStart),
+	)
+}
+
+// ClusterConnectionSuccessTrackOpts are the options for creating a track when a cluster
+// connection has finished
+type ClusterConnectionSuccessTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	ClusterCandidateID uint
+}
+
+// ClusterConnectionSuccessTrack returns a new track for when a cluster
+// connection has finished
+func ClusterConnectionSuccessTrack(opts *ClusterConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["cc_id"] = opts.ClusterCandidateID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ClusterConnectionSuccess),
+	)
+}
+
+// RegistryConnectionStartTrackOpts are the options for creating a track when a registry
+// connection has started
+type RegistryConnectionStartTrackOpts struct {
+	// note that this is a project-scoped track, since the cluster has not been created yet
+	*ProjectScopedTrackOpts
+
+	// a random id assigned to this connection request
+	FlowID string
+}
+
+// RegistryConnectionStartTrack returns a new track for when a registry
+// connection has started
+func RegistryConnectionStartTrack(opts *RegistryConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryConnectionStart),
+	)
+}
+
+// RegistryConnectionSuccessTrackOpts are the options for creating a track when a registry
+// connection has completed
+type RegistryConnectionSuccessTrackOpts struct {
+	*RegistryScopedTrackOpts
+
+	// a random id assigned to this connection request
+	FlowID string
+}
+
+// RegistryConnectionSuccessTrack returns a new track for when a registry
+// connection has completed
+func RegistryConnectionSuccessTrack(opts *RegistryConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentRegistryTrack(
+		opts.RegistryScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryConnectionSuccess),
+	)
+}
+
+// GithubConnectionStartTrackOpts are the options for creating a track when a github account
+// connection has started
+type GithubConnectionStartTrackOpts struct {
+	// note that this is a user-scoped track, since github repos are tied to the user
+	*UserScopedTrackOpts
 }
 
-func (t *segmentNewClusterEventTrack) getUserId() string {
-	return t.userId
+// GithubConnectionStartTrack returns a new track for when a github account
+// connection has started
+func GithubConnectionStartTrack(opts *GithubConnectionStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, GithubConnectionStart),
+	)
+}
+
+// GithubConnectionSuccessTrackOpts are the options for creating a track when a github account
+// connection has completed
+type GithubConnectionSuccessTrackOpts struct {
+	// note that this is a user-scoped track, since github repos are tied to the user
+	*UserScopedTrackOpts
 }
 
-func (t *segmentNewClusterEventTrack) getEvent() SegmentEvent {
-	return NewClusterEvent
+// GithubConnectionSuccessTrack returns a new track when a github account
+// connection has completed
+func GithubConnectionSuccessTrack(opts *GithubConnectionSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, GithubConnectionSuccess),
+	)
+}
+
+// ApplicationLaunchStartTrackOpts are the options for creating a track when an application
+// launch has started
+type ApplicationLaunchStartTrackOpts struct {
+	*ClusterScopedTrackOpts
+
+	FlowID string
+}
+
+// ApplicationLaunchStartTrack returns a new track for when an application
+// launch has started
+func ApplicationLaunchStartTrack(opts *ApplicationLaunchStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentClusterTrack(
+		opts.ClusterScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationLaunchStart),
+	)
+}
+
+// ApplicationLaunchSuccessTrackOpts are the options for creating a track when an application
+// launch has completed
+type ApplicationLaunchSuccessTrackOpts struct {
+	*ApplicationScopedTrackOpts
+
+	FlowID string
 }
 
-func (t *segmentNewClusterEventTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("Project ID", t.projId).Set("Cluster Name", t.clusterName).Set("Cluster Type", t.clusterType).Set("Event Type", t.eventType)
+// ApplicationLaunchSuccessTrack returns a new track for when an application
+// launch has completed
+func ApplicationLaunchSuccessTrack(opts *ApplicationLaunchSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["flow_id"] = opts.FlowID
+
+	return getSegmentApplicationTrack(
+		opts.ApplicationScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationLaunchSuccess),
+	)
+}
+
+// ApplicationDeploymentWebhookTrackOpts are the options for creating a track when an application
+// launch has completed from a webhook
+type ApplicationDeploymentWebhookTrackOpts struct {
+	*ApplicationScopedTrackOpts
+
+	ImageURI string
+}
+
+// ApplicationDeploymentWebhookTrack returns a new track for when an application
+// launch has completed from a webhook
+func ApplicationDeploymentWebhookTrack(opts *ApplicationDeploymentWebhookTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["image_uri"] = opts.ImageURI
+
+	return getSegmentApplicationTrack(
+		opts.ApplicationScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, ApplicationDeploymentWebhook),
+	)
+}
+
+// RegistryProvisioningStartTrackOpts are the options for creating a track when a registry
+// provisioning has started
+type RegistryProvisioningStartTrackOpts struct {
+	// note that this is a project-scoped track, since the registry has not been created yet
+	*ProjectScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+// RegistryProvisioningStartTrack returns a new track for when a registry
+// provisioning has started
+func RegistryProvisioningStartTrack(opts *RegistryProvisioningStartTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningStart),
+	)
+}
+
+// RegistryProvisioningErrorTrackOpts are the options for creating a track when a registry
+// provisioning has failed
+type RegistryProvisioningErrorTrackOpts struct {
+	// note that this is a project-scoped track, since the registry has not been created yet
+	*ProjectScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+// RegistryProvisioningErrorTrack returns a new track for when a registry
+// provisioning has failed
+func RegistryProvisioningErrorTrack(opts *RegistryProvisioningErrorTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningError),
+	)
+}
+
+// RegistryProvisioningSuccessTrackOpts are the options for creating a track when a registry
+// provisioning has completed
+type RegistryProvisioningSuccessTrackOpts struct {
+	*RegistryScopedTrackOpts
+
+	RegistryType models.InfraKind
+	InfraID      uint
+}
+
+// RegistryProvisioningSuccessTrack returns a new track for when a registry
+// provisioning has completed
+func RegistryProvisioningSuccessTrack(opts *RegistryProvisioningSuccessTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["registry_type"] = opts.RegistryType
+	additionalProps["infra_id"] = opts.InfraID
+
+	return getSegmentRegistryTrack(
+		opts.RegistryScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, RegistryProvisioningSuccess),
+	)
 }

+ 1 - 1
internal/forms/git_action.go

@@ -10,7 +10,7 @@ type CreateGitAction struct {
 	Release *models.Release
 
 	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
+	GitBranch      string `json:"branch"`
 	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
 	DockerfilePath string `json:"dockerfile_path"`
 	FolderPath     string `json:"folder_path"`

+ 35 - 2
internal/helm/agent.go

@@ -4,6 +4,13 @@ import (
 	"fmt"
 
 	"github.com/pkg/errors"
+<<<<<<< HEAD
+=======
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+>>>>>>> master
 	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
@@ -38,13 +45,39 @@ func (a *Agent) ListReleases(
 func (a *Agent) GetRelease(
 	name string,
 	version int,
+	getDeps bool,
 ) (*release.Release, error) {
 	// Namespace is already known by the RESTClientGetter.
 	cmd := action.NewGet(a.ActionConfig)
 
 	cmd.Version = version
 
-	return cmd.Run(name)
+	release, err := cmd.Run(name)
+
+	if getDeps {
+		for _, dep := range release.Chart.Metadata.Dependencies {
+			depExists := false
+
+			for _, currDep := range release.Chart.Dependencies() {
+				// we just case on name for now -- there might be edge cases we're missing
+				// but this will cover 99% of cases
+				if dep.Name == currDep.Name() {
+					depExists = true
+					break
+				}
+			}
+
+			if !depExists {
+				depChart, err := loader.LoadChartPublic(dep.Repository, dep.Name, dep.Version)
+
+				if err == nil {
+					release.Chart.AddDependency(depChart)
+				}
+			}
+		}
+	}
+
+	return release, err
 }
 
 // GetReleaseHistory returns a list of charts for a specific release
@@ -90,7 +123,7 @@ func (a *Agent) UpgradeReleaseByValues(
 	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	// grab the latest release
-	rel, err := a.GetRelease(conf.Name, 0)
+	rel, err := a.GetRelease(conf.Name, 0, true)
 
 	if err != nil {
 		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)

+ 24 - 1
internal/helm/loader/loader.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"net/url"
 	"strings"
 
 	"k8s.io/helm/pkg/repo"
@@ -140,7 +141,11 @@ func LoadChart(client *BasicAuthClient, repoURL, chartName, chartVersion string)
 	}
 
 	trimmedRepoURL := strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
-	chartURL := trimmedRepoURL + "/" + strings.TrimPrefix(cv.URLs[0], "/")
+	chartURL := cv.URLs[0]
+
+	if !isValidURL(chartURL) {
+		chartURL = trimmedRepoURL + "/" + strings.TrimPrefix(cv.URLs[0], "/")
+	}
 
 	// download tgz
 	req, err := http.NewRequest("GET", chartURL, nil)
@@ -178,3 +183,21 @@ func LoadChart(client *BasicAuthClient, repoURL, chartName, chartVersion string)
 func LoadChartPublic(repoURL, chartName, chartVersion string) (*chart.Chart, error) {
 	return LoadChart(&BasicAuthClient{}, repoURL, chartName, chartVersion)
 }
+
+// Helper method to test if chart repo URL is valid, or is a path. Chartmuseum saves URLs
+// as paths, other Helm repositories do not.
+func isValidURL(testURI string) bool {
+	_, err := url.ParseRequestURI(testURI)
+
+	if err != nil {
+		return false
+	}
+
+	u, err := url.Parse(testURI)
+
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return false
+	}
+
+	return true
+}

+ 33 - 17
internal/integrations/ci/actions/actions.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/Masterminds/semver/v3"
 	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/google/go-github/v33/github"
 	"github.com/porter-dev/porter/internal/models"
@@ -32,11 +33,12 @@ type GithubActions struct {
 	GithubAppSecretPath  string
 	GithubInstallationID uint
 
-	PorterToken string
-	BuildEnv    map[string]string
-	ProjectID   uint
-	ClusterID   uint
-	ReleaseName string
+	PorterToken      string
+	BuildEnv         map[string]string
+	ProjectID        uint
+	ClusterID        uint
+	ReleaseName      string
+	ReleaseNamespace string
 
 	GitBranch      string
 	DockerFilePath string
@@ -50,6 +52,10 @@ type GithubActions struct {
 	ShouldCreateWorkflow bool
 }
 
+var (
+	deleteWebhookAndEnvSecretsConstraint, _ = semver.NewConstraint(" < 0.1.0")
+)
+
 func (g *GithubActions) Setup() ([]byte, error) {
 	client, err := g.getClient()
 
@@ -113,18 +119,21 @@ func (g *GithubActions) Cleanup() error {
 
 	g.defaultBranch = repo.GetDefaultBranch()
 
-	// delete the webhook token secret
-	err = g.deleteGithubSecret(client, g.getWebhookSecretName())
-
+	actionVersion, err := semver.NewVersion(g.Version)
 	if err != nil {
 		return err
 	}
 
-	// delete the env secret
-	err = g.deleteGithubSecret(client, g.getBuildEnvSecretName())
+	if deleteWebhookAndEnvSecretsConstraint.Check(actionVersion) {
+		// delete the webhook token secret
+		if err := g.deleteGithubSecret(client, g.getWebhookSecretName()); err != nil {
+			return err
+		}
 
-	if err != nil {
-		return err
+		// delete the env secret
+		if err := g.deleteGithubSecret(client, g.getBuildEnvSecretName()); err != nil {
+			return err
+		}
 	}
 
 	return g.deleteGithubFile(client, g.getPorterYMLFileName())
@@ -164,7 +173,8 @@ type GithubActionYAML struct {
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
-		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.Version),
+		getSetTagStep(),
+		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
 	}
 
 	branch := g.GitBranch
@@ -400,25 +410,31 @@ func (g *GithubActions) deleteGithubFile(
 	client *github.Client,
 	filename string,
 ) error {
-	filepath := ".github/workflows/" + filename
-	sha := ""
+	branch := g.GitBranch
+	if branch == "" {
+		branch = g.defaultBranch
+	}
 
+	filepath := ".github/workflows/" + filename
 	// get contents of a file if it exists
 	fileData, _, _, _ := client.Repositories.GetContents(
 		context.TODO(),
 		g.GitRepoOwner,
 		g.GitRepoName,
 		filepath,
-		&github.RepositoryContentGetOptions{},
+		&github.RepositoryContentGetOptions{
+			Ref: branch,
+		},
 	)
 
+	sha := ""
 	if fileData != nil {
 		sha = *fileData.SHA
 	}
 
 	opts := &github.RepositoryContentFileOptions{
 		Message: github.String(fmt.Sprintf("Delete %s file", filename)),
-		Branch:  github.String(g.defaultBranch),
+		Branch:  &branch,
 		SHA:     &sha,
 	}
 

+ 16 - 6
internal/integrations/ci/actions/steps.go

@@ -13,16 +13,26 @@ func getCheckoutCodeStep() GithubActionYAMLStep {
 	}
 }
 
-func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string, actionVersion string) GithubActionYAMLStep {
+func getSetTagStep() GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Set Github tag",
+		ID:   "vars",
+		Run:  `echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"`,
+	}
+}
+
+func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string, appNamespace, actionVersion string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Update Porter App",
 		Uses: fmt.Sprintf("%s@%s", updateAppActionName, actionVersion),
 		With: map[string]string{
-			"app":     appName,
-			"cluster": fmt.Sprintf("%d", clusterID),
-			"host":    serverURL,
-			"project": fmt.Sprintf("%d", projectID),
-			"token":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"app":       appName,
+			"cluster":   fmt.Sprintf("%d", clusterID),
+			"host":      serverURL,
+			"project":   fmt.Sprintf("%d", projectID),
+			"token":     fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"tag":       "${{ steps.vars.outputs.sha_short }}",
+			"namespace": appNamespace,
 		},
 		Timeout: 20,
 	}

+ 2 - 1
internal/kubernetes/agent.go

@@ -749,7 +749,7 @@ func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Relea
 	return helm_object, false, nil
 }
 
-func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
+func (a *Agent) StreamHelmReleases(conn *websocket.Conn, namespace string, chartList []string, selectors string) error {
 	tweakListOptionsFunc := func(options *metav1.ListOptions) {
 		options.LabelSelector = selectors
 	}
@@ -758,6 +758,7 @@ func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, sel
 		a.Clientset,
 		0,
 		informers.WithTweakListOptions(tweakListOptionsFunc),
+		informers.WithNamespace(namespace),
 	)
 
 	informer := factory.Core().V1().Secrets().Informer()

+ 0 - 2
internal/kubernetes/prometheus/metrics.go

@@ -170,8 +170,6 @@ func QueryPrometheus(
 		query = fmt.Sprintf("sum(%s)", query)
 	}
 
-	fmt.Println("QUERY IS", query)
-
 	queryParams := map[string]string{
 		"query": query,
 		"start": fmt.Sprintf("%d", opts.StartRange),

+ 76 - 6
internal/kubernetes/provisioner/global_stream.go

@@ -9,7 +9,10 @@ import (
 	"strings"
 
 	"github.com/aws/aws-sdk-go/service/ecr"
-	"github.com/go-redis/redis/v8"
+	"github.com/porter-dev/porter/internal/analytics"
+	"github.com/porter-dev/porter/internal/repository"
+
+	redis "github.com/go-redis/redis/v8"
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -83,6 +86,7 @@ type ResourceCRUDHandler interface {
 func GlobalStreamListener(
 	client *redis.Client,
 	repo repository.Repository,
+	analyticsClient analytics.AnalyticsSegmentClient,
 	errorChan chan error,
 ) {
 	for {
@@ -163,7 +167,15 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
-				} else if kind == string(types.InfraEKS) {
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
+				} else if kind == string(models.InfraEKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.AWS,
 						ProjectID:        projID,
@@ -197,7 +209,15 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
-				} else if kind == string(types.InfraGCR) {
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				} else if kind == string(models.InfraGCR) {
 					reg := &models.Registry{
 						ProjectID:        projID,
 						GCPIntegrationID: infra.GCPIntegrationID,
@@ -217,7 +237,15 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
-				} else if kind == string(types.InfraGKE) {
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
+				} else if kind == string(models.InfraGKE) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.GCP,
 						ProjectID:        projID,
@@ -251,7 +279,15 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
-				} else if kind == string(types.InfraDOCR) {
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				} else if kind == string(models.InfraDOCR) {
 					reg := &models.Registry{
 						ProjectID:       projID,
 						DOIntegrationID: infra.DOIntegrationID,
@@ -270,7 +306,15 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
-				} else if kind == string(types.InfraDOKS) {
+
+					analyticsClient.Track(analytics.RegistryProvisioningSuccessTrack(
+						&analytics.RegistryProvisioningSuccessTrackOpts{
+							RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, reg.ID),
+							RegistryType:            infra.Kind,
+							InfraID:                 infra.ID,
+						},
+					))
+				} else if kind == string(models.InfraDOKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:   models.DO,
 						ProjectID:       projID,
@@ -304,6 +348,14 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
+
+					analyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+						&analytics.ClusterProvisioningSuccessTrackOpts{
+							ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, cluster.ID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
 				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "error" {
 				infra, err := repo.Infra().ReadInfra(projID, infraID)
@@ -319,6 +371,24 @@ func GlobalStreamListener(
 				if err != nil {
 					continue
 				}
+
+				if infra.Kind == models.InfraDOKS || infra.Kind == models.InfraGKE || infra.Kind == models.InfraEKS {
+					analyticsClient.Track(analytics.ClusterProvisioningErrorTrack(
+						&analytics.ClusterProvisioningErrorTrackOpts{
+							ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID),
+							ClusterType:            infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				} else if infra.Kind == models.InfraDOCR || infra.Kind == models.InfraGCR || infra.Kind == models.InfraECR {
+					analyticsClient.Track(analytics.RegistryProvisioningErrorTrack(
+						&analytics.RegistryProvisioningErrorTrackOpts{
+							ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID),
+							RegistryType:           infra.Kind,
+							InfraID:                infra.ID,
+						},
+					))
+				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "destroyed" {
 				infra, err := repo.Infra().ReadInfra(projID, infraID)
 

+ 51 - 0
internal/models/event.go

@@ -0,0 +1,51 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+type EventStatus int64
+
+const (
+	EventStatusSuccess    EventStatus = 1
+	EventStatusInProgress             = 2
+	EventStatusFailed                 = 3
+)
+
+type EventContainer struct {
+	gorm.Model
+	ReleaseID uint
+	Steps     []SubEvent
+}
+
+type SubEvent struct {
+	gorm.Model
+
+	EventContainerID uint
+
+	EventID string // events with the same id wil be treated the same, and the highest index one is retained
+	Name    string
+	Index   int64 // priority of the event, used for sorting
+	Status  EventStatus
+	Info    string
+}
+
+type SubEventExternal struct {
+	EventID string      `json:"event_id"`
+	Name    string      `json:"name"`
+	Index   int64       `json:"index"`
+	Status  EventStatus `json:"status"`
+	Info    string      `json:"info"`
+	Time    int64       `json:"time""`
+}
+
+func (event *SubEvent) Externalize() SubEventExternal {
+	return SubEventExternal{
+		EventID: event.EventID,
+		Name:    event.Name,
+		Index:   event.Index,
+		Status:  event.Status,
+		Info:    event.Info,
+		Time:    event.UpdatedAt.Unix(),
+	}
+}

+ 3 - 0
internal/models/infra.go

@@ -24,6 +24,9 @@ type Infra struct {
 	// The project that this infra belongs to
 	ProjectID uint `json:"project_id"`
 
+	// The ID of the user that created this infra
+	CreatedByUserID uint
+
 	// Status is the status of the infra
 	Status types.InfraStatus `json:"status"`
 

+ 1 - 0
internal/models/release.go

@@ -22,6 +22,7 @@ type Release struct {
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 
 	GitActionConfig    *GitActionConfig `json:"git_action_config"`
+	EventContainer     uint
 	NotificationConfig uint
 }
 

+ 28 - 11
internal/oauth/config.go

@@ -28,13 +28,24 @@ type GithubAppConf struct {
 	oauth2.Config
 }
 
+const (
+	GithubAuthURL  string = "https://github.com/login/oauth/authorize"
+	GithubTokenURL string = "https://github.com/login/oauth/access_token"
+	DOAuthURL      string = "https://cloud.digitalocean.com/v1/oauth/authorize"
+	DOTokenURL     string = "https://cloud.digitalocean.com/v1/oauth/token"
+	GoogleAuthURL  string = "https://accounts.google.com/o/oauth2/v2/auth"
+	GoogleTokenURL string = "https://oauth2.googleapis.com/token"
+	SlackAuthURL   string = "https://slack.com/oauth/v2/authorize"
+	SlackTokenURL  string = "https://slack.com/api/oauth.v2.access"
+)
+
 func NewGithubClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://github.com/login/oauth/authorize",
-			TokenURL: "https://github.com/login/oauth/access_token",
+			AuthURL:  GithubAuthURL,
+			TokenURL: GithubTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/github/callback",
 		Scopes:      cfg.Scopes,
@@ -51,8 +62,8 @@ func NewGithubAppClient(cfg *Config, name string, secret string, secretPath stri
 			ClientID:     cfg.ClientID,
 			ClientSecret: cfg.ClientSecret,
 			Endpoint: oauth2.Endpoint{
-				AuthURL:  "https://github.com/login/oauth/authorize",
-				TokenURL: "https://github.com/login/oauth/access_token",
+				AuthURL:  GithubAuthURL,
+				TokenURL: GithubTokenURL,
 			},
 			RedirectURL: cfg.BaseURL + "/api/oauth/github-app/callback",
 			Scopes:      cfg.Scopes,
@@ -65,8 +76,8 @@ func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://cloud.digitalocean.com/v1/oauth/authorize",
-			TokenURL: "https://cloud.digitalocean.com/v1/oauth/token",
+			AuthURL:  DOAuthURL,
+			TokenURL: DOTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/digitalocean/callback",
 		Scopes:      cfg.Scopes,
@@ -78,8 +89,8 @@ func NewGoogleClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://accounts.google.com/o/oauth2/v2/auth",
-			TokenURL: "https://oauth2.googleapis.com/token",
+			AuthURL:  GoogleAuthURL,
+			TokenURL: GoogleTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/google/callback",
 		Scopes:      cfg.Scopes,
@@ -91,8 +102,8 @@ func NewSlackClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  "https://slack.com/oauth/v2/authorize",
-			TokenURL: "https://slack.com/api/oauth.v2.access",
+			AuthURL:  SlackAuthURL,
+			TokenURL: SlackTokenURL,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/slack/callback",
 		Scopes:      cfg.Scopes,
@@ -147,11 +158,17 @@ func GetAccessToken(
 	conf *oauth2.Config,
 	updateToken func(accessToken []byte, refreshToken []byte, expiry time.Time) error,
 ) (string, *time.Time, error) {
+	expiry := prevToken.Expiry
+	if conf.Endpoint.AuthURL == DOAuthURL && expiry.IsZero() {
+		// manually set the expiry so refresh token is used
+		expiry = time.Now().Add(-1 * time.Minute)
+	}
+
 	tokSource := conf.TokenSource(context.TODO(), &oauth2.Token{
 		AccessToken:  string(prevToken.AccessToken),
 		RefreshToken: string(prevToken.RefreshToken),
 		TokenType:    "Bearer",
-		Expiry:       prevToken.Expiry,
+		Expiry:       expiry,
 	})
 
 	token, err := tokSource.Token()

+ 12 - 0
internal/repository/event.go

@@ -0,0 +1,12 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+type EventRepository interface {
+	CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error)
+	CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error)
+	ReadEventsByContainerID(id uint) ([]*models.SubEvent, error)
+	ReadEventContainer(id uint) (*models.EventContainer, error)
+	ReadSubEvent(id uint) (*models.SubEvent, error)
+	AppendEvent(container *models.EventContainer, event *models.SubEvent) error
+}

+ 63 - 0
internal/repository/gorm/event.go

@@ -0,0 +1,63 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// EventRepository holds both EventContainer and SubEvent models
+type EventRepository struct {
+	db *gorm.DB
+}
+
+// NewEventRepository returns a EventRepository which uses
+// gorm.DB for querying the database
+func NewEventRepository(db *gorm.DB) repository.EventRepository {
+	return &EventRepository{db}
+}
+
+func (repo EventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+func (repo EventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+func (repo EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
+	var events []*models.SubEvent
+	if err := repo.db.Where("event_container_id = ?", id).Find(&events).Error; err != nil {
+		return nil, err
+	}
+	return events, nil
+}
+
+func (repo EventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
+	container := &models.EventContainer{}
+	if err := repo.db.Where("id = ?", id).First(&container).Error; err != nil {
+		return nil, err
+	}
+	return container, nil
+}
+
+func (repo EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
+	event := &models.SubEvent{}
+	if err := repo.db.Where("id = ?", id).First(&event).Error; err != nil {
+		return nil, err
+	}
+	return event, nil
+}
+
+// AppendEvent will check if subevent with same (id, index) already exists
+// if yes, overrite it, otherwise make a new subevent
+func (repo EventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+	event.EventContainerID = container.ID
+	return repo.db.Create(event).Error
+}

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

@@ -27,6 +27,8 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.DNSRecord{},
 		&models.PWResetToken{},
 		&models.NotificationConfig{},
+		&models.EventContainer{},
+		&models.SubEvent{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 1
internal/repository/gorm/notification.go

@@ -23,7 +23,7 @@ func (repo NotificationConfigRepository) CreateNotificationConfig(am *models.Not
 	return am, nil
 }
 
-// ReadNotificationConfig reads a NotificationConfig by Id
+// ReadNotificationConfig reads a NotificationConfig by ID
 func (repo NotificationConfigRepository) ReadNotificationConfig(id uint) (*models.NotificationConfig, error) {
 	ret := &models.NotificationConfig{}
 

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

@@ -5,6 +5,7 @@ import (
 	"gorm.io/gorm"
 )
 
+<<<<<<< HEAD
 type GormRepository struct {
 	user                      repository.UserRepository
 	session                   repository.SessionRepository
@@ -156,5 +157,36 @@ func NewRepository(db *gorm.DB, key *[32]byte) repository.Repository {
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
+=======
+// NewRepository returns a Repository which uses
+// gorm.DB for querying the database
+func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
+	return &repository.Repository{
+		User:                      NewUserRepository(db),
+		Session:                   NewSessionRepository(db),
+		Project:                   NewProjectRepository(db),
+		Release:                   NewReleaseRepository(db),
+		GitRepo:                   NewGitRepoRepository(db, key),
+		Cluster:                   NewClusterRepository(db, key),
+		HelmRepo:                  NewHelmRepoRepository(db, key),
+		Registry:                  NewRegistryRepository(db, key),
+		Infra:                     NewInfraRepository(db, key),
+		GitActionConfig:           NewGitActionConfigRepository(db),
+		Invite:                    NewInviteRepository(db),
+		AuthCode:                  NewAuthCodeRepository(db),
+		DNSRecord:                 NewDNSRecordRepository(db),
+		PWResetToken:              NewPWResetTokenRepository(db),
+		KubeIntegration:           NewKubeIntegrationRepository(db, key),
+		BasicIntegration:          NewBasicIntegrationRepository(db, key),
+		OIDCIntegration:           NewOIDCIntegrationRepository(db, key),
+		OAuthIntegration:          NewOAuthIntegrationRepository(db, key),
+		GCPIntegration:            NewGCPIntegrationRepository(db, key),
+		AWSIntegration:            NewAWSIntegrationRepository(db, key),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
+		SlackIntegration:          NewSlackIntegrationRepository(db, key),
+		NotificationConfig:        NewNotificationConfigRepository(db),
+		Event:                     NewEventRepository(db),
+>>>>>>> master
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -25,4 +25,5 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
+	Event()                     EventRepository
 }

+ 33 - 19
server/api/api.go

@@ -40,6 +40,7 @@ type TestAgents struct {
 
 // AppConfig is the configuration required for creating a new App
 type AppConfig struct {
+	Version    string
 	DB         *gorm.DB
 	Logger     *lr.Logger
 	Repository repository.Repository
@@ -96,23 +97,34 @@ type App struct {
 	GoogleUserConf    *oauth2.Config
 	SlackConf         *oauth2.Config
 
+<<<<<<< HEAD
 	db              *gorm.DB
 	validator       *vr.Validate
 	translator      *ut.Translator
 	tokenConf       *token.TokenGeneratorConf
 	analyticsClient analytics.AnalyticsSegmentClient
 	notifier        notif.UserNotifier
+=======
+	// analytics client for reporting
+	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	db         *gorm.DB
+	validator  *vr.Validate
+	translator *ut.Translator
+	tokenConf  *token.TokenGeneratorConf
+>>>>>>> master
 }
 
 type AppCapabilities struct {
-	Provisioning       bool `json:"provisioner"`
-	Github             bool `json:"github"`
-	BasicLogin         bool `json:"basic_login"`
-	GithubLogin        bool `json:"github_login"`
-	GoogleLogin        bool `json:"google_login"`
-	SlackNotifications bool `json:"slack_notifs"`
-	Email              bool `json:"email"`
-	Analytics          bool `json:"analytics"`
+	Version            string `json:"version"`
+	Provisioning       bool   `json:"provisioner"`
+	Github             bool   `json:"github"`
+	BasicLogin         bool   `json:"basic_login"`
+	GithubLogin        bool   `json:"github_login"`
+	GoogleLogin        bool   `json:"google_login"`
+	SlackNotifications bool   `json:"slack_notifs"`
+	Email              bool   `json:"email"`
+	Analytics          bool   `json:"analytics"`
 }
 
 // New returns a new App instance
@@ -129,16 +141,18 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:       conf.Logger,
-		Repo:         conf.Repository,
-		ServerConf:   conf.ServerConf,
-		RedisConf:    conf.RedisConf,
-		DBConf:       conf.DBConf,
-		TestAgents:   conf.TestAgents,
-		Capabilities: &AppCapabilities{},
-		db:           conf.DB,
-		validator:    validator,
-		translator:   &translator,
+		Logger:     conf.Logger,
+		Repo:       conf.Repository,
+		ServerConf: conf.ServerConf,
+		RedisConf:  conf.RedisConf,
+		DBConf:     conf.DBConf,
+		TestAgents: conf.TestAgents,
+		Capabilities: &AppCapabilities{
+			Version: conf.Version,
+		},
+		db:         conf.DB,
+		validator:  validator,
+		translator: &translator,
 	}
 
 	// if repository not specified, default to in-memory
@@ -263,7 +277,7 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
-	app.analyticsClient = newSegmentClient
+	app.AnalyticsClient = newSegmentClient
 
 	app.updateChartRepoURLs()
 

+ 1 - 0
server/api/capability_handler.go

@@ -10,5 +10,6 @@ func (app *App) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
 	if err := json.NewEncoder(w).Encode(app.Capabilities); err != nil {
 		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
 		return
+
 	}
 }

+ 22 - 34
server/api/cluster_handler.go

@@ -2,7 +2,6 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"strconv"
 
@@ -17,7 +16,7 @@ import (
 // HandleCreateProjectCluster creates a new cluster
 func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -57,15 +56,6 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", projID),
-			ClusterName: cluster.Name,
-			ClusterType: "EKS",
-			EventType:   "connected",
-		},
-	))
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -279,14 +269,7 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 
 	extClusters := make([]*models.ClusterCandidateExternal, 0)
 
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
-
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
+	userID, _ := app.getUserIDFromRequest(r)
 
 	for _, cc := range ccs {
 		// handle write to the database
@@ -297,6 +280,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			return
 		}
 
+		app.AnalyticsClient.Track(analytics.ClusterConnectionStartTrack(
+			&analytics.ClusterConnectionStartTrackOpts{
+				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+				ClusterCandidateID:     cc.ID,
+			},
+		))
+
 		app.Logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
 
 		// if the ClusterCandidate does not have any actions to perform, create the Cluster
@@ -339,6 +329,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			}
 
 			app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
+
+			app.AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+				&analytics.ClusterConnectionSuccessTrackOpts{
+					ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), cluster.ID),
+					ClusterCandidateID:     cc.ID,
+				},
+			))
 		}
 
 		extClusters = append(extClusters, cc.Externalize())
@@ -401,14 +398,7 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
-
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
+	userID, _ := app.getUserIDFromRequest(r)
 
 	// decode actions from request
 	resolver := &models.ClusterResolverAll{}
@@ -447,13 +437,11 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 	}
 
 	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", projID),
-			ClusterName: cluster.Name,
-			ClusterType: "",
-			EventType:   "connected",
+
+	app.AnalyticsClient.Track(analytics.ClusterConnectionSuccessTrack(
+		&analytics.ClusterConnectionSuccessTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), cluster.ID),
+			ClusterCandidateID:     uint(candID),
 		},
 	))
 

Some files were not shown because too many files changed in this diff