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

Merge branch 'master' into 0.7.0-better-metrics

Ivan Galakhov 4 лет назад
Родитель
Сommit
a36ccc5cca
56 измененных файлов с 2291 добавлено и 814 удалено
  1. 106 0
      cli/cmd/logs.go
  2. 204 3
      cli/cmd/run.go
  3. 0 1
      dashboard/src/components/ResourceTab.tsx
  4. 2 1
      dashboard/src/components/SaveButton.tsx
  5. 5 1
      dashboard/src/components/porter-form/PorterForm.tsx
  6. 59 27
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  7. 12 2
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  8. 4 4
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  9. 14 1
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  10. 20 15
      dashboard/src/components/porter-form/field-components/Input.tsx
  11. 9 7
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  12. 4 2
      dashboard/src/components/porter-form/field-components/Select.tsx
  13. 6 1
      dashboard/src/components/porter-form/types.ts
  14. 11 13
      dashboard/src/main/home/Home.tsx
  15. 3 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  16. 2 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  17. 2 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  18. 4 4
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  19. 24 24
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  20. 145 43
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  21. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  22. 41 42
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  23. 34 18
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  24. 10 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  25. 327 405
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  26. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  27. 219 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx
  28. 72 81
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  29. 4 41
      dashboard/src/main/home/dashboard/ClusterList.tsx
  30. 2 2
      dashboard/src/main/home/integrations/IntegrationList.tsx
  31. 2 2
      dashboard/src/main/home/integrations/IntegrationRow.tsx
  32. 2 2
      dashboard/src/main/home/integrations/Integrations.tsx
  33. 2 2
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  34. 2 0
      dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx
  35. 4 3
      dashboard/src/main/home/launch/Launch.tsx
  36. 2 3
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  37. 154 0
      dashboard/src/main/home/modals/UpgradeChartModal.tsx
  38. 3 3
      dashboard/src/shared/Context.tsx
  39. 11 0
      dashboard/src/shared/api.tsx
  40. 1 1
      dashboard/src/shared/hooks/useWebsockets.ts
  41. 5 3
      dashboard/src/shared/types.tsx
  42. 30 0
      docs/deploy/addons/strapi.md
  43. 168 0
      docs/developing/frontend-guide.md
  44. 79 0
      docs/developing/frontend-roadmap-status.md
  45. 46 0
      docs/developing/frontend-roadmap.md
  46. 53 9
      docs/developing/setup.md
  47. 18 0
      docs/developing/test-autoscaling.md
  48. 25 0
      docs/guides/linking-slack-integration.md
  49. 1 0
      go.mod
  50. 2 0
      go.sum
  51. 73 0
      internal/helm/upgrade/upgrade.go
  52. 114 21
      internal/kubernetes/prometheus/metrics.go
  53. 29 0
      server/api/api.go
  54. 43 14
      server/api/release_handler.go
  55. 63 0
      server/api/template_handler.go
  56. 8 0
      server/router/router.go

+ 106 - 0
cli/cmd/logs.go

@@ -0,0 +1,106 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+// logsCmd represents the "porter logs" base command when called
+// without any subcommands
+var logsCmd = &cobra.Command{
+	Use:   "logs [release]",
+	Short: "Logs the output from a given application.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, logs)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var follow bool
+
+func init() {
+	rootCmd.AddCommand(logsCmd)
+
+	logsCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of release to connect to",
+	)
+
+	logsCmd.PersistentFlags().BoolVarP(
+		&follow,
+		"follow",
+		"f",
+		false,
+		"specify if the logs should be streamed",
+	)
+}
+
+func logs(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	podsSimple, err := getPods(client, namespace, args[0])
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
+	}
+
+	// if length of pods is 0, throw error
+	var selectedPod podSimple
+
+	if len(podsSimple) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
+	} else {
+		podNames := make([]string, 0)
+
+		for _, podSimple := range podsSimple {
+			podNames = append(podNames, podSimple.Name)
+		}
+
+		selectedPodName, err := utils.PromptSelect("Select the pod:", podNames)
+
+		if err != nil {
+			return err
+		}
+
+		// find selected pod
+		for _, podSimple := range podsSimple {
+			if selectedPodName == podSimple.Name {
+				selectedPod = podSimple
+			}
+		}
+	}
+
+	var selectedContainerName string
+
+	// if the selected pod has multiple container, spawn selector
+	if len(selectedPod.ContainerNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(selectedPod.ContainerNames) == 1 {
+		selectedContainerName = selectedPod.ContainerNames[0]
+	} else {
+		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
+
+		if err != nil {
+			return err
+		}
+
+		selectedContainerName = selectedContainer
+	}
+
+	restConf, err := getRESTConfig(client)
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	return pipePodLogsToStdout(restConf, namespace, selectedPod.Name, selectedContainerName, follow)
+}

+ 204 - 3
cli/cmd/run.go

@@ -3,19 +3,25 @@ package cmd
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/kubectl/pkg/util/term"
+
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/remotecommand"
-	"k8s.io/kubectl/pkg/util/term"
 )
 
 var namespace string
@@ -35,6 +41,8 @@ var runCmd = &cobra.Command{
 	},
 }
 
+var existingPod bool
+
 func init() {
 	rootCmd.AddCommand(runCmd)
 
@@ -44,6 +52,14 @@ func init() {
 		"default",
 		"namespace of release to connect to",
 	)
+
+	runCmd.PersistentFlags().BoolVarP(
+		&existingPod,
+		"existing_pod",
+		"e",
+		false,
+		"whether to connect to an existing pod",
+	)
 }
 
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
@@ -106,7 +122,11 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}
 
-	return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	if existingPod {
+		return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	}
+
+	return executeRunEphemeral(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
@@ -189,7 +209,6 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 		Namespace(namespace).
 		SubResource("exec")
 
-	// req.Param("container", "web")
 	for _, arg := range args {
 		req.Param("command", arg)
 	}
@@ -225,3 +244,185 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 
 	return err
 }
+
+func executeRunEphemeral(config *rest.Config, namespace, name, container string, args []string) error {
+	existing, err := getExistingPod(config, name, namespace)
+
+	if err != nil {
+		return err
+	}
+
+	newPod, err := createPodFromExisting(config, existing, args)
+
+	if err != nil {
+		return err
+	}
+
+	podName := newPod.ObjectMeta.Name
+
+	t := term.TTY{
+		In:  os.Stdin,
+		Out: os.Stdout,
+		Raw: true,
+	}
+
+	fn := func() error {
+		restClient, err := rest.RESTClientFor(config)
+
+		if err != nil {
+			return err
+		}
+
+		req := 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)
+
+		exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
+
+		if err != nil {
+			return err
+		}
+
+		return exec.Stream(remotecommand.StreamOptions{
+			Stdin:  os.Stdin,
+			Stdout: os.Stdout,
+			Stderr: os.Stderr,
+			Tty:    true,
+		})
+	}
+
+	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.")
+
+	for i := 0; i < 5; i++ {
+		err = t.Safe(fn)
+
+		if err == nil {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+
+		// ugly way to catch non-TTY errors, such as when running command "echo \"hello\""
+		if i == 4 && err != nil && strings.Contains(err.Error(), "not found in pod") {
+			fmt.Printf("Could not open a shell to this container. Container logs:\n")
+
+			err = pipePodLogsToStdout(config, namespace, podName, container, false)
+		}
+	}
+
+	// delete the ephemeral pod
+	deletePod(config, podName, namespace)
+
+	return err
+}
+
+func pipePodLogsToStdout(config *rest.Config, namespace, name, container string, follow bool) error {
+	podLogOpts := v1.PodLogOptions{
+		Container: container,
+		Follow:    follow,
+	}
+
+	// creates the clientset
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return err
+	}
+
+	req := clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(
+		context.Background(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	defer podLogs.Close()
+
+	_, err = io.Copy(os.Stdout, podLogs)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func getExistingPod(config *rest.Config, name, namespace string) (*v1.Pod, error) {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+func deletePod(config *rest.Config, name, namespace string) error {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return err
+	}
+
+	return clientset.CoreV1().Pods(namespace).Delete(
+		context.Background(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+func createPodFromExisting(config *rest.Config, existing *v1.Pod, args []string) (*v1.Pod, error) {
+	clientset, err := kubernetes.NewForConfig(config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	newPod := existing.DeepCopy()
+
+	// only copy the pod spec, overwrite metadata
+	newPod.ObjectMeta = metav1.ObjectMeta{
+		Name:      strings.ToLower(fmt.Sprintf("%s-copy-%s", existing.ObjectMeta.Name, utils.String(4))),
+		Namespace: existing.ObjectMeta.Namespace,
+	}
+
+	newPod.Status = v1.PodStatus{}
+
+	// set restart policy to never
+	newPod.Spec.RestartPolicy = v1.RestartPolicyNever
+
+	// change the command in the pod to the passed in pod command
+	cmdRoot := args[0]
+	cmdArgs := make([]string, 0)
+
+	if len(args) > 1 {
+		cmdArgs = args[1:]
+	}
+
+	newPod.Spec.Containers[0].Command = []string{cmdRoot}
+	newPod.Spec.Containers[0].Args = cmdArgs
+	newPod.Spec.Containers[0].TTY = true
+	newPod.Spec.Containers[0].Stdin = true
+	newPod.Spec.Containers[0].StdinOnce = true
+
+	// create the pod and return it
+	return clientset.CoreV1().Pods(existing.ObjectMeta.Namespace).Create(
+		context.Background(),
+		newPod,
+		metav1.CreateOptions{},
+	)
+}

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

@@ -142,7 +142,6 @@ export default class ResourceTab extends Component<PropsType, StateType> {
 const StyledResourceTab = styled.div`
   width: 100%;
   margin-bottom: 2px;
-  overflow: hidden;
   background: #ffffff11;
   border-bottom-left-radius: ${(props: {
     isLast: boolean;

+ 2 - 1
dashboard/src/components/SaveButton.tsx

@@ -110,6 +110,8 @@ const StatusTextWrapper = styled.p`
   margin: 0;
 `;
 
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
 const StatusWrapper = styled.div<{
   successful: boolean;
   position: "right" | "left";
@@ -136,7 +138,6 @@ const StatusWrapper = styled.div<{
     color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
   }
 
-  animation: statusFloatIn 0.5s;
   animation-fill-mode: forwards;
 
   @keyframes statusFloatIn {

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

@@ -41,6 +41,7 @@ interface Props {
   showStateDebugger?: boolean;
   currentTab: string;
   setCurrentTab: (nt: string) => void;
+  isLaunch?: boolean;
 }
 
 const PorterForm: React.FC<Props> = (props) => {
@@ -102,11 +103,14 @@ const PorterForm: React.FC<Props> = (props) => {
     let options = (props.leftTabOptions || [])
       .concat(
         formData?.tabs?.map((tab) => {
+          if (props.isLaunch && tab?.settings?.omitFromLaunch) {
+            return undefined;
+          }
           return { label: tab.label, value: tab.name };
         })
       )
       .concat(props.rightTabOptions || []);
-    return options.filter((x) => x !== undefined);
+    return options.filter((x) => !!x);
   };
 
   const showSaveButton = (): boolean => {

+ 59 - 27
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -45,7 +45,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     state: PorterFormState,
     action: PorterFormAction
   ): PorterFormState => {
-    switch (action.type) {
+    switch (action?.type) {
       case "init-field":
         if (!(action.id in state.components)) {
           return {
@@ -58,12 +58,15 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
               ...state.components,
               [action.id]: {
                 state: action.initValue,
-                validation: {
-                  ...{
-                    validated: false,
-                  },
-                  ...action.initValidation,
+              },
+            },
+            validation: {
+              ...state.validation,
+              [action.id]: {
+                ...{
+                  validated: false,
                 },
+                ...action.initValidation,
               },
             },
           };
@@ -94,9 +97,12 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
             ...state.components,
             [action.id]: {
               ...state.components[action.id],
-              validation: action.updateFunc(
-                state.components[action.id].validation
-              ),
+            },
+          },
+          validation: {
+            ...state.validation,
+            [action.id]: {
+              ...action.updateFunc(state.validation[action.id]),
             },
           },
         };
@@ -119,7 +125,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     data?.tabs?.map((tab) =>
       tab.sections?.map((section) =>
         section.contents?.map((field) => {
-          if (field.type == "variable") {
+          if (field?.type == "variable") {
             ret[field.variable] = field.settings?.default;
           }
         })
@@ -128,8 +134,36 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     return ret;
   };
 
+  const getInitialValidation = (data: PorterFormData) => {
+    const ret: Record<string, any> = {};
+    data?.tabs?.map((tab, i) =>
+      tab.sections?.map((section, j) =>
+        section.contents?.map((field, k) => {
+          if (
+            field?.type == "heading" ||
+            field?.type == "subtitle" ||
+            field?.type == "resource-list" ||
+            field?.type == "service-ip-list" ||
+            field?.type == "velero-create-backup"
+          )
+            return;
+          if (
+            field.required &&
+            (field.settings?.default || (field.value && field.value[0]))
+          ) {
+            ret[`${i}-${j}-${k}`] = {
+              validated: true,
+            };
+          }
+        })
+      )
+    );
+    return ret;
+  };
+
   const [state, dispatch] = useReducer(handleAction, {
     components: {},
+    validation: getInitialValidation(props.rawFormData),
     variables: {
       ...props.initialVariables,
       ...getInitialVariables(props.rawFormData),
@@ -189,7 +223,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
               ...section,
               contents: section.contents
                 ?.map((field: any) => {
-                  if (field.type == "number-input") {
+                  if (field?.type == "number-input") {
                     return {
                       ...field,
                       type: "input",
@@ -199,7 +233,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                       },
                     };
                   }
-                  if (field.type == "string-input") {
+                  if (field?.type == "string-input") {
                     return {
                       ...field,
                       type: "input",
@@ -209,7 +243,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                       },
                     };
                   }
-                  if (field.type == "string-input-password") {
+                  if (field?.type == "string-input-password") {
                     return {
                       ...field,
                       type: "input",
@@ -219,7 +253,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                       },
                     };
                   }
-                  if (field.type == "provider-select") {
+                  if (field?.type == "provider-select") {
                     return {
                       ...field,
                       type: "select",
@@ -229,7 +263,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                       },
                     };
                   }
-                  if (field.type == "env-key-value-array") {
+                  if (field?.type == "env-key-value-array") {
                     return {
                       ...field,
                       type: "key-value-array",
@@ -241,7 +275,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                       },
                     };
                   }
-                  if (field.type == "variable") return null;
+                  if (field?.type == "variable") return null;
                   return field;
                 })
                 .filter((x) => x != null),
@@ -301,16 +335,16 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
       tab.sections?.map((section) =>
         section.contents?.map((field) => {
           if (
-            field.type == "heading" ||
-            field.type == "subtitle" ||
-            field.type == "resource-list" ||
-            field.type == "service-ip-list" ||
-            field.type == "velero-create-backup"
+            field?.type == "heading" ||
+            field?.type == "subtitle" ||
+            field?.type == "resource-list" ||
+            field?.type == "service-ip-list" ||
+            field?.type == "velero-create-backup"
           )
             return;
           // fields that have defaults can't be required since we can always
           // compute their value
-          if (field.required && !field.settings?.default) {
+          if (field.required) {
             requiredIds.push(field.id);
           }
           if (!mapping[field.variable]) {
@@ -327,9 +361,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     Validate the form based on a list of required ids
    */
   const doValidation = (requiredIds: string[]) =>
-    requiredIds
-      ?.map((id) => state.components[id]?.validation.validated)
-      .every((x) => x);
+    requiredIds?.map((id) => state.validation[id]?.validated).every((x) => x);
 
   const formData = computeFormStructure(
     restructureToNewFields(props.rawFormData),
@@ -366,9 +398,9 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     data?.tabs?.map((tab) =>
       tab.sections?.map((section) =>
         section.contents?.map((field) => {
-          if (finalFunctions[field.type])
+          if (finalFunctions[field?.type])
             varList.push(
-              finalFunctions[field.type](
+              finalFunctions[field?.type](
                 state.variables,
                 field,
                 state.components[field.id]?.state,

+ 12 - 2
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   addendum?: any;
   saveValuesStatus?: string;
   showStateDebugger?: boolean;
+  isLaunch?: boolean;
 };
 
 const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
@@ -34,9 +35,10 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
   addendum,
   saveValuesStatus,
   showStateDebugger,
+  isLaunch,
 }) => {
   const hashCode = (s: string) => {
-    return s.split("").reduce(function (a, b) {
+    return s?.split("").reduce(function (a, b) {
       a = (a << 5) - a + b.charCodeAt(0);
       return a & a;
     }, 0);
@@ -46,7 +48,13 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
     if (leftTabOptions?.length > 0) {
       return leftTabOptions[0].value;
     } else if (formData?.tabs?.length > 0) {
-      return formData?.tabs[0].name;
+      let includedTabs = formData.tabs;
+      if (isLaunch) {
+        includedTabs = formData.tabs.filter(
+          (tab: any) => !tab?.settings?.omitFromLaunch
+        );
+      }
+      return includedTabs[0].name;
     } else if (rightTabOptions?.length > 0) {
       return rightTabOptions[0].value;
     } else {
@@ -54,6 +62,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
     }
   };
 
+  // Lifted into PorterFormWrapper to allow tab to be remembered on re-render (e.g., on revision select)
   const [currentTab, setCurrentTab] = useState(getInitialTab());
 
   return (
@@ -77,6 +86,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
           saveValuesStatus={saveValuesStatus}
           currentTab={currentTab}
           setCurrentTab={setCurrentTab}
+          isLaunch={isLaunch}
         />
       </PorterFormContextProvider>
     </React.Fragment>

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

@@ -12,7 +12,7 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
     props.id,
     {
       initVars: {
-        [props.variable]: props.value ? props.value[0] : [],
+        [props.variable]: props.value && props.value[0] ? props.value[0] : [],
       },
     }
   );
@@ -42,7 +42,7 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
   const renderInputList = (values: string[]) => {
     return (
       <>
-        {values.map((value: string, i: number) => {
+        {values?.map((value: string, i: number) => {
           return (
             <InputWrapper>
               <Input
@@ -53,7 +53,7 @@ const ArrayInput: React.FC<ArrayInputField> = (props) => {
                   e.persist();
                   setVars((prev) => {
                     return {
-                      [props.variable]: prev[props.variable].map(
+                      [props.variable]: prev[props.variable]?.map(
                         (t: string, j: number) => {
                           return i == j ? e.target.value : t;
                         }
@@ -103,7 +103,7 @@ export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
   return vars[props.variable]
     ? {}
     : {
-        [props.variable]: [],
+        [props.variable]: props.value ? props.value[0] : [],
       };
 };
 

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

@@ -59,10 +59,23 @@ export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
   vars,
   props: CheckboxField
 ) => {
+  // Read from revision values if unrendered (and therefore not in form state)
+  if (vars[props.variable] === null || vars[props.variable] === undefined) {
+    if (props.value[0] === false) {
+      return { [props.variable]: false };
+    } else if (props.value[0] === true) {
+      return { [props.variable]: true };
+    }
+  }
+
+  // Read from form state if set by user
   if (vars[props.variable] === false) {
     return { [props.variable]: false };
   } else if (vars[props.variable] === true) {
     return { [props.variable]: true };
   }
-  return { [props.variable]: !!props.settings?.default };
+
+  return {
+    [props.variable]: props.value ? props.value[0] : !!props.settings?.default,
+  };
 };

+ 20 - 15
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -8,6 +8,15 @@ import {
   StringInputFieldState,
 } from "../types";
 
+const clipOffUnit = (unit: string, x: string) => {
+  if (typeof x === "string" && unit) {
+    return unit === x.slice(x.length - unit.length, x.length)
+      ? x.slice(0, x.length - unit.length)
+      : x;
+  }
+  return x;
+};
+
 const Input: React.FC<InputField> = ({
   id,
   variable,
@@ -19,18 +28,6 @@ const Input: React.FC<InputField> = ({
   isReadOnly,
   value,
 }) => {
-  const clipOffUnit = (x: string) => {
-    let unit = settings?.unit;
-    if (typeof x === "string" && unit) {
-      return unit === x.slice(x.length - unit.length, x.length) ? (
-        x.slice(0, x.length - unit.length)
-      ) : (
-        x
-      );
-    }
-    return x;
-  }
-
   const {
     state,
     variables,
@@ -39,11 +36,13 @@ const Input: React.FC<InputField> = ({
   } = useFormField<StringInputFieldState>(id, {
     initValidation: {
       validated: value
-        ? value[0] !== undefined
+        ? value[0] !== undefined && value[0] !== "" && value[0] != null
         : settings?.default != undefined,
     },
     initVars: {
-      [variable]: value ? clipOffUnit(value[0]) : settings?.default,
+      [variable]: value
+        ? clipOffUnit(settings?.unit, value[0])
+        : settings?.default,
     },
   });
 
@@ -51,6 +50,8 @@ const Input: React.FC<InputField> = ({
     return <></>;
   }
 
+  console.log(value);
+
   const curValue =
     settings?.type == "number"
       ? !isNaN(parseFloat(variables[variable]))
@@ -94,7 +95,11 @@ export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
   vars,
   props: InputField
 ) => {
-  const val = vars[props.variable] || props.settings?.default;
+  const val =
+    vars[props.variable] ||
+    (props.value
+      ? clipOffUnit(props.settings?.unit, props.value[0])
+      : props.settings?.default);
   return {
     [props.variable]:
       props.settings?.unit && !props.settings.omitUnitFromValue

+ 9 - 7
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -132,16 +132,17 @@ const KeyValueArray: React.FC<Props> = (props) => {
     }
   };
 
-  const getProcessedValues = (): any => {
+  const getProcessedValues = (
+    objectArray: { key: string; value: string }[]
+  ): any => {
     let obj = {} as any;
-    state.values?.forEach(({ key, value }) => {
+    objectArray?.forEach(({ key, value }) => {
       obj[key] = value;
     });
     return obj;
-  }
+  };
 
   const renderEnvModal = () => {
-    console.log(state.values)
     if (state.showEnvModal) {
       return (
         <Modal
@@ -154,7 +155,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
           height="542px"
         >
           <LoadEnvGroupModal
-            existingValues={getProcessedValues()}
+            existingValues={getProcessedValues(state.values)}
             namespace={variables.namespace}
             clusterId={variables.clusterId}
             closeModal={() =>
@@ -349,10 +350,11 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
   props: KeyValueArrayField,
   state: KeyValueArrayFieldState
 ) => {
-  if (!state)
+  if (!state) {
     return {
-      [props.variable]: {},
+      [props.variable]: props.value ? props.value[0] : [],
     };
+  }
 
   let obj = {} as any;
   const rg = /(?:^|[^\\])(\\n)/g;

+ 4 - 2
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -73,14 +73,16 @@ export const getFinalVariablesForSelect: GetFinalVariablesFunction = (
   return vars[props.variable]
     ? {}
     : {
-        [props.variable]: props.settings.default
+        [props.variable]: props.value
+          ? props.value[0]
+          : props.settings.default
           ? props.settings.default
           : props.settings.type == "provider"
           ? ({
               gke: "gcp",
               eks: "aws",
               doks: "do",
-            } as Record<string, string>)[context.currentCluster?.service] ||
+            } as Record<string, string>)[context.currentCluster.service] ||
             "aws"
           : props.settings.options[0].value,
       };

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

@@ -145,6 +145,9 @@ export interface Tab {
   name: string;
   label: string;
   sections: Section[];
+  settings?: {
+    omitFromLaunch?: boolean;
+  };
 }
 
 export interface PorterFormData {
@@ -194,9 +197,11 @@ export interface PorterFormState {
   components: {
     [key: string]: {
       state: PorterFormFieldFieldState;
-      validation: PorterFormFieldValidationState;
     };
   };
+  validation: {
+    [key: string]: PorterFormFieldValidationState;
+  };
   variables: PorterFormVariableList;
 }
 

+ 11 - 13
dashboard/src/main/home/Home.tsx

@@ -485,9 +485,9 @@ class Home extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { 
-      currentModal, 
-      setCurrentModal, 
+    let {
+      currentModal,
+      setCurrentModal,
       currentProject,
       currentOverlay,
       setCurrentOverlay,
@@ -578,16 +578,14 @@ class Home extends Component<PropsType, StateType> {
           </Modal>
         )}
 
-        {
-          currentOverlay && (
-            <ConfirmOverlay
-              show={true}
-              message={currentOverlay.message}
-              onYes={currentOverlay.onYes}
-              onNo={currentOverlay.onNo}
-            />
-          )
-        }
+        {currentOverlay && (
+          <ConfirmOverlay
+            show={true}
+            message={currentOverlay.message}
+            onYes={currentOverlay.onYes}
+            onNo={currentOverlay.onNo}
+          />
+        )}
 
         {this.renderSidebar()}
 

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

@@ -253,7 +253,7 @@ const StyledChart = styled.div`
   margin-bottom: 25px;
   padding: 1px;
   border-radius: 8px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  box-shadow: 0 4px 15px 0px #00000055;
   position: relative;
   border: 2px solid #9eb4ff00;
   width: calc(100% + 2px);
@@ -271,7 +271,7 @@ const StyledChart = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;
@@ -304,7 +304,7 @@ const StyledChart = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -306,8 +306,8 @@ const StyledCard = styled.div`
   justify-content: space-between;
   align-items: center;
   border: 1px solid #26282f;
-  box-shadow: 0 5px 8px 0px #00000033;
-  border-radius: 5px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  border-radius: 8px;
   padding: 14px;
   animation: fadeIn 0.5s;
   @keyframes fadeIn {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -146,8 +146,8 @@ const NodeListWrapper = styled.div`
 const StyledChart = styled.div`
   background: #26282f;
   padding: 14px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
   position: relative;
   border: 2px solid #9eb4ff00;
   width: 100%;

+ 4 - 4
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx

@@ -208,8 +208,8 @@ const StyledEnvGroup = styled.div`
   cursor: pointer;
   margin-bottom: 25px;
   padding: 1px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
   position: relative;
   border: 2px solid #9eb4ff00;
   width: calc(100% + 2px);
@@ -227,7 +227,7 @@ const StyledEnvGroup = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;
@@ -260,7 +260,7 @@ const StyledEnvGroup = styled.div`
       padding-top: 4px;
       padding-bottom: 14px;
       margin-left: 0px;
-      box-shadow: 0 5px 8px 0px #00000033;
+      box-shadow: 0 4px 15px 0px #00000055;
       padding-left: 1px;
       margin-bottom: 25px;
       margin-top: 0px;

+ 24 - 24
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -392,30 +392,30 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
             </LastDeployed>
           </InfoWrapper>
 
-          {
-            this.state.deleting ? (
-              <>
-                <LineBreak />
-                <Placeholder>
-                  <TextWrap>
-                    <Header>
-                      <Spinner src={loading} /> Deleting "{this.state.envGroup.name}"
-                    </Header>
-                    You will be automatically redirected after deletion is complete.
-                  </TextWrap>
-                </Placeholder>
-              </>
-            ) : (
-              <TabRegion
-                currentTab={this.state.currentTab}
-                setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-                options={this.state.tabOptions}
-                color={null}
-              >
-                {this.renderTabContents()}
-              </TabRegion>
-            )
-          }
+          {this.state.deleting ? (
+            <>
+              <LineBreak />
+              <Placeholder>
+                <TextWrap>
+                  <Header>
+                    <Spinner src={loading} /> Deleting "
+                    {this.state.envGroup.name}"
+                  </Header>
+                  You will be automatically redirected after deletion is
+                  complete.
+                </TextWrap>
+              </Placeholder>
+            </>
+          ) : (
+            <TabRegion
+              currentTab={this.state.currentTab}
+              setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+              options={this.state.tabOptions}
+              color={null}
+            >
+              {this.renderTabContents()}
+            </TabRegion>
+          )}
         </StyledExpandedChart>
       </>
     );

+ 145 - 43
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -34,6 +34,7 @@ import SettingsSection from "./SettingsSection";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
+import { integrationList } from "shared/common";
 
 type Props = {
   namespace: string;
@@ -79,7 +80,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
   const [newestImage, setNewestImage] = useState<string>(null);
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
-
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
 
   const {
@@ -89,9 +90,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
     closeWebsocket,
   } = useWebsockets();
 
-  const { 
-    currentCluster, 
-    currentProject, 
+  const {
+    currentCluster,
+    currentProject,
     setCurrentError,
     setCurrentOverlay,
   } = useContext(Context);
@@ -228,6 +229,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const onSubmit = async (rawValues: any) => {
+    console.log("raw", rawValues);
     // Convert dotted keys to nested objects
     let values = {};
 
@@ -251,6 +253,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     setSaveValueStatus("loading");
     getChartData(currentChart);
+    console.log("valuesYaml", valuesYaml);
     try {
       await api.upgradeChartValues(
         "<token>",
@@ -468,7 +471,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         (tab: any) => !liveTabs.includes(tab.value)
       );
     }
-    
+
     setLeftTabOptions(leftTabOptions);
     setRightTabOptions(rightTabOptions);
   };
@@ -661,6 +664,46 @@ const ExpandedChart: React.FC<Props> = (props) => {
     return () => (isSubscribed = false);
   }, [components, currentCluster, currentProject, currentChart]);
 
+  const renderDeploymentType = () => {
+    const githubRepository = currentChart?.git_action_config?.git_repo;
+    const icon = githubRepository
+      ? integrationList.repo.icon
+      : integrationList.registry.icon;
+
+    const isWebOrWorkerDeployment = ["web", "worker"].includes(
+      currentChart?.chart?.metadata?.name
+    );
+    if (!isWebOrWorkerDeployment) {
+      return null;
+    }
+
+    const repository =
+      githubRepository ||
+      currentChart?.image_repo_uri ||
+      currentChart?.config?.image?.repository;
+
+    if (repository?.includes("hello-porter")) {
+      return null;
+    }
+
+    return (
+      <DeploymentImageContainer>
+        <DeploymentTypeIcon src={icon} />
+        <RepositoryName
+          onMouseOver={() => {
+            setShowRepoTooltip(true);
+          }}
+          onMouseOut={() => {
+            setShowRepoTooltip(false);
+          }}
+        >
+          {repository}
+        </RepositoryName>
+        {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+      </DeploymentImageContainer>
+    );
+  };
+
   return (
     <>
       <StyledExpandedChart>
@@ -673,6 +716,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
             iconWidth="33px"
           >
             {currentChart.name}
+            {renderDeploymentType()}
             <TagWrapper>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
             </TagWrapper>
@@ -693,39 +737,40 @@ const ExpandedChart: React.FC<Props> = (props) => {
             </LastDeployed>
           </InfoWrapper>
         </HeaderWrapper>
-        {
-          deleting ? (
-            <>
-              <LineBreak />
-              <Placeholder>
-                <TextWrap>
-                  <Header>
-                    <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
-                  </Header>
-                  You will be automatically redirected after deletion is complete.
-                </TextWrap>
-              </Placeholder>
-            </>
-          ) : (
-            <>
-              <RevisionSection
-                showRevisions={showRevisions}
-                toggleShowRevisions={() => {
-                  setShowRevisions(!showRevisions);
-                }}
-                chart={currentChart}
-                refreshChart={() => getChartData(currentChart)}
-                setRevision={setRevision}
-                forceRefreshRevisions={forceRefreshRevisions}
-                refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-                status={chartStatus}
-                shouldUpdate={
-                  currentChart.latest_version &&
-                  currentChart.latest_version !== currentChart.chart.metadata.version
-                }
-                latestVersion={currentChart.latest_version}
-                upgradeVersion={handleUpgradeVersion}
-              />
+        {deleting ? (
+          <>
+            <LineBreak />
+            <Placeholder>
+              <TextWrap>
+                <Header>
+                  <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
+                </Header>
+                You will be automatically redirected after deletion is complete.
+              </TextWrap>
+            </Placeholder>
+          </>
+        ) : (
+          <>
+            <RevisionSection
+              showRevisions={showRevisions}
+              toggleShowRevisions={() => {
+                setShowRevisions(!showRevisions);
+              }}
+              chart={currentChart}
+              refreshChart={() => getChartData(currentChart)}
+              setRevision={setRevision}
+              forceRefreshRevisions={forceRefreshRevisions}
+              refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+              status={chartStatus}
+              shouldUpdate={
+                currentChart.latest_version &&
+                currentChart.latest_version !==
+                  currentChart.chart.metadata.version
+              }
+              latestVersion={currentChart.latest_version}
+              upgradeVersion={handleUpgradeVersion}
+            />
+            {(isPreview || leftTabOptions.length > 0) && (
               <BodyWrapper>
                 <PorterFormWrapper
                   formData={currentChart.form}
@@ -743,16 +788,19 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   leftTabOptions={leftTabOptions}
                   color={isPreview ? "#f5cb42" : null}
                   addendum={
-                    <TabButton onClick={toggleDevOpsMode} devOpsMode={devOpsMode}>
+                    <TabButton
+                      onClick={toggleDevOpsMode}
+                      devOpsMode={devOpsMode}
+                    >
                       <i className="material-icons">offline_bolt</i> DevOps Mode
                     </TabButton>
                   }
                   saveValuesStatus={saveValuesStatus}
                 />
               </BodyWrapper>
-            </>
-          )
-        }
+            )}
+          </>
+        )}
       </StyledExpandedChart>
     </>
   );
@@ -760,6 +808,41 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
 export default ExpandedChart;
 
+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;
+    }
+  }
+`;
+
 const TextWrap = styled.div``;
 
 const LineBreak = styled.div`
@@ -909,7 +992,7 @@ const TagWrapper = styled.div`
   height: 20px;
   font-size: 12px;
   display: flex;
-  margin-left: 20px;
+  margin-left: 15px;
   margin-bottom: -3px;
   align-items: center;
   font-weight: 400;
@@ -976,3 +1059,22 @@ const StyledExpandedChart = styled.div`
     }
   }
 `;
+
+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 DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -93,7 +93,11 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
     let { baseRoute, namespace } = match.params as any;
     let { loading, currentChart } = this.state;
     if (loading) {
-      return <LoadingWrapper><Loading /></LoadingWrapper>;
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
     } else if (currentChart && baseRoute === "jobs") {
       return (
         <ExpandedJobChart

+ 41 - 42
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -587,48 +587,47 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
             </InfoWrapper>
           </HeaderWrapper>
 
-          {
-            this.state.deleting ? (
-              <>
-                <LineBreak />
-                <Placeholder>
-                  <TextWrap>
-                    <Header>
-                      <Spinner src={loading} /> Deleting "{currentChart.name}"
-                    </Header>
-                    You will be automatically redirected after deletion is complete.
-                  </TextWrap>
-                </Placeholder>
-              </>
-            ) : (
-              <BodyWrapper>
-                {(this.state.leftTabOptions?.length > 0 ||
-                  this.state.formData.tabs?.length > 0 ||
-                  this.state.rightTabOptions?.length > 0) && (
-                  <PorterFormWrapper
-                    formData={this.state.formData}
-                    valuesToOverride={{
-                      namespace: chart.namespace,
-                      clusterId: this.props.currentCluster.id,
-                    }}
-                    renderTabContents={this.renderTabContents}
-                    isReadOnly={
-                      this.state.imageIsPlaceholder ||
-                      !this.props.isAuthorized("job", "", ["get", "update"])
-                    }
-                    onSubmit={(formValues) => {
-                      console.log(formValues)
-                      this.handleSaveValues(formValues, false)
-                    }}
-                    leftTabOptions={this.state.leftTabOptions}
-                    rightTabOptions={this.state.rightTabOptions}
-                    saveValuesStatus={this.state.saveValuesStatus}
-                    saveButtonText="Save Config"
-                  />
-                )}
-              </BodyWrapper>
-            )
-          }
+          {this.state.deleting ? (
+            <>
+              <LineBreak />
+              <Placeholder>
+                <TextWrap>
+                  <Header>
+                    <Spinner src={loading} /> Deleting "{currentChart.name}"
+                  </Header>
+                  You will be automatically redirected after deletion is
+                  complete.
+                </TextWrap>
+              </Placeholder>
+            </>
+          ) : (
+            <BodyWrapper>
+              {(this.state.leftTabOptions?.length > 0 ||
+                this.state.formData.tabs?.length > 0 ||
+                this.state.rightTabOptions?.length > 0) && (
+                <PorterFormWrapper
+                  formData={this.state.formData}
+                  valuesToOverride={{
+                    namespace: chart.namespace,
+                    clusterId: this.props.currentCluster.id,
+                  }}
+                  renderTabContents={this.renderTabContents}
+                  isReadOnly={
+                    this.state.imageIsPlaceholder ||
+                    !this.props.isAuthorized("job", "", ["get", "update"])
+                  }
+                  onSubmit={(formValues) => {
+                    console.log(formValues);
+                    this.handleSaveValues(formValues, false);
+                  }}
+                  leftTabOptions={this.state.leftTabOptions}
+                  rightTabOptions={this.state.rightTabOptions}
+                  saveValuesStatus={this.state.saveValuesStatus}
+                  saveButtonText="Save Config"
+                />
+              )}
+            </BodyWrapper>
+          )}
         </StyledExpandedChart>
       </>
     );

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

@@ -9,6 +9,9 @@ import { ChartType, StorageType } from "shared/types";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
+import Modal from "main/home/modals/Modal";
+import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
+
 type PropsType = WithAuthProps & {
   showRevisions: boolean;
   toggleShowRevisions: () => void;
@@ -216,6 +219,13 @@ class RevisionSection extends Component<PropsType, StateType> {
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
+      const isGithubApp = !!this.props.chart.git_action_config;
+      const imageTag = revision.config?.image?.tag;
+
+      const parsedImageTag = isGithubApp
+        ? String(imageTag).slice(0, 7)
+        : imageTag;
+
       return (
         <Tr
           key={i}
@@ -224,7 +234,7 @@ class RevisionSection extends Component<PropsType, StateType> {
         >
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
-          <Td>{this.renderStatus(revision)}</Td>
+          <Td>{parsedImageTag || "N/A"}</Td>
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
@@ -253,7 +263,9 @@ class RevisionSection extends Component<PropsType, StateType> {
               <Tr disableHover={true}>
                 <Th>Revision No.</Th>
                 <Th>Timestamp</Th>
-                <Th>Status</Th>
+                <Th>
+                  {this.props.chart.git_action_config ? "Commit" : "Image Tag"}
+                </Th>
                 <Th>Template Version</Th>
                 <Th>Rollback</Th>
               </Tr>
@@ -281,6 +293,26 @@ class RevisionSection extends Component<PropsType, StateType> {
       this.state.maxVersion === 0;
     return (
       <div>
+        {this.state.upgradeVersion && (
+          <Modal
+            onRequestClose={() => this.setState({ upgradeVersion: "" })}
+            width="500px"
+            height="450px"
+          >
+            <UpgradeChartModal
+              currentChart={this.props.chart}
+              closeModal={() => {
+                this.setState({ upgradeVersion: "" });
+              }}
+              onSubmit={() => {
+                this.props.upgradeVersion(this.state.upgradeVersion, () => {
+                  this.setState({ loading: false });
+                });
+                this.setState({ upgradeVersion: "", loading: true });
+              }}
+            />
+          </Modal>
+        )}
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           isCurrent={isCurrent}
@@ -304,22 +336,6 @@ class RevisionSection extends Component<PropsType, StateType> {
                 <i className="material-icons">notification_important</i>
                 Template Update Available
               </RevisionUpdateMessage>
-              <ConfirmOverlay
-                show={!!this.state.upgradeVersion}
-                message={`Are you sure you want to redeploy and upgrade to version ${this.state.upgradeVersion}?`}
-                onYes={(e) => {
-                  e.stopPropagation();
-
-                  this.props.upgradeVersion(this.state.upgradeVersion, () => {
-                    this.setState({ loading: false });
-                  });
-                  this.setState({ upgradeVersion: "", loading: true });
-                }}
-                onNo={(e) => {
-                  e.stopPropagation();
-                  this.setState({ upgradeVersion: "" });
-                }}
-              />
             </div>
           )}
         </RevisionHeader>

+ 10 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -83,7 +83,9 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
           (option) => option.value === "hpa_replicas"
         );
         const options = [...prev];
-        options.splice(hpaReplicasOptionIndex, 1);
+        if (hpaReplicasOptionIndex > -1) {
+          options.splice(hpaReplicasOptionIndex, 1);
+        }
         return [...options];
       });
     }
@@ -124,7 +126,6 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
         })
         .catch((err) => {
           setCurrentError(JSON.stringify(err));
-          setControllerOptions([]);
         })
         .finally(() => {
           setIsLoading((prev) => prev - 1);
@@ -183,8 +184,13 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
       }
       i += 1;
     }
+
     selectors.push(selector);
 
+    if (selectors[0] === "") {
+      return;
+    }
+
     setIsLoading((prev) => prev + 1);
 
     api
@@ -309,7 +315,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
       );
 
       setHpaData([]);
-      const isHpaEnabled = currentChart.config.autoscaling.enabled;
+      const isHpaEnabled = currentChart?.config?.autoscaling?.enabled;
       if (shouldsum && isHpaEnabled) {
         if (selectedMetric === "cpu") {
           await getAutoscalingThreshold(
@@ -489,7 +495,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
           {currentChart?.config?.autoscaling?.enabled &&
             ["cpu", "memory"].includes(selectedMetric) && (
               <CheckboxRow
-                toggle={() => setHpaEnabled((prev) => !prev)}
+                toggle={() => setHpaEnabled((prev: any) => !prev)}
                 checked={hpaEnabled}
                 label="Show Autoscaling Threshold"
               />

+ 327 - 405
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -1,220 +1,201 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
 import ResourceTab from "components/ResourceTab";
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import PodRow from "./PodRow";
+import { timeFormat } from "d3-time-format";
 
-type PropsType = {
+type Props = {
   controller: any;
   selectedPod: any;
-  selectPod: Function;
+  selectPod: (newPod: any) => unknown;
   selectors: any;
   isLast?: boolean;
   isFirst?: boolean;
   setPodError: (x: string) => void;
 };
 
-type StateType = {
-  pods: any[];
-  raw: any[];
-  showTooltip: boolean[];
-  podPendingDelete: any;
-  websockets: Record<string, any>;
-  selectors: string[];
-  available: number;
-  total: number;
-  canUpdatePod: boolean;
-};
-
 // Controller tab in log section that displays list of pods on click.
-export default class ControllerTab extends Component<PropsType, StateType> {
-  state = {
-    pods: [] as any[],
-    raw: [] as any[],
-    showTooltip: [] as boolean[],
-    podPendingDelete: null as any,
-    websockets: {} as Record<string, any>,
-    selectors: [] as string[],
-    available: null as number,
-    total: null as number,
-    canUpdatePod: true,
-  };
+export type ControllerTabPodType = {
+  namespace: string;
+  name: string;
+  phase: string;
+  status: any;
+  replicaSetName: string;
+  restartCount: number | string;
+  podAge: string;
+  revisionNumber?: number;
+};
 
-  updatePods = () => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let { controller, selectPod, isFirst } = this.props;
+const formatCreationTimestamp = timeFormat("%H:%M:%S %b %d, '%y");
+
+const ControllerTabFC: React.FunctionComponent<Props> = ({
+  controller,
+  selectPod,
+  isFirst,
+  isLast,
+  selectors,
+  setPodError,
+  selectedPod,
+}) => {
+  const [pods, setPods] = useState<ControllerTabPodType[]>([]);
+  const [rawPodList, setRawPodList] = useState<any[]>([]);
+  const [podPendingDelete, setPodPendingDelete] = useState<any>(null);
+  const [available, setAvailable] = useState<number>(null);
+  const [total, setTotal] = useState<number>(null);
+  const [userSelectedPod, setUserSelectedPod] = useState<boolean>(false);
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const currentSelectors = useMemo(() => {
+    if (controller.kind.toLowerCase() == "job" && selectors) {
+      return [...selectors];
+    }
+    let newSelectors = [] as string[];
+    let ml =
+      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    newSelectors.push(selector);
+    return [...newSelectors];
+  }, [controller, selectors]);
+
+  useEffect(() => {
+    updatePods();
+    [controller?.kind, "pod"].forEach((kind) => {
+      setupWebsocket(kind, controller?.metadata?.uid);
+    });
+    () => closeAllWebsockets();
+  }, [currentSelectors, controller, currentCluster, currentProject]);
 
-    api
-      .getMatchingPods(
+  const updatePods = async () => {
+    try {
+      const res = await api.getMatchingPods(
         "<token>",
         {
           cluster_id: currentCluster.id,
           namespace: controller?.metadata?.namespace,
-          selectors: this.state.selectors,
+          selectors: currentSelectors,
         },
         {
           id: currentProject.id,
         }
-      )
-      .then((res) => {
-        let pods = res?.data?.map((pod: any) => {
+      );
+      const data = res?.data as any[];
+      let newPods = data
+        // Parse only data that we need
+        .map<ControllerTabPodType>((pod: any) => {
+          const replicaSetName =
+            Array.isArray(pod?.metadata?.ownerReferences) &&
+            pod?.metadata?.ownerReferences[0]?.name;
+          const containerStatus =
+            Array.isArray(pod?.status?.containerStatuses) &&
+            pod?.status?.containerStatuses[0];
+
+          const restartCount = containerStatus
+            ? containerStatus.restartCount
+            : "N/A";
+
+          const podAge = formatCreationTimestamp(
+            new Date(pod?.metadata?.creationTimestamp)
+          );
+
           return {
             namespace: pod?.metadata?.namespace,
             name: pod?.metadata?.name,
             phase: pod?.status?.phase,
+            status: pod?.status,
+            replicaSetName,
+            restartCount,
+            podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
+            revisionNumber:
+              (pod?.metadata?.annotations &&
+                pod?.metadata?.annotations["helm.sh/revision"]) ||
+              "N/A",
           };
         });
-        let showTooltip = new Array(pods.length);
-        for (let j = 0; j < pods.length; j++) {
-          showTooltip[j] = false;
-        }
-
-        this.setState({ pods, raw: res.data, showTooltip });
-
-        if (isFirst) {
-          let pod = res.data[0];
-          let status = this.getPodStatus(pod.status);
-          status === "failed" &&
-            pod.status?.message &&
-            this.props.setPodError(pod.status?.message);
-          if (this.state.canUpdatePod) {
-            // this prevents multiple requests from changing the first pod
-            selectPod(res.data[0]);
-            this.setState({
-              canUpdatePod: false,
-            });
-          }
-        }
-      })
-      .catch((err) => {
-        console.log(err);
-        setCurrentError(JSON.stringify(err));
-        return;
-      });
-  };
-
-  getPodSelectors = (callback: () => void) => {
-    let { controller } = this.props;
 
-    let selectors = [] as string[];
-    let ml =
-      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
-    let i = 1;
-    let selector = "";
-    for (var key in ml) {
-      selector += key + "=" + ml[key];
-      if (i != Object.keys(ml).length) {
-        selector += ",";
+      setPods(newPods);
+      setRawPodList(data);
+      // If the user didn't click a pod, select the first returned from list.
+      if (!userSelectedPod) {
+        let status = getPodStatus(newPods[0].status);
+        status === "failed" &&
+          newPods[0].status?.message &&
+          setPodError(newPods[0].status?.message);
+        handleSelectPod(newPods[0], data);
       }
-      i += 1;
-    }
-    selectors.push(selector);
-    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
-      selectors = this.props.selectors;
-    }
-
-    this.setState({ selectors }, () => {
-      callback();
-    });
+    } catch (error) {}
   };
 
-  componentDidMount() {
-    this.getPodSelectors(() => {
-      this.updatePods();
-      this.setControllerWebsockets([this.props.controller.kind, "pod"]);
-    });
-  }
-
-  componentWillUnmount() {
-    if (this.state.websockets) {
-      this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close();
-      });
-    }
-  }
-
-  setControllerWebsockets = (controller_types: any[]) => {
-    let websockets = controller_types.map((kind: string) => {
-      return this.setupWebsocket(kind);
-    });
-    this.setState({ websockets });
+  /**
+   * handleSelectPod is a wrapper for the selectPod function received from parent.
+   * Internally we use the ControllerPodType but we want to pass to the parent the
+   * raw pod returned from the API.
+   *
+   * @param pod A ControllerPodType pod that will be used to search the raw pod to pass
+   * @param rawList A rawList of pods in case we don't want to use the state one. Useful to
+   * avoid problems with reactivity
+   */
+  const handleSelectPod = (pod: ControllerTabPodType, rawList?: any[]) => {
+    const rawPod = [...rawPodList, ...(rawList || [])].find(
+      (rawPod) => rawPod?.metadata?.name === pod?.name
+    );
+    selectPod(rawPod);
   };
 
-  setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = this.context;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    let connString = `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    if (kind == "pod" && this.state.selectors) {
-      connString += `&selectors=${this.state.selectors[0]}`;
-    }
-    let ws = new WebSocket(connString);
-
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
-      let object = event.Object;
-      object.metadata.kind = event.Kind;
+  const currentSelectedPod = useMemo(() => {
+    const pod = selectedPod;
+    const replicaSetName =
+      Array.isArray(pod?.metadata?.ownerReferences) &&
+      pod?.metadata?.ownerReferences[0]?.name;
+    return {
+      namespace: pod?.metadata?.namespace,
+      name: pod?.metadata?.name,
+      phase: pod?.status?.phase,
+      status: pod?.status,
+      replicaSetName,
+    } as ControllerTabPodType;
+  }, [selectedPod]);
+
+  const currentControllerStatus = useMemo(() => {
+    let status = available == total ? "running" : "waiting";
 
-      // update pods no matter what if ws message is a pod event.
-      // If controller event, check if ws message corresponds to the designated controller in props.
+    controller?.status?.conditions?.forEach((condition: any) => {
       if (
-        event.Kind != "pod" &&
-        object.metadata.uid != this.props.controller.metadata.uid
-      )
-        return;
-
-      if (event.Kind != "pod") {
-        let [available, total] = this.getAvailability(
-          object.metadata.kind,
-          object
-        );
-        this.setState({ available, total });
+        condition.type == "Progressing" &&
+        condition.status == "False" &&
+        condition.reason == "ProgressDeadlineExceeded"
+      ) {
+        status = "failed";
       }
+    });
 
-      this.updatePods();
-    };
-
-    ws.onclose = () => {
-      console.log("closing websocket");
-    };
-
-    ws.onerror = (err: ErrorEvent) => {
-      console.log(err);
-      ws.close();
-    };
-
-    return ws;
-  };
-
-  getAvailability = (kind: string, c: any) => {
-    switch (kind?.toLowerCase()) {
-      case "deployment":
-      case "replicaset":
-        return [
-          c.status?.availableReplicas ||
-            c.status?.replicas - c.status?.unavailableReplicas ||
-            0,
-          c.status?.replicas || 0,
-        ];
-      case "statefulset":
-        return [c.status?.readyReplicas || 0, c.status?.replicas || 0];
-      case "daemonset":
-        return [
-          c.status?.numberAvailable || 0,
-          c.status?.desiredNumberScheduled || 0,
-        ];
-      case "job":
-        return [1, 1];
+    if (controller.kind.toLowerCase() === "job" && pods.length == 0) {
+      status = "completed";
     }
-  };
+    return status;
+  }, [controller, available, total, pods]);
 
-  getPodStatus = (status: any) => {
+  const getPodStatus = (status: any) => {
     if (
       status?.phase === "Pending" &&
       status?.containerStatuses !== undefined
@@ -245,272 +226,213 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     }
   };
 
-  renderTooltip = (x: string, ind: number): JSX.Element | undefined => {
-    if (this.state.showTooltip[ind]) {
-      return <Tooltip>{x}</Tooltip>;
-    }
-  };
-
-  handleDeletePod = (pod: any) => {
+  const handleDeletePod = (pod: any) => {
     api
       .deletePod(
         "<token>",
         {
-          cluster_id: this.context.currentCluster.id,
+          cluster_id: currentCluster.id,
         },
         {
           name: pod.metadata?.name,
           namespace: pod.metadata?.namespace,
-          id: this.context.currentProject.id,
+          id: currentProject.id,
         }
       )
       .then((res) => {
-        this.updatePods();
-        this.setState({ podPendingDelete: null });
+        updatePods();
+        setPodPendingDelete(null);
       })
       .catch((err) => {
-        this.context.setCurrentError(JSON.stringify(err));
-        this.setState({ podPendingDelete: null });
+        setCurrentError(JSON.stringify(err));
+        setPodPendingDelete(null);
       });
   };
 
-  renderDeleteButton = (pod: any) => {
-    return (
-      <CloseIcon
-        className="material-icons-outlined"
-        onClick={() => this.setState({ podPendingDelete: pod })}
-      >
-        close
-      </CloseIcon>
-    );
-  };
-
-  render() {
-    let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
-    let { available, total } = this.state;
-    let status = available == total ? "running" : "waiting";
-
-    controller?.status?.conditions?.forEach((condition: any) => {
+  const replicaSetArray = useMemo(() => {
+    const podsDividedByReplicaSet = pods.reduce<
+      Array<Array<ControllerTabPodType>>
+    >(function (prev, currentPod, i) {
       if (
-        condition.type == "Progressing" &&
-        condition.status == "False" &&
-        condition.reason == "ProgressDeadlineExceeded"
+        !i ||
+        prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
       ) {
-        status = "failed";
+        return prev.concat([[currentPod]]);
       }
-    });
+      prev[prev.length - 1].push(currentPod);
+      return prev;
+    }, []);
+
+    if (podsDividedByReplicaSet.length === 1) {
+      return [];
+    } else {
+      return podsDividedByReplicaSet;
+    }
+  }, [pods]);
 
-    if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
-      status = "completed";
+  const getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return [
+          c.status?.availableReplicas ||
+            c.status?.replicas - c.status?.unavailableReplicas ||
+            0,
+          c.status?.replicas || 0,
+        ];
+      case "statefulset":
+        return [c.status?.readyReplicas || 0, c.status?.replicas || 0];
+      case "daemonset":
+        return [
+          c.status?.numberAvailable || 0,
+          c.status?.desiredNumberScheduled || 0,
+        ];
+      case "job":
+        return [1, 1];
     }
+  };
 
-    return (
-      <ResourceTab
-        label={controller.kind}
-        // handle CronJob case
-        name={controller.metadata?.name || controller.name}
-        status={{ label: status, available, total }}
-        isLast={isLast}
-        expanded={isFirst}
-      >
-        {this.state.raw.map((pod, i) => {
-          let status = this.getPodStatus(pod.status);
-          return (
-            <Tab
-              key={pod.metadata?.name}
-              selected={selectedPod?.metadata?.name === pod?.metadata?.name}
-              onClick={() => {
-                this.props.setPodError("");
-                status === "failed" &&
-                  pod.status?.message &&
-                  this.props.setPodError(pod.status?.message);
-                selectPod(pod);
-                this.setState({
-                  canUpdatePod: false,
-                });
-              }}
-            >
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={i === this.state.raw.length - 1} />
-              </Gutter>
-              <Name
-                onMouseOver={() => {
-                  let showTooltip = this.state.showTooltip;
-                  showTooltip[i] = true;
-                  this.setState({ showTooltip });
-                }}
-                onMouseOut={() => {
-                  let showTooltip = this.state.showTooltip;
-                  showTooltip[i] = false;
-                  this.setState({ showTooltip });
-                }}
-              >
-                {pod.metadata?.name}
-              </Name>
-              {this.renderTooltip(pod.metadata?.name, i)}
-              <Status>
-                <StatusColor status={status} />
-                {status}
-                {status === "failed" && this.renderDeleteButton(pod)}
-              </Status>
-            </Tab>
-          );
-        })}
-        <ConfirmOverlay
-          message="Are you sure you want to delete this pod?"
-          show={this.state.podPendingDelete}
-          onYes={() => this.handleDeletePod(this.state.podPendingDelete)}
-          onNo={() => this.setState({ podPendingDelete: null })}
-        />
-      </ResourceTab>
-    );
-  }
-}
+  const setupWebsocket = (kind: string, controllerUid: string) => {
+    let apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+    if (kind == "pod" && currentSelectors) {
+      apiEndpoint += `&selectors=${currentSelectors[0]}`;
+    }
 
-ControllerTab.contextType = Context;
+    const options: NewWebsocketOptions = {};
+    options.onopen = () => {
+      console.log("connected to websocket");
+    };
 
-const CloseIcon = styled.i`
-  font-size: 14px;
-  display: flex;
-  font-weight: bold;
-  align-items: center;
-  justify-content: center;
-  border-radius: 5px;
-  background: #ffffff22;
-  width: 18px;
-  height: 18px;
-  margin-right: -6px;
-  margin-left: 10px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff44;
-  }
-`;
+    options.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
 
-const Rail = styled.div`
-  width: 2px;
-  background: ${(props: { lastTab?: boolean }) =>
-    props.lastTab ? "" : "#52545D"};
-  height: 50%;
-`;
+      // Make a new API call to update pods only when the event type is UPDATE
+      if (event.event_type !== "UPDATE") {
+        return;
+      }
+      // update pods no matter what if ws message is a pod event.
+      // If controller event, check if ws message corresponds to the designated controller in props.
+      if (event.Kind != "pod" && object.metadata.uid !== controllerUid) {
+        return;
+      }
 
-const Circle = styled.div`
-  min-width: 10px;
-  min-height: 2px;
-  margin-bottom: -2px;
-  margin-left: 8px;
-  background: #52545d;
-`;
+      if (event.Kind != "pod") {
+        let [available, total] = getAvailability(object.metadata.kind, object);
+        setAvailable(available);
+        setTotal(total);
+        return;
+      }
+      updatePods();
+    };
 
-const Gutter = styled.div`
-  position: absolute;
-  top: 0px;
-  left: 10px;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  overflow: visible;
-`;
+    options.onclose = () => {
+      console.log("closing websocket");
+    };
 
-const Status = styled.div`
-  display: flex;
-  font-size: 12px;
-  text-transform: capitalize;
-  margin-left: 5px;
-  justify-content: flex-end;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  color: #aaaabb;
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
+    options.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      closeWebsocket(kind);
+    };
 
-const StatusColor = styled.div`
-  margin-right: 7px;
-  width: 7px;
-  min-width: 7px;
-  height: 7px;
-  background: ${(props: { status: string }) =>
-    props.status === "running"
-      ? "#4797ff"
-      : props.status === "failed"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
-  border-radius: 20px;
-`;
+    newWebsocket(kind, apiEndpoint, options);
+    openWebsocket(kind);
+  };
+
+  const mapPods = (podList: ControllerTabPodType[]) => {
+    return podList.map((pod, i, arr) => {
+      let status = getPodStatus(pod.status);
+      return (
+        <PodRow
+          key={i}
+          pod={pod}
+          isSelected={currentSelectedPod?.name === pod?.name}
+          podStatus={status}
+          isLastItem={i === arr.length - 1}
+          onTabClick={() => {
+            setPodError("");
+            status === "failed" &&
+              pod.status?.message &&
+              setPodError(pod.status?.message);
+            handleSelectPod(pod);
+            setUserSelectedPod(true);
+          }}
+          onDeleteClick={() => setPodPendingDelete(pod)}
+        />
+      );
+    });
+  };
 
-const Name = styled.div`
-  overflow: hidden;
-  text-overflow: ellipsis;
-  line-height: 16px;
-  word-wrap: break-word;
-  max-height: 32px;
-  display: -webkit-box;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
+  return (
+    <ResourceTab
+      label={controller.kind}
+      // handle CronJob case
+      name={controller.metadata?.name || controller.name}
+      status={{ label: currentControllerStatus, available, total }}
+      isLast={isLast}
+      expanded={isFirst}
+    >
+      {!!replicaSetArray.length &&
+        replicaSetArray.map((subArray, index) => {
+          const firstItem = subArray[0];
+          return (
+            <div key={firstItem.replicaSetName + index}>
+              <ReplicaSetContainer>
+                <ReplicaSetName>
+                  {firstItem?.revisionNumber &&
+                    firstItem?.revisionNumber.toString() != "N/A" && (
+                      <Bold>Revision {firstItem.revisionNumber}:</Bold>
+                    )}{" "}
+                  {firstItem.replicaSetName}
+                </ReplicaSetName>
+              </ReplicaSetContainer>
+              {mapPods(subArray)}
+            </div>
+          );
+        })}
+      {!replicaSetArray.length && mapPods(pods)}
+      <ConfirmOverlay
+        message="Are you sure you want to delete this pod?"
+        show={podPendingDelete}
+        onYes={() => handleDeletePod(podPendingDelete)}
+        onNo={() => setPodPendingDelete(null)}
+      />
+    </ResourceTab>
+  );
+};
+
+export default ControllerTabFC;
+
+const Bold = styled.span`
+  font-weight: 500;
+  display: inline;
+  color: #ffffff;
 `;
 
-const Tooltip = styled.div`
-  position: absolute;
-  left: 35px;
-  word-wrap: break-word;
-  top: 38px;
-  min-height: 18px;
-  max-width: calc(100% - 75px);
-  padding: 2px 5px;
-  background: #383842dd;
-  display: flex;
-  justify-content: center;
-  flex: 1;
-  color: white;
-  text-transform: none;
+const RevisionLabel = styled.div`
   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;
-    }
-  }
+  color: #ffffff33;
+  width: 78px;
+  text-align: right;
+  padding-top: 7px;
+  margin-right: 10px;
+  margin-left: 10px;
+  overflow-wrap: anywhere;
 `;
 
-const Tab = styled.div`
-  width: 100%;
-  height: 50px;
-  position: relative;
+const ReplicaSetContainer = styled.div`
+  padding: 10px 5px;
   display: flex;
-  align-items: center;
+  overflow-wrap: anywhere;
   justify-content: space-between;
-  color: ${(props: { selected: boolean }) =>
-    props.selected ? "white" : "#ffffff66"};
-  background: ${(props: { selected: boolean }) =>
-    props.selected ? "#ffffff18" : ""};
-  font-size: 13px;
-  padding: 20px 19px 20px 42px;
-  text-shadow: 0px 0px 8px none;
-  overflow: visible;
-  cursor: pointer;
-  :hover {
-    color: white;
-    background: #ffffff18;
-  }
+  border-top: 2px solid #ffffff11;
+`;
+
+const ReplicaSetName = styled.span`
+  padding-left: 10px;
+  overflow-wrap: anywhere;
+  max-width: calc(100% - 45px);
+  line-height: 1.5em;
+  color: #ffffff33;
 `;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -337,7 +337,7 @@ const Scroll = styled.div`
   }
 
   > input {
-    width; 18px;
+    width: 18px;
     margin-left: 10px;
     margin-right: 6px;
     pointer-events: none;

+ 219 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx

@@ -0,0 +1,219 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { ControllerTabPodType } from "./ControllerTab";
+
+type PodRowProps = {
+  pod: ControllerTabPodType;
+  isSelected: boolean;
+  isLastItem: boolean;
+  onTabClick: any;
+  onDeleteClick: any;
+  podStatus: string;
+};
+
+const PodRow: React.FunctionComponent<PodRowProps> = ({
+  pod,
+  isSelected,
+  onTabClick,
+  onDeleteClick,
+  isLastItem,
+  podStatus,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  return (
+    <Tab key={pod?.name} selected={isSelected} onClick={onTabClick}>
+      <Gutter>
+        <Rail />
+        <Circle />
+        <Rail lastTab={isLastItem} />
+      </Gutter>
+      <Name
+        onMouseOver={() => {
+          setShowTooltip(true);
+        }}
+        onMouseOut={() => {
+          setShowTooltip(false);
+        }}
+      >
+        {pod?.name}
+      </Name>
+      {showTooltip && (
+        <Tooltip>
+          {pod?.name}
+          <Grey>Restart count: {pod.restartCount}</Grey>
+          <Grey>Created on: {pod.podAge}</Grey>
+        </Tooltip>
+      )}
+
+      <Status>
+        <StatusColor status={podStatus} />
+        {podStatus}
+        {podStatus === "failed" && (
+          <CloseIcon
+            className="material-icons-outlined"
+            onClick={onDeleteClick}
+          >
+            close
+          </CloseIcon>
+        )}
+      </Status>
+    </Tab>
+  );
+};
+
+export default PodRow;
+
+const InfoIcon = styled.div`
+  width: 22px;
+`;
+
+const Grey = styled.div`
+  margin-top: 5px;
+  color: #aaaabb;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 35px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  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;
+    }
+  }
+`;
+
+const CloseIcon = styled.i`
+  font-size: 14px;
+  display: flex;
+  font-weight: bold;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  background: #ffffff22;
+  width: 18px;
+  height: 18px;
+  margin-right: -6px;
+  margin-left: 10px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff44;
+  }
+`;
+
+const Tab = styled.div`
+  width: 100%;
+  height: 50px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: ${(props: { selected: boolean }) =>
+    props.selected ? "white" : "#ffffff66"};
+  background: ${(props: { selected: boolean }) =>
+    props.selected ? "#ffffff18" : ""};
+  font-size: 13px;
+  padding: 20px 19px 20px 42px;
+  text-shadow: 0px 0px 8px none;
+  overflow: visible;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
+const Rail = styled.div`
+  width: 2px;
+  background: ${(props: { lastTab?: boolean }) =>
+    props.lastTab ? "" : "#52545D"};
+  height: 50%;
+`;
+
+const Circle = styled.div`
+  min-width: 10px;
+  min-height: 2px;
+  margin-bottom: -2px;
+  margin-left: 8px;
+  background: #52545d;
+`;
+
+const Gutter = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 10px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+`;
+
+const Status = styled.div`
+  display: flex;
+  font-size: 12px;
+  text-transform: capitalize;
+  margin-left: 5px;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const Name = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.5em;
+  display: -webkit-box;
+  overflow-wrap: anywhere;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+`;

+ 72 - 81
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import api from "shared/api";
@@ -9,82 +9,108 @@ import Loading from "components/Loading";
 import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
 
-type PropsType = {
+type Props = {
   selectors?: string[];
   currentChart: ChartType;
 };
 
-type StateType = {
-  logs: string[];
-  pods: any[];
-  selectedPod: any;
-  controllers: any[];
-  loading: boolean;
-  podError: string;
-};
-
-export default class StatusSection extends Component<PropsType, StateType> {
-  state = {
-    logs: [] as string[],
-    pods: [] as any[],
-    selectedPod: {} as any,
-    controllers: [] as any[],
-    loading: true,
-    podError: "",
-  };
+const StatusSectionFC: React.FunctionComponent<Props> = ({
+  currentChart,
+  selectors,
+}) => {
+  const [selectedPod, setSelectedPod] = useState<any>({});
+  const [controllers, setControllers] = useState<any[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [podError, setPodError] = useState<string>("");
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getChartControllers(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res: any) => {
+        if (!isSubscribed) {
+          return;
+        }
+        let controllers =
+          currentChart.chart.metadata.name == "job"
+            ? res.data[0]?.status.active
+            : res.data;
+        setControllers(controllers);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        if (!isSubscribed) {
+          return;
+        }
+        setCurrentError(JSON.stringify(err));
+        setControllers([]);
+        setIsLoading(false);
+      });
+    return () => (isSubscribed = false);
+  }, [currentProject, currentCluster, setCurrentError, currentChart]);
 
-  renderLogs = () => {
+  const renderLogs = () => {
     return (
       <Logs
-        podError={this.state.podError}
-        key={this.state.selectedPod?.metadata?.name}
-        selectedPod={this.state.selectedPod}
+        podError={podError}
+        key={selectedPod?.metadata?.name}
+        selectedPod={selectedPod}
       />
     );
   };
 
-  selectPod = (pod: any) => {
-    this.setState({
-      selectedPod: pod,
-    });
-  };
-
-  renderTabs = () => {
-    return this.state.controllers.map((c, i) => {
+  const renderTabs = () => {
+    return controllers.map((c, i) => {
       return (
         <ControllerTab
           // handle CronJob case
           key={c.metadata?.uid || c.uid}
-          selectedPod={this.state.selectedPod}
-          selectPod={this.selectPod.bind(this)}
-          selectors={this.props.selectors ? [this.props.selectors[i]] : null}
+          selectedPod={selectedPod}
+          selectPod={setSelectedPod}
+          selectors={selectors ? [selectors[i]] : null}
           controller={c}
-          isLast={i === this.state.controllers?.length - 1}
+          isLast={i === controllers?.length - 1}
           isFirst={i === 0}
-          setPodError={(x: string) => this.setState({ podError: x })}
+          setPodError={(x: string) => setPodError(x)}
         />
       );
     });
   };
 
-  renderStatusSection = () => {
-    if (this.state.loading) {
+  const renderStatusSection = () => {
+    if (isLoading) {
       return (
         <NoControllers>
           <Loading />
         </NoControllers>
       );
     }
-    if (this.state.controllers?.length > 0) {
+    if (controllers?.length > 0) {
       return (
         <Wrapper>
-          <TabWrapper>{this.renderTabs()}</TabWrapper>
-          {this.renderLogs()}
+          <TabWrapper>{renderTabs()}</TabWrapper>
+          {renderLogs()}
         </Wrapper>
       );
     }
 
-    if (this.props.currentChart.chart.metadata.name === "job") {
+    if (currentChart?.chart?.metadata?.name === "job") {
       return (
         <NoControllers>
           <i className="material-icons">category</i>
@@ -102,45 +128,10 @@ export default class StatusSection extends Component<PropsType, StateType> {
     );
   };
 
-  componentDidMount() {
-    const { currentChart } = this.props;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    api
-      .getChartControllers(
-        "<token>",
-        {
-          namespace: currentChart.namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-        },
-        {
-          id: currentProject.id,
-          name: currentChart.name,
-          revision: currentChart.version,
-        }
-      )
-      .then((res: any) => {
-        let controllers =
-          currentChart.chart.metadata.name == "job"
-            ? res.data[0]?.status.active
-            : res.data;
-        this.setState({ controllers, loading: false });
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        this.setState({ controllers: [], loading: false });
-      });
-  }
-
-  render() {
-    return (
-      <StyledStatusSection>{this.renderStatusSection()}</StyledStatusSection>
-    );
-  }
-}
+  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
+};
 
-StatusSection.contextType = Context;
+export default StatusSectionFC;
 
 const TabWrapper = styled.div`
   width: 35%;

+ 4 - 41
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -233,7 +233,6 @@ const CodeBlock = styled.span`
   width: 90%;
   margin-left: 5%;
   margin-top: 20px;
-  overflow-x: hidden;
   overflow-y: auto;
   padding: 10px;
   overflow-wrap: break-word;
@@ -242,6 +241,7 @@ const CodeBlock = styled.span`
 const StyledClusterList = styled.div`
   margin-top: -17px;
   padding-left: 2px;
+  overflow: visible;
 `;
 
 const TitleContainer = styled.div`
@@ -283,7 +283,7 @@ const TemplateBlock = styled.div`
   border: 1px solid #ffffff00;
   align-items: center;
   user-select: none;
-  border-radius: 5px;
+  border-radius: 8px;
   display: flex;
   font-size: 13px;
   font-weight: 500;
@@ -296,7 +296,7 @@ const TemplateBlock = styled.div`
   color: #ffffff;
   position: relative;
   background: #26282f;
-  box-shadow: 0 5px 8px 0px #00000033;
+  box-shadow: 0 4px 15px 0px #00000055;
   :hover {
     background: #ffffff11;
   }
@@ -314,6 +314,7 @@ const TemplateBlock = styled.div`
 
 const TemplateList = styled.div`
   overflow-y: auto;
+  overflow: visible;
   margin-top: 32px;
   padding-bottom: 150px;
   display: grid;
@@ -322,44 +323,6 @@ const TemplateList = styled.div`
   grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 `;
 
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-
-  > a {
-    > i {
-      display: flex;
-      align-items: center;
-      margin-bottom: -2px;
-      font-size: 18px;
-      margin-left: 18px;
-      color: #858faaaa;
-      cursor: pointer;
-      :hover {
-        color: #aaaabb;
-      }
-    }
-  }
-`;
-
-const TemplatesWrapper = styled.div`
-  width: calc(90% - 150px);
-  min-width: 300px;
-  padding-top: 50px;
-`;
-
 const Url = styled.a`
   width: 100%;
   font-size: 13px;

+ 2 - 2
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -237,8 +237,8 @@ const Integration = styled.div`
   cursor: ${(props: { disabled: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   margin-bottom: 15px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
 `;
 
 const Label = styled.div`

+ 2 - 2
dashboard/src/main/home/integrations/IntegrationRow.tsx

@@ -133,8 +133,8 @@ const Integration = styled.div`
   cursor: ${(props: { disabled: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   margin-bottom: 15px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
 `;
 
 const Icon = styled.img`

+ 2 - 2
dashboard/src/main/home/integrations/Integrations.tsx

@@ -39,6 +39,7 @@ const Integrations: React.FC<PropsType> = (props) => {
                 >
                   {integrationList[integration].label}
                 </TitleSection>
+                <Buffer />
                 <CreateIntegrationForm
                   integrationName={integration}
                   closeForm={() => {
@@ -101,8 +102,7 @@ const Icon = styled.img`
 `;
 
 const Flex = styled.div`
-  display: flex;
-  align-items: center;
+  width: 100%;
 
   > i {
     cursor: pointer;

+ 2 - 2
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -160,8 +160,8 @@ const Integration = styled.div`
   cursor: ${(props: { disabled: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   margin-bottom: 15px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
 `;
 
 const Icon = styled.img`

+ 2 - 0
dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx

@@ -1,5 +1,7 @@
 import React, { Component } from "react";
 
+import styled from "styled-components";
+
 import DockerHubForm from "./DockerHubForm";
 import GKEForm from "./GKEForm";
 import EKSForm from "./EKSForm";

+ 4 - 3
dashboard/src/main/home/launch/Launch.tsx

@@ -361,7 +361,7 @@ const TemplateBlock = styled.div`
   border: 1px solid #ffffff00;
   align-items: center;
   user-select: none;
-  border-radius: 5px;
+  border-radius: 8px;
   display: flex;
   font-size: 13px;
   font-weight: 500;
@@ -374,7 +374,7 @@ const TemplateBlock = styled.div`
   color: #ffffff;
   position: relative;
   background: #26282f;
-  box-shadow: 0 5px 8px 0px #00000033;
+  box-shadow: 0 4px 15px 0px #00000044;
   :hover {
     background: #ffffff11;
   }
@@ -391,7 +391,7 @@ const TemplateBlock = styled.div`
 `;
 
 const TemplateList = styled.div`
-  overflow-y: auto;
+  overflow: visible;
   margin-top: 35px;
   padding-bottom: 150px;
   display: grid;
@@ -402,5 +402,6 @@ const TemplateList = styled.div`
 
 const TemplatesWrapper = styled.div`
   width: calc(85%);
+  overflow: visible;
   min-width: 300px;
 `;

+ 2 - 3
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -151,13 +151,12 @@ class SettingsPage extends Component<PropsType, StateType> {
               namespace: selectedNamespace,
               clusterId: this.context.currentCluster.id,
             }}
-            //externalValues={{
-            //  isLaunch: true,
-            //}}
+            isLaunch={true}
             isReadOnly={
               !this.props.isAuthorized("namespace", "", ["get", "create"])
             }
             onSubmit={(val) => {
+              console.log(val);
               onSubmit(val);
             }}
           />

+ 154 - 0
dashboard/src/main/home/modals/UpgradeChartModal.tsx

@@ -0,0 +1,154 @@
+import React, { Component, createRef } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+
+import Loading from "components/Loading";
+
+import Markdown from "markdown-to-jsx";
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  currentChart: ChartType;
+  onSubmit: () => void;
+  closeModal: () => void;
+};
+
+type StateType = {
+  notes: string;
+};
+
+export default class UpgradeChartModal extends Component<PropsType, StateType> {
+  state = {
+    notes: "Loading",
+  };
+
+  componentDidMount() {
+    // get the chart update notes from the api
+    let repoURL = process.env.ADDON_CHART_REPO_URL;
+    let chartName = this.props.currentChart.chart.metadata.name
+      .toLowerCase()
+      .trim();
+
+    if (chartName == "web" || chartName == "worker") {
+      repoURL = process.env.APPLICATION_CHART_REPO_URL;
+    }
+
+    api
+      .getTemplateUpgradeNotes(
+        "<token>",
+        {
+          repo_url: repoURL,
+          prev_version: this.props.currentChart.chart.metadata.version,
+        },
+        {
+          name: chartName,
+          version: this.props.currentChart.latest_version,
+        }
+      )
+      .then((res) => {
+        if (!res.data.upgrade_notes || res.data.upgrade_notes.length == 0) {
+          this.setState({
+            notes: `
+## Version ${this.props.currentChart.chart.metadata.version} -> ${this.props.currentChart.latest_version}
+No upgrade notes available. This update should be backwards-compatible. 
+        `,
+          });
+
+          return;
+        }
+
+        let noteArr = res.data.upgrade_notes.map((note: any) => {
+          return `
+## Version ${note.previous} -> ${note.target}
+${note.note}
+            `;
+        });
+
+        this.setState({ notes: noteArr.join("\n") });
+      })
+      .catch((err) => console.log(err));
+  }
+
+  renderContent() {
+    if (this.state.notes == "Loading") {
+      return <Loading />;
+    }
+
+    return <Markdown>{this.state.notes}</Markdown>;
+  }
+
+  render() {
+    return (
+      <StyledUpgradeChartModal>
+        <CloseButton onClick={this.props.closeModal}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        {this.renderContent()}
+        <SaveButton
+          disabled={false}
+          text="Upgrade Template"
+          status={""}
+          onClick={this.props.onSubmit}
+        />
+      </StyledUpgradeChartModal>
+    );
+  }
+}
+
+UpgradeChartModal.contextType = Context;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 12px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpgradeChartModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+  font-size: 13px;
+  line-height: 1.8em;
+  font-family: Work Sans, sans-serif;
+`;

+ 3 - 3
dashboard/src/shared/Context.tsx

@@ -26,9 +26,9 @@ export interface GlobalContextType {
   currentModalData: any;
   setCurrentModal: (currentModal: string, currentModalData?: any) => void;
   currentOverlay: {
-    message: string,
-    onYes: any,
-    onNo: any,
+    message: string;
+    onYes: any;
+    onNo: any;
   };
   setCurrentOverlay: (x: any) => void;
   currentError: string | null;

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

@@ -718,6 +718,16 @@ const getTemplateInfo = baseApi<
   return `/api/templates/${pathParams.name}/${pathParams.version}`;
 });
 
+const getTemplateUpgradeNotes = baseApi<
+  {
+    repo_url?: string;
+    prev_version: string;
+  },
+  { name: string; version: string }
+>("GET", (pathParams) => {
+  return `/api/templates/upgrade_notes/${pathParams.name}/${pathParams.version}`;
+});
+
 const getTemplates = baseApi<
   {
     repo_url?: string;
@@ -1072,6 +1082,7 @@ export default {
   getRepos,
   getRevisions,
   getTemplateInfo,
+  getTemplateUpgradeNotes,
   getTemplates,
   getUser,
   linkGithubProject,

+ 1 - 1
dashboard/src/shared/hooks/useWebsockets.ts

@@ -1,6 +1,6 @@
 import { useRef } from "react";
 
-interface NewWebsocketOptions {
+export interface NewWebsocketOptions {
   onopen?: () => void;
   onmessage?: (evt: MessageEvent) => void;
   onerror?: (err: ErrorEvent) => void;

+ 5 - 3
dashboard/src/shared/types.tsx

@@ -19,6 +19,8 @@ export interface DetailedIngressError {
 }
 
 export interface ChartType {
+  image_repo_uri: string;
+  git_action_config: any;
   name: string;
   info: {
     last_deployed: string;
@@ -267,9 +269,9 @@ export interface ContextProps {
   currentModalData: any;
   setCurrentModal: (currentModal: string, currentModalData?: any) => void;
   currentOverlay: {
-    message: string,
-    onYes: any,
-    onNo: any,
+    message: string;
+    onYes: any;
+    onNo: any;
   };
   setCurrentOverlay: (x: any) => void;
   currentError?: string;

+ 30 - 0
docs/deploy/addons/strapi.md

@@ -0,0 +1,30 @@
+# Deploy Strapi with Porter
+This is a quick guide on how to deploy Strapi to a Kubernetes cluster in AWS/GCP/DO using Porter. This guide uses PostgresDB by default - to customize your database settings, modify the files in `/app/config/env/production` in the [example repository](https://github.com/porter-dev/strapi).
+
+# Quick Deploy
+## Deploying Strapi
+1. Create an account on [Porter](https://dashboard.getporter.dev).
+2. [One-click provision a Kubernetes cluster](https://docs.porter.run/docs/getting-started-with-porter-on-aws) in a cloud provider of your choice, or [connect an existing cluster.](https://docs.porter.run/docs/cli-documentation#connecting-to-an-existing-cluster)
+3. Fork [this repository](https://github.com/porter-dev/strapi).
+4. From the [Launch tab](https://dashboard.getporter.dev/launch), navigate to **Web Service > Deploy from Git Repository**. Then select the forked repository and `Dockerfile` in the root directory.
+5. Configure the port to `1337`, add environment variable `NODE_ENV=production`, and set resources to the [recommended settings](https://strapi.io/documentation/developer-docs/latest/setup-deployment-guides/deployment.html#general-guidelines) (i.e. 2048Mi RAM, 1000 CPU).
+6. Hit Deploy!
+
+## Deploying PostgresDB
+1. Strapi instance deployed through Porter connects to PostgresDB. You can connect Strapi instance deployed on Porter to any external database, but it is also possible to use a database that is also deployed on Porter. Follow [this guide to deploy a PostgresDB instance to your cluster in one click](https://docs.getporter.dev/docs/postgresdb).
+2. After the database has been deployed, navigate to the **Environment Variables** tab of your deployed Strapi instance. Configure the following environment variables:
+```
+NODE_ENV=production
+DATABASE_HOST=
+DATABASE_PORT=5432
+DATABASE_NAME=
+DATABASE_USERNAME=
+DATABASE_PASSWORD=
+```
+To determine what the correct environment variables are in order to connect to the deployed database, [see this guide](https://docs.getporter.dev/docs/postgresdb#connecting-to-the-database).
+
+# Development
+To develop, clone the [example repository](https://github.com/porter-dev/strapi) to your local environment and run `npm install && npm run develop;` from the `app` directory. Porter will automatically handle CI/CD and propagate your changes to production on every push to the repository.
+
+# Questions?
+Join the [Porter Discord community](https://discord.gg/FaaFjb6DXA) if you have any questions or need help.

+ 168 - 0
docs/developing/frontend-guide.md

@@ -0,0 +1,168 @@
+# Porter Frontend Contribution Guide
+
+The purpose of this guide is to provide guidance on the development of the trickier parts of Porter's current frontend. 
+
+## Getting Started
+
+### Components
+
+#### Functional Components and Migration
+
+Currently, most of the frontend is written using Class Components. If you contribute to any part of the frontend, you are encouraged to rewrite any class component into a functional component. While doing so, keep a few things in mind:
+
+
+#### Typing and Internal Organization
+
+To keep a functional component consistent, it should be typed using `React.FC` with a `Props` interface. Internally, the hooks used by the component should be listed near each other at the start of the function body, to avoid confusion. For example, the following is a component that obeys these rules:
+
+```typescript
+interface Props {
+    ...
+}
+
+const MyComponent: React.FC<Props> = (props) => {
+    const [foo, updateFoo] = useState(...);
+    const [bar, updateBar] = useState(...);
+    const { baz } = useContext(...);
+
+    const qux  = ...;
+
+    ...
+}
+```
+
+## Data Flow
+
+### Context
+
+If a prop is passed down more than three levels in the component heirarchy, it should be rewritten to be contained in a context. Make sure that all the components that then consume this context are functional, since the Context API isn't very good for class components.
+
+### Hooks
+
+One of the advantages of using react 16+ is the posibility to create custom hooks, so if you feel that something could be easier or you could reduce the repetition of logic by using custom hooks we encourage you to do so!
+
+#### Where should I place my new hooks
+
+If the hook that you're creating may be used all over the application you can add it to the `src/shared/hooks` folder, if not, you can place them right next to the components that will be using the hooks.
+
+#### Typing and documentation
+
+Please note that every hook that you may create will be used by other people, so typing it and adding comments on how it works/why you create such hook is super useful and we encourage this behaviour.
+
+
+## Routing
+
+### Routing model for new standard:
+
+The idea is to keep something similar to what NextJS or Angular+2 with submodules does, if we have a route the folder structure should try to respect that path
+
+For example:
+
+Having the following routes
+
+```
+URL dashboard.getporter.dev/cluster-dashboard/node-list
+URL dashboard.getporter.dev/cluster-dashboard/expanded-node-view
+URL dashboard.getporter.dev/project-dashboard/cluster-list
+URL dashboard.getporter.dev/applications?cluster_id=somename
+```
+
+We should end with the following structure
+
+```
+|-- src/
+    |-- project-dashboard
+ 	|	|-- Routes.tsx
+    |   |-- ProjectDashboard.tsx
+ 	|	|-- _SomeSpecificComponentNeeded.tsx
+ 	|	`-- cluster-list
+ 	|		`-- ClusterList.tsx
+    |-- cluster-dashboard
+ 	|	|-- Routes.tsx
+ 	|	|-- ClusterDashboard.tsx
+ 	|	|-- node-list
+ 	|	|	`-- NodeList.tsx
+ 	|	`-- expanded-node-view
+ 	|		`-- ExpandedNodeView
+    `-- applications
+        |-- Route.tsx
+        `-- Applications.tsx
+```
+
+All first level routes should have it's own folder as it may be considered a new module of the application, inside each module we may have specific components we don't wanna share between modules, those should be named with an underscore first to be clear that they're not pages but simple components.
+In the case that the Routes.tsx on the module became too long, it can be divided into subroutes inside the subfollowing folders.
+
+## Advanced
+
+### Forms
+
+Porter allows developers to build customizable forms on top of Helm by adding an optional `form.yaml` file to any Helm chart. Currently, forms can write to any field specified in a chart's `values.yaml`. We are working to add support for user-defined applets that can directly perform CRUD operations on cluster resources.
+
+On the frontend, there are two components responsible for making forms work and are separated such that one only handles 
+the form logic while the other does the rendering. The first one is `PorterFormContextProvider`,
+which provides a context that the second component `PorterForm` subscribes to using a custom hook.
+This relationship should be kept in mind when adding new functionality to this system: logic and rendering must be
+separated between these components.
+
+### Debugging Forms
+
+To get an easy-to-access version of the form component with all the relevant props
+already passed in for you, navigate to a project dashboard and press and hold `command+k+z`;
+
+### Form State
+As a whole, the frontend form stores its state in three places:
+1. The variables of the form - these are shared and can be modified by any form field
+2. The state specific to each form field which can only be modified by the form field itself
+3. The validation information for each field which can only be modified by the form field itself
+
+This state is exposed to each form field through the `useFormFieldHook<T>` (where `T` is the interface describing the state of the component), which every component calls with a unique id passed down
+to it through props:
+```typescript
+interface FieldStateInterface {
+    some: string;
+    fields: boolean;
+}
+
+const { state, variables, setVars, setState, setValidation } = useFormField<FieldStateInterface>(
+    props.id,
+    {
+      initState: {
+          some: "foo",
+          fields: false,
+      },
+      initValidation: {
+        validated: !props.required
+      },
+      initVars: {},
+    }
+  );
+```
+The returned state changing functions behave in the same way as the `setState` function behaves in Class components. So,
+for example, if we wanted to change the value of variable "foo" in the form, we could write:
+```typescript
+setVars((vars) => {
+    return {
+        ...vars,
+        foo: "bar"
+    }
+})
+```
+To see more about how this system works, check out the implementation for some simpler form components like `Checkbox` or 
+`Input`.
+
+### Extracting Variables on Submit
+
+If you looked at the implementations of other form fields, you may have noticed that each form field file
+exports a function in the form `getFinalVariablesFor[FieldName]`. This function is neccesarry for two reasons:
+1. Sometimes, when the form is submitted, some fields have not yet been rendered but still need their values included
+in the final variable output (for example, if a string input has a default value).
+   
+2. Sometimes, a field wants to make modification to the variable/state belonging to it before the form is submitted
+   (for example, a string input could append units to its value on submission).
+   
+So, if a field has a default/wants to modify variables on submit, this functions should be included in the file
+(and in the appropriate place in the `PorterFormContextProvider`). In general, this function takes in three arguments:
+the list of unmodified variables on submission, the props of the field, and the state of the field upon submission. It 
+returns an object that will be applied on top of the variable list. Also note that the state passed into this function
+could be `null` or `undefined` if the field has never been rendered. For more details, look at the implementation of this function for `Input`.
+   

+ 79 - 0
docs/developing/frontend-roadmap-status.md

@@ -0,0 +1,79 @@
+# Frontend roadmap status
+
+In this page you will be able to see how the roadmap status is going! Right now we have a work in progress list of components that needs to be tested and worked on!
+
+Keep in mind that this is still not the final version, and this document will be updated to also divide the components by their correspondant module to wich they have to be migrated to!
+
+| Component                     | Current path                                                                        | Migrated to functional | Tested up | Cleaned up |
+| ----------------------------- | ----------------------------------------------------------------------------------- | ---------------------- | --------- | ---------- |
+| App                           | `dashboard/src/App.tsx`                                                             |                        |           |            |
+| MainWrapper                   | `dashboard/src/main/MainWrapper.tsx`                                                |                        |           |            |
+| ContextProvider               | `dashboard/src/shared/Context.tsx`                                                  |                        |           |            |
+| Main                          | `dashboard/src/main/Main.tsx`                                                       |                        |           |            |
+| Login                         | `dashboard/src/main/auth/Login.tsx`                                                 |                        |           |            |
+| Register                      | `dashboard/src/main/auth/Register.tsx`                                              |                        |           |            |
+| ResetPasswordFinalize         | `dashboard/src/main/auth/ResetPasswordFinalize.tsx`                                 |                        |           |            |
+| ResetPasswordInit             | `dashboard/src/main/auth/ResetPasswordInit.tsx`                                     |                        |           |            |
+| VerifyEmail                   | `dashboard/src/main/auth/VerifyEmail.tsx`                                           |                        |           |            |
+| CurrentError                  | `dashboard/src/main/CurrentError.tsx`                                               |                        |           |            |
+| Home                          | `dashboard/src/main/home/Home.tsx`                                                  |                        |           |            |
+| NoClusterPlaceholder          | `dashboard/src/main/home/NoClusterPlaceholder.tsx`                                  |                        |           |            |
+| ClusterSection                | `dashboard/src/main/home/sidebar/ClusterSection.tsx`                                |                        |           |            |
+| Drawer                        | `dashboard/src/main/home/sidebar/Drawer.tsx`                                        |                        |           |            |
+| ProjectSection                | `dashboard/src/main/home/sidebar/ProjectSection.tsx`                                |                        |           |            |
+| ProjectSectionContainer       | `dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx`                       |                        |           |            |
+| Sidebar                       | `dashboard/src/main/home/sidebar/Sidebar.tsx`                                       |                        |           |            |
+| AWSFormSection                | `dashboard/src/main/home/provisioner/AWSFormSection.tsx`                            |                        |           |            |
+| DOFormSection                 | `dashboard/src/main/home/provisioner/DOFormSection.tsx`                             |                        |           |            |
+| ExistingClusterSection        | `dashboard/src/main/home/provisioner/ExistingClusterSection.tsx`                    |                        |           |            |
+| GCPFormSection                | `dashboard/src/main/home/provisioner/GCPFormSection.tsx`                            |                        |           |            |
+| InfraStatuses                 | `dashboard/src/main/home/provisioner/InfraStatuses.tsx`                             |                        |           |            |
+| Provisioner                   | `dashboard/src/main/home/provisioner/Provisioner.tsx`                               |                        |           |            |
+| ProvisionerLogs               | `dashboard/src/main/home/provisioner/ProvisionerLogs.tsx`                           |                        |           |            |
+| ProvisionerSettings           | `dashboard/src/main/home/provisioner/ProvisionerSettings.tsx`                       |                        |           |            |
+| InvitePage                    | `dashboard/src/main/home/project-settings/InviteList.tsx`                           | ✅                     |           |            |
+| ProjectSettings               | `dashboard/src/main/home/project-settings/ProjectSettings.tsx`                      |                        |           |            |
+| NewProject                    | `dashboard/src/main/home/new-project/NewProject.tsx`                                |                        |           |            |
+| Feedback                      | `dashboard/src/main/home/navbar/Feedback.tsx`                                       |                        |           |            |
+| Navbar                        | `dashboard/src/main/home/navbar/Navbar.tsx`                                         |                        |           |            |
+| AccountSettingsModal          | `dashboard/src/main/home/modals/AccountSettingsModal.tsx`                           |                        |           |            |
+| ClusterInstructionsModal      | `dashboard/src/main/home/modals/ClusterInstructionsModal.tsx`                       |                        |           |            |
+| DeleteNamespaceModal          | `dashboard/src/main/home/modals/DeleteNamespaceModal.tsx`                           |                        |           |            |
+| EditInviteOrCollaboratorModal | `dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx`                  |                        |           |            |
+| EnvEditorModal                | `dashboard/src/main/home/modals/EnvEditorModal.tsx`                                 |                        |           |            |
+| IntegrationsInstructionsModal | `dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx`                  |                        |           |            |
+| IntegrationsModal             | `dashboard/src/main/home/modals/IntegrationsModal.tsx`                              |                        |           |            |
+| LoadEnvGroupModal             | `dashboard/src/main/home/modals/LoadEnvGroupModal.tsx`                              |                        |           |            |
+| Modal                         | `dashboard/src/main/home/modals/Modal.tsx`                                          |                        |           |            |
+| NamespaceModal                | `dashboard/src/main/home/modals/NamespaceModal.tsx`                                 |                        |           |            |
+| UpdateClusterModal            | `dashboard/src/main/home/modals/UpdateClusterModal.tsx`                             |                        |           |            |
+| Launch                        | `dashboard/src/main/home/launch/Launch.tsx`                                         |                        |           |            |
+| LaunchFlow                    | `dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx`                         |                        |           |            |
+| SettingsPage                  | `dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx`                       |                        |           |            |
+| SourcePage                    | `dashboard/src/main/home/launch/launch-flow/SourcePage.tsx`                         |                        |           |            |
+| ExpandedTemplate              | `dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx`             |                        |           |            |
+| TemplateInfo                  | `dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx`                 |                        |           |            |
+| SlackIntegrationList          | `dashboard/src/main/home/integrations/SlackIntegrationList.tsx`                     | ✅                     |           |            |
+| Integrations                  | `dashboard/src/main/home/integrations/Integrations.tsx`                             | ✅                     |           |            |
+| IntegrationRow                | `dashboard/src/main/home/integrations/IntegrationRow.tsx`                           |                        |           |            |
+| IntegrationList               | `dashboard/src/main/home/integrations/IntegrationList.tsx`                          |                        |           |            |
+| IntegrationCategories         | `dashboard/src/main/home/integrations/IntegrationCategories.tsx`                    | ✅                     |           |            |
+| DockerHubForm                 | `dashboard/src/main/home/integrations/edit-integration/DockerHubForm.tsx`           |                        |           |            |
+| ECRForm                       | `dashboard/src/main/home/integrations/edit-integration/ECRForm.tsx`                 |                        |           |            |
+| EditIntegrationForm           | `dashboard/src/main/home/integrations/edit-integration/EditIntegrationForm.tsx`     |                        |           |            |
+| EKSForm                       | `dashboard/src/main/home/integrations/edit-integration/EKSForm.tsx`                 |                        |           |            |
+| GCRForm                       | `dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx`                 |                        |           |            |
+| GKEForm                       | `dashboard/src/main/home/integrations/edit-integration/GKEForm.tsx`                 |                        |           |            |
+| CreateIntegrationForm         | `dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx` |                        |           |            |
+| DockerHubForm                 | `dashboard/src/main/home/integrations/create-integration/DockerHubForm.tsx`         |                        |           |            |
+| ECRForm                       | `dashboard/src/main/home/integrations/create-integration/ECRForm.tsx`               |                        |           |            |
+| EKSForm                       | `dashboard/src/main/home/integrations/create-integration/EKSForm.tsx`               |                        |           |            |
+| GCRForm                       | `dashboard/src/main/home/integrations/create-integration/GCRForm.tsx`               |                        |           |            |
+| GKEForm                       | `dashboard/src/main/home/integrations/create-integration/GKEForm.tsx`               |                        |           |            |
+| ClusterList                   | `dashboard/src/main/home/dashboard/ClusterList.tsx`                                 |                        |           |            |
+| ClusterPlaceholder            | `dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx`                          |                        |           |            |
+| ClusterPlaceholderContainer   | `dashboard/src/main/home/dashboard/ClusterPlaceholderContainer.tsx`                 |                        |           |            |
+| Dashboard                     | `dashboard/src/main/home/dashboard/Dashboard.tsx`                                   |                        |           |            |
+| PipelinesSection              | `dashboard/src/main/home/dashboard/PipelinesSection.tsx`                            |                        |           |            |
+| ExpandedChartWrapper          | `dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx` | ✅                     |           |            |
+| ExpandedChart                 | `dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx`        | ✅                     |           |            |

+ 46 - 0
docs/developing/frontend-roadmap.md

@@ -0,0 +1,46 @@
+# Frontend Roadmap
+
+We know that the current state of the Porter Dashboard is not the most updated one in terms of React practices, but the idea is not to keep it that way. That's why we want to introduce a new roadmap that every contributor can help on in terms to improve the current functionality!
+
+If you want to see the current state of the roadmap you can check out this document! [Frontend Roadmap Status](frontend-roadmap-status.md)
+
+## Roadmap
+
+The next image represents a raw perspective of how we want to face this migration to update and improve our dashboard in terms of technical debt! You can see a more step by step detailed guide below.
+
+![image](https://user-images.githubusercontent.com/23369263/128541304-4e7a8d3d-08f5-4c3c-841f-91f8abfbef4c.png)
+
+### Migrate to functional components
+
+This step is pretty self explinatory, we want to leave behind class components and build a new era of Functional components with custom hooks and all the pretty stuff that came after React 16 version. The main idea is to have the chance to have almost all the application on functional components and start separating stuff to custom hooks when it's needed to improve the readability of components.
+
+If you want to help on this step, you need to consider two things:
+
+- We want to rewrite the minimal amount of logic necessary to migrate, don't spend hours trying to improve the component, just having it as functional instead of class based is enough for this step.
+- The functional component should be an equivalent version of it's class component (this means, that the functionality should be the same even if the implementation may vary a little).
+
+### Setup Jest and testing
+
+This is one of the big ones, after migrate a component to functional components, the idea is to setup tests to be sure that the components behaviour is the one that we expect, after all, testing is one of the keys to have a more stable system. We want to use [Jest](https://jestjs.io/docs/getting-started) and the [React Testing library](https://testing-library.com/docs/react-testing-library/intro/) to get this step done, mainly because we want to implement as much as possible black box testing, this is mainly because of the next step `Clean up data flows` where we will be updating most of the internal data flows of the components, but we want to keep the functionality for the user to be the same.
+
+#### Want to help on this step?
+
+We are not testing experts, so there's probably a lot of things to improve the way of how we think about this, don't doubt on asking us or suggesting on our [discord channel](https://discord.gg/GJynMR3KXK)!
+
+### Clean up datas flows
+
+Right here is where all the magic will happen, currently the components are highly coupled and this makes really hard for implementing new features and we detect a lot of cases where the performance of the app is dropped.
+This step should be composed by a couple of questions:
+
+- Can we reduce the http calls that we're making?
+- Can we reduce the amount of data that we're handling?
+- Do we need a context for handling data to be more optimal and clean?
+- And last, is there anything that we can atomize and share between multiple components?
+
+With this questions in mind, is pretty obvious that the work for clean up can be really hard, but the main idea is to make the components as readable as possible, adding comments, removing useless logic or making it more accessible for new contributors!
+
+### Migrate routes
+
+This one is a little bit more trickier in terms of implementation, as its not aimed for just one component but instead, for a whole set of components that have deep relations between them. A clear example of this is the applications module, it's a tightly coupled set of components that are mixed with jobs and env groups, even if they don't share any logic and a really small sets of components, this is clearly not clean in any way and this step should help the project to be more organized for exploring it and to know where to add new stuff or find the component that we want to change!
+
+You can find more about the routing system that we will implement on the [frontend guide document](frontend-guide.md)!

+ 53 - 9
docs/developing/setup.md

@@ -22,21 +22,65 @@ DB_NAME=porter
 SQL_LITE=false
 ```
 
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen.
+Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. Create a user by entering an email/password on the "Register" screen. 
 
-Next, register your admin account. Once it's complete, it will ask you to verify your email; we will manually verify it through Postgres.
+At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
 
-Open your terminal in the root repository and enter:
+## Getting PostgreSQL Access
+
+You can get `psql` access by running the following:
 
 `psql --host localhost --port 5400 --username porter --dbname porter -W`
 
-It will promt you for a password. Enter `porter`
+This will prompt you for a password. Enter `porter`, and you should see the `psql` shell!
+
+### Setting your email to be verified 
 
-Next, update your email in the database to `verified":
+If you are getting blocked out of the dashboard because your email is not verified (fixed in `v0.6.2` of Porter, so make sure you've pulled from `master` recently), you can update your email in the database to `verified":
 
 `UPDATE users SET email_verified='t' WHERE id=1;`
 
-At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload.
+## Setting up Minikube
+
+These steps will help you get set up with a minikube cluster that can be used for development. Prerequisities:
+- `kubectl` installed locally
+- Development instance of Porter is running
+
+Following the OS-specific steps to get minikube running:
+- [MacOS](#macos)
+- [Linux](#linux)
+
+If you now navigate to `http://localhost:8080`, you should see the minikube cluster attached! There will be some limitations:
+- **It is not possible to expose a service that you create. Whenever you create a web service, de-select the "Expose to external traffic" option.**
+
+
+### MacOS
+
+1. [Install minikube](https://minikube.sigs.k8s.io/docs/start/), and install the `hyperkit` driver. The easiest way to do this is via:
+
+```sh
+brew install minikube
+brew install hyperkit
+```
+
+2. Start minikube with the `hyperkit` driver:
+
+```sh
+minikube start --driver hyperkit
+```
+
+3. Make sure that you've downloaded the latest version of the Porter CLI, and that your development version of Porter is running. Then run:
+
+```sh
+porter config set-host http://localhost:8080
+porter auth login
+```
+
+4. Make sure that `minikube` is selected as the current context (`kubectl config current-context`), and then run:
+
+```sh
+porter connect kubeconfig
+```
 
 ## Setup for WSL
 
@@ -45,9 +89,9 @@ Follow the steps to install WSL on Windows here https://docs.microsoft.com/en-us
 ### Requirements
 
 `sudo apt install xdg-utils` <br/>
-`sudo apt install postgres`
+`sudo apt install postgresql`
 
-### Setup Proccess
+### Setup Process
 
 Once WSL is installed, head to docker and enable WSL Integration.
 ![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
@@ -77,4 +121,4 @@ If using Chrome, paste the following into the Chrome address bar:
 
 And then Enable the **Allow invalid certificates for resources loaded from localhost** field. 
 
-Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file. 
+Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file. 

+ 18 - 0
docs/developing/test-autoscaling.md

@@ -0,0 +1,18 @@
+# Test Cluster and HPA Autoscaling
+
+Prerequisites: 
+- [`metrics-server`](https://artifacthub.io/packages/helm/bitnami/metrics-server) must be installed (installed by default on all Porter clusters). 
+- [`cluster-autoscaler`](https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler) must be enabled (enabled by default on all Porter clusters). 
+- Have `kubectl` access to the cluster, as it is easiest to port-forward to the exposed service. 
+- Download [`hey`](https://github.com/rakyll/hey) or an equivalent. 
+
+# Steps
+
+1. Launch a Docker template with image URL `k8s.gcr.io/hpa-example`. Enable autoscaling under the **Resources** tab -- suggested params are 100 Mi RAM, 100m CPU, 1-100 replicas, 50% CPU & RAM util. Do not **Expose to external traffic** to run load tests.
+
+2. Confirm that the horizontal pod autoscaler was created via kubectl (`kubectl get hpa`) and that the metrics server is reporting metrics via `kubectl top pods`. Current utilization may initially show up as `<undefined>`, but this information should load after a minute or so.
+
+3. Port-forward to the service via `kubectl port-forward svc/<svc-name> 10000:80`. 
+4. In a different terminal window, run `hey http://localhost:10000`. Vary the `hey` parameters to test autoscaling under different loads (`hey -h` for options). 
+
+Check that replicas scale when passing the threshold from the metrics tab. Use kubectl to probe the nodes and confirm cluster-level upscaling has also occurred. Delete the chart and confirm node downscaling when done.

+ 25 - 0
docs/guides/linking-slack-integration.md

@@ -0,0 +1,25 @@
+# Configuring Slack Integration
+
+Porter has a Slack application that you can install into a channel of any workspace you're part of. This app will send notifications on updates to your deployments.
+
+## Installing Application
+
+To install the Slack application, navigate to the **Integrations** section on the left and click **Slack**. Then, click **Install Application** in the top right:
+
+![image](https://user-images.githubusercontent.com/25856165/128559944-d14cb6f9-8bfd-4294-8ed1-5455f3c3304d.png)
+
+Next, install the application into the relevant workspace (top right) and channel. Note that you can install the same app into multiple channels in one workspace.
+
+![image](https://user-images.githubusercontent.com/25856165/128560147-ab5308db-ec0c-49f2-a2c8-9405f205665b.png)
+
+After that, the application should show up in the Slack integrations tab:
+
+![image](https://user-images.githubusercontent.com/25856165/128560472-bd4d35c3-31d5-4ee1-b137-206d3b914c13.png)
+
+## Uninstalling Application
+
+To delete the Slack application from a channel, first click the icon next to the delete icon, which will bring you to the Slack configuration page. From this page, you can revoke the Slack app's access to your workspace. Next, click the garbage icon on the relevant integration:
+
+![image](https://user-images.githubusercontent.com/25856165/128560956-35b5051d-cb7e-49d7-bc70-b26cfdd718f8.png)
+
+This will delete the application data on the Porter side, which will prevent it from attempting to send notifications. 

+ 1 - 0
go.mod

@@ -6,6 +6,7 @@ require (
 	cloud.google.com/go v0.65.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/DATA-DOG/go-sqlmock v1.5.0
+	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation v1.1.1
 	github.com/buildpacks/pack v0.19.0

+ 2 - 0
go.sum

@@ -189,6 +189,7 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 h1:7aWHqerlJ41y6FOsEUvknqgXnGmJyJSbjhAWq5pO4F8=
 github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw=
 github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -327,6 +328,7 @@ github.com/evanphx/json-patch v4.9.0+incompatible h1:kLcOMZeuLAJvL2BPWLMIj5oaZQo
 github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM=
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4=
+github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8=
 github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=

+ 73 - 0
internal/helm/upgrade/upgrade.go

@@ -0,0 +1,73 @@
+package upgrade
+
+import (
+	semver "github.com/Masterminds/semver/v3"
+	"sigs.k8s.io/yaml"
+)
+
+// UpgradeFile is a collection of upgrade notes between specific versions
+type UpgradeFile struct {
+	UpgradeNotes []*UpgradeNote `yaml:"upgrade_notes" json:"upgrade_notes"`
+}
+
+// UpgradeNote is a single note for upgrading between a previous version and
+// a target version.
+type UpgradeNote struct {
+	PreviousVersion string `yaml:"previous" json:"previous"`
+	TargetVersion   string `yaml:"target" json:"target"`
+	Note            string `yaml:"note" json:"note"`
+}
+
+// ParseUpgradeFileFromBytes parses the raw bytes of an upgrade file and returns an
+// UpgradeFile object. sigs.k8s.io/yaml parser is used.
+func ParseUpgradeFileFromBytes(upgradeNotes []byte) (*UpgradeFile, error) {
+	// parse bytes into object
+	res := &UpgradeFile{}
+
+	err := yaml.Unmarshal(upgradeNotes, res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, err
+}
+
+// GetUpgradeFileBetweenVersions gets the set of upgrade notes that are applicable to an upgrade
+// between a previous and target version.
+func (u *UpgradeFile) GetUpgradeFileBetweenVersions(prev, target string) (*UpgradeFile, error) {
+	prevVersion, err := semver.NewVersion(prev)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// for each upgrade note, determine if it's geq than the previous version, leq the target
+	// version
+	resNotes := make([]*UpgradeNote, 0)
+
+	for _, note := range u.UpgradeNotes {
+		notePrevVersion, err := semver.NewVersion(note.PreviousVersion)
+
+		if err != nil {
+			return nil, err
+		}
+
+		noteTargetVersion, err := semver.NewVersion(note.TargetVersion)
+
+		if err != nil {
+			return nil, err
+		}
+
+		// if note(prev) <= prev and note(next) >= prev, render the note
+		if comp := notePrevVersion.Compare(prevVersion); comp != -1 {
+			if comp := noteTargetVersion.Compare(prevVersion); comp != -1 {
+				resNotes = append(resNotes, note)
+			}
+		}
+	}
+
+	return &UpgradeFile{
+		UpgradeNotes: resNotes,
+	}, nil
+}

+ 114 - 21
internal/kubernetes/prometheus/metrics.go

@@ -126,7 +126,8 @@ func QueryPrometheus(
 		query = fmt.Sprintf(`%s / %s * 100 OR on() vector(0)`, num, denom)
 	} else if opts.Metric == "cpu_hpa_threshold" {
 		// get the name of the kube hpa metric
-		metricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
+		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
+		cpuMetricName := getKubeCPUMetricName(clientset, service, opts)
 		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
 		appLabel := ""
 
@@ -134,9 +135,10 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPAAbsoluteCPUThresholdQuery(metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel)
+		query = createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	} else if opts.Metric == "memory_hpa_threshold" {
-		metricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
+		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
+		memMetricName := getKubeMemoryMetricName(clientset, service, opts)
 		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
 		appLabel := ""
 
@@ -144,9 +146,9 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPAAbsoluteMemoryThresholdQuery(metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel)
+		query = createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	} else if opts.Metric == "hpa_replicas" {
-		metricName := getKubeHPAMetricName(clientset, service, opts, "status_current_replicas")
+		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "status_current_replicas")
 		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
 		appLabel := ""
 
@@ -154,13 +156,15 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPACurrentReplicasQuery(metricName, opts.Name, opts.Namespace, appLabel)
+		query = createHPACurrentReplicasQuery(metricName, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	}
 
 	if opts.ShouldSum {
 		query = fmt.Sprintf("sum(%s)", query)
 	}
 
+	fmt.Println("QUERY IS", query)
+
 	queryParams := map[string]string{
 		"query": query,
 		"start": fmt.Sprintf("%d", opts.StartRange),
@@ -276,15 +280,20 @@ func getPodSelectionRegex(kind, name string) (string, error) {
 	return fmt.Sprintf("%s-%s", name, suffix), nil
 }
 
-func createHPAAbsoluteCPUThresholdQuery(metricName, podSelectionRegex, hpaName, namespace, appLabel string) string {
+func createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionRegex, hpaName, namespace, appLabel, hpaMetricName string) string {
 	kubeMetricsPodSelector := getKubeMetricsPodSelector(podSelectionRegex, namespace)
 
 	kubeMetricsHPASelector := fmt.Sprintf(
-		`hpa="%s",namespace="%s",metric_name="cpu",metric_target_type="utilization"`,
+		`%s="%s",namespace="%s",metric_name="cpu",metric_target_type="utilization"`,
+		hpaMetricName,
 		hpaName,
 		namespace,
 	)
 
+	if cpuMetricName == "kube_pod_container_resource_requests" {
+		kubeMetricsPodSelector += `,resource="cpu",unit="core"`
+	}
+
 	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
 	// as well
 	if appLabel != "" {
@@ -293,8 +302,11 @@ func createHPAAbsoluteCPUThresholdQuery(metricName, podSelectionRegex, hpaName,
 	}
 
 	requestCPU := fmt.Sprintf(
-		`sum by (hpa) (label_replace(kube_pod_container_resource_requests_cpu_cores{%s},"hpa", "%s", "", ""))`,
+		`sum by (%s) (label_replace(%s{%s},"%s", "%s", "", ""))`,
+		hpaMetricName,
+		cpuMetricName,
 		kubeMetricsPodSelector,
+		hpaMetricName,
 		hpaName,
 	)
 
@@ -304,18 +316,23 @@ func createHPAAbsoluteCPUThresholdQuery(metricName, podSelectionRegex, hpaName,
 		kubeMetricsHPASelector,
 	)
 
-	return fmt.Sprintf(`%s * on(hpa) %s`, requestCPU, targetCPUUtilThreshold)
+	return fmt.Sprintf(`%s * on(%s) %s`, requestCPU, hpaMetricName, targetCPUUtilThreshold)
 }
 
-func createHPAAbsoluteMemoryThresholdQuery(metricName, podSelectionRegex, hpaName, namespace, appLabel string) string {
+func createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelectionRegex, hpaName, namespace, appLabel, hpaMetricName string) string {
 	kubeMetricsPodSelector := getKubeMetricsPodSelector(podSelectionRegex, namespace)
 
 	kubeMetricsHPASelector := fmt.Sprintf(
-		`hpa="%s",namespace="%s",metric_name="memory",metric_target_type="utilization"`,
+		`%s="%s",namespace="%s",metric_name="memory",metric_target_type="utilization"`,
+		hpaMetricName,
 		hpaName,
 		namespace,
 	)
 
+	if memMetricName == "kube_pod_container_resource_requests" {
+		kubeMetricsPodSelector += `,resource="memory",unit="byte"`
+	}
+
 	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
 	// as well
 	if appLabel != "" {
@@ -324,8 +341,11 @@ func createHPAAbsoluteMemoryThresholdQuery(metricName, podSelectionRegex, hpaNam
 	}
 
 	requestMem := fmt.Sprintf(
-		`sum by (hpa) (label_replace(kube_pod_container_resource_requests_memory_bytes{%s},"hpa", "%s", "", ""))`,
+		`sum by (%s) (label_replace(%s{%s},"%s", "%s", "", ""))`,
+		hpaMetricName,
+		memMetricName,
 		kubeMetricsPodSelector,
+		hpaMetricName,
 		hpaName,
 	)
 
@@ -335,7 +355,7 @@ func createHPAAbsoluteMemoryThresholdQuery(metricName, podSelectionRegex, hpaNam
 		kubeMetricsHPASelector,
 	)
 
-	return fmt.Sprintf(`%s * on(hpa) %s`, requestMem, targetMemUtilThreshold)
+	return fmt.Sprintf(`%s * on(%s) %s`, requestMem, hpaMetricName, targetMemUtilThreshold)
 }
 
 func getKubeMetricsPodSelector(podSelectionRegex, namespace string) string {
@@ -346,9 +366,10 @@ func getKubeMetricsPodSelector(podSelectionRegex, namespace string) string {
 	)
 }
 
-func createHPACurrentReplicasQuery(metricName, hpaName, namespace, appLabel string) string {
+func createHPACurrentReplicasQuery(metricName, hpaName, namespace, appLabel, hpaMetricName string) string {
 	kubeMetricsHPASelector := fmt.Sprintf(
-		`hpa="%s",namespace="%s"`,
+		`%s="%s",namespace="%s"`,
+		hpaMetricName,
 		hpaName,
 		namespace,
 	)
@@ -372,7 +393,7 @@ type promRawValuesQuery struct {
 }
 
 // getKubeHPAMetricName performs a "best guess" for the name of the kube HPA metric,
-// which was renamed to kube_horizontal_pod_autoscaler... in later versions of kube-state-metrics.
+// which was renamed to kube_horizontalpodautoscaler... in later versions of kube-state-metrics.
 // we query Prometheus for a list of metric names to see if any match the new query
 // value, otherwise we return the deprecated name.
 func getKubeHPAMetricName(
@@ -380,9 +401,81 @@ func getKubeHPAMetricName(
 	service *v1.Service,
 	opts *QueryOpts,
 	suffix string,
+) (string, string) {
+	queryParams := map[string]string{
+		"match[]": fmt.Sprintf("kube_horizontalpodautoscaler_%s", suffix),
+		"start":   fmt.Sprintf("%d", opts.StartRange),
+		"end":     fmt.Sprintf("%d", opts.EndRange),
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/label/__name__/values",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return fmt.Sprintf("kube_hpa_%s", suffix), "hpa"
+	}
+
+	rawQueryObj := &promRawValuesQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	if rawQueryObj.Status == "success" && len(rawQueryObj.Data) == 1 {
+		return fmt.Sprintf("kube_horizontalpodautoscaler_%s", suffix), "horizontalpodautoscaler"
+	}
+
+	return fmt.Sprintf("kube_hpa_%s", suffix), "hpa"
+}
+
+func getKubeCPUMetricName(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *QueryOpts,
+) string {
+	queryParams := map[string]string{
+		"match[]": "kube_pod_container_resource_requests",
+		"start":   fmt.Sprintf("%d", opts.StartRange),
+		"end":     fmt.Sprintf("%d", opts.EndRange),
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/label/__name__/values",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return "kube_pod_container_resource_requests_cpu_cores"
+	}
+
+	rawQueryObj := &promRawValuesQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	if rawQueryObj.Status == "success" && len(rawQueryObj.Data) == 1 {
+		return "kube_pod_container_resource_requests"
+	}
+
+	return "kube_pod_container_resource_requests_cpu_cores"
+}
+
+func getKubeMemoryMetricName(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *QueryOpts,
 ) string {
 	queryParams := map[string]string{
-		"match[]": fmt.Sprintf("kube_horizontal_pod_autoscaler_%s", suffix),
+		"match[]": "kube_pod_container_resource_requests",
 		"start":   fmt.Sprintf("%d", opts.StartRange),
 		"end":     fmt.Sprintf("%d", opts.EndRange),
 	}
@@ -398,7 +491,7 @@ func getKubeHPAMetricName(
 	rawQuery, err := resp.DoRaw(context.TODO())
 
 	if err != nil {
-		return fmt.Sprintf("kube_hpa_%s", suffix)
+		return "kube_pod_container_resource_requests_memory_bytes"
 	}
 
 	rawQueryObj := &promRawValuesQuery{}
@@ -406,8 +499,8 @@ func getKubeHPAMetricName(
 	json.Unmarshal(rawQuery, rawQueryObj)
 
 	if rawQueryObj.Status == "success" && len(rawQueryObj.Data) == 1 {
-		return fmt.Sprintf("kube_horizontal_pod_autoscaler_%s", suffix)
+		return "kube_pod_container_resource_requests"
 	}
 
-	return fmt.Sprintf("kube_hpa_%s", suffix)
+	return "kube_pod_container_resource_requests_memory_bytes"
 }

+ 29 - 0
server/api/api.go

@@ -18,6 +18,7 @@ import (
 
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
@@ -81,6 +82,11 @@ type App struct {
 	// config for capabilities
 	Capabilities *AppCapabilities
 
+	// ChartLookupURLs contains an in-memory store of Porter chart names matched with
+	// a repo URL, so that finding a chart does not involve multiple lookups to our
+	// chart repo's index.yaml file
+	ChartLookupURLs map[string]string
+
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
@@ -238,6 +244,8 @@ func New(conf *AppConfig) (*App, error) {
 	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
 	app.analyticsClient = newSegmentClient
 
+	app.updateChartRepoURLs()
+
 	return app, nil
 }
 
@@ -312,3 +320,24 @@ func (app *App) getTokenFromRequest(r *http.Request) *token.Token {
 
 	return tok
 }
+
+func (app *App) updateChartRepoURLs() {
+	newCharts := make(map[string]string)
+
+	for _, chartRepo := range []string{
+		app.ServerConf.DefaultApplicationHelmRepoURL,
+		app.ServerConf.DefaultAddonHelmRepoURL,
+	} {
+		indexFile, err := loader.LoadRepoIndexPublic(chartRepo)
+
+		if err != nil {
+			continue
+		}
+
+		for chartName, _ := range indexFile.Entries {
+			newCharts[chartName] = chartRepo
+		}
+	}
+
+	app.ChartLookupURLs = newCharts
+}

+ 43 - 14
server/api/release_handler.go

@@ -11,6 +11,7 @@ import (
 
 	"gorm.io/gorm"
 
+	semver "github.com/Masterminds/semver/v3"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
@@ -88,8 +89,6 @@ type PorterRelease struct {
 	ImageRepoURI    string                          `json:"image_repo_uri"`
 }
 
-var porterApplications = map[string]string{"web": "", "job": "", "worker": ""}
-
 // HandleGetRelease retrieves a single release based on a name and revision
 func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
@@ -206,13 +205,26 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 
 	// detect if Porter application chart and attempt to get the latest version
 	// from chart repo
-	if _, found := porterApplications[res.Chart.Metadata.Name]; found {
-		repoIndex, err := loader.LoadRepoIndexPublic(app.ServerConf.DefaultApplicationHelmRepoURL)
+	chartRepoURL, firstFound := app.ChartLookupURLs[res.Chart.Metadata.Name]
+
+	if !firstFound {
+		app.updateChartRepoURLs()
+
+		chartRepoURL, _ = app.ChartLookupURLs[res.Chart.Metadata.Name]
+	}
+
+	if chartRepoURL != "" {
+		repoIndex, err := loader.LoadRepoIndexPublic(chartRepoURL)
 
 		if err == nil {
 			porterChart := loader.FindPorterChartInIndexList(repoIndex, res.Chart.Metadata.Name)
+			res.LatestVersion = res.Chart.Metadata.Version
 
-			if porterChart != nil && len(porterChart.Versions) > 0 {
+			// set latest version to the greater of porterChart.Versions and res.Chart.Metadata.Version
+			porterChartVersion, porterChartErr := semver.NewVersion(porterChart.Versions[0])
+			currChartVersion, currChartErr := semver.NewVersion(res.Chart.Metadata.Version)
+
+			if currChartErr == nil && porterChartErr == nil && porterChartVersion.GreaterThan(currChartVersion) {
 				res.LatestVersion = porterChart.Versions[0]
 			}
 		}
@@ -952,20 +964,22 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		if err != nil {
 			app.sendExternalError(err, http.StatusNotFound, HTTPError{
 				Code:   ErrReleaseReadData,
-				Errors: []string{"release not found"},
+				Errors: []string{"chart version not found"},
 			}, w)
 
 			return
 		}
 
-		if _, found := porterApplications[release.Chart.Metadata.Name]; found {
-			chart, err := loader.LoadChartPublic(
-				app.ServerConf.DefaultApplicationHelmRepoURL,
-				release.Chart.Metadata.Name,
-				form.ChartVersion,
-			)
+		chartRepoURL, foundFirst := app.ChartLookupURLs[release.Chart.Metadata.Name]
 
-			if err != nil {
+		if !foundFirst {
+			app.updateChartRepoURLs()
+
+			var found bool
+
+			chartRepoURL, found = app.ChartLookupURLs[release.Chart.Metadata.Name]
+
+			if !found {
 				app.sendExternalError(err, http.StatusNotFound, HTTPError{
 					Code:   ErrReleaseReadData,
 					Errors: []string{"chart not found"},
@@ -973,9 +987,24 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 				return
 			}
+		}
+
+		chart, err := loader.LoadChartPublic(
+			chartRepoURL,
+			release.Chart.Metadata.Name,
+			form.ChartVersion,
+		)
+
+		if err != nil {
+			app.sendExternalError(err, http.StatusNotFound, HTTPError{
+				Code:   ErrReleaseReadData,
+				Errors: []string{"chart not found"},
+			}, w)
 
-			conf.Chart = chart
+			return
 		}
+
+		conf.Chart = chart
 	}
 
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))

+ 63 - 0
server/api/template_handler.go

@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/helm/upgrade"
 	"github.com/porter-dev/porter/internal/templater/parser"
 
 	"github.com/porter-dev/porter/internal/models"
@@ -101,3 +102,65 @@ func (app *App) HandleReadTemplate(w http.ResponseWriter, r *http.Request) {
 
 	json.NewEncoder(w).Encode(res)
 }
+
+// HandleGetTemplateUpgradeNotes gets the upgrade notes for a template
+func (app *App) HandleGetTemplateUpgradeNotes(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	version := chi.URLParam(r, "version")
+
+	// if version passed as latest, pass empty string to loader to get latest
+	if version == "latest" {
+		version = ""
+	}
+
+	form := &forms.ChartForm{
+		Name:    name,
+		Version: version,
+		RepoURL: app.ServerConf.DefaultApplicationHelmRepoURL,
+	}
+
+	// look for the prev_version in the query params
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	form.PopulateRepoURLFromQueryParams(vals)
+
+	prevVersion := "v0.0.0"
+
+	if prevVersionArr, ok := vals["prev_version"]; ok && len(prevVersionArr) == 1 {
+		prevVersion = prevVersionArr[0]
+	}
+
+	chart, err := loader.LoadChartPublic(form.RepoURL, form.Name, form.Version)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	res := &upgrade.UpgradeFile{}
+
+	for _, file := range chart.Files {
+		if strings.Contains(file.Name, "upgrade.yaml") {
+			upgradeFile, err := upgrade.ParseUpgradeFileFromBytes(file.Data)
+
+			if err != nil {
+				break
+			}
+
+			upgradeFile, err = upgradeFile.GetUpgradeFileBetweenVersions(prevVersion, version)
+
+			if err != nil {
+				break
+			}
+
+			res = upgradeFile
+		}
+	}
+
+	json.NewEncoder(w).Encode(res)
+}

+ 8 - 0
server/router/router.go

@@ -232,6 +232,14 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/templates/upgrade_notes/{name}/{version}",
+				auth.BasicAuthenticate(
+					requestlog.NewHandler(a.HandleGetTemplateUpgradeNotes, l),
+				),
+			)
+
 			// /api/oauth routes
 			r.Method(
 				"GET",