Просмотр исходного кода

Merge branch 'master' into 0.8.0-live-deployment-updates

Ivan Galakhov 4 лет назад
Родитель
Сommit
eaefffad78
57 измененных файлов с 1703 добавлено и 463 удалено
  1. 5 1
      .gitignore
  2. 1 2
      cli/cmd/login/server.go
  3. 128 46
      cli/cmd/run.go
  4. 14 14
      cmd/app/main.go
  5. 6 2
      dashboard/babel.config.json
  6. 1 1
      dashboard/src/assets/GoogleIcon.tsx
  7. 4 0
      dashboard/src/assets/Iconly/Bulk/Info Square.svg
  8. 56 0
      dashboard/src/components/Banner.tsx
  9. 25 0
      dashboard/src/components/Placeholder.tsx
  10. 1 1
      dashboard/src/components/porter-form/PorterForm.tsx
  11. 16 7
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  12. 5 1
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  13. 5 1
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  14. 8 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  15. 5 1
      dashboard/src/components/porter-form/field-components/Select.tsx
  16. 5 1
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  17. 2 15
      dashboard/src/index.html
  18. 3 2
      dashboard/src/main/auth/Login.tsx
  19. 6 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  20. 25 19
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  21. 3 3
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  22. 104 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentType.tsx
  23. 2 41
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  24. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  25. 79 56
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  26. 17 21
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  27. 13 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  28. 3 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  29. 5 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts
  30. 5 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  31. 1 1
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  32. 4 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  33. 69 5
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  34. 54 0
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  35. 59 7
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  36. 31 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  37. 6 1
      dashboard/src/shared/Context.tsx
  38. 71 18
      docs/developing/analytics.md
  39. 0 3
      go.sum
  40. 4 4
      internal/analytics/identifiers.go
  41. 26 3
      internal/analytics/track_events.go
  42. 167 0
      internal/analytics/track_scopes.go
  43. 376 62
      internal/analytics/tracks.go
  44. 2 0
      internal/kubernetes/prometheus/metrics.go
  45. 68 0
      internal/kubernetes/provisioner/global_stream.go
  46. 3 0
      internal/models/infra.go
  47. 8 6
      server/api/api.go
  48. 22 34
      server/api/cluster_handler.go
  49. 51 2
      server/api/deploy_handler.go
  50. 9 0
      server/api/integration_handler.go
  51. 11 2
      server/api/oauth_github_handler.go
  52. 4 2
      server/api/oauth_google_handler.go
  53. 5 0
      server/api/project_handler.go
  54. 62 53
      server/api/provision_handler.go
  55. 17 1
      server/api/registry_handler.go
  56. 14 3
      server/api/release_handler.go
  57. 5 2
      server/api/user_handler.go

+ 5 - 1
.gitignore

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

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

@@ -127,8 +127,7 @@ const successScreen = `
     <meta charset='UTF-8'>
     <meta charset='UTF-8'>
     <title>Porter | Login</title>
     <title>Porter | Login</title>
     <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
     <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="//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">
     <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@600&display=swap" rel="stylesheet">
     <style>
     <style>

+ 128 - 46
cli/cmd/run.go

@@ -2,6 +2,7 @@ package cmd
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"os"
 	"os"
@@ -14,6 +15,8 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/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/kubectl/pkg/util/term"
 
 
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -254,8 +257,10 @@ func executeRun(config *PorterRunSharedConfig, namespace, name, container string
 		Out: os.Stdout,
 		Out: os.Stdout,
 		Raw: true,
 		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())
 		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
 
 
 		if err != nil {
 		if err != nil {
@@ -267,14 +272,10 @@ func executeRun(config *PorterRunSharedConfig, namespace, name, container string
 			Stdout: os.Stdout,
 			Stdout: os.Stdout,
 			Stderr: os.Stderr,
 			Stderr: os.Stderr,
 			Tty:    true,
 			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 {
 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)
 	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{
 	t := term.TTY{
 		In:  os.Stdin,
 		In:  os.Stdin,
 		Out: os.Stdout,
 		Out: os.Stdout,
 		Raw: true,
 		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())
 		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
-
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-
 		return exec.Stream(remotecommand.StreamOptions{
 		return exec.Stream(remotecommand.StreamOptions{
 			Stdin:  os.Stdin,
 			Stdin:  os.Stdin,
 			Stdout: os.Stdout,
 			Stdout: os.Stdout,
 			Stderr: os.Stderr,
 			Stderr: os.Stderr,
 			Tty:    true,
 			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 {
 		if err == nil {
 			break
 			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 {
 	if err != nil {
-		color.New(color.FgYellow).Println("Could not open a shell to this container. Container logs:\n")
+		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:\n")
+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:\n")
+	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)
 		pipeEventsToStdout(config, namespace, podName, container, false)
 	}
 	}
-
-	// delete the ephemeral pod
-	deletePod(config, podName, namespace)
-
 	return err
 	return err
 }
 }
 
 

+ 14 - 14
cmd/app/main.go

@@ -58,6 +58,19 @@ func main() {
 
 
 	repo := gorm.NewRepository(db, &key)
 	repo := gorm.NewRepository(db, &key)
 
 
+	a, err := api.New(&api.AppConfig{
+		Logger:     logger,
+		Repository: repo,
+		ServerConf: appConf.Server,
+		RedisConf:  &appConf.Redis,
+		CapConf:    appConf.Capabilities,
+		DBConf:     appConf.Db,
+	})
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	if appConf.Redis.Enabled {
 	if appConf.Redis.Enabled {
 		redis, err := adapter.NewRedisClient(&appConf.Redis)
 		redis, err := adapter.NewRedisClient(&appConf.Redis)
 
 
@@ -70,20 +83,7 @@ func main() {
 
 
 		errorChan := make(chan error)
 		errorChan := make(chan error)
 
 
-		go prov.GlobalStreamListener(redis, *repo, errorChan)
-	}
-
-	a, err := api.New(&api.AppConfig{
-		Logger:     logger,
-		Repository: repo,
-		ServerConf: appConf.Server,
-		RedisConf:  &appConf.Redis,
-		CapConf:    appConf.Capabilities,
-		DBConf:     appConf.Db,
-	})
-
-	if err != nil {
-		logger.Fatal().Err(err).Msg("")
+		go prov.GlobalStreamListener(redis, *repo, a.AnalyticsClient, errorChan)
 	}
 	}
 
 
 	appRouter := router.New(a)
 	appRouter := router.New(a)

+ 6 - 2
dashboard/babel.config.json

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

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

@@ -8,7 +8,7 @@ type StateType = {};
 export default class GHIcon extends Component<PropsType, StateType> {
 export default class GHIcon extends Component<PropsType, StateType> {
   render() {
   render() {
     return (
     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>
         <title>btn_google_light_normal_ios</title>
         <desc>Created with Sketch.</desc>
         <desc>Created with Sketch.</desc>
         <defs>
         <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"};
+  }
+`;

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

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

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

@@ -8,7 +8,7 @@ import {
   ResourceListField,
   ResourceListField,
   Section,
   Section,
   SelectField,
   SelectField,
-  ServiceIPListField
+  ServiceIPListField,
 } from "./types";
 } from "./types";
 import TabRegion, { TabOption } from "../TabRegion";
 import TabRegion, { TabOption } from "../TabRegion";
 import Heading from "../form-components/Heading";
 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
     For example, number-input becomes input with a setting that makes it
     a number input
     a number input
    */
    */
   const restructureToNewFields = (data: PorterFormData) => {
   const restructureToNewFields = (data: PorterFormData) => {
     return {
     return {
       ...data,
       ...data,
-      tabs: data?.tabs?.map((tab) => {
+      tabs: data?.tabs?.map((tab, i) => {
         return {
         return {
           ...tab,
           ...tab,
-          sections: tab.sections?.map((section) => {
+          sections: tab.sections?.map((section, j) => {
             return {
             return {
               ...section,
               ...section,
               contents: section.contents
               contents: section.contents
-                ?.map((field: any) => {
+                ?.map((field: any, k) => {
+                  const id = `${i}-${j}-${k}`;
                   if (field?.type == "number-input") {
                   if (field?.type == "number-input") {
                     return {
                     return {
+                      id,
                       ...field,
                       ...field,
                       type: "input",
                       type: "input",
                       settings: {
                       settings: {
@@ -247,6 +249,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   }
                   if (field?.type == "string-input") {
                   if (field?.type == "string-input") {
                     return {
                     return {
+                      id,
                       ...field,
                       ...field,
                       type: "input",
                       type: "input",
                       settings: {
                       settings: {
@@ -257,6 +260,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   }
                   if (field?.type == "string-input-password") {
                   if (field?.type == "string-input-password") {
                     return {
                     return {
+                      id,
                       ...field,
                       ...field,
                       type: "input",
                       type: "input",
                       settings: {
                       settings: {
@@ -267,6 +271,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   }
                   if (field?.type == "provider-select") {
                   if (field?.type == "provider-select") {
                     return {
                     return {
+                      id,
                       ...field,
                       ...field,
                       type: "select",
                       type: "select",
                       settings: {
                       settings: {
@@ -277,6 +282,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                   }
                   }
                   if (field?.type == "env-key-value-array") {
                   if (field?.type == "env-key-value-array") {
                     return {
                     return {
+                      id,
                       ...field,
                       ...field,
                       type: "key-value-array",
                       type: "key-value-array",
                       secretOption: true,
                       secretOption: true,
@@ -288,7 +294,10 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                     };
                     };
                   }
                   }
                   if (field?.type == "variable") return null;
                   if (field?.type == "variable") return null;
-                  return field;
+                  return {
+                    id,
+                    ...field,
+                  };
                 })
                 })
                 .filter((x) => x != null),
                 .filter((x) => x != null),
             };
             };
@@ -321,7 +330,6 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                 contents: section.contents?.map((field, k) => {
                 contents: section.contents?.map((field, k) => {
                   return {
                   return {
                     ...field,
                     ...field,
-                    id: `${i}-${j}-${k}`,
                   };
                   };
                 }),
                 }),
               };
               };
@@ -412,7 +420,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     data?.tabs?.map((tab) =>
     data?.tabs?.map((tab) =>
       tab.sections?.map((section) =>
       tab.sections?.map((section) =>
         section.contents?.map((field) => {
         section.contents?.map((field) => {
-          if (finalFunctions[field?.type])
+          if (finalFunctions[field?.type]) {
             varList.push(
             varList.push(
               finalFunctions[field?.type](
               finalFunctions[field?.type](
                 state.variables,
                 state.variables,
@@ -421,6 +429,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                 context
                 context
               )
               )
             );
             );
+          }
         })
         })
       )
       )
     );
     );

+ 5 - 1
dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -1,6 +1,10 @@
 import React from "react";
 import React from "react";
 import styled from "styled-components";
 import styled from "styled-components";
-import { ArrayInputField, ArrayInputFieldState, GetFinalVariablesFunction } from "../types";
+import {
+  ArrayInputField,
+  ArrayInputFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
 import useFormField from "../hooks/useFormField";
 import useFormField from "../hooks/useFormField";
 
 
 const ArrayInput: React.FC<ArrayInputField> = (props) => {
 const ArrayInput: React.FC<ArrayInputField> = (props) => {

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

@@ -1,5 +1,9 @@
 import React from "react";
 import React from "react";
-import { CheckboxField, CheckboxFieldState, GetFinalVariablesFunction } from "../types";
+import {
+  CheckboxField,
+  CheckboxFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
 import CheckboxRow from "../../form-components/CheckboxRow";
 import CheckboxRow from "../../form-components/CheckboxRow";
 import useFormField from "../hooks/useFormField";
 import useFormField from "../hooks/useFormField";
 
 

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

@@ -1,5 +1,9 @@
 import React from "react";
 import React from "react";
-import { GetFinalVariablesFunction, KeyValueArrayField, KeyValueArrayFieldState } from "../types";
+import {
+  GetFinalVariablesFunction,
+  KeyValueArrayField,
+  KeyValueArrayFieldState,
+} from "../types";
 import sliders from "../../../assets/sliders.svg";
 import sliders from "../../../assets/sliders.svg";
 import upload from "../../../assets/upload.svg";
 import upload from "../../../assets/upload.svg";
 import styled from "styled-components";
 import styled from "styled-components";
@@ -345,6 +349,9 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
   props: KeyValueArrayField,
   props: KeyValueArrayField,
   state: KeyValueArrayFieldState
   state: KeyValueArrayFieldState
 ) => {
 ) => {
+  console.log(vars);
+  console.log(props);
+  console.log(state);
   if (!state) {
   if (!state) {
     return {
     return {
       [props.variable]: props.value ? props.value[0] : [],
       [props.variable]: props.value ? props.value[0] : [],

+ 5 - 1
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -1,5 +1,9 @@
 import React, { useContext } from "react";
 import React, { useContext } from "react";
-import { GetFinalVariablesFunction, SelectField, SelectFieldState } from "../types";
+import {
+  GetFinalVariablesFunction,
+  SelectField,
+  SelectFieldState,
+} from "../types";
 import Selector from "../../Selector";
 import Selector from "../../Selector";
 import styled from "styled-components";
 import styled from "styled-components";
 import useFormField from "../hooks/useFormField";
 import useFormField from "../hooks/useFormField";

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

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

+ 2 - 15
dashboard/src/index.html

@@ -81,17 +81,8 @@
       content="Kubernetes powered PaaS that runs in your own cloud."
       content="Kubernetes powered PaaS that runs in your own cloud."
     />
     />
     <meta property="og:url" content="https://porter.run" />
     <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
     <link
-      href="https://fonts.googleapis.com/icon?family=Material+Icons"
+      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
       rel="stylesheet"
       rel="stylesheet"
     />
     />
     <link
     <link
@@ -99,11 +90,7 @@
       rel="stylesheet"
       rel="stylesheet"
     />
     />
     <link
     <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"
       rel="stylesheet"
     />
     />
   </head>
   </head>

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

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

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -6,7 +6,12 @@ import { Route, Switch } from "react-router-dom";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";
 import { ChartType, ClusterType } from "shared/types";
-import { getQueryParam, PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
+import {
+  getQueryParam,
+  PorterUrl,
+  pushFiltered,
+  pushQueryParams,
+} from "shared/routing";
 
 
 import DashboardHeader from "./DashboardHeader";
 import DashboardHeader from "./DashboardHeader";
 import ChartList from "./chart/ChartList";
 import ChartList from "./chart/ChartList";

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

@@ -179,9 +179,24 @@ const Chart: React.FunctionComponent<Props> = ({
             margin_left={"17px"}
             margin_left={"17px"}
           />
           />
           <LastDeployed>
           <LastDeployed>
-            <Dot>•</Dot> Last deployed{" "}
-            {readableDate(
-              release?.info?.last_deployed || chart.info.last_deployed
+            {isJob && jobStatus?.status ? (
+              <>
+                <Dot>•</Dot>
+                <JobStatus status={jobStatus.status}>
+                  {jobStatus.status === "running" ? "Started" : "Last run"} {jobStatus.status} at{" "}
+                  {readableDate(jobStatus.start_time)}
+                </JobStatus>
+              </>
+            ) : (
+              <>
+                <Dot>•</Dot>
+                <JobStatus>
+                  Last deployed{" "}
+                  {readableDate(
+                    release?.info?.last_deployed || chart.info.last_deployed
+                  )}
+                </JobStatus>
+              </>
             )}
             )}
           </LastDeployed>
           </LastDeployed>
         </InfoWrapper>
         </InfoWrapper>
@@ -193,15 +208,6 @@ const Chart: React.FunctionComponent<Props> = ({
       </BottomWrapper>
       </BottomWrapper>
 
 
       <TopRightContainer>
       <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{release?.version || chart.version}</span>
       </TopRightContainer>
       </TopRightContainer>
     </StyledChart>
     </StyledChart>
@@ -331,17 +337,17 @@ const Title = styled.div`
   }
   }
 `;
 `;
 
 
-const JobStatus = styled.span`
-  font-weight: bold;
-  ${(props: { status: string }) => `
+const JobStatus = styled.span<{ status?: string }>`
+  font-size: 13px;
+  font-weight: ${props => props.status && props.status !== "running" ? "500" : ""};
+  ${props => `
   color: ${
   color: ${
     props.status === "succeeded"
     props.status === "succeeded"
       ? "rgb(56, 168, 138)"
       ? "rgb(56, 168, 138)"
       : props.status === "failed"
       : props.status === "failed"
-      ? "rgb(204, 61, 66)"
-      : "#aaaabb"
-  }
-`}
+      ? "#ff385d"
+      : "#aaaabb66"
+  }`}
 `;
 `;
 
 
 const StyledChart = styled.div`
 const StyledChart = styled.div`

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

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

+ 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;
+    }
+  }
+`;

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

@@ -32,6 +32,7 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
 import { integrationList } from "shared/common";
 import { integrationList } from "shared/common";
+import DeploymentType from "./DeploymentType";
 
 
 type Props = {
 type Props = {
   namespace: string;
   namespace: string;
@@ -661,46 +662,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
     return () => (isSubscribed = false);
     return () => (isSubscribed = false);
   }, [components, currentCluster, currentProject, currentChart]);
   }, [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 (
   return (
     <>
     <>
       <StyledExpandedChart>
       <StyledExpandedChart>
@@ -713,7 +674,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
             iconWidth="33px"
             iconWidth="33px"
           >
           >
             {currentChart.name}
             {currentChart.name}
-            {renderDeploymentType()}
+            <DeploymentType currentChart={currentChart} />
             <TagWrapper>
             <TagWrapper>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
             </TagWrapper>
             </TagWrapper>

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

@@ -17,6 +17,7 @@ import SettingsSection from "./SettingsSection";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import ValuesYaml from "./ValuesYaml";
 import ValuesYaml from "./ValuesYaml";
+import DeploymentType from "./DeploymentType";
 
 
 type PropsType = WithAuthProps & {
 type PropsType = WithAuthProps & {
   namespace: string;
   namespace: string;
@@ -464,7 +465,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         return (
         return (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
             <SettingsSection
             <SettingsSection
-              showSource={true}
               currentChart={this.state.currentChart}
               currentChart={this.state.currentChart}
               refreshChart={() => this.refreshChart(0)}
               refreshChart={() => this.refreshChart(0)}
               setShowDeleteOverlay={(x: boolean) => {
               setShowDeleteOverlay={(x: boolean) => {
@@ -586,6 +586,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               iconWidth="33px"
               iconWidth="33px"
             >
             >
               {chart.name}
               {chart.name}
+              <DeploymentType currentChart={currentChart} />
               <TagWrapper>
               <TagWrapper>
                 Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
                 Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
               </TagWrapper>
               </TagWrapper>

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

@@ -1,12 +1,14 @@
 import React, { useContext, useState, useEffect } from "react";
 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"];
 const NOTIF_CATEGORIES = ["success", "fail"];
 
 
@@ -105,62 +107,72 @@ const NotificationSettingsSection: React.FC<Props> = (props) => {
   return (
   return (
     <>
     <>
       <Heading>Notification Settings</Heading>
       <Heading>Notification Settings</Heading>
+      <Helper>Configure notification settings for this application.</Helper>
       {initLoading ? (
       {initLoading ? (
         <Loading />
         <Loading />
       ) : !hasRelease ? (
       ) : !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"}
+                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;
 export default NotificationSettingsSection;
+
+const A = styled.a`
+  text-decoration: underline;
+  cursor: pointer;
+  margin-left: 5px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;

+ 17 - 21
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -20,7 +20,6 @@ type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
   refreshChart: () => void;
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
   setShowDeleteOverlay: (x: boolean) => void;
-  showSource?: boolean;
   saveButtonText?: string | null;
   saveButtonText?: string | null;
 };
 };
 
 
@@ -28,7 +27,6 @@ const SettingsSection: React.FC<PropsType> = ({
   currentChart,
   currentChart,
   refreshChart,
   refreshChart,
   setShowDeleteOverlay,
   setShowDeleteOverlay,
-  showSource,
   saveButtonText,
   saveButtonText,
 }) => {
 }) => {
   const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
   const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
@@ -201,21 +199,19 @@ const SettingsSection: React.FC<PropsType> = ({
 
 
     return (
     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}
+          />
+          <Br />
+        </>
 
 
         <>
         <>
           <Heading>Redeploy Webhook</Heading>
           <Heading>Redeploy Webhook</Heading>
@@ -257,7 +253,7 @@ const SettingsSection: React.FC<PropsType> = ({
   return (
   return (
     <Wrapper>
     <Wrapper>
       {!loadingWebhookToken ? (
       {!loadingWebhookToken ? (
-        <StyledSettingsSection showSource={showSource}>
+        <StyledSettingsSection>
           {renderWebhookSection()}
           {renderWebhookSection()}
           <NotificationSettingsSection currentChart={currentChart} />
           <NotificationSettingsSection currentChart={currentChart} />
           <Heading>Additional Settings</Heading>
           <Heading>Additional Settings</Heading>
@@ -268,7 +264,7 @@ const SettingsSection: React.FC<PropsType> = ({
       ) : (
       ) : (
         <Loading />
         <Loading />
       )}
       )}
-      {!loadingWebhookToken && showSource && (
+      {!loadingWebhookToken && (
         <SaveButton
         <SaveButton
           text={saveButtonText || "Save Config"}
           text={saveButtonText || "Save Config"}
           status={saveValuesStatus}
           status={saveValuesStatus}
@@ -372,7 +368,7 @@ const Wrapper = styled.div`
   height: 100%;
   height: 100%;
 `;
 `;
 
 
-const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+const StyledSettingsSection = styled.div`
   width: 100%;
   width: 100%;
   background: #ffffff11;
   background: #ffffff11;
   padding: 0 35px;
   padding: 0 35px;
@@ -380,7 +376,7 @@ const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   position: relative;
   position: relative;
   border-radius: 8px;
   border-radius: 8px;
   overflow: auto;
   overflow: auto;
-  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+  height: calc(100% - 55px);
 `;
 `;
 
 
 const Holder = styled.div`
 const Holder = styled.div`

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

@@ -39,7 +39,17 @@ class JobList extends Component<PropsType, StateType> {
               <JobResource
               <JobResource
                 key={job?.metadata?.name}
                 key={job?.metadata?.name}
                 job={job}
                 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={
                 deleting={
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
                 }
                 }
@@ -61,6 +71,7 @@ class JobList extends Component<PropsType, StateType> {
   deleteJob = () => {
   deleteJob = () => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let job = this.state.deletionCandidate;
     let job = this.state.deletionCandidate;
+    this.context.setCurrentOverlay(null);
 
 
     api
     api
       .deleteJob(
       .deleteJob(
@@ -92,15 +103,7 @@ class JobList extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     return (
     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>
-      </>
+      <JobListWrapper>{this.renderJobList()}</JobListWrapper>
     );
     );
   }
   }
 }
 }

+ 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 = (
   let saveButton = (
     <ButtonWrapper>
     <ButtonWrapper>
       <SaveButton
       <SaveButton
-        onClick={() => props.handleSaveValues(getSubmitValues(), true)}
+        onClick={() => {
+          props.handleSaveValues(getSubmitValues(), true);
+        }}
         status={props.saveValuesStatus}
         status={props.saveValuesStatus}
         makeFlush={true}
         makeFlush={true}
         clearPosition={true}
         clearPosition={true}

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

@@ -5,7 +5,7 @@ import {
   MetricsNetworkDataResponse,
   MetricsNetworkDataResponse,
   MetricsNGINXErrorsDataResponse,
   MetricsNGINXErrorsDataResponse,
   AvailableMetrics,
   AvailableMetrics,
-  MetricsHpaReplicasDataResponse, 
+  MetricsHpaReplicasDataResponse,
   MetricsNGINXLatencyDataResponse,
   MetricsNGINXLatencyDataResponse,
   NormalizedMetricsData,
   NormalizedMetricsData,
 } from "./types";
 } from "./types";
@@ -40,7 +40,10 @@ export class MetricNormalizer {
     if (this.kind.includes("nginx:errors")) {
     if (this.kind.includes("nginx:errors")) {
       return this.parseNGINXErrorsMetrics(this.metric_results);
       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);
       return this.parseNGINXLatencyMetrics(this.metric_results);
     }
     }
     if (this.kind.includes("hpa_replicas")) {
     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 { Context } from "shared/Context";
 import api from "shared/api";
 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 Helper from "components/form-components/Helper";
 import { pushFiltered } from "shared/routing";
 import { pushFiltered } from "shared/routing";
 
 

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

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

+ 4 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -9,7 +9,10 @@ import { Context } from "shared/Context";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import { KeyValue } from "components/form-components/KeyValueArray";
 import { KeyValue } from "components/form-components/KeyValueArray";
-import { EnvGroupData, formattedEnvironmentValue } from "../cluster-dashboard/env-groups/EnvGroup";
+import {
+  EnvGroupData,
+  formattedEnvironmentValue,
+} from "../cluster-dashboard/env-groups/EnvGroup";
 
 
 type PropsType = {
 type PropsType = {
   namespace: string;
   namespace: string;

+ 69 - 5
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -16,12 +16,14 @@ import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
 import CheckboxList from "components/form-components/CheckboxList";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import Tooltip from "@material-ui/core/Tooltip";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   setSelectedProvisioner: (x: string | null) => void;
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   handleError: () => void;
   projectName: string;
   projectName: string;
   infras: InfraType[];
   infras: InfraType[];
+  highlightCosts?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -73,6 +75,15 @@ const machineTypeOptions = [
   { value: "t3.2xlarge", label: "t3.2xlarge" },
   { value: "t3.2xlarge", label: "t3.2xlarge" },
 ];
 ];
 
 
+const costMapping: Record<string, number> = {
+  "t2.medium": 35,
+  "t2.xlarge": 135,
+  "t2.2xlarge": 270,
+  "t3.medium": 30,
+  "t3.xlarge": 120,
+  "t3.2xlarge": 240,
+};
+
 // TODO: Consolidate across forms w/ HOC
 // TODO: Consolidate across forms w/ HOC
 class AWSFormSection extends Component<PropsType, StateType> {
 class AWSFormSection extends Component<PropsType, StateType> {
   state = {
   state = {
@@ -370,8 +381,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
           <Heading isAtTop={true}>
           <Heading isAtTop={true}>
             AWS Credentials
             AWS Credentials
             <GuideButton
             <GuideButton
-              href="https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-              target="_blank"
+              onClick={() => window.open("https://docs.getporter.dev/docs/getting-started-with-porter-on-aws")}
             >
             >
               <i className="material-icons-outlined">help</i>
               <i className="material-icons-outlined">help</i>
               Guide
               Guide
@@ -393,6 +403,38 @@ class AWSFormSection extends Component<PropsType, StateType> {
             setActiveValue={(x: string) => this.setState({ awsMachineType: x })}
             setActiveValue={(x: string) => this.setState({ awsMachineType: x })}
             label="⚙️ AWS Machine Type"
             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
           <InputRow
             type="text"
             type="text"
             value={awsAccessId}
             value={awsAccessId}
@@ -518,7 +560,7 @@ const CloseButton = styled.div`
   }
   }
 `;
 `;
 
 
-const GuideButton = styled.a`
+const GuideButton = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   margin-left: 20px;
   margin-left: 20px;
@@ -527,7 +569,7 @@ const GuideButton = styled.a`
   margin-bottom: -1px;
   margin-bottom: -1px;
   border: 1px solid #aaaabb;
   border: 1px solid #aaaabb;
   padding: 5px 10px;
   padding: 5px 10px;
-  padding-left: 6px;
+  padding-left: 8px;
   border-radius: 5px;
   border-radius: 5px;
   cursor: pointer;
   cursor: pointer;
   :hover {
   :hover {
@@ -543,7 +585,7 @@ const GuideButton = styled.a`
   > i {
   > i {
     color: #aaaabb;
     color: #aaaabb;
     font-size: 16px;
     font-size: 16px;
-    margin-right: 6px;
+    margin-right: 7px;
   }
   }
 `;
 `;
 
 
@@ -551,3 +593,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   width: 14px;
   margin: 0 auto;
   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;
+    }
+  }
+`;

+ 54 - 0
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -14,11 +14,14 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
 import CheckboxList from "components/form-components/CheckboxList";
+import InfoTooltip from "../../../components/InfoTooltip";
+import Tooltip from "@material-ui/core/Tooltip";
 
 
 type PropsType = {
 type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   handleError: () => void;
   projectName: string;
   projectName: string;
+  highlightCosts?: boolean;
   infras: InfraType[];
   infras: InfraType[];
 };
 };
 
 
@@ -277,6 +280,35 @@ export default class DOFormSection extends Component<PropsType, StateType> {
             </Highlight>
             </Highlight>
             .
             .
           </Helper>
           </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>
+              }
+              placement="top"
+            >
+              <StyledInfoTooltip>
+                <i className="material-icons">help_outline</i>
+              </StyledInfoTooltip>
+            </Tooltip>
+          </Helper>
+          */}
           <CheckboxRow
           <CheckboxRow
             isRequired={true}
             isRequired={true}
             checked={this.state.provisionConfirmed}
             checked={this.state.provisionConfirmed}
@@ -388,3 +420,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   width: 14px;
   margin: 0 auto;
   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;
+    }
+  }
+`;

+ 59 - 7
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -17,11 +17,13 @@ import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
 import CheckboxList from "components/form-components/CheckboxList";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import Tooltip from "@material-ui/core/Tooltip";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   setSelectedProvisioner: (x: string | null) => void;
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   handleError: () => void;
   projectName: string;
   projectName: string;
+  highlightCosts?: boolean;
   infras: InfraType[];
   infras: InfraType[];
 };
 };
 
 
@@ -330,8 +332,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
           <Heading isAtTop={true}>
           <Heading isAtTop={true}>
             GCP Credentials
             GCP Credentials
             <GuideButton
             <GuideButton
-              href="https://docs.getporter.dev/docs/getting-started-on-gcp"
-              target="_blank"
+              onClick={() => window.open("https://docs.getporter.dev/docs/getting-started-on-gcp")}
             >
             >
               <i className="material-icons-outlined">help</i>
               <i className="material-icons-outlined">help</i>
               Guide
               Guide
@@ -377,8 +378,8 @@ class GCPFormSection extends Component<PropsType, StateType> {
           />
           />
           {this.renderClusterNameSection()}
           {this.renderClusterNameSection()}
           <Helper>
           <Helper>
-            By default, Porter creates a cluster with three e2-medium instances
-            (2vCPUs and 4GB RAM each). Google Cloud will bill you for any
+            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
             provisioned resources. Learn more about GKE pricing
             <Highlight
             <Highlight
               href="https://cloud.google.com/kubernetes-engine/pricing"
               href="https://cloud.google.com/kubernetes-engine/pricing"
@@ -388,6 +389,35 @@ class GCPFormSection extends Component<PropsType, StateType> {
             </Highlight>
             </Highlight>
             .
             .
           </Helper>
           </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
           <CheckboxRow
             isRequired={true}
             isRequired={true}
             checked={this.state.provisionConfirmed}
             checked={this.state.provisionConfirmed}
@@ -470,7 +500,7 @@ const CloseButton = styled.div`
   }
   }
 `;
 `;
 
 
-const GuideButton = styled.a`
+const GuideButton = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   margin-left: 20px;
   margin-left: 20px;
@@ -479,7 +509,7 @@ const GuideButton = styled.a`
   margin-bottom: -1px;
   margin-bottom: -1px;
   border: 1px solid #aaaabb;
   border: 1px solid #aaaabb;
   padding: 5px 10px;
   padding: 5px 10px;
-  padding-left: 6px;
+  padding-left: 8px;
   border-radius: 5px;
   border-radius: 5px;
   cursor: pointer;
   cursor: pointer;
   :hover {
   :hover {
@@ -495,7 +525,7 @@ const GuideButton = styled.a`
   > i {
   > i {
     color: #aaaabb;
     color: #aaaabb;
     font-size: 16px;
     font-size: 16px;
-    margin-right: 6px;
+    margin-right: 7px;
   }
   }
 `;
 `;
 
 
@@ -503,3 +533,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   width: 14px;
   margin: 0 auto;
   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;
+    }
+  }
+`;

+ 31 - 1
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -13,6 +13,7 @@ import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
 import ExistingClusterSection from "./ExistingClusterSection";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered } from "shared/routing";
 import { pushFiltered } from "shared/routing";
+import InfoTooltip from "../../../components/InfoTooltip";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   isInNewProject?: boolean;
   isInNewProject?: boolean;
@@ -23,6 +24,7 @@ type PropsType = RouteComponentProps & {
 
 
 type StateType = {
 type StateType = {
   selectedProvider: string | null;
   selectedProvider: string | null;
+  highlightCosts: boolean;
   infras: InfraType[];
   infras: InfraType[];
 };
 };
 
 
@@ -31,6 +33,7 @@ const providers = ["aws", "gcp", "do"];
 class NewProject extends Component<PropsType, StateType> {
 class NewProject extends Component<PropsType, StateType> {
   state = {
   state = {
     selectedProvider: null as string | null,
     selectedProvider: null as string | null,
+    highlightCosts: true,
     infras: [] as InfraType[],
     infras: [] as InfraType[],
   };
   };
 
 
@@ -95,6 +98,7 @@ class NewProject extends Component<PropsType, StateType> {
             handleError={this.handleError}
             handleError={this.handleError}
             projectName={projectName}
             projectName={projectName}
             infras={infras}
             infras={infras}
+            highlightCosts={this.state.highlightCosts}
             setSelectedProvisioner={(x: string | null) => {
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
               this.setState({ selectedProvider: x });
             }}
             }}
@@ -108,6 +112,7 @@ class NewProject extends Component<PropsType, StateType> {
             handleError={this.handleError}
             handleError={this.handleError}
             projectName={projectName}
             projectName={projectName}
             infras={infras}
             infras={infras}
+            highlightCosts={this.state.highlightCosts}
             setSelectedProvisioner={(x: string | null) => {
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
               this.setState({ selectedProvider: x });
             }}
             }}
@@ -121,6 +126,7 @@ class NewProject extends Component<PropsType, StateType> {
             handleError={this.handleError}
             handleError={this.handleError}
             projectName={projectName}
             projectName={projectName}
             infras={infras}
             infras={infras}
+            highlightCosts={this.state.highlightCosts}
             setSelectedProvisioner={(x: string | null) => {
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
               this.setState({ selectedProvider: x });
             }}
             }}
@@ -214,11 +220,30 @@ class NewProject extends Component<PropsType, StateType> {
                 <Block
                 <Block
                   key={i}
                   key={i}
                   onClick={() => {
                   onClick={() => {
-                    this.setState({ selectedProvider: provider });
+                    this.setState({
+                      selectedProvider: provider,
+                      highlightCosts: false,
+                    });
                   }}
                   }}
                 >
                 >
                   <Icon src={providerInfo.icon} />
                   <Icon src={providerInfo.icon} />
                   <BlockTitle>{providerInfo.label}</BlockTitle>
                   <BlockTitle>{providerInfo.label}</BlockTitle>
+                  <CostSection
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      this.setState({
+                        selectedProvider: provider,
+                        highlightCosts: true,
+                      });
+                    }}
+                  >
+                    {/*
+                    {provider == "aws" && "$205/Month"}
+                    {provider == "gcp" && "$250/Month"}
+                    {provider == "do" && "$90/Month"}
+                    <InfoTooltip text={""} />
+                    */}
+                  </CostSection>
                   <BlockDescription>Hosted in your own cloud.</BlockDescription>
                   <BlockDescription>Hosted in your own cloud.</BlockDescription>
                 </Block>
                 </Block>
               );
               );
@@ -335,3 +360,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 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";
 import { pushQueryParams } from "shared/routing";
 
 

+ 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
 - 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.
   _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
 - 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.
   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.
   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:
 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.
 - 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.
 - 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
 - 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 - 3
go.sum

@@ -527,7 +527,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/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 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM=
 github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg=
 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.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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
@@ -1413,8 +1412,6 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

+ 4 - 4
internal/analytics/identifiers.go

@@ -18,14 +18,14 @@ type segmentIdentifyNewUser struct {
 	isGithub  bool
 	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)
 	userId := fmt.Sprintf("%v", user.ID)
+
 	return &segmentIdentifyNewUser{
 	return &segmentIdentifyNewUser{
 		userId:    userId,
 		userId:    userId,
 		userEmail: user.Email,
 		userEmail: user.Email,
-		isGithub:  registeredViaGithub,
+		isGithub:  user.GithubUserID != 0,
 	}
 	}
 }
 }
 
 

+ 26 - 3
internal/analytics/track_events.go

@@ -3,7 +3,30 @@ package analytics
 type SegmentEvent string
 type SegmentEvent string
 
 
 const (
 const (
-	NewUser            SegmentEvent = "New User"
-	RedeployViaWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
-	NewClusterEvent    SegmentEvent = "New Cluster Event"
+	// onboarding flow
+	UserCreate    SegmentEvent = "New User"
+	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,
+	}
+}

+ 376 - 62
internal/analytics/tracks.go

@@ -1,8 +1,6 @@
 package analytics
 package analytics
 
 
 import (
 import (
-	"fmt"
-
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 )
@@ -13,97 +11,413 @@ type segmentTrack interface {
 	getProperties() segment.Properties
 	getProperties() segment.Properties
 }
 }
 
 
-type segmentNewUserTrack struct {
-	userId    string
-	userEmail string
+type defaultTrackOpts struct {
+	AdditionalProps map[string]interface{}
+}
+
+type defaultSegmentTrack struct {
+	event      SegmentEvent
+	properties segmentProperties
+}
+
+func getDefaultSegmentTrack(additionalProps map[string]interface{}, event SegmentEvent) *defaultSegmentTrack {
+	props := newSegmentProperties()
+	props.addAdditionalProperties(additionalProps)
+
+	return &defaultSegmentTrack{
+		event:      event,
+		properties: props,
+	}
+}
+
+func (t *defaultSegmentTrack) getEvent() SegmentEvent {
+	return t.event
 }
 }
 
 
-// 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 (t *defaultSegmentTrack) getProperties() segment.Properties {
+	props := segment.NewProperties()
 
 
-	return &segmentNewUserTrack{
-		userId:    userId,
-		userEmail: user.Email,
+	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 (t *segmentNewUserTrack) getUserId() string {
-	return t.userId
+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
 }
 }
 
 
-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{})
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserCreate),
+	)
 }
 }
 
 
-func (t *segmentRedeployViaWebhookTrack) getProperties() segment.Properties {
-	return segment.NewProperties().Set("repository", t.repository)
+// ProjectCreateTrackOpts are the options for creating a track when a project is created
+type ProjectCreateTrackOpts struct {
+	*ProjectScopedTrackOpts
 }
 }
 
 
-type segmentNewClusterEventTrack struct {
-	userId      string
-	projId      string
-	clusterName string
-	clusterType string // EKS, DOKS, or GKE
-	eventType   string // connected, provisioned, or destroyed
+// 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),
+	)
 }
 }
 
 
-// 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
+// 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
 }
 }
 
 
-// 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,
-	}
+// 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
 }
 }
 
 
-func (t *segmentNewClusterEventTrack) getUserId() string {
-	return t.userId
+// 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),
+	)
 }
 }
 
 
-func (t *segmentNewClusterEventTrack) getEvent() SegmentEvent {
-	return NewClusterEvent
+// 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
 }
 }
 
 
-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)
+// 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
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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),
+	)
 }
 }

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

@@ -169,6 +169,8 @@ func QueryPrometheus(
 	if opts.ShouldSum {
 	if opts.ShouldSum {
 		query = fmt.Sprintf("sum(%s)", query)
 		query = fmt.Sprintf("sum(%s)", query)
 	}
 	}
+	
+	fmt.Println("QUERY IS:", query)
 
 
 	fmt.Println("QUERY IS", query)
 	fmt.Println("QUERY IS", query)
 
 

+ 68 - 0
internal/kubernetes/provisioner/global_stream.go

@@ -9,6 +9,7 @@ import (
 	"strings"
 	"strings"
 
 
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 
 
 	redis "github.com/go-redis/redis/v8"
 	redis "github.com/go-redis/redis/v8"
@@ -83,6 +84,7 @@ type ResourceCRUDHandler interface {
 func GlobalStreamListener(
 func GlobalStreamListener(
 	client *redis.Client,
 	client *redis.Client,
 	repo repository.Repository,
 	repo repository.Repository,
+	analyticsClient analytics.AnalyticsSegmentClient,
 	errorChan chan error,
 	errorChan chan error,
 ) {
 ) {
 	for {
 	for {
@@ -163,6 +165,14 @@ func GlobalStreamListener(
 					if err != nil {
 					if err != nil {
 						continue
 						continue
 					}
 					}
+
+					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) {
 				} else if kind == string(models.InfraEKS) {
 					cluster := &models.Cluster{
 					cluster := &models.Cluster{
 						AuthMechanism:    models.AWS,
 						AuthMechanism:    models.AWS,
@@ -197,6 +207,14 @@ func GlobalStreamListener(
 					if err != nil {
 					if err != nil {
 						continue
 						continue
 					}
 					}
+
+					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) {
 				} else if kind == string(models.InfraGCR) {
 					reg := &models.Registry{
 					reg := &models.Registry{
 						ProjectID:        projID,
 						ProjectID:        projID,
@@ -217,6 +235,14 @@ func GlobalStreamListener(
 					if err != nil {
 					if err != nil {
 						continue
 						continue
 					}
 					}
+
+					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) {
 				} else if kind == string(models.InfraGKE) {
 					cluster := &models.Cluster{
 					cluster := &models.Cluster{
 						AuthMechanism:    models.GCP,
 						AuthMechanism:    models.GCP,
@@ -251,6 +277,14 @@ func GlobalStreamListener(
 					if err != nil {
 					if err != nil {
 						continue
 						continue
 					}
 					}
+
+					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) {
 				} else if kind == string(models.InfraDOCR) {
 					reg := &models.Registry{
 					reg := &models.Registry{
 						ProjectID:       projID,
 						ProjectID:       projID,
@@ -270,6 +304,14 @@ func GlobalStreamListener(
 					if err != nil {
 					if err != nil {
 						continue
 						continue
 					}
 					}
+
+					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) {
 				} else if kind == string(models.InfraDOKS) {
 					cluster := &models.Cluster{
 					cluster := &models.Cluster{
 						AuthMechanism:   models.DO,
 						AuthMechanism:   models.DO,
@@ -304,6 +346,14 @@ func GlobalStreamListener(
 					if err != nil {
 					if err != nil {
 						continue
 						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" {
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "error" {
 				infra, err := repo.Infra.ReadInfra(infraID)
 				infra, err := repo.Infra.ReadInfra(infraID)
@@ -319,6 +369,24 @@ func GlobalStreamListener(
 				if err != nil {
 				if err != nil {
 					continue
 					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" {
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "destroyed" {
 				infra, err := repo.Infra.ReadInfra(infraID)
 				infra, err := repo.Infra.ReadInfra(infraID)
 
 

+ 3 - 0
internal/models/infra.go

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

+ 8 - 6
server/api/api.go

@@ -95,11 +95,13 @@ type App struct {
 	GoogleUserConf    *oauth2.Config
 	GoogleUserConf    *oauth2.Config
 	SlackConf         *oauth2.Config
 	SlackConf         *oauth2.Config
 
 
-	db              *gorm.DB
-	validator       *vr.Validate
-	translator      *ut.Translator
-	tokenConf       *token.TokenGeneratorConf
-	analyticsClient analytics.AnalyticsSegmentClient
+	// analytics client for reporting
+	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	db         *gorm.DB
+	validator  *vr.Validate
+	translator *ut.Translator
+	tokenConf  *token.TokenGeneratorConf
 }
 }
 
 
 type AppCapabilities struct {
 type AppCapabilities struct {
@@ -242,7 +244,7 @@ func New(conf *AppConfig) (*App, error) {
 	}
 	}
 
 
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
-	app.analyticsClient = newSegmentClient
+	app.AnalyticsClient = newSegmentClient
 
 
 	app.updateChartRepoURLs()
 	app.updateChartRepoURLs()
 
 

+ 22 - 34
server/api/cluster_handler.go

@@ -2,7 +2,6 @@ package api
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 
 
@@ -17,7 +16,7 @@ import (
 // HandleCreateProjectCluster creates a new cluster
 // HandleCreateProjectCluster creates a new cluster
 func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	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 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		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.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)
 	w.WriteHeader(http.StatusCreated)
 
 
@@ -279,14 +269,7 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 
 
 	extClusters := make([]*models.ClusterCandidateExternal, 0)
 	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 {
 	for _, cc := range ccs {
 		// handle write to the database
 		// handle write to the database
@@ -297,6 +280,13 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 			return
 			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)
 		app.Logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
 
 
 		// if the ClusterCandidate does not have any actions to perform, create the Cluster
 		// 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.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())
 		extClusters = append(extClusters, cc.Externalize())
@@ -401,14 +398,7 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 		return
 		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
 	// decode actions from request
 	resolver := &models.ClusterResolverAll{}
 	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.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),
 		},
 		},
 	))
 	))
 
 

+ 51 - 2
server/api/deploy_handler.go

@@ -3,18 +3,21 @@ package api
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
-	"gorm.io/gorm"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 
 
+	"gorm.io/gorm"
+
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"gopkg.in/yaml.v2"
 	"gopkg.in/yaml.v2"
 )
 )
@@ -22,6 +25,8 @@ import (
 // HandleDeployTemplate triggers a chart deployment from a template
 // HandleDeployTemplate triggers a chart deployment from a template
 func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -57,6 +62,13 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
+		&analytics.ApplicationLaunchStartTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), uint(clusterID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
 
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
@@ -190,12 +202,28 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
 		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
+		&analytics.ApplicationLaunchSuccessTrackOpts{
+			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+				userID,
+				uint(projID),
+				uint(clusterID),
+				release.Name,
+				release.Namespace,
+				chart.Metadata.Name,
+			),
+			FlowID: flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }
 
 
 // HandleDeployAddon triggers a addon deployment from a template
 // HandleDeployAddon triggers a addon deployment from a template
 func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -253,6 +281,13 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
+		&analytics.ApplicationLaunchStartTrackOpts{
+			ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(userID, uint(projID), uint(form.ReleaseForm.Cluster.ID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	agent, err := app.getAgentFromReleaseForm(
 	agent, err := app.getAgentFromReleaseForm(
 		w,
 		w,
 		r,
 		r,
@@ -281,7 +316,7 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		Registries: registries,
 		Registries: registries,
 	}
 	}
 
 
-	_, err = agent.InstallChart(conf, app.DOConf)
+	rel, err := agent.InstallChart(conf, app.DOConf)
 
 
 	if err != nil {
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -292,6 +327,20 @@ func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
+		&analytics.ApplicationLaunchSuccessTrackOpts{
+			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+				userID,
+				uint(projID),
+				uint(form.ReleaseForm.Cluster.ID),
+				rel.Name,
+				rel.Namespace,
+				chart.Metadata.Name,
+			),
+			FlowID: flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }
 
 

+ 9 - 0
server/api/integration_handler.go

@@ -16,6 +16,7 @@ import (
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/google/go-github/github"
 	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
@@ -481,6 +482,14 @@ func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request)
 
 
 // HandleGithubAppOauthInit redirects the user to the Porter github app authorization page
 // HandleGithubAppOauthInit redirects the user to the Porter github app authorization page
 func (app *App) HandleGithubAppOauthInit(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleGithubAppOauthInit(w http.ResponseWriter, r *http.Request) {
+	userID, _ := app.getUserIDFromRequest(r)
+
+	app.AnalyticsClient.Track(analytics.GithubConnectionStartTrack(
+		&analytics.GithubConnectionStartTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(userID),
+		},
+	))
+
 	http.Redirect(w, r, app.GithubAppConf.AuthCodeURL("", oauth2.AccessTypeOffline), 302)
 	http.Redirect(w, r, app.GithubAppConf.AuthCodeURL("", oauth2.AccessTypeOffline), 302)
 }
 }
 
 

+ 11 - 2
server/api/oauth_github_handler.go

@@ -131,8 +131,11 @@ func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request
 		}
 		}
 
 
 		// send to segment
 		// send to segment
-		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
-		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		}))
 
 
 		// log the user in
 		// log the user in
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
@@ -352,6 +355,12 @@ func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.GithubConnectionSuccessTrack(
+		&analytics.GithubConnectionSuccessTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		},
+	))
+
 	if session.Values["query_params"] != "" {
 	if session.Values["query_params"] != "" {
 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
 	} else {
 	} else {

+ 4 - 2
server/api/oauth_google_handler.go

@@ -95,9 +95,11 @@ func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request
 	}
 	}
 
 
 	// send to segment
 	// send to segment
-	app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
+	app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
 
 
-	app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+	app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+	}))
 
 
 	// log the user in
 	// log the user in
 	app.Logger.Info().Msgf("New user created: %d", user.ID)
 	app.Logger.Info().Msgf("New user created: %d", user.ID)

+ 5 - 0
server/api/project_handler.go

@@ -7,6 +7,7 @@ import (
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 )
 )
@@ -72,6 +73,10 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
+		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, projModel.ID),
+	}))
+
 	app.Logger.Info().Msgf("New project created: %d", projModel.ID)
 	app.Logger.Info().Msgf("New project created: %d", projModel.ID)
 
 
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)

+ 62 - 53
server/api/provision_handler.go

@@ -7,8 +7,6 @@ import (
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 
 
-	"fmt"
-
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -22,6 +20,7 @@ import (
 // container pod
 // container pod
 func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -46,6 +45,8 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -147,6 +148,7 @@ func (app *App) HandleDestroyTestInfra(w http.ResponseWriter, r *http.Request) {
 // HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
 // HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
 func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -177,6 +179,8 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -219,6 +223,14 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 
 
 	app.Logger.Info().Msgf("New aws ecr infra created: %d", infra.ID)
 	app.Logger.Info().Msgf("New aws ecr infra created: %d", infra.ID)
 
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraECR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)
 
 
 	infraExt := infra.Externalize()
 	infraExt := infra.Externalize()
@@ -336,6 +348,8 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -378,13 +392,12 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	app.Logger.Info().Msgf("New aws eks infra created: %d", infra.ID)
 	app.Logger.Info().Msgf("New aws eks infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.EKSName,
-			ClusterType: "EKS",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraEKS,
+			InfraID:                infra.ID,
 		},
 		},
 	))
 	))
 
 
@@ -402,7 +415,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -469,15 +482,6 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	app.Logger.Info().Msgf("AWS EKS infra marked for destruction: %d", infra.ID)
 	app.Logger.Info().Msgf("AWS EKS infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.EKSName,
-			ClusterType: "EKS",
-			EventType:   "destroyed",
-		},
-	))
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }
@@ -485,6 +489,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 // HandleProvisionGCPGCRInfra enables GCR for a project
 // HandleProvisionGCPGCRInfra enables GCR for a project
 func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -515,6 +520,8 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -556,6 +563,14 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 
 
 	app.Logger.Info().Msgf("New gcp gcr infra created: %d", infra.ID)
 	app.Logger.Info().Msgf("New gcp gcr infra created: %d", infra.ID)
 
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraGCR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)
 
 
 	infraExt := infra.Externalize()
 	infraExt := infra.Externalize()
@@ -600,6 +615,8 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -641,13 +658,12 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	app.Logger.Info().Msgf("New gcp gke infra created: %d", infra.ID)
 	app.Logger.Info().Msgf("New gcp gke infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.GKEName,
-			ClusterType: "GKE",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraGKE,
+			InfraID:                infra.ID,
 		},
 		},
 	))
 	))
 
 
@@ -665,7 +681,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
+	// userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -731,15 +747,6 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	app.Logger.Info().Msgf("GCP GKE infra marked for destruction: %d", infra.ID)
 	app.Logger.Info().Msgf("GCP GKE infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.GKEName,
-			ClusterType: "GKE",
-			EventType:   "destroyed",
-		},
-	))
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }
@@ -791,6 +798,7 @@ func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request
 // HandleProvisionDODOCRInfra provisions a new digitalocean DOCR instance for a project
 // HandleProvisionDODOCRInfra provisions a new digitalocean DOCR instance for a project
 func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -821,6 +829,8 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -865,6 +875,14 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 
 
 	app.Logger.Info().Msgf("New do docr infra created: %d", infra.ID)
 	app.Logger.Info().Msgf("New do docr infra created: %d", infra.ID)
 
 
+	app.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+		&analytics.RegistryProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			RegistryType:           models.InfraDOCR,
+			InfraID:                infra.ID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)
 
 
 	infraExt := infra.Externalize()
 	infraExt := infra.Externalize()
@@ -984,6 +1002,8 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
+	infra.CreatedByUserID = userID
+
 	// handle write to the database
 	// handle write to the database
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 
@@ -1027,13 +1047,12 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	app.Logger.Info().Msgf("New do doks infra created: %d", infra.ID)
 	app.Logger.Info().Msgf("New do doks infra created: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.DOKSName,
-			ClusterType: "DOKS",
-			EventType:   "provisioned",
+
+	app.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+		&analytics.ClusterProvisioningStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			ClusterType:            models.InfraDOKS,
+			InfraID:                infra.ID,
 		},
 		},
 	))
 	))
 
 
@@ -1051,7 +1070,6 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
 	// get path parameters
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
 	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
-	userID, err := app.getUserIDFromRequest(r)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -1119,15 +1137,6 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	app.Logger.Info().Msgf("DO DOKS infra marked for destruction: %d", infra.ID)
 	app.Logger.Info().Msgf("DO DOKS infra marked for destruction: %d", infra.ID)
-	app.analyticsClient.Track(analytics.CreateSegmentNewClusterEvent(
-		&analytics.NewClusterEventOpts{
-			UserId:      fmt.Sprintf("%d", userID),
-			ProjId:      fmt.Sprintf("%d", infra.ProjectID),
-			ClusterName: form.DOKSName,
-			ClusterType: "DOKS",
-			EventType:   "destroyed",
-		},
-	))
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }

+ 17 - 1
server/api/registry_handler.go

@@ -8,8 +8,8 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
-
 	"github.com/porter-dev/porter/internal/registry"
 	"github.com/porter-dev/porter/internal/registry"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
@@ -22,12 +22,21 @@ import (
 // HandleCreateRegistry creates a new registry
 // HandleCreateRegistry creates a new registry
 func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	userID, err := app.getUserIDFromRequest(r)
+	flowID := oauth.CreateRandomState()
 
 
 	if err != nil || projID == 0 {
 	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 		return
 	}
 	}
 
 
+	app.AnalyticsClient.Track(analytics.RegistryConnectionStartTrack(
+		&analytics.RegistryConnectionStartTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(userID, uint(projID)),
+			FlowID:                 flowID,
+		},
+	))
+
 	form := &forms.CreateRegistry{
 	form := &forms.CreateRegistry{
 		ProjectID: uint(projID),
 		ProjectID: uint(projID),
 	}
 	}
@@ -62,6 +71,13 @@ func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 
 
 	app.Logger.Info().Msgf("New registry created: %d", registry.ID)
 	app.Logger.Info().Msgf("New registry created: %d", registry.ID)
 
 
+	app.AnalyticsClient.Track(analytics.RegistryConnectionSuccessTrack(
+		&analytics.RegistryConnectionSuccessTrackOpts{
+			RegistryScopedTrackOpts: analytics.GetRegistryScopedTrackOpts(userID, uint(projID), registry.ID),
+			FlowID:                  flowID,
+		},
+	))
+
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)
 
 
 	regExt := registry.Externalize()
 	regExt := registry.Externalize()

+ 14 - 3
server/api/release_handler.go

@@ -1059,8 +1059,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		notifyOpts.Status = slack.StatusFailed
 		notifyOpts.Status = slack.StatusFailed
 		notifyOpts.Info = upgradeErr.Error()
 		notifyOpts.Info = upgradeErr.Error()
 
 
-		slackErr := notifier.Notify(notifyOpts)
-		fmt.Println("SLACK ERROR IS", slackErr)
+		notifier.Notify(notifyOpts)
 
 
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
 			Code:   ErrReleaseDeploy,
@@ -1316,7 +1315,19 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 
 
 	notifier.Notify(notifyOpts)
 	notifier.Notify(notifyOpts)
 
 
-	app.analyticsClient.Track(analytics.CreateSegmentRedeployViaWebhookTrack("anonymous", repository.(string)))
+	userID, _ := app.getUserIDFromRequest(r)
+
+	app.AnalyticsClient.Track(analytics.ApplicationDeploymentWebhookTrack(&analytics.ApplicationDeploymentWebhookTrackOpts{
+		ImageURI: fmt.Sprintf("%v", repository),
+		ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
+			userID,
+			release.ProjectID,
+			release.ClusterID,
+			release.Name,
+			release.Namespace,
+			rel.Chart.Metadata.Name,
+		),
+	}))
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)
 }
 }

+ 5 - 2
server/api/user_handler.go

@@ -54,8 +54,11 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 
 	if err == nil {
 	if err == nil {
 		// send to segment
 		// send to segment
-		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, false))
-		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
+		app.AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		}))
 
 
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
 		app.Logger.Info().Msgf("New user created: %d", user.ID)