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

Merge branch 'master' of github.com:porter-dev/porter into nico/sidebar-replace-buttons-by-links

jnfrati 4 лет назад
Родитель
Сommit
c5d5c21e4b
44 измененных файлов с 2586 добавлено и 1561 удалено
  1. 33 1
      .github/workflows/dev.yaml
  2. 3 7
      .github/workflows/prerelease.yaml
  3. 33 1
      .github/workflows/production.yaml
  4. 33 1
      .github/workflows/staging.yaml
  5. 7 0
      api/server/handlers/infra/get_state.go
  6. 14 1
      api/server/handlers/namespace/stream_job_runs.go
  7. 2 4
      api/server/shared/config/loader/loader.go
  8. 1 1
      api/server/shared/config/metadata.go
  9. 4 0
      api/types/namespace.go
  10. 222 0
      cli/cmd/bluegreen.go
  11. 18 4
      cli/cmd/create.go
  12. 21 0
      cli/cmd/deploy.go
  13. 32 0
      cli/cmd/deploy/deploy.go
  14. 10 8
      cli/cmd/run.go
  15. 17 0
      cli/cmd/utils/prompt.go
  16. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  17. 341 1075
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  18. 15 23
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  19. 387 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  20. 165 73
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  21. 76 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  22. 8 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  23. 427 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  24. 15 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  25. 0 3
      dashboard/src/main/home/modals/UpgradeChartModal.tsx
  26. 26 21
      dashboard/src/main/home/project-settings/InviteList.tsx
  27. 295 0
      dashboard/src/shared/hooks/useChart.ts
  28. 34 0
      dashboard/src/shared/hooks/useEffectDebugger.ts
  29. 103 0
      dashboard/src/shared/hooks/usePagination.ts
  30. 9 0
      dashboard/src/shared/hooks/usePrevious.ts
  31. 13 3
      dashboard/src/shared/routing.tsx
  32. 7 0
      internal/helm/postrenderer.go
  33. 7 1
      internal/kubernetes/agent.go
  34. 3 2
      internal/oauth/config.go
  35. 7 0
      provisioner/client/get_state.go
  36. 14 5
      provisioner/integrations/redis_stream/global.go
  37. 0 102
      provisioner/integrations/state/s3/s3.go
  38. 0 119
      provisioner/processor/resource.go
  39. 9 0
      provisioner/server/config/config.go
  40. 0 7
      provisioner/server/grpc/store_log.go
  41. 21 0
      provisioner/server/handlers/provision/apply.go
  42. 120 85
      provisioner/server/handlers/state/create_resource.go
  43. 8 0
      provisioner/server/handlers/state/delete_resource.go
  44. 24 0
      provisioner/server/handlers/state/report_error.go

+ 33 - 1
.github/workflows/dev.yaml

@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Set up Cloud SDK
-        uses: google-github-actions/setup-gcloud@master
+        uses: google-github-actions/setup-gcloud@v0
         with:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
@@ -51,3 +51,35 @@ jobs:
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
           kubectl rollout restart deployment/porter
+  deploy-provisioner:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Cloud SDK
+        uses: google-github-actions/setup-gcloud@v0
+        with:
+          project_id: ${{ secrets.GCP_PROJECT_ID }}
+          service_account_key: ${{ secrets.GCP_SA_KEY }}
+          export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
+      - name: Install kubectl
+        uses: azure/setup-kubectl@v1
+      - name: Log in to gcloud CLI
+        run: gcloud auth configure-docker
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/provisioner-service:dev -f ./ee/docker/provisioner.Dockerfile
+      - name: Push
+        run: |
+          docker push gcr.io/porter-dev-273614/provisioner-service:dev
+      - name: Deploy to cluster
+        run: |
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
+            
+          kubectl rollout restart deployment/provisioner

+ 3 - 7
.github/workflows/prerelease.yaml

@@ -179,8 +179,8 @@ jobs:
           p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
       - name: Install gon via HomeBrew for code signing and app notarization
         run: |
-          brew tap mitchellh/gon
-          brew install mitchellh/gon/gon
+          brew tap porter-dev/gon
+          brew install porter-dev/gon/gon
       - name: Create a porter.gon.json file
         run: |
           echo "
@@ -503,11 +503,7 @@ jobs:
           git config user.name "Update Bot"
           git config user.email "support@porter.run"
 
-          git add .
-
-          git diff --quiet --exit-code || git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}"
-
-          git push -f
+          git diff --quiet --exit-code || git add . && git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}" && git push -f
   run-new-release-tests-workflows:
     name: Run new-release-tests Porter workflows
     runs-on: ubuntu-latest

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

@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Set up Cloud SDK
-        uses: google-github-actions/setup-gcloud@master
+        uses: google-github-actions/setup-gcloud@v0
         with:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
@@ -53,3 +53,35 @@ jobs:
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
             
           kubectl rollout restart deployment/porter
+  deploy-provisioner:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Cloud SDK
+        uses: google-github-actions/setup-gcloud@v0
+        with:
+          project_id: ${{ secrets.GCP_PROJECT_ID }}
+          service_account_key: ${{ secrets.GCP_SA_KEY }}
+          export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
+      - name: Install kubectl
+        uses: azure/setup-kubectl@v1
+      - name: Log in to gcloud CLI
+        run: gcloud auth configure-docker
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/provisioner-service:latest -f ./ee/docker/provisioner.Dockerfile
+      - name: Push
+        run: |
+          docker push gcr.io/porter-dev-273614/provisioner-service:latest
+      - name: Deploy to cluster
+        run: |
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
+            
+          kubectl rollout restart deployment/provisioner

+ 33 - 1
.github/workflows/staging.yaml

@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Set up Cloud SDK
-        uses: google-github-actions/setup-gcloud@master
+        uses: google-github-actions/setup-gcloud@v0
         with:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
@@ -52,3 +52,35 @@ jobs:
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
             
           kubectl rollout restart deployment/porter
+  deploy-provisioner:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Cloud SDK
+        uses: google-github-actions/setup-gcloud@v0
+        with:
+          project_id: ${{ secrets.GCP_PROJECT_ID }}
+          service_account_key: ${{ secrets.GCP_SA_KEY }}
+          export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
+      - name: Install kubectl
+        uses: azure/setup-kubectl@v1
+      - name: Log in to gcloud CLI
+        run: gcloud auth configure-docker
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/provisioner-service:staging -f ./ee/docker/provisioner.Dockerfile
+      - name: Push
+        run: |
+          docker push gcr.io/porter-dev-273614/provisioner-service:staging
+      - name: Deploy to cluster
+        run: |
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
+            
+          kubectl rollout restart deployment/provisioner

+ 7 - 0
api/server/handlers/infra/get_state.go

@@ -2,6 +2,7 @@ package infra
 
 import (
 	"context"
+	"errors"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -10,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/provisioner/client"
 )
 
 type InfraGetStateHandler struct {
@@ -33,6 +35,11 @@ func (c *InfraGetStateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	resp, err := c.Config().ProvisionerClient.GetState(context.Background(), proj.ID, infra.ID)
 
 	if err != nil {
+		if errors.Is(err, client.ErrDoesNotExist) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 14 - 1
api/server/handlers/namespace/stream_job_runs.go

@@ -1,6 +1,7 @@
 package namespace
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -36,6 +37,12 @@ func (c *StreamJobRunsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
+	req := &types.StreamJobRunsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
 	agent, err := c.GetAgent(r, cluster, "")
 
 	if err != nil {
@@ -47,7 +54,13 @@ func (c *StreamJobRunsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		namespace = ""
 	}
 
-	err = agent.StreamJobs(namespace, "", safeRW)
+	selectors := ""
+
+	if req.Name != "" {
+		selectors = fmt.Sprintf("meta.helm.sh/release-name=%s", req.Name)
+	}
+
+	err = agent.StreamJobs(namespace, selectors, safeRW)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 2 - 4
api/server/shared/config/loader/loader.go

@@ -204,12 +204,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 
 	provClient, err := getProvisionerServiceClient(sc)
 
-	if err != nil {
-		return nil, err
+	if err == nil && provClient != nil {
+		res.ProvisionerClient = provClient
 	}
 
-	res.ProvisionerClient = provClient
-
 	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, res.Logger)
 
 	if sc.PowerDNSAPIKey != "" && sc.PowerDNSAPIServerURL != "" {

+ 1 - 1
api/server/shared/config/metadata.go

@@ -18,7 +18,7 @@ type Metadata struct {
 
 func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
 	return &Metadata{
-		Provisioning:       sc.ProvisionerServerURL != "",
+		Provisioning:       sc.ProvisionerServerURL != "" && sc.ProvisionerToken != "",
 		Github:             hasGithubAppVars(sc),
 		GithubLogin:        sc.GithubClientID != "" && sc.GithubClientSecret != "" && sc.GithubLoginEnabled,
 		BasicLogin:         sc.BasicLoginEnabled,

+ 4 - 0
api/types/namespace.go

@@ -185,3 +185,7 @@ type GetJobRunsRequest struct {
 	Status string `schema:"status"`
 	Sort   string `schema:"sort"`
 }
+
+type StreamJobRunsRequest struct {
+	Name string `schema:"name"`
+}

+ 222 - 0
cli/cmd/bluegreen.go

@@ -0,0 +1,222 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/spf13/cobra"
+	appsv1 "k8s.io/api/apps/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	intstrutil "k8s.io/apimachinery/pkg/util/intstr"
+)
+
+var deployCmd = &cobra.Command{
+	Use: "deploy",
+}
+
+var bluegreenCmd = &cobra.Command{
+	Use:   "blue-green-switch",
+	Short: "Automatically switches the traffic of a blue-green deployment once the new application is ready.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, bluegreenSwitch)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(deployCmd)
+	deployCmd.AddCommand(bluegreenCmd)
+
+	bluegreenCmd.PersistentFlags().StringVar(
+		&app,
+		"app",
+		"",
+		"Application in the Porter dashboard",
+	)
+
+	bluegreenCmd.MarkPersistentFlagRequired("app")
+
+	bluegreenCmd.PersistentFlags().StringVar(
+		&tag,
+		"tag",
+		"",
+		"The image tag to switch traffic to.",
+	)
+
+	bluegreenCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"",
+		"The namespace of the jobs.",
+	)
+}
+
+func bluegreenSwitch(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	// get the web release
+	webRelease, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, app)
+
+	if err != nil {
+		return err
+	}
+
+	// if this application is not a web chart, throw an error
+	if webRelease.Chart.Name() != "web" {
+		return fmt.Errorf("target application is not a web chart")
+	}
+
+	currActiveImage := deploy.GetCurrActiveBlueGreenImage(webRelease.Config)
+
+	sharedConf := &PorterRunSharedConfig{
+		Client: client,
+	}
+
+	err = sharedConf.setSharedConfig()
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	// if no job exists with the given revision, wait up to 30 minutes
+	timeWait := time.Now().Add(30 * time.Minute)
+	prevRefresh := time.Now()
+
+	success := false
+
+	color.New(color.FgGreen).Printf("Waiting for the new version of the application %s to be ready\n", app)
+
+	for time.Now().Before(timeWait) {
+		// refresh the client every 10 minutes
+		if time.Now().After(prevRefresh.Add(10 * time.Minute)) {
+			err = sharedConf.setSharedConfig()
+
+			if err != nil {
+				return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+			}
+
+			prevRefresh = time.Now()
+		}
+
+		depls, err := sharedConf.Clientset.AppsV1().Deployments(namespace).List(
+			context.Background(),
+			metav1.ListOptions{
+				LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", app),
+			},
+		)
+
+		if err != nil {
+			return fmt.Errorf("could not get deployments: %s", err.Error())
+		}
+
+		foundDeployment := false
+
+		// get the deployment which matches the new image tag
+		for _, depl := range depls.Items {
+			if depl.ObjectMeta.Name == fmt.Sprintf("%s-web-%s", app, tag) || depl.ObjectMeta.Name == fmt.Sprintf("%s-%s", app, tag) {
+				foundDeployment = true
+
+				// determine if the deployment has an appropriate number of ready replicas
+				minUnavailable := *(depl.Spec.Replicas) - getMaxUnavailable(depl)
+
+				// if the number of ready replicas is greater than the number of min unavailable,
+				// the controller is ready for a traffic switch
+				if minUnavailable <= depl.Status.ReadyReplicas {
+					// push the deployment
+					color.New(color.FgGreen).Printf("Switching traffic for app %s\n", app)
+
+					deployAgent, err := updateGetAgent(client)
+
+					if err != nil {
+						return err
+					}
+
+					if currActiveImage == "" {
+						err = deployAgent.UpdateImageAndValues(map[string]interface{}{
+							"bluegreen": map[string]interface{}{
+								"enabled":                  true,
+								"disablePrimaryDeployment": true,
+								"activeImageTag":           tag,
+								"imageTags":                []string{tag},
+							},
+						})
+					} else {
+						err = deployAgent.UpdateImageAndValues(map[string]interface{}{
+							"bluegreen": map[string]interface{}{
+								"enabled":                  true,
+								"disablePrimaryDeployment": true,
+								"activeImageTag":           tag,
+								"imageTags":                []string{currActiveImage, tag},
+							},
+						})
+					}
+
+					if err != nil {
+						return err
+					} else {
+						success = true
+					}
+				}
+			}
+		}
+
+		if !foundDeployment {
+			return fmt.Errorf("target deployment not found. Did you specify the correct tag?")
+		}
+
+		if success {
+			break
+		}
+
+		// otherwise, return no error
+		time.Sleep(2 * time.Second)
+	}
+
+	if !success {
+		return fmt.Errorf("new application was not ready within 30 minutes")
+	}
+
+	// wait 30 seconds before removing old deployment
+	time.Sleep(30 * time.Second)
+
+	deployAgent, err := updateGetAgent(client)
+
+	if err != nil {
+		return err
+	}
+
+	err = deployAgent.UpdateImageAndValues(map[string]interface{}{
+		"bluegreen": map[string]interface{}{
+			"enabled":                  true,
+			"disablePrimaryDeployment": true,
+			"activeImageTag":           tag,
+			"imageTags":                []string{tag},
+		},
+	})
+
+	return nil
+}
+
+func getMaxUnavailable(deployment appsv1.Deployment) int32 {
+	if deployment.Spec.Strategy.Type != appsv1.RollingUpdateDeploymentStrategyType || *(deployment.Spec.Replicas) == 0 {
+		return int32(0)
+	}
+
+	desired := *(deployment.Spec.Replicas)
+	maxUnavailable := deployment.Spec.Strategy.RollingUpdate.MaxUnavailable
+
+	unavailable, err := intstrutil.GetScaledValueFromIntOrPercent(intstrutil.ValueOrDefault(maxUnavailable, intstrutil.FromInt(0)), int(desired), false)
+
+	if err != nil {
+		return 0
+	}
+
+	return int32(unavailable)
+}

+ 18 - 4
cli/cmd/create.go

@@ -12,7 +12,9 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/gitutils"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	"k8s.io/client-go/util/homedir"
 	"sigs.k8s.io/yaml"
 )
 
@@ -175,20 +177,32 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 
 	var err error
 
-	// read the values if necessary
-	valuesObj, err := readValuesFile()
+	fullPath, err := filepath.Abs(localPath)
+
 	if err != nil {
 		return err
 	}
 
-	color.New(color.FgGreen).Printf("Creating %s release: %s\n", args[0], name)
+	if os.Getenv("GITHUB_ACTIONS") == "" && source == "local" && fullPath == homedir.HomeDir() {
+		proceed, err := utils.PromptConfirm("You are deploying your home directory. Do you want to continue?", false)
 
-	fullPath, err := filepath.Abs(localPath)
+		if err != nil {
+			return err
+		}
 
+		if !proceed {
+			return nil
+		}
+	}
+
+	// read the values if necessary
+	valuesObj, err := readValuesFile()
 	if err != nil {
 		return err
 	}
 
+	color.New(color.FgGreen).Printf("Creating %s release: %s\n", args[0], name)
+
 	var buildMethod deploy.DeployBuildType
 
 	if method != "" {

+ 21 - 0
cli/cmd/deploy.go

@@ -3,13 +3,16 @@ package cmd
 import (
 	"fmt"
 	"os"
+	"path/filepath"
 	"strings"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	"k8s.io/client-go/util/homedir"
 )
 
 // updateCmd represents the "porter update" base command when called
@@ -318,6 +321,24 @@ func init() {
 }
 
 func updateFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	fullPath, err := filepath.Abs(localPath)
+
+	if err != nil {
+		return err
+	}
+
+	if os.Getenv("GITHUB_ACTIONS") == "" && source == "local" && fullPath == homedir.HomeDir() {
+		proceed, err := utils.PromptConfirm("You are deploying your home directory. Do you want to continue?", false)
+
+		if err != nil {
+			return err
+		}
+
+		if !proceed {
+			return nil
+		}
+	}
+
 	color.New(color.FgGreen).Println("Deploying app:", app)
 
 	updateAgent, err := updateGetAgent(client)

+ 32 - 0
cli/cmd/deploy/deploy.go

@@ -341,6 +341,19 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 
 	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
 
+	activeBlueGreenTagVal := GetCurrActiveBlueGreenImage(mergedValues)
+
+	// only overwrite if the active tag value is not the same as the target tag. otherwise
+	// this has been modified already and inserted into overrideValues.
+	if activeBlueGreenTagVal != "" && activeBlueGreenTagVal != d.tag {
+		mergedValues["bluegreen"] = map[string]interface{}{
+			"enabled":                  true,
+			"disablePrimaryDeployment": true,
+			"activeImageTag":           activeBlueGreenTagVal,
+			"imageTags":                []string{activeBlueGreenTagVal, d.tag},
+		}
+	}
+
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
@@ -677,3 +690,22 @@ func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]inte
 
 	return res, nil
 }
+
+func GetCurrActiveBlueGreenImage(vals map[string]interface{}) string {
+	if bgInter, ok := vals["bluegreen"]; ok {
+		if bgVal, ok := bgInter.(map[string]interface{}); ok {
+			if enabledInter, ok := bgVal["enabled"]; ok {
+				if enabledVal, ok := enabledInter.(bool); ok && enabledVal {
+					// they're enabled -- read the activeTagValue and construct the new bluegreen object
+					if activeTagInter, ok := bgVal["activeImageTag"]; ok {
+						if activeTagVal, ok := activeTagInter.(string); ok {
+							return activeTagVal
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return ""
+}

+ 10 - 8
cli/cmd/run.go

@@ -332,16 +332,18 @@ func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, er
 	res := make([]podSimple, 0)
 
 	for _, pod := range pods {
-		containerNames := make([]string, 0)
+		if pod.Status.Phase == v1.PodRunning {
+			containerNames := make([]string, 0)
 
-		for _, container := range pod.Spec.Containers {
-			containerNames = append(containerNames, container.Name)
-		}
+			for _, container := range pod.Spec.Containers {
+				containerNames = append(containerNames, container.Name)
+			}
 
-		res = append(res, podSimple{
-			Name:           pod.ObjectMeta.Name,
-			ContainerNames: containerNames,
-		})
+			res = append(res, podSimple{
+				Name:           pod.ObjectMeta.Name,
+				ContainerNames: containerNames,
+			})
+		}
 	}
 
 	return res, nil

+ 17 - 0
cli/cmd/utils/prompt.go

@@ -92,3 +92,20 @@ func PromptMultiselect(prompt string, options []string) ([]string, error) {
 
 	return ans, err
 }
+
+func PromptConfirm(message string, defaultVal bool) (bool, error) {
+	value := false
+
+	prompt := &survey.Confirm{
+		Message: message,
+		Default: defaultVal,
+	}
+
+	err := survey.AskOne(prompt, &value)
+
+	if err != nil {
+		return false, err
+	}
+
+	return value, nil
+}

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

@@ -10,7 +10,7 @@ import {
 } from "shared/types";
 import api from "shared/api";
 import { getQueryParam, pushFiltered } from "shared/routing";
-import ExpandedJobChart from "./ExpandedJobChart";
+import ExpandedJobChart, { ExpandedJobChartFC } from "./ExpandedJobChart";
 import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";
 import PageNotFound from "components/PageNotFound";
@@ -96,7 +96,7 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
       );
     } else if (currentChart && baseRoute === "jobs") {
       return (
-        <ExpandedJobChart
+        <ExpandedJobChartFC
           namespace={namespace}
           currentChart={currentChart}
           currentCluster={this.context.currentCluster}

Разница между файлами не показана из-за своего большого размера
+ 341 - 1075
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx


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

@@ -14,17 +14,16 @@ import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
 import { readableDate } from "shared/string_utils";
 
 type PropsType = WithAuthProps & {
-  showRevisions: boolean;
-  toggleShowRevisions: () => void;
   chart: ChartType;
   refreshChart: () => void;
   setRevision: (x: ChartType, isCurrent?: boolean) => void;
   forceRefreshRevisions: boolean;
   refreshRevisionsOff: () => void;
-  status: string;
   shouldUpdate: boolean;
   upgradeVersion: (version: string, cb: () => void) => void;
   latestVersion: string;
+  showRevisions?: boolean;
+  toggleShowRevisions?: () => void;
 };
 
 type StateType = {
@@ -33,6 +32,7 @@ type StateType = {
   upgradeVersion: string;
   loading: boolean;
   maxVersion: number;
+  expandRevisions: boolean;
 };
 
 // TODO: handle refresh when new revision is generated from an old revision
@@ -43,6 +43,7 @@ class RevisionSection extends Component<PropsType, StateType> {
     upgradeVersion: "",
     loading: false,
     maxVersion: 0, // Track most recent version even when previewing old revisions
+    expandRevisions: false,
   };
 
   refreshHistory = () => {
@@ -191,23 +192,6 @@ class RevisionSection extends Component<PropsType, StateType> {
     }
   };
 
-  renderStatus = (revision: ChartType) => {
-    if (
-      this.props.chart.version === revision.version &&
-      this.props.status == "loading"
-    ) {
-      return (
-        <div>
-          {this.props.status}
-          <LoadingGif src={loading} revision={true} />
-        </div>
-      );
-    } else if (this.props.chart.version === revision.version) {
-      return this.props.status;
-    }
-    return revision.info.status;
-  };
-
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
@@ -263,7 +247,7 @@ class RevisionSection extends Component<PropsType, StateType> {
   };
 
   renderExpanded = () => {
-    if (this.props.showRevisions) {
+    if (this.state.expandRevisions) {
       return (
         <TableWrapper>
           <RevisionsTable>
@@ -324,7 +308,15 @@ class RevisionSection extends Component<PropsType, StateType> {
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           isCurrent={isCurrent}
-          onClick={this.props.toggleShowRevisions}
+          onClick={() => {
+            if (typeof this.props.toggleShowRevisions === "function") {
+              this.props.toggleShowRevisions();
+            }
+            this.setState((prev) => ({
+              ...prev,
+              expandRevisions: !prev.expandRevisions,
+            }));
+          }}
         >
           <RevisionPreview>
             {isCurrent
@@ -354,7 +346,7 @@ class RevisionSection extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledRevisionSection showRevisions={this.props.showRevisions}>
+      <StyledRevisionSection showRevisions={this.state.expandRevisions}>
         {this.renderContents()}
         <ConfirmOverlay
           show={this.state.rollbackRevision && true}

+ 387 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -0,0 +1,387 @@
+import React, { useContext, useEffect, useState } from "react";
+import { get, isEmpty } from "lodash";
+import styled from "styled-components";
+
+import backArrow from "assets/back_arrow.png";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import Loading from "components/Loading";
+import TabRegion from "components/TabRegion";
+import TitleSection from "components/TitleSection";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import DeploymentType from "../DeploymentType";
+import JobMetricsSection from "../metrics/JobMetricsSection";
+import Logs from "../status/Logs";
+import { useRouting } from "shared/routing";
+
+const readableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
+};
+
+const renderStatus = (job: any, time: string) => {
+  if (job.status?.succeeded >= 1) {
+    return <Status color="#38a88a">Succeeded {time}</Status>;
+  }
+
+  if (job.status?.failed >= 1) {
+    return (
+      <Status color="#cc3d42">
+        Failed {time}
+        {job.status.conditions.length > 0 &&
+          `: ${job.status.conditions[0].reason}`}
+      </Status>
+    );
+  }
+
+  return <Status color="#ffffff11">Running</Status>;
+};
+
+const ExpandedJobRun = ({
+  currentChart,
+  jobRun,
+  onClose,
+}: {
+  currentChart: ChartType;
+  jobRun: any;
+  onClose: () => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [currentTab, setCurrentTab] = useState<
+    "logs" | "metrics" | "config" | string
+  >("logs");
+  const [pods, setPods] = useState<any>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const { pushQueryParams } = useRouting();
+
+  let chart = currentChart;
+  let run = jobRun;
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIsLoading(true);
+    api
+      .getJobPods(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: jobRun.metadata?.name,
+          cluster_id: currentCluster.id,
+          namespace: jobRun.metadata?.namespace,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setPods(res.data);
+          setIsLoading(false);
+        }
+      })
+      .catch((err) => setCurrentError(JSON.stringify(err)));
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [jobRun]);
+
+  useEffect(() => {
+    return () => {
+      pushQueryParams({}, ["job"]);
+    };
+  }, []);
+
+  const renderConfigSection = (job: any) => {
+    let commandString = job?.spec?.template?.spec?.containers[0]?.command?.join(
+      " "
+    );
+    let envArray = job?.spec?.template?.spec?.containers[0]?.env;
+    let envObject = {} as any;
+    envArray &&
+      envArray.forEach((env: any, i: number) => {
+        const secretName = get(env, "valueFrom.secretKeyRef.name");
+        envObject[env.name] = secretName
+          ? `PORTERSECRET_${secretName}`
+          : env.value;
+      });
+
+    // Handle no config to show
+    if (!commandString && isEmpty(envObject)) {
+      return <Placeholder>No config was found.</Placeholder>;
+    }
+
+    let tag = job.spec.template.spec.containers[0].image.split(":")[1];
+    return (
+      <ConfigSection>
+        {commandString ? (
+          <>
+            Command: <Command>{commandString}</Command>
+          </>
+        ) : (
+          <DarkMatter size="-18px" />
+        )}
+        <Row>
+          Image Tag: <Command>{tag}</Command>
+        </Row>
+        {!isEmpty(envObject) && (
+          <>
+            <KeyValueArray
+              envLoader={true}
+              values={envObject}
+              label="Environment Variables:"
+              disabled={true}
+            />
+            <DarkMatter />
+          </>
+        )}
+      </ConfigSection>
+    );
+  };
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <StyledExpandedChart>
+      <HeaderWrapper>
+        <BackButton onClick={() => onClose()}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection icon={currentChart.chart.metadata.icon} iconWidth="33px">
+          {chart.name} <Gray>at {readableDate(run.status.startTime)}</Gray>
+        </TitleSection>
+
+        <InfoWrapper>
+          <LastDeployed>
+            {renderStatus(
+              run,
+              run.status.completionTime
+                ? readableDate(run.status.completionTime)
+                : ""
+            )}
+            <TagWrapper>
+              Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
+            </TagWrapper>
+            <DeploymentType currentChart={currentChart} />
+          </LastDeployed>
+        </InfoWrapper>
+      </HeaderWrapper>
+      <BodyWrapper>
+        <TabRegion
+          currentTab={currentTab}
+          setCurrentTab={(x: string) => setCurrentTab(x)}
+          options={[
+            {
+              label: "Logs",
+              value: "logs",
+            },
+            {
+              label: "Metrics",
+              value: "metrics",
+            },
+            {
+              label: "Config",
+              value: "config",
+            },
+          ]}
+        >
+          {currentTab === "logs" && (
+            <JobLogsWrapper>
+              <Logs
+                selectedPod={pods[0]}
+                podError={!pods[0] ? "Pod no longer exists." : ""}
+                rawText={true}
+              />
+            </JobLogsWrapper>
+          )}
+          {currentTab === "config" && <>{renderConfigSection(run)}</>}
+          {currentTab === "metrics" && (
+            <JobMetricsSection jobChart={currentChart} jobRun={run} />
+          )}
+        </TabRegion>
+      </BodyWrapper>
+    </StyledExpandedChart>
+  );
+};
+
+export default ExpandedJobRun;
+
+const Row = styled.div`
+  margin-top: 20px;
+`;
+
+const DarkMatter = styled.div<{ size?: string }>`
+  width: 100%;
+  margin-bottom: ${(props) => props.size || "-13px"};
+`;
+
+const Command = styled.span`
+  font-family: monospace;
+  color: #aaaabb;
+  margin-left: 7px;
+`;
+
+const ConfigSection = styled.div`
+  padding: 20px 30px 30px;
+  font-size: 13px;
+  font-weight: 500;
+  width: 100%;
+  border-radius: 8px;
+  background: #ffffff08;
+`;
+
+const JobLogsWrapper = styled.div`
+  min-height: 450px;
+  height: 55vh;
+  width: 100%;
+  border-radius: 8px;
+  background-color: black;
+  overflow-y: auto;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  background: ${(props) => props.color};
+  font-size: 13px;
+  border-radius: 3px;
+  height: 25px;
+  color: #ffffff;
+  margin-bottom: -3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Gray = styled.div`
+  color: #ffffff44;
+  margin-left: 15px;
+  font-weight: 400;
+  font-size: 18px;
+`;
+
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const Placeholder = styled.div`
+  min-height: 400px;
+  height: 50vh;
+  padding: 30px;
+  padding-bottom: 70px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 24px 0px 17px 0px;
+  height: 20px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 0;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  height: 25px;
+  font-size: 12px;
+  display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 100%;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
+  flex-direction: column;
+  overflow: visible;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 165 - 73
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -1,80 +1,53 @@
-import React, { Component } from "react";
+import React, { useContext, useState } from "react";
 import styled from "styled-components";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
 import JobResource from "./JobResource";
-import ConfirmOverlay from "components/ConfirmOverlay";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import useAuth from "shared/auth/useAuth";
+import usePagination from "shared/hooks/usePagination";
+import Selector from "components/Selector";
 
-type PropsType = WithAuthProps & {
+type PropsType = {
   jobs: any[];
   setJobs: (job: any) => void;
   expandJob: any;
+  currentChartVersion: number;
+  latestChartVersion: number;
+  isDeployedFromGithub: boolean;
+  repositoryUrl?: string;
 };
 
-type StateType = {
-  deletionCandidate: any;
-  deletionJob: any;
-};
+const JobListFC = (props: PropsType): JSX.Element => {
+  const [isAuthorized] = useAuth();
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentOverlay,
+    setCurrentError,
+  } = useContext(Context);
+  const [deletionCandidate, setDeletionCandidate] = useState(null);
+  const [deletionJob, setDeletionJob] = useState(null);
 
-class JobList extends Component<PropsType, StateType> {
-  state = {
-    deletionCandidate: null as any,
-    deletionJob: null as any,
-  };
-
-  renderJobList = () => {
-    if (this.props.jobs.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i>
-          There are no jobs currently running.
-        </Placeholder>
-      );
-    } else {
-      return (
-        <>
-          {this.props.jobs.map((job: any, i: number) => {
-            return (
-              <JobResource
-                key={job?.metadata?.name}
-                expandJob={this.props.expandJob}
-                job={job}
-                handleDelete={() => {
-                  this.setState({ deletionCandidate: job });
-                  this.context.setCurrentOverlay({
-                    message: `Are you sure you want to delete this job run?`,
-                    onYes: this.deleteJob,
-                    onNo: () => {
-                      this.setState({ deletionCandidate: null });
-                      this.context.setCurrentOverlay(null);
-                    },
-                  });
-                }}
-                deleting={
-                  this.state.deletionJob?.metadata?.name == job.metadata?.name
-                }
-                readOnly={
-                  !this.props.isAuthorized("job", "", [
-                    "get",
-                    "update",
-                    "delete",
-                  ])
-                }
-              />
-            );
-          })}
-        </>
-      );
-    }
-  };
-
-  deleteJob = () => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let job = this.state.deletionCandidate;
-    this.context.setCurrentOverlay(null);
+  const {
+    firstContentIndex,
+    lastContentIndex,
+    nextPage,
+    page,
+    prevPage,
+    totalPages,
+    pageSize,
+    setPageSize,
+    canNextPage,
+    canPreviousPage,
+  } = usePagination({
+    count: props.jobs?.length,
+    initialPageSize: 30,
+  });
 
+  const deleteJob = () => {
+    let job = deletionCandidate;
+    setCurrentOverlay(null);
     api
       .deleteJob(
         "<token>",
@@ -87,10 +60,8 @@ class JobList extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        this.setState({
-          deletionJob: this.state.deletionCandidate,
-          deletionCandidate: null,
-        });
+        setDeletionJob(deletionCandidate);
+        setDeletionCandidate(null);
       })
       .catch((err) => {
         let parsedErr = err?.response?.data?.error;
@@ -101,14 +72,135 @@ class JobList extends Component<PropsType, StateType> {
       });
   };
 
-  render() {
-    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
+  if (!props.jobs?.length) {
+    return (
+      <JobListWrapper>
+        <Placeholder>
+          <i className="material-icons">category</i>
+          There are no jobs currently running.
+        </Placeholder>
+      </JobListWrapper>
+    );
+  }
+
+  return (
+    <>
+      <JobListWrapper>
+        {props.jobs
+          .slice(firstContentIndex, lastContentIndex)
+          .map((job: any, i: number) => {
+            return (
+              <JobResource
+                key={job?.metadata?.name}
+                expandJob={props.expandJob}
+                job={job}
+                handleDelete={() => {
+                  setDeletionCandidate(job);
+                  setCurrentOverlay({
+                    message: "Are you sure you want to delete this job run?",
+                    onYes: deleteJob,
+                    onNo: () => {
+                      setDeletionCandidate(null);
+                      setCurrentOverlay(null);
+                    },
+                  });
+                }}
+                deleting={deletionJob?.metadata?.name == job.metadata?.name}
+                readOnly={!isAuthorized("job", "", ["get", "update", "delete"])}
+                isDeployedFromGithub={props.isDeployedFromGithub}
+                repositoryUrl={props.repositoryUrl}
+                currentChartVersion={props.currentChartVersion}
+                latestChartVersion={props.latestChartVersion}
+              />
+            );
+          })}
+      </JobListWrapper>
+      <FlexEnd style={{ marginTop: "15px" }}>
+        {/* Disable the page count selector until find a fix for their styles */}
+        {/* <PageCountWrapper>
+          Page size:
+          <Selector
+            activeValue={String(pageSize)}
+            options={[
+              {
+                label: "10",
+                value: "10",
+              },
+              {
+                label: "20",
+                value: "20",
+              },
+              {
+                label: "50",
+                value: "50",
+              },
+              {
+                label: "100",
+                value: "100",
+              },
+            ]}
+            setActiveValue={(val) => setPageSize(Number(val))}
+            width="70px"
+          ></Selector>
+        </PageCountWrapper> */}
+        <PaginationActionsWrapper>
+          <PaginationAction disabled={!canPreviousPage} onClick={prevPage}>
+            {"<"}
+          </PaginationAction>
+          <PageCounter>
+            Page {page} of {totalPages}
+          </PageCounter>
+          <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+            {">"}
+          </PaginationAction>
+        </PaginationActionsWrapper>
+      </FlexEnd>
+    </>
+  );
+};
+
+export default JobListFC;
+
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
   }
-}
 
-JobList.contextType = Context;
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
 
-export default withAuth(JobList);
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
 
 const Placeholder = styled.div`
   width: 100%;

+ 76 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -8,6 +8,7 @@ import Logs from "../status/Logs";
 import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
 import KeyValueArray from "components/form-components/KeyValueArray";
+import DynamicLink from "components/DynamicLink";
 import { readableDate } from "shared/string_utils";
 import CommandLineIcon from "assets/command-line-icon";
 import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
@@ -18,6 +19,10 @@ type PropsType = {
   deleting: boolean;
   readOnly?: boolean;
   expandJob: any;
+  currentChartVersion: number;
+  latestChartVersion: number;
+  isDeployedFromGithub: boolean;
+  repositoryUrl?: string;
 };
 
 type StateType = {
@@ -256,6 +261,40 @@ export default class JobResource extends Component<PropsType, StateType> {
     }
   };
 
+  getImageTag = () => {
+    const container = this.props.job?.spec?.template?.spec?.containers[0];
+    const tag = container?.image?.split(":")[1];
+
+    if (!tag) {
+      return "unknown";
+    }
+
+    if (this.props.isDeployedFromGithub && tag !== "latest") {
+      return (
+        <DynamicLink
+          to={`https://github.com/${this.props.repositoryUrl}/commit/${tag}`}
+          onClick={(e) => e.preventDefault()}
+          target="_blank"
+        ></DynamicLink>
+      );
+    }
+
+    return tag;
+  };
+
+  getRevisionNumber = () => {
+    const revision = this.props.job?.metadata?.labels["helm.sh/revision"];
+    let status: RevisionContainerProps["status"] = "current";
+    if (this.props.currentChartVersion > revision) {
+      status = "outdated";
+    }
+    return (
+      <RevisionContainer status={status}>
+        Revision No - {revision || "unknown"}
+      </RevisionContainer>
+    );
+  };
+
   render() {
     let icon =
       "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png";
@@ -272,12 +311,23 @@ export default class JobResource extends Component<PropsType, StateType> {
               <Description>
                 <Label>
                   Started at {readableDate(this.props.job.status?.startTime)}
+                  <Dot>•</Dot>
+                  <span>
+                    {this.props.isDeployedFromGithub
+                      ? "Commit: "
+                      : "Image tag:"}{" "}
+                    {this.getImageTag()}
+                  </span>
                 </Label>
                 <Subtitle>{this.getSubtitle()}</Subtitle>
               </Description>
             </Flex>
             <EndWrapper>
-              <CommandString>{commandString}</CommandString>
+              <Flex>
+                {this.getRevisionNumber()}
+                <CommandString>{commandString}</CommandString>
+              </Flex>
+
               {this.renderStatus()}
               <MaterialIconTray disabled={false}>
                 {this.renderStopButton()}
@@ -310,6 +360,26 @@ export default class JobResource extends Component<PropsType, StateType> {
 
 JobResource.contextType = Context;
 
+type RevisionContainerProps = {
+  status: "outdated" | "current";
+};
+
+const RevisionContainer = styled.span<RevisionContainerProps>`
+  margin-right: 15px;
+  ${({ status }) => {
+    if (status === "outdated") {
+      return "color: rgb(245, 203, 66);";
+    }
+    return "";
+  }}
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  margin-left: 9px;
+  color: #ffffff88;
+`;
+
 const Row = styled.div`
   margin-top: 20px;
 `;
@@ -357,7 +427,7 @@ const CommandString = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  max-width: 300px;
+  max-width: 200px;
   color: #ffffff55;
   margin-right: 27px;
   font-family: monospace;
@@ -458,6 +528,10 @@ const Label = styled.div`
   color: #ffffff;
   font-size: 13px;
   font-weight: 500;
+  display: flex;
+  > span {
+    color: #ffffff88;
+  }
 `;
 
 const Subtitle = styled.div`

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx

@@ -15,6 +15,10 @@ interface Props {
   jobs: any;
   handleSaveValues: any;
   expandJob: any;
+  currentChartVersion: number;
+  latestChartVersion: number;
+  isDeployedFromGithub: boolean;
+  repositoryUrl?: string;
   chartName: string;
   isLoading: boolean;
 }
@@ -69,6 +73,10 @@ const TempJobList: React.FC<Props> = (props) => {
         jobs={props.jobs}
         setJobs={props.setJobs}
         expandJob={props.expandJob}
+        isDeployedFromGithub={props.isDeployedFromGithub}
+        repositoryUrl={props.repositoryUrl}
+        currentChartVersion={props.currentChartVersion}
+        latestChartVersion={props.latestChartVersion}
       />
       <ConnectToJobInstructionsModal
         show={showConnectionModal}

+ 427 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -0,0 +1,427 @@
+import { set } from "lodash";
+import { useContext, useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import { ChartType } from "shared/types";
+import yaml from "js-yaml";
+import { usePrevious } from "shared/hooks/usePrevious";
+import { useRouting } from "shared/routing";
+
+const PORTER_IMAGE_TEMPLATES = [
+  "porterdev/hello-porter-job",
+  "porterdev/hello-porter-job:latest",
+  "public.ecr.aws/o1j4x7p4/hello-porter-job",
+  "public.ecr.aws/o1j4x7p4/hello-porter-job:latest",
+];
+
+export const useJobs = (chart: ChartType) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [jobs, setJobs] = useState([]);
+  const jobsRef = useRef([]);
+  const lastStreamStatus = useRef("");
+  const [hasError, setHasError] = useState(false);
+  const [hasPorterImageTemplate, setHasPorterImageTemplate] = useState(true);
+  const [selectedJob, setSelectedJob] = useState(null);
+  const [status, setStatus] = useState<"loading" | "ready">("loading");
+  const [triggerRunStatus, setTriggerRunStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const previousChart = usePrevious(chart, null);
+
+  const { pushQueryParams, getQueryParam } = useRouting();
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const sortJobsAndSave = (newJobs: any[]) => {
+    // Set job run from URL if needed
+    const urlParams = new URLSearchParams(location.search);
+
+    const getTime = (job: any) => {
+      return new Date(job?.status?.startTime).getTime();
+    };
+
+    newJobs.sort((job1, job2) => getTime(job2) - getTime(job1));
+
+    let latestImageDetected =
+      newJobs[0]?.spec?.template?.spec?.containers[0]?.image;
+    if (!PORTER_IMAGE_TEMPLATES.includes(latestImageDetected)) {
+      // this.setState({ jobs, newestImage, imageIsPlaceholder: false });
+      setHasPorterImageTemplate(false);
+    }
+    jobsRef.current = newJobs;
+    setJobs(newJobs);
+  };
+
+  const addJob = (newJob: any) => {
+    let newJobs = [...jobsRef.current];
+    const existingJobIndex = newJobs.findIndex((currentJob) => {
+      return (
+        currentJob.metadata?.name === newJob.metadata?.name &&
+        currentJob.metadata?.namespace === newJob.metadata?.namespace
+      );
+    });
+
+    if (existingJobIndex > -1) {
+      return;
+    }
+
+    newJobs.push(newJob);
+    sortJobsAndSave(newJobs);
+  };
+
+  const mergeNewJob = (newJob: any) => {
+    let newJobs = [...jobsRef.current];
+    const existingJobIndex = newJobs.findIndex((currentJob) => {
+      return (
+        currentJob.metadata?.name === newJob.metadata?.name &&
+        currentJob.metadata?.namespace === newJob.metadata?.namespace
+      );
+    });
+
+    if (existingJobIndex > -1) {
+      newJobs.splice(existingJobIndex, 1, newJob);
+    } else {
+      newJobs.push(newJob);
+    }
+    sortJobsAndSave(newJobs);
+  };
+
+  const removeJob = (deletedJob: any) => {
+    let newJobs = jobsRef.current.filter((job: any) => {
+      return deletedJob.metadata?.name !== job.metadata?.name;
+    });
+
+    sortJobsAndSave([...newJobs]);
+  };
+
+  const setupCronJobWebsocket = () => {
+    const releaseName = chart.name;
+    const releaseNamespace = chart.namespace;
+    if (!releaseName || !releaseNamespace) {
+      return;
+    }
+
+    const websocketId = `cronjob-websocket-${releaseName}`;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/cronjob/status`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (evt: MessageEvent) => {
+        const event = JSON.parse(evt.data);
+        const object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setHasPorterImageTemplate((prevValue) => {
+          // if imageIsPlaceholder is true update the newestImage and imageIsPlaceholder fields
+
+          if (event.event_type !== "ADD" && event.event_type !== "UPDATE") {
+            return prevValue;
+          }
+
+          if (!hasPorterImageTemplate) {
+            return prevValue;
+          }
+
+          if (!event.Object?.metadata?.annotations) {
+            return prevValue;
+          }
+
+          // filter job belonging to chart
+          const relNameAnnotation =
+            event.Object?.metadata?.annotations["meta.helm.sh/release-name"];
+          const relNamespaceAnnotation =
+            event.Object?.metadata?.annotations[
+              "meta.helm.sh/release-namespace"
+            ];
+
+          if (
+            releaseName !== relNameAnnotation ||
+            releaseNamespace !== relNamespaceAnnotation
+          ) {
+            return prevValue;
+          }
+
+          const newestImage =
+            event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
+              ?.image;
+
+          if (!PORTER_IMAGE_TEMPLATES.includes(newestImage)) {
+            return false;
+          }
+
+          return true;
+        });
+      },
+      onclose: console.log,
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketId);
+      },
+    };
+
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+  };
+
+  const setupJobWebsocket = () => {
+    const chartVersion = `${chart?.chart?.metadata?.name}-${chart?.chart?.metadata?.version}`;
+
+    const websocketId = `job-websocket-${chart.name}`;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/job/status`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (evt: MessageEvent) => {
+        const event = JSON.parse(evt.data);
+
+        const chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
+        const releaseLabel =
+          event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+
+        if (chartLabel !== chartVersion || releaseLabel !== chart.name) {
+          return;
+        }
+
+        if (event.event_type === "ADD") {
+          addJob(event.Object);
+          return;
+        }
+
+        // if event type is add or update, merge with existing jobs
+        if (event.event_type === "UPDATE") {
+          mergeNewJob(event.Object);
+          return;
+        }
+
+        if (event.event_type === "DELETE") {
+          // filter job belonging to chart
+          removeJob(event.Object);
+        }
+      },
+      onclose: console.log,
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketId);
+      },
+    };
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+  };
+
+  const loadJobFromurl = () => {
+    const jobName = getQueryParam("job");
+
+    const job: any = jobs.find((tmpJob) => tmpJob.metadata.name === jobName);
+
+    if (!job) {
+      return;
+    }
+
+    setSelectedJob(job);
+  };
+
+  // useEffect(() => {
+  //   let isSubscribed = true;
+
+  //   if (!chart) {
+  //     return () => {
+  //       isSubscribed = false;
+  //     };
+  //   }
+
+  //   if (
+  //     previousChart?.name === chart?.name &&
+  //     previousChart?.namespace === chart?.namespace
+  //   ) {
+  //     return () => {
+  //       isSubscribed = false;
+  //     };
+  //   }
+
+  //   setStatus("loading");
+  //   const newestImage = chart?.config?.image?.repository;
+
+  //   setHasPorterImageTemplate(PORTER_IMAGE_TEMPLATES.includes(newestImage));
+
+  //   api
+  //     .getJobs(
+  //       "<token>",
+  //       {},
+  //       {
+  //         id: currentProject?.id,
+  //         cluster_id: currentCluster?.id,
+  //         namespace: chart.namespace,
+  //         release_name: chart.name,
+  //       }
+  //     )
+  //     .then((res) => {
+  //       if (isSubscribed) {
+  //         sortJobsAndSave(res.data);
+  //         setStatus("ready");
+  //       }
+  //     });
+  //   return () => {
+  //     isSubscribed = false;
+  //   };
+  // }, [chart]);
+
+  useEffect(() => {
+    if (!chart || !chart.namespace || !chart.name) {
+      return () => {};
+    }
+
+    if (
+      previousChart?.name === chart?.name &&
+      previousChart?.namespace === chart?.namespace
+    ) {
+      return () => {};
+    }
+
+    setStatus("loading");
+    const newestImage = chart?.config?.image?.repository;
+
+    setHasPorterImageTemplate(PORTER_IMAGE_TEMPLATES.includes(newestImage));
+
+    const namespace = chart.namespace;
+    const release_name = chart.name;
+
+    closeAllWebsockets();
+    jobsRef.current = [];
+    lastStreamStatus.current = "";
+    setJobs([]);
+
+    const websocketId = `job-runs-websocket-${release_name}-${namespace}`;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/jobs/stream?name=${release_name}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (message) => {
+        const data = JSON.parse(message.data);
+
+        if (data.streamStatus === "finished") {
+          setHasError(false);
+          setStatus("ready");
+          sortJobsAndSave(jobsRef.current);
+          lastStreamStatus.current = data.streamStatus;
+          setupJobWebsocket();
+          setupCronJobWebsocket();
+          return;
+        }
+
+        if (data.streamStatus === "errored") {
+          setHasError(true);
+          jobsRef.current = [];
+          setJobs([]);
+          setStatus("ready");
+          return;
+        }
+
+        jobsRef.current = [...jobsRef.current, data];
+      },
+      onclose: (event) => {
+        console.log(event);
+        closeWebsocket(websocketId);
+      },
+      onerror: (error) => {
+        setHasError(true);
+        setStatus("ready");
+        console.log(error);
+        closeWebsocket(websocketId);
+      },
+    };
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+  }, [chart]);
+
+  useEffect(() => {
+    if (!jobs.length) {
+      return;
+    }
+
+    loadJobFromurl();
+  }, [jobs]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  const runJob = () => {
+    setTriggerRunStatus("loading");
+    const config = chart.config;
+    const values = {};
+
+    for (let key in config) {
+      set(values, key, config[key]);
+    }
+
+    set(values, "paused", false);
+
+    const yamlValues = yaml.dump(
+      {
+        ...values,
+      },
+      { forceQuotes: true }
+    );
+
+    api
+      .upgradeChartValues(
+        "<token>",
+        {
+          values: yamlValues,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        setTriggerRunStatus("successful");
+        setTimeout(() => setTriggerRunStatus(""), 500);
+      })
+      .catch((err) => {
+        let parsedErr = err?.response?.data?.error;
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        // this.setState({
+        //   saveValuesStatus: parsedErr,
+        // });
+        setTriggerRunStatus("Couldn't trigger a new run for this job.");
+        setTimeout(() => setTriggerRunStatus(""), 500);
+        setCurrentError(parsedErr);
+      });
+  };
+
+  const handleSetSelectedJob = (job: any) => {
+    setSelectedJob(job);
+    pushQueryParams({ job: job?.metadata?.name });
+  };
+
+  return {
+    jobs,
+    hasPorterImageTemplate,
+    status,
+    triggerRunStatus,
+    runJob,
+    selectedJob,
+    setSelectedJob: handleSetSelectedJob,
+  };
+};

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

@@ -402,9 +402,7 @@ const useLogs = (
   useEffect(() => {
     console.log("Selected pod updated");
     if (currentPod?.metadata?.name === currentPodName.current) {
-      return () => {
-        closeAllWebsockets();
-      };
+      return () => {};
     }
     currentPodName.current = currentPod?.metadata?.name;
     const currentContainers =
@@ -412,22 +410,19 @@ const useLogs = (
 
     setContainers(currentContainers);
     setCurrentContainer(currentContainers[0]);
-    return () => {
-      closeAllWebsockets();
-    };
   }, [currentPod]);
 
   // Retrieve all previous logs for containers
   useEffect(() => {
+    if (!Array.isArray(containers)) {
+      return;
+    }
+
     closeAllWebsockets();
 
     setPrevLogs({});
     setLogs({});
 
-    if (!Array.isArray(containers)) {
-      return;
-    }
-
     getSystemLogs();
     containers.forEach((containerName) => {
       const websocketKey = `${currentPodName.current}-${containerName}-websocket`;
@@ -438,8 +433,18 @@ const useLogs = (
         setupWebsocket(containerName, websocketKey);
       }
     });
+
+    return () => {
+      closeAllWebsockets();
+    };
   }, [containers]);
 
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
   const currentLogs = useMemo(() => {
     return logs[currentContainer] || [];
   }, [currentContainer, logs]);

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

@@ -84,9 +84,6 @@ ${note.note}
   render() {
     return (
       <StyledUpgradeChartModal>
-        <CloseButton onClick={this.props.closeModal}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
         {this.renderContent()}
         <SaveButton
           disabled={false}

+ 26 - 21
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -44,7 +44,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
   useEffect(() => {
     api
-      .getAvailableRoles("<token>", {}, { project_id: currentProject.id })
+      .getAvailableRoles("<token>", {}, { project_id: currentProject?.id })
       .then(({ data }: { data: string[] }) => {
         const availableRoleList = data?.map((role) => ({
           value: role,
@@ -69,7 +69,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         "<token>",
         {},
         {
-          id: currentProject.id,
+          id: currentProject?.id,
         }
       );
       invites = response.data.filter((i: InviteType) => !i.accepted);
@@ -82,7 +82,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         "<token>",
         {},
         {
-          project_id: currentProject.id,
+          project_id: currentProject?.id,
         }
       );
       collaborators = parseCollaboratorsResponse(response.data);
@@ -96,24 +96,25 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const parseCollaboratorsResponse = (
     collaborators: Array<Collaborator>
   ): Array<InviteType> => {
-    return (
-      collaborators
-        // Parse role id to number
-        .map((c) => ({ ...c, id: Number(c.id) }))
-        // Sort them so the owner will be first allways
-        .sort((curr, prev) => curr.id - prev.id)
-        // Remove the owner from list
-        .slice(1)
-        // Parse the remainings to InviteType
-        .map((c) => ({
-          email: c.email,
-          expired: false,
-          id: Number(c.user_id),
-          kind: c.kind,
-          accepted: true,
-          token: "",
-        }))
-    );
+    const admins = collaborators
+      .filter((c) => c.kind === "admin")
+      .map((c) => ({ ...c, id: Number(c.id) }))
+      .sort((curr, prev) => curr.id - prev.id)
+      .slice(1);
+
+    const nonAdmins = collaborators
+      .filter((c) => c.kind !== "admin")
+      .map((c) => ({ ...c, id: Number(c.id) }))
+      .sort((curr, prev) => curr.id - prev.id);
+
+    return [...admins, ...nonAdmins].map((c) => ({
+      email: c.email,
+      expired: false,
+      id: Number(c.user_id),
+      kind: c.kind,
+      accepted: true,
+      token: "",
+    }));
   };
 
   const createInvite = () => {
@@ -333,6 +334,10 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     }/invites/${token}
     `;
 
+    if (!user) {
+      return [];
+    }
+
     const mappedInviteList = inviteList.map(
       ({ accepted, expired, token, ...rest }) => {
         const currentUser: boolean = user.email === rest.email;

+ 295 - 0
dashboard/src/shared/hooks/useChart.ts

@@ -0,0 +1,295 @@
+import yaml from "js-yaml";
+import { useContext, useEffect, useState } from "react";
+import { useRouteMatch } from "react-router";
+import api from "shared/api";
+import { onlyInLeft } from "shared/array_utils";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ChartType, ChartTypeWithExtendedConfig } from "shared/types";
+
+export const useChart = (oldChart: ChartType, closeChart: () => void) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [chart, setChart] = useState<ChartTypeWithExtendedConfig>(null);
+  const { url: matchUrl } = useRouteMatch();
+
+  const [status, setStatus] = useState<"ready" | "loading" | "deleting">(
+    "loading"
+  );
+
+  const [saveStatus, setSaveStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const { pushFiltered, getQueryParam, pushQueryParams } = useRouting();
+
+  useEffect(() => {
+    const { namespace, name: chartName } = oldChart;
+    setStatus("loading");
+
+    const revision = getQueryParam("chart_revision");
+
+    api
+      .getChart<ChartTypeWithExtendedConfig>(
+        "token",
+        {},
+        {
+          id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+          namespace,
+          name: chartName,
+          revision: Number(revision) ? Number(revision) : 0,
+        }
+      )
+      .then((res) => {
+        if (res?.data) {
+          setChart(res.data);
+        }
+      })
+      .finally(() => {
+        setStatus("ready");
+      });
+  }, [oldChart, currentCluster, currentProject]);
+
+  /**
+   * Upgrade chart version
+   */
+  const upgradeChart = async () => {
+    // convert current values to yaml
+    let valuesYaml = yaml.dump({
+      ...(chart.config as Object),
+    });
+
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+          version: chart.latest_version,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      window.analytics.track("Chart Upgraded", {
+        chart: chart.name,
+        values: valuesYaml,
+      });
+    } catch (err) {
+      let parsedErr = err?.response?.data?.error;
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+      setCurrentError(parsedErr);
+
+      window.analytics.track("Failed to Upgrade Chart", {
+        chart: chart.name,
+        values: valuesYaml,
+        error: err,
+      });
+    }
+  };
+
+  /**
+   * Delete/Uninstall chart
+   */
+  const deleteChart = async () => {
+    try {
+      await api.uninstallTemplate(
+        "<token>",
+        {},
+        {
+          namespace: chart.namespace,
+          name: chart.name,
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      setStatus("ready");
+      closeChart();
+      return;
+    } catch (error) {
+      console.log(error);
+      throw new Error("Couldn't uninstall the chart");
+    }
+  };
+
+  /**
+   * Update chart values
+   */
+  const updateChart = async (
+    processValues:
+      | ((chart: ChartType) => string)
+      | ((chart: ChartType, oldChart?: ChartType) => string)
+  ) => {
+    setSaveStatus("loading");
+    const values = processValues(chart, oldChart);
+
+    const oldSyncedEnvGroups = oldChart.config?.container?.env?.synced || [];
+    const newSyncedEnvGroups = chart.config?.container?.env?.synced || [];
+
+    const deletedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      oldSyncedEnvGroups,
+      newSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      newSyncedEnvGroups,
+      oldSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addApplicationToEnvGroupPromises = addedEnvGroups.map(
+      (envGroup: any) => {
+        return api.addApplicationToEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: chart.namespace,
+          }
+        );
+      }
+    );
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't sync the env group to the application, please try again."
+      );
+    }
+
+    const removeApplicationToEnvGroupPromises = deletedEnvGroups.map(
+      (envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: chart.namespace,
+          }
+        );
+      }
+    );
+    try {
+      await Promise.all(removeApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't remove the synced env group from the application, please try again."
+      );
+    }
+
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      setSaveStatus("successful");
+      setTimeout(() => setSaveStatus(""), 500);
+    } catch (err) {
+      let parsedErr = err?.response?.data?.error;
+
+      if (!parsedErr) {
+        parsedErr = err;
+      }
+      setCurrentError(parsedErr);
+      setSaveStatus("Couldn't process the request.");
+      // throw new Error(parsedErr);
+    }
+  };
+
+  /**
+   * Refresh the chart data
+   */
+  const refreshChart = async () => {
+    try {
+      const newChart = await api
+        .getChart(
+          "<token>",
+          {},
+          {
+            name: chart.name,
+            revision: 0,
+            namespace: chart.namespace,
+            cluster_id: currentCluster.id,
+            id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      pushQueryParams({
+        chart_version: newChart.version,
+      });
+
+      setChart(newChart);
+    } catch (error) {}
+  };
+
+  const loadChartWithSpecificRevision = async (revision: number) => {
+    try {
+      const newChart = await api
+        .getChart(
+          "<token>",
+          {},
+          {
+            name: chart.name,
+            revision: revision,
+            namespace: chart.namespace,
+            cluster_id: currentCluster.id,
+            id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      pushQueryParams({
+        chart_revision: newChart.version,
+      });
+
+      setChart(newChart);
+    } catch (error) {}
+  };
+
+  return {
+    chart,
+    status,
+    saveStatus,
+    upgradeChart,
+    deleteChart,
+    updateChart,
+    refreshChart,
+    loadChartWithSpecificRevision,
+  };
+};

+ 34 - 0
dashboard/src/shared/hooks/useEffectDebugger.ts

@@ -0,0 +1,34 @@
+import { useEffect } from "react";
+import { usePrevious } from "./usePrevious";
+
+export const useEffectDebugger = (
+  effectHook: any,
+  dependencies: any,
+  dependencyNames: any = []
+) => {
+  const previousDeps = usePrevious(dependencies, []);
+
+  const changedDeps = dependencies.reduce(
+    (accum: any, dependency: any, index: any) => {
+      if (dependency !== previousDeps[index]) {
+        const keyName = dependencyNames[index] || index;
+        return {
+          ...accum,
+          [keyName]: {
+            before: previousDeps[index],
+            after: dependency,
+          },
+        };
+      }
+
+      return accum;
+    },
+    {}
+  );
+
+  if (Object.keys(changedDeps).length) {
+    console.log("[use-effect-debugger] ", changedDeps);
+  }
+
+  useEffect(effectHook, dependencies);
+};

+ 103 - 0
dashboard/src/shared/hooks/usePagination.ts

@@ -0,0 +1,103 @@
+/**
+ * Improved version using as base the usePagination hook by gh user @damiisdandy
+ * Base hook on his repo https://github.com/damiisdandy/use-pagination
+ */
+
+import { useState } from "react";
+
+interface UsePaginationProps {
+  count: number;
+  initialPageSize?: number;
+}
+
+interface UsePaginationReturn {
+  page: number;
+  totalPages: number;
+  setPage: (page: number) => void;
+  nextPage: () => void;
+  prevPage: () => void;
+  firstContentIndex: number;
+  lastContentIndex: number;
+  pageSize: number;
+  setPageSize: (pageSize: number) => void;
+  canNextPage: boolean;
+  canPreviousPage: boolean;
+}
+
+type UsePagination = (props: UsePaginationProps) => UsePaginationReturn;
+
+const usePagination: UsePagination = ({ count, initialPageSize }) => {
+  const [pageSize, setPageSize] = useState(() => {
+    if (typeof initialPageSize === "number" && initialPageSize !== NaN) {
+      return initialPageSize;
+    }
+
+    return 10;
+  });
+
+  const [page, setPage] = useState(1);
+  // number of pages in total (total items / content on each page)
+  const pageCount = Math.ceil(count / pageSize);
+  // index of last item of current page
+  const lastContentIndex = page * pageSize;
+  // index of first item of current page
+  const firstContentIndex = lastContentIndex - pageSize;
+
+  // change page based on direction either front or back
+  const changePage = (direction: boolean) => {
+    setPage((state) => {
+      // move forward
+      if (direction) {
+        // if page is the last page, do nothing
+        if (state === pageCount) {
+          return state;
+        }
+        return state + 1;
+        // go back
+      } else {
+        // if page is the first page, do nothing
+        if (state === 1) {
+          return state;
+        }
+        return state - 1;
+      }
+    });
+  };
+
+  const setPageSAFE = (num: number) => {
+    // if number is greater than number of pages, set to last page
+    if (num > pageCount) {
+      setPage(pageCount);
+      // if number is less than 1, set page to first page
+    } else if (num < 1) {
+      setPage(1);
+    } else {
+      setPage(num);
+    }
+  };
+
+  const setPageSizeSAFE = (pageSize: number) => {
+    if (typeof initialPageSize === "number" && initialPageSize !== NaN) {
+      setPageSize(pageSize);
+    }
+  };
+
+  const canNextPage = page <= pageCount - 1;
+  const canPreviousPage = page > 1;
+
+  return {
+    totalPages: pageCount,
+    nextPage: () => changePage(true),
+    prevPage: () => changePage(false),
+    setPage: setPageSAFE,
+    firstContentIndex,
+    lastContentIndex,
+    page,
+    pageSize,
+    setPageSize: setPageSizeSAFE,
+    canNextPage,
+    canPreviousPage,
+  };
+};
+
+export default usePagination;

+ 9 - 0
dashboard/src/shared/hooks/usePrevious.ts

@@ -0,0 +1,9 @@
+import { useEffect, useRef } from "react";
+
+export const usePrevious = (value: any, initialValue: any) => {
+  const ref = useRef(initialValue);
+  useEffect(() => {
+    ref.current = value;
+  });
+  return ref.current;
+};

+ 13 - 3
dashboard/src/shared/routing.tsx

@@ -30,12 +30,19 @@ export const PorterUrls = [
 ];
 
 // TODO: consolidate with pushFiltered
-export const pushQueryParams = (props: any, params: any) => {
+export const pushQueryParams = (
+  props: any,
+  params: any,
+  removedParams?: string[]
+) => {
   let { location, history } = props;
   const urlParams = new URLSearchParams(location.search);
   Object.keys(params)?.forEach((key: string) => {
     params[key] && urlParams.set(key, params[key]);
   });
+
+  removedParams?.map((deletedParam) => urlParams.delete(deletedParam));
+
   history.push({
     pathname: location.pathname,
     search: urlParams.toString(),
@@ -80,8 +87,11 @@ export const useRouting = () => {
   const history = useHistory();
 
   return {
-    pushQueryParams: (params: { [key: string]: unknown }) => {
-      return pushQueryParams({ location, history }, params);
+    pushQueryParams: (
+      params: { [key: string]: unknown },
+      removedParams?: string[]
+    ) => {
+      return pushQueryParams({ location, history }, params, removedParams);
     },
     pushFiltered: (
       pathname: string,

+ 7 - 0
internal/helm/postrenderer.go

@@ -2,9 +2,11 @@ package helm
 
 import (
 	"bytes"
+	"fmt"
 	"io"
 	"net/url"
 	"regexp"
+	"sort"
 	"strings"
 
 	"github.com/aws/aws-sdk-go/aws/arn"
@@ -749,6 +751,11 @@ func (e *EnvironmentVariablePostrenderer) updatePodSpecs() error {
 				envVarArr = append(envVarArr, envVar)
 			}
 
+			// Sort the slices according to a stable ordering. This is hacky and inefficient.
+			sort.SliceStable(envVarArr, func(i, j int) bool {
+				return fmt.Sprintf("%v", envVarArr[i]) > fmt.Sprintf("%v", envVarArr[j])
+			})
+
 			_container["env"] = envVarArr
 			newContainers = append(newContainers, _container)
 		}

+ 7 - 1
internal/kubernetes/agent.go

@@ -744,12 +744,18 @@ func (a *Agent) StreamJobs(namespace string, selectors string, rw *websocket.Web
 					return
 				}
 
+				labelSelector := "meta.helm.sh/release-name"
+
+				if selectors != "" {
+					labelSelector = selectors
+				}
+
 				jobs, err := a.Clientset.BatchV1().Jobs(namespace).List(
 					ctx,
 					metav1.ListOptions{
 						Limit:         100,
 						Continue:      continueVal,
-						LabelSelector: "meta.helm.sh/release-name",
+						LabelSelector: labelSelector,
 					},
 				)
 

+ 3 - 2
internal/oauth/config.go

@@ -76,8 +76,9 @@ func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 		ClientID:     cfg.ClientID,
 		ClientSecret: cfg.ClientSecret,
 		Endpoint: oauth2.Endpoint{
-			AuthURL:  DOAuthURL,
-			TokenURL: DOTokenURL,
+			AuthURL:   DOAuthURL,
+			TokenURL:  DOTokenURL,
+			AuthStyle: oauth2.AuthStyleInParams,
 		},
 		RedirectURL: cfg.BaseURL + "/api/oauth/digitalocean/callback",
 		Scopes:      cfg.Scopes,

+ 7 - 0
provisioner/client/get_state.go

@@ -3,10 +3,13 @@ package client
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	ptypes "github.com/porter-dev/porter/provisioner/types"
 )
 
+var ErrDoesNotExist = fmt.Errorf("state file does not exist")
+
 // CreateResource posts Terraform output to the provisioner service and creates the backing
 // resource in the database
 func (c *Client) GetState(
@@ -24,5 +27,9 @@ func (c *Client) GetState(
 		resp,
 	)
 
+	if err != nil && strings.Contains(err.Error(), "current state file does not exist yet") {
+		return nil, ErrDoesNotExist
+	}
+
 	return resp, err
 }

+ 14 - 5
provisioner/integrations/redis_stream/global.go

@@ -160,41 +160,50 @@ func GlobalStreamListener(
 				continue
 			}
 
+			config.Logger.Debug().Msg(fmt.Sprintf("pushing state and log file for %s with status %v", workspaceID, statusVal))
+
 			switch fmt.Sprintf("%v", statusVal) {
-			case "created":
-				err := handleOperationCreated(config, client, infra, operation, workspaceID)
+			case "created", "error", "destroyed":
+				err := cleanupOperation(config, client, infra, operation, workspaceID)
 
 				if err != nil {
 					config.Alerter.SendAlert(context.Background(), err, map[string]interface{}{
 						"workspace_id": workspaceID,
 					})
 				}
-			case "error":
-			case "destroyed":
 			}
 		}
 	}
 }
 
-func handleOperationCreated(config *config.Config, client *redis.Client, infra *models.Infra, operation *models.Operation, workspaceID string) error {
+func cleanupOperation(config *config.Config, client *redis.Client, infra *models.Infra, operation *models.Operation, workspaceID string) error {
+	l := config.Logger
+	l.Debug().Msg(fmt.Sprintf("pushing state for %s", workspaceID))
+
 	err := pushNewStateToStorage(config, client, infra, operation, workspaceID)
 
 	if err != nil {
 		return err
 	}
 
+	l.Debug().Msg(fmt.Sprintf("cleaning state stream for %s", workspaceID))
+
 	err = cleanupStateStream(config, client, workspaceID)
 
 	if err != nil {
 		return nil
 	}
 
+	l.Debug().Msg(fmt.Sprintf("pushing logs for %s", workspaceID))
+
 	err = pushLogsToStorage(config, client, infra, workspaceID)
 
 	if err != nil {
 		return err
 	}
 
+	l.Debug().Msg(fmt.Sprintf("cleaning logs for %s", workspaceID))
+
 	err = cleanupLogStream(config, client, infra, workspaceID)
 
 	if err != nil {

+ 0 - 102
provisioner/integrations/state/s3/s3.go

@@ -1,102 +0,0 @@
-package s3
-
-// type Client struct {
-// 	client *s3.S3
-// 	bucket string
-// }
-
-// var LOCAL_RUN string
-// var ENCRYPT_KEY string
-
-// func init() {
-// 	LOCAL_RUN = os.Getenv("LOCAL_RUN")
-
-// 	ENCRYPT_KEY = os.Getenv("ENCRYPT_KEY")
-
-// 	if ENCRYPT_KEY == "" {
-// 		if LOCAL_RUN == "true" {
-// 			ENCRYPT_KEY = "the-key-has-to-be-32-bytes-long!"
-// 		} else {
-// 			panic("no encryption key set for storage")
-// 		}
-// 	}
-// }
-
-// func NewS3Client(bucket string) *Client {
-// 	var sess *session.Session
-// 	var err error
-
-// 	if LOCAL_RUN == "true" {
-// 		sess, err = session.NewSession(&aws.Config{
-// 			Region:   aws.String("us-east-1"),
-// 			Endpoint: aws.String("localhost.localstack.cloud:4566"),
-// 		})
-// 	} else {
-// 		sess, err = session.NewSession()
-// 		if err != nil {
-// 			log.Fatal("cannot create aws session", err.Error())
-// 		}
-// 	}
-
-// 	return &Client{
-// 		client: s3.New(sess),
-// 		bucket: bucket,
-// 	}
-// }
-
-// func (s *Client) GetObject(org, filename string) (io.ReadCloser, error) {
-// 	log.Println(org, filename)
-// 	output, err := s.client.GetObject(&s3.GetObjectInput{
-// 		Bucket: &s.bucket,
-// 		Key:    aws.String(fmt.Sprintf("%s/%s", org, filename)),
-// 	})
-
-// 	if err != nil {
-// 		return nil, err
-// 	}
-
-// 	var encryptedData bytes.Buffer
-// 	_, err = encryptedData.ReadFrom(output.Body)
-// 	if err != nil {
-// 		return nil, err
-// 	}
-
-// 	data, err := encryption.Decrypt(encryptedData.Bytes(), []byte(ENCRYPT_KEY))
-// 	if err != nil {
-// 		return nil, err
-// 	}
-
-// 	return io.NopCloser(bytes.NewReader(data)), nil
-// }
-
-// func (s *Client) PutObject(org, filename string, body []byte) error {
-// 	encryptedBody, err := encryption.Encrypt(body, []byte(ENCRYPT_KEY))
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	_, err = s.client.PutObject(&s3.PutObjectInput{
-// 		Body:   aws.ReadSeekCloser(bytes.NewReader(encryptedBody)),
-// 		Bucket: &s.bucket,
-// 		Key:    aws.String(fmt.Sprintf("%s/%s", org, filename)),
-// 	})
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	return nil
-// }
-
-// func (s *Client) DeleteObject(org, filename string) error {
-// 	_, err := s.client.DeleteObject(&s3.DeleteObjectInput{
-// 		Bucket: &s.bucket,
-// 		Key:    aws.String(fmt.Sprintf("%s/%s", org, filename)),
-// 	})
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	return nil
-// }

+ 0 - 119
provisioner/processor/resource.go

@@ -1,119 +0,0 @@
-package processor
-
-// type EventProcessor struct {}
-
-// type Event struct {
-// 	OrgID string
-// 	*types.TFLogLine
-// }
-
-// func (e *EventProcessor) GetFileData(ordID string) (bytes.Buffer, error) {
-// 	var data bytes.Buffer
-
-// 	reader, err := e.client.GetObject(ordID, "desired.json")
-// 	if err != nil {
-// 		return data, err
-// 	}
-
-// 	_, err = data.ReadFrom(reader)
-// 	if err != nil {
-// 		return data, err
-// 	}
-
-// 	return data, nil
-// }
-
-// func (e *EventProcessor) WriteFileData(orgID string, data []byte) error {
-// 	err := e.client.PutObject(orgID, "desired.json", data)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	return nil
-// }
-
-// func (e *EventProcessor) MarkErroredResourceInDesiredState(event *Event) error {
-// 	fileData, err := e.GetFileData(event.OrgID)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	var desiredState types.DesiredTFState
-// 	err = json.Unmarshal(fileData.Bytes(), &desiredState)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	// find the correct matching resource name in the desired state
-// 	for i, resource := range desiredState {
-// 		if resource.Resource == event.Hook.Resource.Resource {
-// 			// add error message to this resource
-// 			resource.Errored.ErroredOut = true
-// 			desiredState[i] = resource
-
-// 			// write back the file
-// 			data, err := json.Marshal(desiredState)
-// 			if err != nil {
-// 				return err
-// 			}
-
-// 			return e.client.PutObject(event.OrgID, "desired.json", data)
-// 		}
-// 	}
-
-// 	return fmt.Errorf("cannot find a matching resource entry")
-// }
-
-// func (e *EventProcessor) AddErrorContextToResource(event *Event) error {
-// 	fileData, err := e.GetFileData(event.OrgID)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	var desiredState types.DesiredTFState
-// 	err = json.Unmarshal(fileData.Bytes(), &desiredState)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	// find and add error context to the matching resource
-// 	for i, resource := range desiredState {
-// 		if event.Diagnostic.Address == resource.Resource {
-// 			resource.Errored.ErrorSummary = event.Diagnostic.Summary
-// 			desiredState[i] = resource
-
-// 			// write back
-// 			data, err := json.Marshal(desiredState)
-// 			if err != nil {
-// 				return err
-// 			}
-
-// 			return e.client.PutObject(event.OrgID, "desired.json", data)
-// 		}
-// 	}
-
-// 	return fmt.Errorf("cannot find a matching resource entry")
-// }
-
-// func (e *EventProcessor) Filter(event *Event) error {
-// 	switch event.Type {
-// 	case types.ApplyErrored:
-// 		return e.MarkErroredResourceInDesiredState(event)
-// 	case types.Diagnostic:
-// 		if event.Level == "error" {
-// 			return e.AddErrorContextToResource(event)
-// 		}
-
-// 		return nil
-// 	default:
-// 		return nil
-// 	}
-// }
-
-// func NewEventProcessor() *EventProcessor {
-// 	BUCKET := os.Getenv("BUCKET")
-
-// 	return &EventProcessor{
-// 		client: s3.NewS3Client(BUCKET),
-// 	}
-// }

+ 9 - 0
provisioner/server/config/config.go

@@ -11,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
 	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	klocal "github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/oauth"
@@ -68,6 +69,9 @@ type Config struct {
 	RedisClient *redis.Client
 
 	Provisioner provisioner.Provisioner
+
+	// AnalyticsClient if Segment analytics reporting is enabled on the API instance
+	AnalyticsClient analytics.AnalyticsSegmentClient
 }
 
 // ProvisionerConf is the env var configuration for the provisioner server
@@ -110,6 +114,9 @@ type ProvisionerConf struct {
 
 	// Options to configure for the "local" provisioner method
 	LocalTerraformDirectory string `env:"LOCAL_TERRAFORM_DIRECTORY"`
+
+	// Client key for segment to report provisioning events
+	SegmentClientKey string `env:"SEGMENT_CLIENT_KEY"`
 }
 
 type EnvConf struct {
@@ -221,6 +228,8 @@ func GetConfig(envConf *EnvConf) (*Config, error) {
 		})
 	}
 
+	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(envConf.ProvisionerConf.SegmentClientKey, res.Logger)
+
 	return res, nil
 }
 

+ 0 - 7
provisioner/server/grpc/store_log.go

@@ -33,13 +33,6 @@ func (s *ProvisionerServer) StoreLog(stream pb.Provisioner_StoreLogServer) error
 		tfLog, err := stream.Recv()
 
 		if err == io.EOF {
-			// push to the global stream
-			err := redis_stream.PushToGlobalStream(s.config.RedisClient, infra, operation, "created")
-
-			if err != nil {
-				return err
-			}
-
 			return stream.SendAndClose(&pb.TerraformStateMeta{})
 		} else if err != nil {
 			return err

+ 21 - 0
provisioner/server/handlers/provision/apply.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/random"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner"
@@ -141,6 +142,26 @@ func (c *ProvisionApplyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	// return the operation response type to the server
 	c.resultWriter.WriteResult(w, r, op)
+
+	// if this is a cluster or registry infra type, send to analytics client
+	switch infra.Kind {
+	case types.InfraDOKS, types.InfraEKS, types.InfraGKE:
+		c.Config.AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
+			&analytics.ClusterProvisioningStartTrackOpts{
+				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(0, infra.ProjectID),
+				ClusterType:            infra.Kind,
+				InfraID:                infra.ID,
+			},
+		))
+	case types.InfraDOCR, types.InfraECR, types.InfraGCR:
+		c.Config.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
+			&analytics.RegistryProvisioningStartTrackOpts{
+				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(0, infra.ProjectID),
+				RegistryType:           infra.Kind,
+				InfraID:                infra.ID,
+			},
+		))
+	}
 }
 
 func createCredentialsExchangeToken(conf *config.Config, infra *models.Infra) (*models.CredentialsExchangeToken, string, error) {

+ 120 - 85
provisioner/server/handlers/state/create_resource.go

@@ -3,6 +3,7 @@ package state
 import (
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"regexp"
@@ -12,12 +13,14 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/provisioner/integrations/redis_stream"
 	"github.com/porter-dev/porter/provisioner/server/config"
 	ptypes "github.com/porter-dev/porter/provisioner/types"
+	"gorm.io/gorm"
 )
 
 type CreateResourceHandler struct {
@@ -63,6 +66,14 @@ func (c *CreateResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
+	// push to the global stream
+	err = redis_stream.PushToGlobalStream(c.Config.RedisClient, infra, operation, "created")
+
+	if err != nil {
+		apierrors.HandleAPIError(c.Config.Logger, c.Config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+
 	// update the infra to indicate completion
 	infra.Status = "created"
 
@@ -75,20 +86,28 @@ func (c *CreateResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	// switch on the kind of resource and write the corresponding objects to the database
 	switch req.Kind {
+	case string(types.InfraEKS), string(types.InfraDOKS), string(types.InfraGKE):
+		var cluster *models.Cluster
+
+		cluster, err = createCluster(c.Config, infra, operation, req.Output)
+
+		if cluster != nil {
+			c.Config.AnalyticsClient.Track(analytics.ClusterProvisioningSuccessTrack(
+				&analytics.ClusterProvisioningSuccessTrackOpts{
+					ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(0, infra.ProjectID, cluster.ID),
+					ClusterType:            infra.Kind,
+					InfraID:                infra.ID,
+				},
+			))
+		}
 	case string(types.InfraECR):
 		_, err = createECRRegistry(c.Config, infra, operation, req.Output)
-	case string(types.InfraEKS):
-		_, err = createEKSCluster(c.Config, infra, operation, req.Output)
 	case string(types.InfraRDS):
 		_, err = createRDSDatabase(c.Config, infra, operation, req.Output)
 	case string(types.InfraDOCR):
 		_, err = createDOCRRegistry(c.Config, infra, operation, req.Output)
-	case string(types.InfraDOKS):
-		_, err = createDOKSCluster(c.Config, infra, operation, req.Output)
 	case string(types.InfraGCR):
 		_, err = createGCRRegistry(c.Config, infra, operation, req.Output)
-	case string(types.InfraGKE):
-		_, err = createGKECluster(c.Config, infra, operation, req.Output)
 	}
 
 	if err != nil {
@@ -138,22 +157,41 @@ func createECRRegistry(config *config.Config, infra *models.Infra, operation *mo
 }
 
 func createRDSDatabase(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Database, error) {
-	database := &models.Database{
-		ProjectID:        infra.ProjectID,
-		ClusterID:        infra.ParentClusterID,
-		InfraID:          infra.ID,
-		InstanceID:       output["rds_instance_id"].(string),
-		InstanceEndpoint: output["rds_connection_endpoint"].(string),
-		InstanceName:     output["rds_instance_name"].(string),
-		Status:           "Running",
+	// check for infra id being 0 as a safeguard so that all non-provisioned
+	// clusters are not matched by read
+	if infra.ID == 0 {
+		return nil, fmt.Errorf("infra id cannot be 0")
 	}
 
-	database, err := config.Repo.Database().CreateDatabase(database)
+	var database *models.Database
+	var err error
+	var isNotFound bool
 
-	if err != nil {
+	database, err = config.Repo.Database().ReadDatabaseByInfraID(infra.ProjectID, infra.ID)
+
+	isNotFound = err != nil && errors.Is(err, gorm.ErrRecordNotFound)
+
+	if isNotFound {
+		database = &models.Database{
+			ProjectID: infra.ProjectID,
+			ClusterID: infra.ParentClusterID,
+			InfraID:   infra.ID,
+			Status:    "Running",
+		}
+	} else if err != nil {
 		return nil, err
 	}
 
+	database.InstanceID = output["rds_instance_id"].(string)
+	database.InstanceEndpoint = output["rds_connection_endpoint"].(string)
+	database.InstanceName = output["rds_instance_name"].(string)
+
+	if isNotFound {
+		database, err = config.Repo.Database().CreateDatabase(database)
+	} else {
+		database, err = config.Repo.Database().UpdateDatabase(database)
+	}
+
 	infra.DatabaseID = database.ID
 	infra, err = config.Repo.Infra().UpdateInfra(infra)
 
@@ -177,67 +215,79 @@ func createRDSDatabase(config *config.Config, infra *models.Infra, operation *mo
 	return database, nil
 }
 
-func createEKSCluster(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Cluster, error) {
-	cluster := &models.Cluster{
-		AuthMechanism:            models.AWS,
-		ProjectID:                infra.ProjectID,
-		AWSIntegrationID:         infra.AWSIntegrationID,
-		InfraID:                  infra.ID,
-		Name:                     output["cluster_id"].(string),
-		Server:                   output["cluster_endpoint"].(string),
-		CertificateAuthorityData: []byte(output["cluster_ca_data"].(string)),
+func createCluster(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Cluster, error) {
+	// check for infra id being 0 as a safeguard so that all non-provisioned
+	// clusters are not matched by read
+	if infra.ID == 0 {
+		return nil, fmt.Errorf("infra id cannot be 0")
 	}
 
-	re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+	var cluster *models.Cluster
+	var err error
+	var isNotFound bool
 
-	// if it matches the base64 regex, decode it
-	caData := string(cluster.CertificateAuthorityData)
-	if re.MatchString(caData) {
-		decoded, err := base64.StdEncoding.DecodeString(caData)
+	// look for cluster matching infra in database; if the cluster already exists, update the cluster but
+	// don't add it again
+	cluster, err = config.Repo.Cluster().ReadClusterByInfraID(infra.ProjectID, infra.ID)
 
-		if err != nil {
-			return nil, err
-		}
+	isNotFound = err != nil && errors.Is(err, gorm.ErrRecordNotFound)
 
-		cluster.CertificateAuthorityData = []byte(decoded)
+	if isNotFound {
+		cluster = getNewCluster(infra)
+	} else if err != nil {
+		return nil, err
 	}
 
-	cluster, err := config.Repo.Cluster().CreateCluster(cluster)
+	caData, err := transformClusterCAData([]byte(output["cluster_ca_data"].(string)))
 
 	if err != nil {
 		return nil, err
 	}
 
-	return cluster, nil
-}
+	cluster.Name = output["cluster_name"].(string)
+	cluster.Server = output["cluster_endpoint"].(string)
+	cluster.CertificateAuthorityData = caData
 
-func createDOCRRegistry(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Registry, error) {
-	reg := &models.Registry{
-		ProjectID:       infra.ProjectID,
-		DOIntegrationID: infra.DOIntegrationID,
-		InfraID:         infra.ID,
-		URL:             output["url"].(string),
-		Name:            output["name"].(string),
+	if isNotFound {
+		cluster, err = config.Repo.Cluster().CreateCluster(cluster)
+	} else {
+		cluster, err = config.Repo.Cluster().UpdateCluster(cluster)
 	}
 
-	return config.Repo.Registry().CreateRegistry(reg)
+	if err != nil {
+		return nil, err
+	}
+
+	return cluster, nil
 }
 
-func createDOKSCluster(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Cluster, error) {
-	cluster := &models.Cluster{
-		AuthMechanism:            models.DO,
-		ProjectID:                infra.ProjectID,
-		DOIntegrationID:          infra.DOIntegrationID,
-		InfraID:                  infra.ID,
-		Name:                     output["cluster_name"].(string),
-		Server:                   output["cluster_endpoint"].(string),
-		CertificateAuthorityData: []byte(output["cluster_ca_data"].(string)),
+func getNewCluster(infra *models.Infra) *models.Cluster {
+	res := &models.Cluster{
+		ProjectID: infra.ProjectID,
+		InfraID:   infra.ID,
+	}
+
+	switch infra.Kind {
+	case types.InfraEKS:
+		res.AuthMechanism = models.AWS
+		res.AWSIntegrationID = infra.AWSIntegrationID
+	case types.InfraGKE:
+		res.AuthMechanism = models.GCP
+		res.GCPIntegrationID = infra.GCPIntegrationID
+	case types.InfraDOKS:
+		res.AuthMechanism = models.DO
+		res.DOIntegrationID = infra.DOIntegrationID
 	}
 
+	return res
+}
+
+func transformClusterCAData(ca []byte) ([]byte, error) {
 	re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
 
 	// if it matches the base64 regex, decode it
-	caData := string(cluster.CertificateAuthorityData)
+	caData := string(ca)
+
 	if re.MatchString(caData) {
 		decoded, err := base64.StdEncoding.DecodeString(caData)
 
@@ -245,10 +295,23 @@ func createDOKSCluster(config *config.Config, infra *models.Infra, operation *mo
 			return nil, err
 		}
 
-		cluster.CertificateAuthorityData = []byte(decoded)
+		return []byte(decoded), nil
+	}
+
+	// otherwise just return the CA
+	return ca, nil
+}
+
+func createDOCRRegistry(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Registry, error) {
+	reg := &models.Registry{
+		ProjectID:       infra.ProjectID,
+		DOIntegrationID: infra.DOIntegrationID,
+		InfraID:         infra.ID,
+		URL:             output["url"].(string),
+		Name:            output["name"].(string),
 	}
 
-	return config.Repo.Cluster().CreateCluster(cluster)
+	return config.Repo.Registry().CreateRegistry(reg)
 }
 
 func createGCRRegistry(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Registry, error) {
@@ -263,34 +326,6 @@ func createGCRRegistry(config *config.Config, infra *models.Infra, operation *mo
 	return config.Repo.Registry().CreateRegistry(reg)
 }
 
-func createGKECluster(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Cluster, error) {
-	cluster := &models.Cluster{
-		AuthMechanism:            models.GCP,
-		ProjectID:                infra.ProjectID,
-		GCPIntegrationID:         infra.GCPIntegrationID,
-		InfraID:                  infra.ID,
-		Name:                     output["cluster_name"].(string),
-		Server:                   output["cluster_endpoint"].(string),
-		CertificateAuthorityData: []byte(output["cluster_ca_data"].(string)),
-	}
-
-	re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
-
-	// if it matches the base64 regex, decode it
-	caData := string(cluster.CertificateAuthorityData)
-	if re.MatchString(caData) {
-		decoded, err := base64.StdEncoding.DecodeString(caData)
-
-		if err != nil {
-			return nil, err
-		}
-
-		cluster.CertificateAuthorityData = []byte(decoded)
-	}
-
-	return config.Repo.Cluster().CreateCluster(cluster)
-}
-
 func createRDSEnvGroup(config *config.Config, infra *models.Infra, database *models.Database, lastApplied map[string]interface{}) error {
 	cluster, err := config.Repo.Cluster().ReadCluster(infra.ProjectID, infra.ParentClusterID)
 

+ 8 - 0
provisioner/server/handlers/state/delete_resource.go

@@ -48,6 +48,14 @@ func (c *DeleteResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
+	// push to the global stream
+	err = redis_stream.PushToGlobalStream(c.Config.RedisClient, infra, operation, "destroyed")
+
+	if err != nil {
+		apierrors.HandleAPIError(c.Config.Logger, c.Config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+
 	// update the infra to indicate deletion
 	infra.Status = "deleted"
 

+ 24 - 0
provisioner/server/handlers/state/report_error.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/provisioner/integrations/redis_stream"
 	"github.com/porter-dev/porter/provisioner/server/config"
@@ -68,8 +69,31 @@ func (c *ReportErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// push to the global stream
+	err = redis_stream.PushToGlobalStream(c.Config.RedisClient, infra, operation, "error")
+
+	if err != nil {
+		apierrors.HandleAPIError(c.Config.Logger, c.Config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+
 	// report the error to the error alerter but don't send to client
 	apierrors.HandleAPIError(c.Config.Logger, c.Config.Alerter, w, r, apierrors.NewErrInternal(
 		fmt.Errorf(req.Error),
 	), false)
+
+	switch infra.Kind {
+	case types.InfraEKS, types.InfraDOKS, types.InfraGKE:
+		var cluster *models.Cluster
+
+		if cluster != nil {
+			c.Config.AnalyticsClient.Track(analytics.ClusterProvisioningErrorTrack(
+				&analytics.ClusterProvisioningErrorTrackOpts{
+					ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(0, infra.ProjectID),
+					ClusterType:            infra.Kind,
+					InfraID:                infra.ID,
+				},
+			))
+		}
+	}
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов