Explorar o código

fix merge conflict

Alexander Belanger %!s(int64=4) %!d(string=hai) anos
pai
achega
570b523695
Modificáronse 100 ficheiros con 6126 adicións e 1978 borrados
  1. 33 1
      .github/workflows/dev.yaml
  2. 3 7
      .github/workflows/prerelease.yaml
  3. 33 2
      .github/workflows/production.yaml
  4. 33 2
      .github/workflows/staging.yaml
  5. 17 4
      api/server/handlers/cluster/detect_agent_installed.go
  6. 64 0
      api/server/handlers/cluster/get_incident_event_logs.go
  7. 94 0
      api/server/handlers/cluster/get_incidents.go
  8. 89 0
      api/server/handlers/cluster/notify_new_incident.go
  9. 89 0
      api/server/handlers/cluster/notify_resolved_incident.go
  10. 81 0
      api/server/handlers/cluster/upgrade_agent.go
  11. 7 0
      api/server/handlers/infra/get_state.go
  12. 16 2
      api/server/handlers/namespace/get_ingress.go
  13. 14 1
      api/server/handlers/namespace/stream_job_runs.go
  14. 1 1
      api/server/handlers/registry/create_repository.go
  15. 19 1
      api/server/handlers/registry/get_token.go
  16. 146 1
      api/server/router/cluster.go
  17. 2 4
      api/server/shared/config/loader/loader.go
  18. 1 1
      api/server/shared/config/metadata.go
  19. 5 0
      api/types/agent.go
  20. 14 0
      api/types/cluster.go
  21. 4 0
      api/types/namespace.go
  22. 2 1
      api/types/registry.go
  23. 55 6
      cli/cmd/apply.go
  24. 222 0
      cli/cmd/bluegreen.go
  25. 54 4
      cli/cmd/create.go
  26. 44 0
      cli/cmd/deploy.go
  27. 5 15
      cli/cmd/deploy/build.go
  28. 17 15
      cli/cmd/deploy/create.go
  29. 40 8
      cli/cmd/deploy/deploy.go
  30. 1 0
      cli/cmd/deploy/shared.go
  31. 16 1
      cli/cmd/docker.go
  32. 15 15
      cli/cmd/docker/agent.go
  33. 2 1
      cli/cmd/docker/auth.go
  34. 2 1
      cli/cmd/docker/builder.go
  35. 1 1
      cli/cmd/docker/config.go
  36. 12 12
      cli/cmd/docker/porter.go
  37. 37 2
      cli/cmd/job.go
  38. 41 13
      cli/cmd/pack/logger.go
  39. 21 9
      cli/cmd/pack/pack.go
  40. 10 8
      cli/cmd/run.go
  41. 17 0
      cli/cmd/utils/prompt.go
  42. 1 1
      cli/cmd/version.go
  43. 23 0
      dashboard/package-lock.json
  44. 3 0
      dashboard/package.json
  45. 98 16
      dashboard/src/components/Table.tsx
  46. 17 5
      dashboard/src/components/expanded-object/Header.tsx
  47. 4 2
      dashboard/src/components/form-components/InputRow.tsx
  48. 8 0
      dashboard/src/components/porter-form/PorterForm.tsx
  49. 89 0
      dashboard/src/components/porter-form/field-components/CronInput.tsx
  50. 128 0
      dashboard/src/components/porter-form/field-components/TextAreaInput.tsx
  51. 2 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  52. 27 2
      dashboard/src/components/porter-form/types.ts
  53. 37 23
      dashboard/src/components/repo-selector/RepoList.tsx
  54. 2 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  55. 20 1
      dashboard/src/main/home/cluster-dashboard/SortSelector.tsx
  56. 82 0
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  57. 36 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  58. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  59. 5 5
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  60. 4 0
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  61. 233 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx
  62. 62 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx
  63. 524 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx
  64. 215 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  65. 209 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx
  66. 20 3
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx
  67. 28 3
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx
  68. 121 20
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx
  69. 9 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  70. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  71. 406 1064
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  72. 15 23
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  73. 217 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx
  74. 217 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx
  75. 387 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  76. 165 73
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  77. 76 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  78. 8 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  79. 427 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  80. 15 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  81. 0 3
      dashboard/src/main/home/modals/UpgradeChartModal.tsx
  82. 27 22
      dashboard/src/main/home/project-settings/InviteList.tsx
  83. 19 10
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  84. 47 242
      dashboard/src/main/home/sidebar/Sidebar.tsx
  85. 93 0
      dashboard/src/shared/api.tsx
  86. 295 0
      dashboard/src/shared/hooks/useChart.ts
  87. 34 0
      dashboard/src/shared/hooks/useEffectDebugger.ts
  88. 103 0
      dashboard/src/shared/hooks/usePagination.ts
  89. 9 0
      dashboard/src/shared/hooks/usePrevious.ts
  90. 13 3
      dashboard/src/shared/routing.tsx
  91. 4 0
      dashboard/src/shared/types.tsx
  92. 39 33
      go.mod
  93. 121 4
      go.sum
  94. 0 23
      helm/.helmignore
  95. 0 23
      helm/Chart.yaml
  96. 0 21
      helm/templates/NOTES.txt
  97. 0 63
      helm/templates/_helpers.tpl
  98. 0 60
      helm/templates/deployment.yaml
  99. 0 28
      helm/templates/hpa.yaml
  100. 0 43
      helm/templates/ingress.yaml

+ 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 - 2
.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 }}
@@ -40,7 +40,6 @@ jobs:
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
           SENTRY_ENV=production
-          DISABLE_BILLING=true
           EOL
       - name: Build
         run: |
@@ -53,3 +52,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 - 2
.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 }}
@@ -39,7 +39,6 @@ jobs:
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
           SENTRY_ENV=staging
-          DISABLE_BILLING=true
           EOL
       - name: Build
         run: |
@@ -52,3 +51,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

+ 17 - 4
api/server/handlers/cluster/detect_agent_installed.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -14,15 +15,16 @@ import (
 )
 
 type DetectAgentInstalledHandler struct {
-	handlers.PorterHandler
+	handlers.PorterHandlerWriter
 	authz.KubernetesAgentGetter
 }
 
 func NewDetectAgentInstalledHandler(
 	config *config.Config,
+	writer shared.ResultWriter,
 ) *DetectAgentInstalledHandler {
 	return &DetectAgentInstalledHandler{
-		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
 		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
 	}
 }
@@ -37,7 +39,7 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	_, err = agent.GetPorterAgent()
+	depl, err := agent.GetPorterAgent()
 
 	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
 		http.NotFound(w, r)
@@ -47,5 +49,16 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	w.WriteHeader(http.StatusOK)
+	// detect the version of the agent which is installed
+	res := &types.GetAgentResponse{}
+
+	versionAnn, ok := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
+
+	if !ok {
+		res.Version = "v1"
+	} else {
+		res.Version = versionAnn
+	}
+
+	c.WriteResult(w, r, res)
 }

+ 64 - 0
api/server/handlers/cluster/get_incident_event_logs.go

@@ -0,0 +1,64 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIncidentEventLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIncidentEventLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetIncidentEventLogsHandler {
+	return &GetIncidentEventLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIncidentEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetIncidentEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	logs, err := porter_agent.GetLogs(agent.Clientset, agentSvc, request.LogID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 94 - 0
api/server/handlers/cluster/get_incidents.go

@@ -0,0 +1,94 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIncidentsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIncidentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetIncidentsHandler {
+	return &GetIncidentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIncidentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetIncidentsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	incidentID := request.IncidentID
+	releaseName := request.ReleaseName
+	namespace := request.Namespace
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if incidentID != "" {
+		events, err := porter_agent.GetIncidentEventsByID(agent.Clientset, agentSvc, incidentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.WriteResult(w, r, events)
+		return
+	} else if releaseName != "" {
+		if namespace == "" {
+			namespace = "default"
+		}
+
+		incidents, err := porter_agent.GetIncidentsByReleaseNamespace(agent.Clientset, agentSvc, releaseName, namespace)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.WriteResult(w, r, incidents)
+		return
+	}
+
+	incidents, err := porter_agent.GetAllIncidents(agent.Clientset, agentSvc)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, incidents)
+}

+ 89 - 0
api/server/handlers/cluster/notify_new_incident.go

@@ -0,0 +1,89 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotifyNewIncidentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewNotifyNewIncidentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *NotifyNewIncidentHandler {
+	return &NotifyNewIncidentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *NotifyNewIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &porter_agent.Incident{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// FIXME: better error detection for correct incident ID
+	segments := strings.Split(request.ID, ":")
+	if len(segments) != 4 || (len(segments) > 0 && segments[0] != "incident") {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid incident ID: %s", request.ID)))
+		return
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, segments[1], segments[2])
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var notifConf *types.NotificationConfig
+
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+
+	if !cluster.NotificationsDisabled {
+		err := notifier.NotifyNew(
+			request, fmt.Sprintf(
+				"%s/cluster-dashboard/incidents/%s?namespace=%s",
+				c.Config().ServerConf.ServerURL,
+				request.ID,
+				segments[2],
+			),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 89 - 0
api/server/handlers/cluster/notify_resolved_incident.go

@@ -0,0 +1,89 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotifyResolvedIncidentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewNotifyResolvedIncidentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *NotifyResolvedIncidentHandler {
+	return &NotifyResolvedIncidentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *NotifyResolvedIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &porter_agent.Incident{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// FIXME: better error detection for correct incident ID
+	segments := strings.Split(request.ID, ":")
+	if len(segments) != 4 || (len(segments) > 0 && segments[0] != "incident") {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid incident ID: %s", request.ID)))
+		return
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, segments[1], segments[2])
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var notifConf *types.NotificationConfig
+
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+
+	if !cluster.NotificationsDisabled {
+		err := notifier.NotifyResolved(
+			request, fmt.Sprintf(
+				"%s/cluster-dashboard/incidents/%s?namespace=%s",
+				c.Config().ServerConf.ServerURL,
+				request.ID,
+				segments[2],
+			),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 81 - 0
api/server/handlers/cluster/upgrade_agent.go

@@ -0,0 +1,81 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UpgradeAgentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpgradeAgentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpgradeAgentHandler {
+	return &UpgradeAgentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpgradeAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	currRelease, err := helmAgent.GetRelease("porter-agent", 0, false)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	chart, err := loader.LoadChartPublic(c.Config().ServerConf.DefaultAddonHelmRepoURL, "porter-agent", "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newValues := currRelease.Config
+
+	// TODO: update values
+	// newValues["redis"] =
+
+	_, err = helmAgent.UpgradeReleaseByValues(&helm.UpgradeReleaseConfig{
+		Chart:      chart,
+		Name:       "porter-agent",
+		Values:     newValues,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: []*models.Registry{},
+	}, c.Config().DOConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error upgrading the chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

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

+ 16 - 2
api/server/handlers/namespace/get_ingress.go

@@ -42,7 +42,21 @@ func (c *GetIngressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	ingress, err := agent.GetIngress(namespace, name)
+	ingress1, err := agent.GetExtensionsV1Beta1Ingress(namespace, name)
+
+	if err == nil && ingress1 != nil {
+		c.WriteResult(w, r, ingress1)
+		return
+	}
+
+	ingress2, err := agent.GetNetworkingV1Beta1Ingress(namespace, name)
+
+	if err == nil && ingress2 != nil {
+		c.WriteResult(w, r, ingress2)
+		return
+	}
+
+	ingress3, err := agent.GetNetworkingV1Ingress(namespace, name)
 
 	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -56,5 +70,5 @@ func (c *GetIngressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	c.WriteResult(w, r, ingress)
+	c.WriteResult(w, r, ingress3)
 }

+ 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))

+ 1 - 1
api/server/handlers/registry/create_repository.go

@@ -43,7 +43,7 @@ func (p *RegistryCreateRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 	// parse the name from the registry
 	nameSpl := strings.Split(request.ImageRepoURI, "/")
-	repoName := nameSpl[len(nameSpl)-1]
+	repoName := strings.ReplaceAll(nameSpl[len(nameSpl)-1], "_", "-")
 
 	err := regAPI.CreateRepository(p.Repo(), repoName)
 

+ 19 - 1
api/server/handlers/registry/get_token.go

@@ -15,6 +15,8 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
 )
 
 type RegistryGetECRTokenHandler struct {
@@ -60,7 +62,23 @@ func (c *RegistryGetECRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 				return
 			}
 
-			if awsInt.AWSRegion == request.Region {
+			// if the aws integration doesn't have an ARN populated, populate it
+			if awsInt.AWSArn == "" {
+				err = awsInt.PopulateAWSArn()
+
+				if err != nil {
+					continue
+				}
+			}
+
+			parsedARN, err := arn.Parse(awsInt.AWSArn)
+
+			if err != nil {
+				continue
+			}
+
+			// if the account id is passed as part of the request, verify the account id matches the account id in the ARN
+			if awsInt.AWSRegion == request.Region && (request.AccountID == "" || request.AccountID == parsedARN.AccountID) {
 				// get the aws integration and session
 				sess, err := awsInt.GetSession()
 

+ 146 - 1
api/server/router/cluster.go

@@ -585,7 +585,7 @@ func getClusterRoutes(
 		},
 	)
 
-	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config)
+	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config, factory.GetResultWriter())
 
 	routes = append(routes, &Route{
 		Endpoint: detectAgentInstalledEndpoint,
@@ -622,6 +622,35 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/agent/upgrade -> cluster.NewInstallAgentHandler
+	upgradeAgentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/upgrade",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	upgradeAgentHandler := cluster.NewUpgradeAgentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: upgradeAgentEndpoint,
+		Handler:  upgradeAgentHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
 	listKubeEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -917,5 +946,121 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents -> cluster.NewGetIncidentsHandler
+	getIncidentsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getIncidentsHandler := cluster.NewGetIncidentsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getIncidentsEndpoint,
+		Handler:  getIncidentsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents/logs -> cluster.NewGetIncidentsHandler
+	getIncidentEventLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/logs",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getIncidentEventLogsHandler := cluster.NewGetIncidentEventLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getIncidentEventLogsEndpoint,
+		Handler:  getIncidentEventLogsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/incidents/notify_new -> cluster.NewNotifyNewIncidentHandler
+	notifyNewIncidentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/notify_new",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	notifyNewIncidentHandler := cluster.NewNotifyNewIncidentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: notifyNewIncidentEndpoint,
+		Handler:  notifyNewIncidentHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/incidents/notify_resolved -> cluster.NewNotifyResolvedIncidentHandler
+	notifyResolvedIncidentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/notify_resolved",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	notifyResolvedIncidentHandler := cluster.NewNotifyResolvedIncidentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: notifyResolvedIncidentEndpoint,
+		Handler:  notifyResolvedIncidentHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 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,

+ 5 - 0
api/types/agent.go

@@ -0,0 +1,5 @@
+package types
+
+type GetAgentResponse struct {
+	Version string `json:"version"`
+}

+ 14 - 0
api/types/cluster.go

@@ -247,3 +247,17 @@ type ListClusterResponse []*Cluster
 type CreateClusterCandidateResponse []*ClusterCandidate
 
 type ListClusterCandidateResponse []*ClusterCandidate
+
+type GetIncidentsRequest struct {
+	IncidentID  string `schema:"incident_id"`
+	ReleaseName string `schema:"release_name"`
+	Namespace   string `schema:"namespace"`
+}
+
+type GetIncidentEventLogsRequest struct {
+	LogID string `schema:"log_id"`
+}
+
+type IncidentNotifyRequest struct {
+	IncidentID string `json:"incident_id" form:"required"`
+}

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

+ 2 - 1
api/types/registry.go

@@ -108,7 +108,8 @@ type GetRegistryGCRTokenRequest struct {
 }
 
 type GetRegistryECRTokenRequest struct {
-	Region string `schema:"region"`
+	Region    string `schema:"region"`
+	AccountID string `schema:"account_id"`
 }
 
 type GetRegistryDOCRTokenRequest struct {

+ 55 - 6
cli/cmd/apply.go

@@ -171,9 +171,14 @@ type Target struct {
 type ApplicationConfig struct {
 	WaitForJob bool
 
+	// If set to true, this does not run an update, it only creates the initial application and job,
+	// skipping subsequent updates
+	OnlyCreate bool
+
 	Build struct {
 		ForceBuild bool
 		ForcePush  bool
+		UseCache   bool
 		Method     string
 		Context    string
 		Dockerfile string
@@ -358,6 +363,16 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 		OverrideTag:     tag,
 		Method:          deploy.DeployBuildType(method),
 		EnvGroups:       appConfig.EnvGroups,
+		UseCache:        appConfig.Build.UseCache,
+	}
+
+	if appConfig.Build.UseCache {
+		// set the docker config so that pack caching can use the repo credentials
+		err := setDockerConfig(client)
+
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	if shouldCreate {
@@ -366,19 +381,21 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 		if err != nil {
 			return nil, err
 		}
-	} else {
+	} else if !appConfig.OnlyCreate {
 		resource, err = d.updateApplication(resource, client, sharedOpts, appConfig)
 
 		if err != nil {
 			return nil, err
 		}
+	} else {
+		color.New(color.FgYellow).Printf("Skipping creation for %s as onlyCreate is set to true\n", resource.Name)
 	}
 
 	if err = d.assignOutput(resource, client); err != nil {
 		return nil, err
 	}
 
-	if d.source.Name == "job" && appConfig.WaitForJob {
+	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
 
 		prevProject := config.Project
@@ -424,7 +441,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-			repoSuffix = fmt.Sprintf("%s-%s", repoOwner, repoName)
+			repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-")
 		}
 	}
 
@@ -453,6 +470,28 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 	if appConf.Build.Method == "registry" {
 		subdomain, err = createAgent.CreateFromRegistry(appConf.Build.Image, appConf.Values)
 	} else {
+		// if useCache is set, create the image repository first
+		if appConf.Build.UseCache {
+			regID, imageURL, err := createAgent.GetImageRepoURL(resource.Name, sharedOpts.Namespace)
+
+			if err != nil {
+				return nil, err
+			}
+
+			err = client.CreateRepository(
+				context.Background(),
+				sharedOpts.ProjectID,
+				regID,
+				&types.CreateRegistryRepositoryRequest{
+					ImageRepoURI: imageURL,
+				},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		}
+
 		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig, appConf.Build.ForceBuild)
 	}
 
@@ -507,10 +546,12 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 			return nil, err
 		}
 
-		err = updateAgent.Push(appConf.Build.ForcePush)
+		if !appConf.Build.UseCache {
+			err = updateAgent.Push(appConf.Build.ForcePush)
 
-		if err != nil {
-			return nil, err
+			if err != nil {
+				return nil, err
+			}
 		}
 	}
 
@@ -616,6 +657,14 @@ func getSource(input map[string]interface{}, output *Source) error {
 		}
 
 		return fmt.Errorf("source does not exist in any repo")
+	} else {
+		// we look in the passed-in repo
+		values, err := existsInRepo(output.Name, output.Version, output.Repo)
+
+		if err == nil {
+			output.SourceValues = values
+			return nil
+		}
 	}
 
 	return fmt.Errorf("source '%s' does not exist in repo '%s'", output.Name, output.Repo)

+ 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)
+}

+ 54 - 4
cli/cmd/create.go

@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"context"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -12,7 +13,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"
 )
 
@@ -163,6 +166,13 @@ func init() {
 		false,
 		"set this to force build an image",
 	)
+
+	createCmd.PersistentFlags().BoolVar(
+		&useCache,
+		"use-cache",
+		false,
+		"Whether to use cache (currently in beta)",
+	)
 }
 
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
@@ -175,20 +185,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 != "" {
@@ -217,6 +239,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 				LocalDockerfile: dockerfile,
 				Method:          buildMethod,
 				AdditionalEnv:   additionalEnv,
+				UseCache:        useCache,
 			},
 			Kind:        args[0],
 			ReleaseName: name,
@@ -225,6 +248,33 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 	}
 
 	if source == "local" {
+		if useCache {
+			regID, imageURL, err := createAgent.GetImageRepoURL(name, namespace)
+
+			if err != nil {
+				return err
+			}
+
+			err = client.CreateRepository(
+				context.Background(),
+				config.Project,
+				regID,
+				&types.CreateRegistryRepositoryRequest{
+					ImageRepoURI: imageURL,
+				},
+			)
+
+			if err != nil {
+				return err
+			}
+
+			err = setDockerConfig(createAgent.Client)
+
+			if err != nil {
+				return err
+			}
+		}
+
 		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil, forceBuild)
 
 		return handleSubdomainCreate(subdomain, err)

+ 44 - 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
@@ -207,6 +210,7 @@ var method string
 var stream bool
 var buildFlagsEnv []string
 var forcePush bool
+var useCache bool
 
 func init() {
 	buildFlagsEnv = []string{}
@@ -222,6 +226,13 @@ func init() {
 
 	updateCmd.MarkPersistentFlagRequired("app")
 
+	updateCmd.PersistentFlags().BoolVar(
+		&useCache,
+		"use-cache",
+		false,
+		"Whether to use cache (currently in beta)",
+	)
+
 	updateCmd.PersistentFlags().StringVar(
 		&namespace,
 		"namespace",
@@ -318,6 +329,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)
@@ -431,6 +460,7 @@ func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 			OverrideTag:     tag,
 			Method:          buildMethod,
 			AdditionalEnv:   additionalEnv,
+			UseCache:        useCache,
 		},
 		Local: source != "github",
 	})
@@ -450,6 +480,14 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 	}
 
+	if useCache {
+		err := setDockerConfig(updateAgent.Client)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	// read the values if necessary
 	valuesObj, err := readValuesFile()
 	if err != nil {
@@ -518,6 +556,12 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 }
 
 func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
+	if useCache {
+		color.New(color.FgGreen).Println("Skipping image push for", app, "as use-cache is set")
+
+		return nil
+	}
+
 	// push the deployment
 	color.New(color.FgGreen).Println("Pushing new image for", app)
 

+ 5 - 15
cli/cmd/deploy/build.go

@@ -49,6 +49,7 @@ func (b *BuildAgent) BuildDocker(
 		Env:               b.env,
 		DockerfilePath:    dockerfilePath,
 		IsDockerfileInCtx: isDockerfileInCtx,
+		UseCache:          b.UseCache,
 	}
 
 	return dockerAgent.BuildLocal(
@@ -74,26 +75,15 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag stri
 	packAgent := &pack.Agent{}
 
 	opts := &docker.BuildOpts{
-		ImageRepo: b.imageRepo,
-		// We tag the image with a stable param "pack-cache" so that pack can use the
-		// local image without attempting to re-pull from registry. We handle getting
-		// registry credentials and pushing/pulling the image.
-		Tag:          "pack-cache",
+		ImageRepo:    b.imageRepo,
+		Tag:          tag,
 		BuildContext: dst,
 		Env:          b.env,
+		UseCache:     b.UseCache,
 	}
 
 	// call builder
-	err := packAgent.Build(opts, buildConfig)
-
-	if err != nil {
-		return err
-	}
-
-	return dockerAgent.TagImage(
-		fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
-		fmt.Sprintf("%s:%s", b.imageRepo, tag),
-	)
+	return packAgent.Build(opts, buildConfig, fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"))
 }
 
 // ResolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path

+ 17 - 15
cli/cmd/deploy/create.go

@@ -313,24 +313,26 @@ func (c *CreateAgent) CreateFromDocker(
 			return "", err
 		}
 
-		// create repository
-		err = c.Client.CreateRepository(
-			context.Background(),
-			opts.ProjectID,
-			regID,
-			&types.CreateRegistryRepositoryRequest{
-				ImageRepoURI: imageURL,
-			},
-		)
+		if !opts.SharedOpts.UseCache {
+			// create repository
+			err = c.Client.CreateRepository(
+				context.Background(),
+				opts.ProjectID,
+				regID,
+				&types.CreateRegistryRepositoryRequest{
+					ImageRepoURI: imageURL,
+				},
+			)
 
-		if err != nil {
-			return "", err
-		}
+			if err != nil {
+				return "", err
+			}
 
-		err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
+			err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
 
-		if err != nil {
-			return "", err
+			if err != nil {
+				return "", err
+			}
 		}
 	}
 

+ 40 - 8
cli/cmd/deploy/deploy.go

@@ -32,7 +32,7 @@ const (
 type DeployAgent struct {
 	App string
 
-	client         *client.Client
+	Client         *client.Client
 	release        *types.GetReleaseResponse
 	agent          *docker.Agent
 	opts           *DeployOpts
@@ -57,7 +57,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	deployAgent := &DeployAgent{
 		App:    app,
 		opts:   opts,
-		client: client,
+		Client: client,
 		env:    make(map[string]string),
 	}
 
@@ -137,7 +137,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 
 	deployAgent.tag = opts.OverrideTag
 
-	err = coalesceEnvGroups(deployAgent.client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
+	err = coalesceEnvGroups(deployAgent.Client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
 		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
 
 	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
@@ -160,7 +160,7 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 		}
 	}
 
-	env, err := GetEnvForRelease(d.client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
+	env, err := GetEnvForRelease(d.Client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
 
 	if err != nil {
 		return nil, err
@@ -250,7 +250,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 			return fmt.Errorf("invalid formatting of repo name")
 		}
 
-		zipResp, err := d.client.GetRepoZIPDownloadURL(
+		zipResp, err := d.Client.GetRepoZIPDownloadURL(
 			context.Background(),
 			d.opts.ProjectID,
 			int64(d.release.GitActionConfig.GitRepoID),
@@ -292,7 +292,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 
 	buildAgent := &BuildAgent{
 		SharedOpts:  d.opts.SharedOpts,
-		client:      d.client,
+		client:      d.Client,
 		imageRepo:   d.imageRepo,
 		env:         d.env,
 		imageExists: d.imageExists,
@@ -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{})
 
@@ -370,7 +383,7 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 		return err
 	}
 
-	return d.client.UpgradeRelease(
+	return d.Client.UpgradeRelease(
 		context.Background(),
 		d.opts.ProjectID,
 		d.opts.ClusterID,
@@ -637,7 +650,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 }
 
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
-	return d.client.CreateEvent(
+	return d.Client.CreateEvent(
 		context.Background(),
 		d.opts.ProjectID, d.opts.ClusterID,
 		d.release.Namespace, d.release.Name,
@@ -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 ""
+}

+ 1 - 0
cli/cmd/deploy/shared.go

@@ -19,6 +19,7 @@ type SharedOpts struct {
 	Method          DeployBuildType
 	AdditionalEnv   map[string]string
 	EnvGroups       []types.EnvGroupMeta
+	UseCache        bool
 }
 
 func coalesceEnvGroups(

+ 16 - 1
cli/cmd/docker.go

@@ -46,6 +46,10 @@ func init() {
 }
 
 func dockerConfig(user *ptypes.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	return setDockerConfig(client)
+}
+
+func setDockerConfig(client *api.Client) error {
 	pID := config.Project
 
 	// get all registries that should be added
@@ -82,10 +86,21 @@ func dockerConfig(user *ptypes.GetAuthenticatedUserResponse, client *api.Client,
 		}
 	}
 
+	// create a docker dir if it does not exist
+	dockerDir := filepath.Join(home, ".docker")
+
+	if _, err := os.Stat(dockerDir); os.IsNotExist(err) {
+		err = os.Mkdir(dockerDir, 0700)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	dockerConfigFile := filepath.Join(home, ".docker", "config.json")
 
 	// determine if configfile exists
-	if info, err := os.Stat(dockerConfigFile); info.IsDir() || os.IsNotExist(err) {
+	if _, err := os.Stat(dockerConfigFile); os.IsNotExist(err) {
 		// if it does not exist, create it
 		err := ioutil.WriteFile(dockerConfigFile, []byte("{}"), 0700)
 

+ 15 - 15
cli/cmd/docker/agent.go

@@ -26,8 +26,8 @@ import (
 // Agent is a Docker client for performing operations that interact
 // with the Docker engine over REST
 type Agent struct {
+	*client.Client
 	authGetter *AuthGetter
-	client     *client.Client
 	ctx        context.Context
 	label      string
 }
@@ -36,7 +36,7 @@ type Agent struct {
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.
 func (a *Agent) CreateLocalVolumeIfNotExist(name string) (*types.Volume, error) {
-	volListBody, err := a.client.VolumeList(a.ctx, filters.Args{})
+	volListBody, err := a.VolumeList(a.ctx, filters.Args{})
 
 	if err != nil {
 		return nil, a.handleDockerClientErr(err, "Could not list volumes")
@@ -67,7 +67,7 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 		Labels: labels,
 	}
 
-	vol, err := a.client.VolumeCreate(a.ctx, opts)
+	vol, err := a.VolumeCreate(a.ctx, opts)
 
 	if err != nil {
 		return nil, a.handleDockerClientErr(err, "Could not create volume "+name)
@@ -78,14 +78,14 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 
 // RemoveLocalVolume removes a volume by name
 func (a *Agent) RemoveLocalVolume(name string) error {
-	return a.client.VolumeRemove(a.ctx, name, true)
+	return a.VolumeRemove(a.ctx, name, true)
 }
 
 // CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.
 func (a *Agent) CreateBridgeNetworkIfNotExist(name string) (id string, err error) {
-	networks, err := a.client.NetworkList(a.ctx, types.NetworkListOptions{})
+	networks, err := a.NetworkList(a.ctx, types.NetworkListOptions{})
 
 	if err != nil {
 		return "", a.handleDockerClientErr(err, "Could not list volumes")
@@ -113,7 +113,7 @@ func (a *Agent) CreateBridgeNetwork(name string) (id string, err error) {
 		Attachable: true,
 	}
 
-	net, err := a.client.NetworkCreate(a.ctx, name, opts)
+	net, err := a.NetworkCreate(a.ctx, name, opts)
 
 	if err != nil {
 		return "", a.handleDockerClientErr(err, "Could not create network "+name)
@@ -125,7 +125,7 @@ func (a *Agent) CreateBridgeNetwork(name string) (id string, err error) {
 // ConnectContainerToNetwork attaches a container to a specified network
 func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName string) error {
 	// check if the container is connected already
-	net, err := a.client.NetworkInspect(a.ctx, networkID, types.NetworkInspectOptions{})
+	net, err := a.NetworkInspect(a.ctx, networkID, types.NetworkInspectOptions{})
 
 	if err != nil {
 		return a.handleDockerClientErr(err, "Could not inspect network"+networkID)
@@ -138,11 +138,11 @@ func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName
 		}
 	}
 
-	return a.client.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
+	return a.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
 }
 
 func (a *Agent) TagImage(old, new string) error {
-	return a.client.ImageTag(a.ctx, old, new)
+	return a.ImageTag(a.ctx, old, new)
 }
 
 // PullImageEvent represents a response from the Docker API with an image pull event
@@ -267,7 +267,7 @@ func (a *Agent) CheckIfImageExists(imageRepo, imageTag string) bool {
 		return false
 	}
 
-	_, err = a.client.DistributionInspect(context.Background(), image, encodedRegistryAuth)
+	_, err = a.DistributionInspect(context.Background(), image, encodedRegistryAuth)
 
 	if err == nil {
 		return true
@@ -288,7 +288,7 @@ func (a *Agent) PullImage(image string) error {
 	}
 
 	// pull the specified image
-	out, err := a.client.ImagePull(a.ctx, image, opts)
+	out, err := a.ImagePull(a.ctx, image, opts)
 
 	if err != nil {
 		if client.IsErrNotFound(err) {
@@ -315,7 +315,7 @@ func (a *Agent) PushImage(image string) error {
 		return err
 	}
 
-	out, err := a.client.ImagePush(
+	out, err := a.ImagePush(
 		context.Background(),
 		image,
 		opts,
@@ -437,7 +437,7 @@ func GetServerURLFromTag(image string) (string, error) {
 // WaitForContainerStop waits until a container has stopped to exit
 func (a *Agent) WaitForContainerStop(id string) error {
 	// wait for container to stop before exit
-	statusCh, errCh := a.client.ContainerWait(a.ctx, id, container.WaitConditionNotRunning)
+	statusCh, errCh := a.ContainerWait(a.ctx, id, container.WaitConditionNotRunning)
 
 	select {
 	case err := <-errCh:
@@ -455,7 +455,7 @@ func (a *Agent) WaitForContainerStop(id string) error {
 // checks.
 func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
 	for {
-		cont, err := a.client.ContainerInspect(a.ctx, id)
+		cont, err := a.ContainerInspect(a.ctx, id)
 
 		if err != nil {
 			return a.handleDockerClientErr(err, "Error waiting for stopped container")
@@ -479,7 +479,7 @@ func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
 
 func (a *Agent) handleDockerClientErr(err error, errPrefix string) error {
 	if strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
-		return fmt.Errorf("The Docker daemon must be running in order to start Porter: connection to %s failed", a.client.DaemonHost())
+		return fmt.Errorf("The Docker daemon must be running in order to start Porter: connection to %s failed", a.DaemonHost())
 	}
 
 	return fmt.Errorf("%s:%s", errPrefix, err.Error())

+ 2 - 1
cli/cmd/docker/auth.go

@@ -154,7 +154,8 @@ func (a *AuthGetter) GetECRCredentials(serverURL string, projID uint) (user stri
 	} else {
 		// get a token from the server
 		tokenResp, err := a.Client.GetECRAuthorizationToken(context.Background(), projID, &types.GetRegistryECRTokenRequest{
-			Region: matches[3],
+			Region:    matches[3],
+			AccountID: matches[1],
 		})
 
 		if err != nil {

+ 2 - 1
cli/cmd/docker/builder.go

@@ -25,6 +25,7 @@ type BuildOpts struct {
 	BuildContext      string
 	DockerfilePath    string
 	IsDockerfileInCtx bool
+	UseCache          bool
 
 	Env map[string]string
 }
@@ -66,7 +67,7 @@ func (a *Agent) BuildLocal(opts *BuildOpts) error {
 	inlineCacheVal := "1"
 	buildArgs["BUILDKIT_INLINE_CACHE"] = &inlineCacheVal
 
-	out, err := a.client.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
+	out, err := a.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
 		Dockerfile: dockerfilePath,
 		BuildArgs:  buildArgs,
 		Tags: []string{

+ 1 - 1
cli/cmd/docker/config.go

@@ -23,7 +23,7 @@ func NewAgentFromEnv() (*Agent, error) {
 	}
 
 	return &Agent{
-		client: cli,
+		Client: cli,
 		ctx:    ctx,
 		label:  label,
 	}, nil

+ 12 - 12
cli/cmd/docker/porter.go

@@ -213,13 +213,13 @@ func (a *Agent) upsertPorterContainer(opts PorterServerStartOpts) (id string, er
 		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
 			timeout, _ := time.ParseDuration("15s")
 
-			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+			err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 			if err != nil {
 				return "", a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 			}
 
-			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+			err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
 
 			if err != nil {
 				return "", a.handleDockerClientErr(err, "Could not remove container "+container.ID)
@@ -247,7 +247,7 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id str
 	labels[a.label] = "true"
 
 	// create the container with a label specifying this was created via the CLI
-	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+	resp, err := a.ContainerCreate(a.ctx, &container.Config{
 		Image:   opts.Image,
 		Cmd:     opts.StartCmd,
 		Tty:     false,
@@ -274,7 +274,7 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id str
 
 // start the container
 func (a *Agent) startPorterContainer(id string) error {
-	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+	if err := a.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
 		return a.handleDockerClientErr(err, "Could not start Porter container")
 	}
 
@@ -328,7 +328,7 @@ func (a *Agent) upsertPostgresContainer(opts PostgresOpts) (id string, err error
 		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
 			timeout, _ := time.ParseDuration("15s")
 
-			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+			err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 			if err != nil {
 				return "", a.handleDockerClientErr(err, "Could not stop postgres container "+container.ID)
@@ -349,7 +349,7 @@ func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, er
 	labels[a.label] = "true"
 
 	// create the container with a label specifying this was created via the CLI
-	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+	resp, err := a.ContainerCreate(a.ctx, &container.Config{
 		Image:   opts.Image,
 		Tty:     false,
 		Labels:  labels,
@@ -377,7 +377,7 @@ func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, er
 
 // start the container in the background
 func (a *Agent) startPostgresContainer(id string) error {
-	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+	if err := a.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
 		return a.handleDockerClientErr(err, "Could not start Postgres container")
 	}
 
@@ -397,14 +397,14 @@ func (a *Agent) StopPorterContainers(remove bool) error {
 	for _, container := range containers {
 		timeout, _ := time.ParseDuration("15s")
 
-		err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+		err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 		if err != nil {
 			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 		}
 
 		if remove {
-			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+			err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
 
 			if err != nil {
 				return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
@@ -430,14 +430,14 @@ func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool)
 		if strings.Contains(container.Names[0], "_"+processID) {
 			timeout, _ := time.ParseDuration("15s")
 
-			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+			err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 			if err != nil {
 				return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 			}
 
 			if remove {
-				err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+				err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
 
 				if err != nil {
 					return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
@@ -452,7 +452,7 @@ func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool)
 // getContainersCreatedByStart gets all containers that were created by the "porter start"
 // command by looking for the label "CreatedByPorterCLI" (or .label of the agent)
 func (a *Agent) getContainersCreatedByStart() ([]types.Container, error) {
-	containers, err := a.client.ContainerList(a.ctx, types.ContainerListOptions{
+	containers, err := a.ContainerList(a.ctx, types.ContainerListOptions{
 		All: true,
 	})
 

+ 37 - 2
cli/cmd/job.go

@@ -166,8 +166,14 @@ func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		return pausedErr
 	}
 
-	// if no job exists with the given revision, wait up to 30 minutes
-	timeWait := time.Now().Add(30 * time.Minute)
+	// attempt to parse out the timeout value for the job, given by `sidecar.timeout`
+	// if it does not exist, we set the default to 30 minutes
+	timeoutVal := getJobTimeoutValue(jobRelease.Release.Config)
+
+	color.New(color.FgYellow).Printf("Waiting for timeout seconds %.1f\n", timeoutVal.Seconds())
+
+	// if no job exists with the given revision, wait for the timeout value
+	timeWait := time.Now().Add(timeoutVal)
 
 	for time.Now().Before(timeWait) {
 		// get the jobs for that job chart
@@ -222,3 +228,32 @@ func getJobMatchingRevision(revision uint, jobs []v1.Job) *v1.Job {
 
 	return nil
 }
+
+func getJobTimeoutValue(values map[string]interface{}) time.Duration {
+	defaultTimeout := time.Minute * 60
+	sidecarInter, ok := values["sidecar"]
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	sidecarVal, ok := sidecarInter.(map[string]interface{})
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	timeoutInter, ok := sidecarVal["timeout"]
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	timeoutVal, ok := timeoutInter.(int64)
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	return time.Second * time.Duration(timeoutVal)
+}

+ 41 - 13
cli/cmd/pack/logger.go

@@ -3,21 +3,29 @@ package pack
 import (
 	"fmt"
 	"io"
+	"io/ioutil"
 	"log"
 	"os"
 	"strings"
 
-	"github.com/buildpacks/pack/logging"
+	"github.com/buildpacks/pack/pkg/logging"
 )
 
 type packLogger struct {
-	out *log.Logger
+	outDiscard *log.Logger
+	outStderr  *log.Logger
+	safeWriter *safeWriter
 }
 
 // Replicate the exact behavior of https://github.com/buildpacks/pack/blob/main/pkg/logging/logger_simple.go
 func newPackLogger() logging.Logger {
+	discard := log.New(ioutil.Discard, "", log.LstdFlags|log.Lmicroseconds)
+	stderr := log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds)
+
 	return &packLogger{
-		out: log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds),
+		outDiscard: discard,
+		outStderr:  stderr,
+		safeWriter: &safeWriter{discard, stderr},
 	}
 }
 
@@ -30,44 +38,64 @@ const (
 )
 
 func (l *packLogger) Debug(msg string) {
-	l.out.Printf(prefixFmt, debugPrefix, msg)
+	l.outStderr.Printf(prefixFmt, debugPrefix, msg)
 }
 
 func (l *packLogger) Debugf(format string, v ...interface{}) {
 	// We do not want to print the environment variables for now as they might
 	// contain sensitive information like client IDs and secrets
 	// Refer: https://github.com/buildpacks/pack/blob/main/internal/builder/builder.go#L349
-	if !strings.HasPrefix(format, "Provided Environment Variables") {
-		l.out.Printf(prefixFmt, debugPrefix, fmt.Sprintf(format, v...))
+	if strings.HasPrefix(format, "Provided Environment Variables") {
+		return
+	}
+
+	// We do not print the registry auth credentials -- this should also be treated as sensitive information
+	if strings.Contains(fmt.Sprintf(format, v...), "CNB_REGISTRY_AUTH") {
+		return
 	}
+
+	l.outStderr.Printf(prefixFmt, debugPrefix, fmt.Sprintf(format, v...))
 }
 
 func (l *packLogger) Info(msg string) {
-	l.out.Printf(prefixFmt, infoPrefix, msg)
+	l.outStderr.Printf(prefixFmt, infoPrefix, msg)
 }
 
 func (l *packLogger) Infof(format string, v ...interface{}) {
-	l.out.Printf(prefixFmt, infoPrefix, fmt.Sprintf(format, v...))
+	l.outStderr.Printf(prefixFmt, infoPrefix, fmt.Sprintf(format, v...))
 }
 
 func (l *packLogger) Warn(msg string) {
-	l.out.Printf(prefixFmt, warnPrefix, msg)
+	l.outStderr.Printf(prefixFmt, warnPrefix, msg)
 }
 
 func (l *packLogger) Warnf(format string, v ...interface{}) {
-	l.out.Printf(prefixFmt, warnPrefix, fmt.Sprintf(format, v...))
+	l.outStderr.Printf(prefixFmt, warnPrefix, fmt.Sprintf(format, v...))
 }
 
 func (l *packLogger) Error(msg string) {
-	l.out.Printf(prefixFmt, errorPrefix, msg)
+	l.outStderr.Printf(prefixFmt, errorPrefix, msg)
 }
 
 func (l *packLogger) Errorf(format string, v ...interface{}) {
-	l.out.Printf(prefixFmt, errorPrefix, fmt.Sprintf(format, v...))
+	l.outStderr.Printf(prefixFmt, errorPrefix, fmt.Sprintf(format, v...))
+}
+
+type safeWriter struct {
+	outDiscard *log.Logger
+	outStderr  *log.Logger
+}
+
+func (s *safeWriter) Write(p []byte) (n int, err error) {
+	if strings.Contains(string(p), "Unable to delete previous cache image") {
+		return s.outDiscard.Writer().Write(p)
+	}
+
+	return s.outStderr.Writer().Write(p)
 }
 
 func (l *packLogger) Writer() io.Writer {
-	return l.out.Writer()
+	return l.safeWriter
 }
 
 func (l *packLogger) IsVerbose() bool {

+ 21 - 9
cli/cmd/pack/pack.go

@@ -9,7 +9,7 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/buildpacks/pack"
+	packclient "github.com/buildpacks/pack/pkg/client"
 	githubApi "github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -17,29 +17,41 @@ import (
 	"k8s.io/client-go/util/homedir"
 )
 
-type Agent struct{}
+var sharedPackClient *packclient.Client
 
-func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) error {
+func init() {
+	var err error
 	//initialize a pack client
-	client, err := pack.NewClient(pack.WithLogger(newPackLogger()))
+	logger := newPackLogger()
+
+	sharedPackClient, err = packclient.NewClient(packclient.WithLogger(logger))
 
 	if err != nil {
-		return err
+		panic(err)
 	}
+}
 
+type Agent struct{}
+
+func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig, cacheImage string) error {
 	absPath, err := filepath.Abs(opts.BuildContext)
 
 	if err != nil {
 		return err
 	}
 
-	buildOpts := pack.BuildOptions{
+	buildOpts := packclient.BuildOptions{
 		RelativeBaseDir: filepath.Dir(absPath),
 		Image:           fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
 		Builder:         "paketobuildpacks/builder:full",
 		AppPath:         opts.BuildContext,
-		TrustBuilder:    true,
 		Env:             opts.Env,
+		GroupID:         0,
+	}
+
+	if opts.UseCache {
+		buildOpts.CacheImage = cacheImage
+		buildOpts.Publish = true
 	}
 
 	if buildConfig != nil {
@@ -128,8 +140,8 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 	}
 
 	if len(buildOpts.Buildpacks) > 0 && strings.HasPrefix(buildOpts.Builder, "heroku") {
-		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile")
+		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile@1.0.0")
 	}
 
-	return client.Build(context.Background(), buildOpts)
+	return sharedPackClient.Build(context.Background(), buildOpts)
 }

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

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "v0.19.6"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 23 - 0
dashboard/package-lock.json

@@ -3898,6 +3898,24 @@
         "sha.js": "^2.4.8"
       }
     },
+    "cron-parser": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.3.0.tgz",
+      "integrity": "sha512-mK6qJ6k9Kn0/U7Cv6LKQnReUW3GqAW4exgwmHJGb3tPgcy0LrS+PeqxPPiwL8uW/4IJsMsCZrCc4vf1nnXMjzA==",
+      "requires": {
+        "luxon": "^1.28.0"
+      }
+    },
+    "cron-validator": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz",
+      "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A=="
+    },
+    "cronstrue": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.2.0.tgz",
+      "integrity": "sha512-oM/ftAvCNIdygVGGfYp8gxrVc81mDSA2mff0kvu6+ehrZhfYPzGHG8DVcFdrRVizjHnzWoFIlgEq6KTM/9lPBw=="
+    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -6472,6 +6490,11 @@
         "yallist": "^4.0.0"
       }
     },
+    "luxon": {
+      "version": "1.28.0",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz",
+      "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ=="
+    },
     "make-dir": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",

+ 3 - 0
dashboard/package.json

@@ -25,6 +25,9 @@
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",
     "core-js": "^3.16.1",
+    "cron-parser": "^4.3.0",
+    "cron-validator": "^1.3.1",
+    "cronstrue": "^2.2.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",

+ 98 - 16
dashboard/src/components/Table.tsx

@@ -9,8 +9,13 @@ import {
 } from "react-table";
 import Loading from "components/Loading";
 import Selector from "./Selector";
+import loading from "assets/loading.gif";
 
-const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
   const [value, setValue] = React.useState("");
   const onChange = (value: string) => {
     setValue(value);
@@ -18,16 +23,29 @@ const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
   };
 
   return (
-    <SearchRow>
-      <i className="material-icons">search</i>
-      <SearchInput
-        value={value}
-        onChange={(e: any) => {
-          onChange(e.target.value);
-        }}
-        placeholder="Search"
-      />
-    </SearchRow>
+    <SearchRowWrapper>
+      <SearchRow>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={value}
+          onChange={(e: any) => {
+            onChange(e.target.value);
+          }}
+          placeholder="Search"
+        />
+      </SearchRow>
+      {typeof onRefresh === "function" && (
+        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+      )}
+    </SearchRowWrapper>
   );
 };
 
@@ -39,6 +57,10 @@ export type TableProps = {
   disableGlobalFilter?: boolean;
   disableHover?: boolean;
   enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
 };
 
 const MIN_PAGE_SIZE = 1;
@@ -51,6 +73,10 @@ const Table: React.FC<TableProps> = ({
   disableGlobalFilter = false,
   disableHover,
   enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
 }) => {
   const {
     getTableProps,
@@ -87,10 +113,20 @@ const Table: React.FC<TableProps> = ({
   }, [data, enablePagination]);
 
   const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
     if (isLoading) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
             <Loading />
           </StyledTd>
         </StyledTr>
@@ -100,7 +136,9 @@ const Table: React.FC<TableProps> = ({
     if (!page.length) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
         </StyledTr>
       );
     }
@@ -140,7 +178,11 @@ const Table: React.FC<TableProps> = ({
   return (
     <TableWrapper>
       {!disableGlobalFilter && (
-        <GlobalFilter setGlobalFilter={setGlobalFilter} />
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
       )}
       <StyledTable {...getTableProps()}>
         <StyledTHead>
@@ -281,6 +323,12 @@ export const StyledTd = styled.td`
     padding-right: 10px;
   }
   user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
 `;
 
 export const StyledTHead = styled.thead`
@@ -332,8 +380,7 @@ const SearchRow = styled.div`
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
-  margin-bottom: 15px;
-  margin-top: 0px;
+
   i {
     width: 18px;
     height: 18px;
@@ -342,3 +389,38 @@ const SearchRow = styled.div`
     font-size: 20px;
   }
 `;
+
+const SearchRowWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 0px;
+`;
+
+const RefreshButton = styled.button`
+  justify-self: flex-end;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;

+ 17 - 5
dashboard/src/components/expanded-object/Header.tsx

@@ -10,24 +10,36 @@ type Props = {
   name: string;
   icon: string;
   inline_title_items?: React.ReactNodeArray;
+  sub_title_items?: React.ReactNodeArray;
+  materialIconClass?: string;
 };
 
 const Header: React.FunctionComponent<Props> = (props) => {
-  const { last_updated, back_link, icon, name, inline_title_items } = props;
+  const {
+    last_updated,
+    back_link,
+    icon,
+    name,
+    inline_title_items,
+    sub_title_items,
+    materialIconClass,
+  } = props;
 
   return (
     <HeaderWrapper>
       <BackButton to={back_link}>
         <BackButtonImg src={backArrow} />
       </BackButton>
-      <Title icon={icon} iconWidth="25px">
+      <Title icon={icon} iconWidth="25px" materialIconClass={materialIconClass}>
         {name}
         <Flex>{inline_title_items}</Flex>
       </Title>
 
-      <InfoWrapper>
-        <InfoText>Last updated {last_updated}</InfoText>
-      </InfoWrapper>
+      {sub_title_items || (
+        <InfoWrapper>
+          <InfoText>Last updated {last_updated}</InfoText>
+        </InfoWrapper>
+      )}
     </HeaderWrapper>
   );
 };

+ 4 - 2
dashboard/src/components/form-components/InputRow.tsx

@@ -15,6 +15,7 @@ type PropsType = {
   isRequired?: boolean;
   className?: string;
   maxLength?: number;
+  hasError?: boolean;
 };
 
 type StateType = {
@@ -65,7 +66,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             {this.props.isRequired && <Required>{" *"}</Required>}
           </Label>
         )}
-        <InputWrapper>
+        <InputWrapper hasError={this.props.hasError}>
           <Input
             readOnly={this.state.readOnly}
             onFocus={() => this.setState({ readOnly: false })}
@@ -103,7 +104,8 @@ const InputWrapper = styled.div`
   display: flex;
   margin-bottom: -1px;
   align-items: center;
-  border: 1px solid #ffffff55;
+  border: 1px solid
+    ${(props: { hasError: boolean }) => (props.hasError ? "red" : "#ffffff55")};
   border-radius: 3px;
 `;
 

+ 8 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -2,6 +2,7 @@ import React, { useContext } from "react";
 import {
   ArrayInputField,
   CheckboxField,
+  CronField,
   FormField,
   InputField,
   KeyValueArrayField,
@@ -9,6 +10,7 @@ import {
   Section,
   SelectField,
   ServiceIPListField,
+  TextAreaField,
 } from "./types";
 import TabRegion, { TabOption } from "../TabRegion";
 import Heading from "../form-components/Heading";
@@ -24,6 +26,8 @@ import Select from "./field-components/Select";
 import ServiceIPList from "./field-components/ServiceIPList";
 import ResourceList from "./field-components/ResourceList";
 import VeleroForm from "./field-components/VeleroForm";
+import CronInput from "./field-components/CronInput";
+import TextAreaInput from "./field-components/TextAreaInput";
 
 interface Props {
   leftTabOptions?: TabOption[];
@@ -84,6 +88,10 @@ const PorterForm: React.FC<Props> = (props) => {
         return <ResourceList {...(bundledProps as ResourceListField)} />;
       case "velero-create-backup":
         return <VeleroForm />;
+      case "cron":
+        return <CronInput {...(bundledProps as CronField)} />;
+      case "text-area":
+        return <TextAreaInput {...(bundledProps as TextAreaField)} />;
     }
     return <p>Not Implemented: {(field as any).type}</p>;
   };

+ 89 - 0
dashboard/src/components/porter-form/field-components/CronInput.tsx

@@ -0,0 +1,89 @@
+import InputRow from "components/form-components/InputRow";
+import React from "react";
+import useFormField from "../hooks/useFormField";
+import { CronField } from "../types";
+import { hasSetValue } from "../utils";
+import { isValidCron } from "cron-validator";
+import CronParser from "cronstrue";
+import styled from "styled-components";
+import DocsHelper from "components/DocsHelper";
+import DynamicLink from "components/DynamicLink";
+
+const CronInput: React.FC<CronField> = (props) => {
+  const { id, variable, label, placeholder, value } = props;
+
+  const { state, variables, setVars, setValidation, validation } = useFormField(
+    id,
+    {
+      initValidation: {
+        validated: hasSetValue(props) ? isValidCron(value[0]) : true,
+      },
+      initVars: {
+        [variable]: hasSetValue(props) ? value[0] : undefined,
+      },
+    }
+  );
+
+  if (!state || validation[id]?.validated === undefined) {
+    return null;
+  }
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        label={label}
+        placeholder={placeholder}
+        value={variables[variable]}
+        setValue={(x: string) => {
+          setVars((vars) => {
+            return {
+              ...vars,
+              [variable]: x,
+            };
+          });
+          setValidation((prev) => {
+            return {
+              ...prev,
+              validated: isValidCron(x),
+            };
+          });
+        }}
+        width={"100%"}
+        hasError={!validation[id]?.validated}
+      />
+      <Label error={!validation[id]?.validated}>
+        {!validation[id]?.validated ? (
+          <>
+            The expresion is not valid, to learn more about cron jobs please
+            click{" "}
+            <DynamicLink
+              style={{ color: "red", textDecoration: "underline" }}
+              to="https://docs.porter.run/running-jobs/deploying-jobs#deploying-a-cron-job"
+            >
+              here
+            </DynamicLink>
+          </>
+        ) : (
+          <>
+            {CronParser.toString(variables[variable], {
+              throwExceptionOnParseError: false,
+              verbose: true,
+            })}{" "}
+            UTC
+          </>
+        )}
+      </Label>
+    </>
+  );
+};
+
+const Label = styled.label`
+  ${(props: { error: boolean }) => {
+    if (props.error) {
+      return "color: red;";
+    }
+  }}
+`;
+
+export default CronInput;

+ 128 - 0
dashboard/src/components/porter-form/field-components/TextAreaInput.tsx

@@ -0,0 +1,128 @@
+import { Tooltip } from "@material-ui/core";
+import React from "react";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import { StringInputFieldState, TextAreaField } from "../types";
+import { hasSetValue } from "../utils";
+
+const TextAreaInput: React.FC<TextAreaField> = (props) => {
+  const {
+    id,
+    variable,
+    label,
+    info,
+    placeholder,
+    required,
+    settings,
+    isReadOnly,
+    value,
+  } = props;
+
+  const { state, variables, setVars } = useFormField<StringInputFieldState>(
+    id,
+    {
+      initVars: {
+        [variable]: hasSetValue(props) ? value[0] : undefined,
+      },
+    }
+  );
+
+  if (!state) {
+    return null;
+  }
+
+  return (
+    <div>
+      {label || info ? (
+        <Label>
+          {label}
+          {info && (
+            <Tooltip
+              title={
+                <div
+                  style={{
+                    fontFamily: "Work Sans, sans-serif",
+                    fontSize: "12px",
+                    fontWeight: "normal",
+                    padding: "5px 6px",
+                  }}
+                >
+                  {info}
+                </div>
+              }
+              placement="top"
+            >
+              <StyledInfoTooltip>
+                <i className="material-icons">help_outline</i>
+              </StyledInfoTooltip>
+            </Tooltip>
+          )}
+          {required && <Required>{" *"}</Required>}
+        </Label>
+      ) : null}
+      <TextArea
+        maxLength={settings?.options?.maxCount}
+        minLength={settings?.options?.minCount}
+        disabled={isReadOnly}
+        value={variables[variable]}
+        placeholder={placeholder}
+        onChange={(e) => {
+          e?.persist();
+          setVars((prev) => {
+            return {
+              ...prev,
+              [variable]: e?.target?.value,
+            };
+          });
+        }}
+      ></TextArea>
+    </div>
+  );
+};
+
+export default TextAreaInput;
+
+const TextArea = styled.textarea`
+  width: 100%;
+  max-width: 100%;
+  min-height: 150px;
+  height: auto;
+  max-height: 300px;
+  background: #ffffff11;
+  color: #ffffff;
+  border-radius: 5px;
+  padding: 10px;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : ""};
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    font-size: 16px;
+    margin-left: 5px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;

+ 2 - 0
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -9,6 +9,7 @@ import {
 interface FormFieldData<T> {
   state: T;
   variables: PorterFormVariableList;
+  validation: { [key: string]: PorterFormFieldValidationState };
   setState: (setFunc: (prev: T) => Partial<T>) => void;
   setVars: (
     setFunc: (vars: PorterFormVariableList) => PorterFormVariableList
@@ -89,6 +90,7 @@ const useFormField = <T extends PorterFormFieldFieldState>(
   return {
     state: formState.components[fieldId]?.state as T,
     variables: formState.variables,
+    validation: formState.validation,
     setState,
     setVars,
     setValidation,

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

@@ -85,7 +85,7 @@ export interface KeyValueArrayField extends GenericInputField {
   settings?: {
     options?: {
       enable_synced_env_groups: boolean;
-    },
+    };
     type: "env" | "normal";
   };
 }
@@ -119,6 +119,29 @@ export interface VariableField extends GenericInputField {
   };
 }
 
+export interface CronField extends GenericInputField {
+  type: "cron";
+  label: string;
+  placeholder: string;
+  settings: {
+    default: string;
+  };
+}
+
+export interface TextAreaField extends GenericInputField {
+  type: "text-area";
+  label: string;
+  placeholder: string;
+  info: string;
+  settings: {
+    default?: string;
+    options?: {
+      maxCount?: number;
+      minCount?: number;
+    };
+  };
+}
+
 export type FormField =
   | HeadingField
   | SubtitleField
@@ -130,7 +153,9 @@ export type FormField =
   | ServiceIPListField
   | ResourceListField
   | VeleroBackupField
-  | VariableField;
+  | VariableField
+  | CronField
+  | TextAreaField;
 
 export interface ShowIfAnd {
   and: ShowIf[];

+ 37 - 23
dashboard/src/components/repo-selector/RepoList.tsx

@@ -20,6 +20,7 @@ type Props = {
   setActionConfig: (x: ActionConfigType) => void;
   userId?: number;
   readOnly: boolean;
+  filteredRepos?: string[];
 };
 
 const RepoList: React.FC<Props> = ({
@@ -27,6 +28,7 @@ const RepoList: React.FC<Props> = ({
   setActionConfig,
   userId,
   readOnly,
+  filteredRepos,
 }) => {
   const [repos, setRepos] = useState<RepoType[]>([]);
   const [repoLoading, setRepoLoading] = useState(true);
@@ -123,7 +125,6 @@ const RepoList: React.FC<Props> = ({
       });
   }, []);
 
-
   // clear out actionConfig and SelectedRepository if new search is performed
   useEffect(() => {
     setActionConfig({
@@ -132,15 +133,15 @@ const RepoList: React.FC<Props> = ({
       git_branch: null,
       git_repo_id: 0,
     });
-    setSelectedRepo(null)
-  }, [searchFilter])
+    setSelectedRepo(null);
+  }, [searchFilter]);
 
   const setRepo = (x: RepoType) => {
     let updatedConfig = actionConfig;
     updatedConfig.git_repo = x.FullName;
     updatedConfig.git_repo_id = x.GHRepoID;
     setActionConfig(updatedConfig);
-    setSelectedRepo(x.FullName)
+    setSelectedRepo(x.FullName);
   };
 
   const renderRepoList = () => {
@@ -191,6 +192,9 @@ const RepoList: React.FC<Props> = ({
       return <LoadingWrapper>No matching Github repos found.</LoadingWrapper>;
     } else {
       return results.map((repo: RepoType, i: number) => {
+        const shouldDisable = !!filteredRepos?.find(
+          (filteredRepo) => repo.FullName === filteredRepo
+        );
         return (
           <RepoName
             key={i}
@@ -198,9 +202,11 @@ const RepoList: React.FC<Props> = ({
             lastItem={i === repos.length - 1}
             onClick={() => setRepo(repo)}
             readOnly={readOnly}
+            disabled={shouldDisable}
           >
             <img src={github} alt={"github icon"} />
             {repo.FullName}
+            {shouldDisable && ` - This repo was already added`}
           </RepoName>
         );
       });
@@ -237,32 +243,40 @@ const RepoListWrapper = styled.div`
   overflow-y: auto;
 `;
 
-const RepoName = styled.div`
+type RepoNameProps = {
+  lastItem: boolean;
+  isSelected: boolean;
+  readOnly: boolean;
+  disabled: boolean;
+};
+
+const RepoName = styled.div<RepoNameProps>`
   display: flex;
   width: 100%;
   font-size: 13px;
   border-bottom: 1px solid
-    ${(props: { lastItem: boolean; isSelected: boolean; readOnly: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
-  color: #ffffff;
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+  color: ${(props) => (props.disabled ? "#ffffff88" : "#ffffff")};
   user-select: none;
   align-items: center;
   padding: 10px 0px;
-  cursor: ${(props: {
-    lastItem: boolean;
-    isSelected: boolean;
-    readOnly: boolean;
-  }) => (props.readOnly ? "default" : "pointer")};
-  pointer-events: ${(props: {
-    lastItem: boolean;
-    isSelected: boolean;
-    readOnly: boolean;
-  }) => (props.readOnly ? "none" : "auto")};
-  background: ${(props: {
-    lastItem: boolean;
-    isSelected: boolean;
-    readOnly: boolean;
-  }) => (props.isSelected ? "#ffffff22" : "#ffffff11")};
+  cursor: ${(props) =>
+    props.readOnly || props.disabled ? "default" : "pointer"};
+  pointer-events: ${(props) =>
+    props.readOnly || props.disabled ? "none" : "auto"};
+
+  ${(props) => {
+    if (props.disabled) {
+      return "";
+    }
+
+    if (props.isSelected) {
+      return `background: #ffffff22;`;
+    }
+
+    return `background: #ffffff11;`;
+  }}
+
   :hover {
     background: #ffffff22;
 

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -177,6 +177,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               sortType={this.state.sortType}
+              currentView={currentView}
             />
           </SortFilterWrapper>
         </ControlRow>
@@ -247,6 +248,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               sortType={this.state.sortType}
+              currentView={currentView}
             />
           </SortFilterWrapper>
         </ControlRow>

+ 20 - 1
dashboard/src/main/home/cluster-dashboard/SortSelector.tsx

@@ -8,6 +8,7 @@ import Selector from "components/Selector";
 type PropsType = {
   setSortType: (x: string) => void;
   sortType: string;
+  currentView: string;
 };
 
 type StateType = {
@@ -21,9 +22,27 @@ export default class SortSelector extends Component<PropsType, StateType> {
       { label: "Newest", value: "Newest" },
       { label: "Oldest", value: "Oldest" },
       { label: "Alphabetical", value: "Alphabetical" },
+      { label: "Next Run", value: "Next Run" },
     ] as { label: string; value: string }[],
   };
 
+  getSortOptions() {
+    if (this.props.currentView === "jobs") {
+      return [
+        { label: "Newest", value: "Newest" },
+        { label: "Oldest", value: "Oldest" },
+        { label: "Alphabetical", value: "Alphabetical" },
+        { label: "Next Run", value: "Next Run" },
+      ];
+    }
+
+    return [
+      { label: "Newest", value: "Newest" },
+      { label: "Oldest", value: "Oldest" },
+      { label: "Alphabetical", value: "Alphabetical" },
+    ];
+  }
+
   render() {
     return (
       <StyledSortSelector>
@@ -33,7 +52,7 @@ export default class SortSelector extends Component<PropsType, StateType> {
         <Selector
           activeValue={this.props.sortType}
           setActiveValue={(sortType) => this.props.setSortType(sortType)}
-          options={this.state.sortOptions}
+          options={this.getSortOptions()}
           dropdownLabel="Sort By"
           width="150px"
           dropdownWidth="230px"

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

@@ -13,6 +13,14 @@ import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
 import api from "shared/api";
 import { readableDate } from "shared/string_utils";
+import { Tooltip, Zoom } from "@material-ui/core";
+import CronParser from "cron-parser";
+
+import {
+  createTheme,
+  MuiThemeProvider,
+  withStyles,
+} from "@material-ui/core/styles";
 
 type Props = {
   chart: ChartType;
@@ -22,6 +30,17 @@ type Props = {
   closeChartRedirectUrl?: string;
 };
 
+const theme = createTheme({
+  overrides: {
+    MuiTooltip: {
+      tooltip: {
+        backgroundColor: "#3E3F44",
+        border: "1px solid #ffffff33",
+      },
+    },
+  },
+});
+
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
@@ -31,6 +50,7 @@ const Chart: React.FunctionComponent<Props> = ({
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
+  const [showDescription, setShowDescription] = useState(false);
   const context = useContext(Context);
   const location = useLocation();
   const history = useHistory();
@@ -83,6 +103,21 @@ const Chart: React.FunctionComponent<Props> = ({
     return tmpControllers;
   }, [chartControllers, controllers]);
 
+  let interval = null;
+  if (chart?.config?.schedule?.enabled) {
+    interval = CronParser.parseExpression(chart?.config?.schedule.value, {
+      utc: true,
+    });
+  }
+
+  // @ts-ignore
+  const rtf = new Intl.DateTimeFormat("en", {
+    localeMatcher: "best fit", // other values: "lookup"
+    // @ts-ignore
+    dateStyle: "full",
+    timeStyle: "long",
+  });
+
   return (
     <StyledChart
       onMouseEnter={() => setExpand(true)}
@@ -101,6 +136,33 @@ const Chart: React.FunctionComponent<Props> = ({
       <Title>
         <IconWrapper>{renderIcon()}</IconWrapper>
         {chart.name}
+        {chart?.config?.description && (
+          <>
+            <Dot style={{ marginLeft: "9px", color: "#ffffff88" }}>•</Dot>
+            <MuiThemeProvider theme={theme}>
+              <Tooltip
+                TransitionComponent={Zoom}
+                placement={"bottom-start"}
+                title={
+                  <div
+                    style={{
+                      fontFamily: "Work Sans, sans-serif",
+                      fontSize: "12px",
+                      fontWeight: "normal",
+                      padding: "5px 6px",
+                      color: "#ffffffdd",
+                      lineHeight: "16px",
+                    }}
+                  >
+                    {chart.config.description as string}
+                  </div>
+                }
+              >
+                <Description>{chart.config.description}</Description>
+              </Tooltip>
+            </MuiThemeProvider>
+          </>
+        )}
       </Title>
 
       <BottomWrapper>
@@ -129,6 +191,14 @@ const Chart: React.FunctionComponent<Props> = ({
                 </JobStatus>
               </>
             )}
+            {chart.config?.schedule?.enabled ? (
+              <>
+                <Dot style={{ marginLeft: "10px" }}>•</Dot>
+                <JobStatus>
+                  Next run {rtf.format(interval?.next().toDate() || new Date())}
+                </JobStatus>
+              </>
+            ) : null}
           </LastDeployed>
         </InfoWrapper>
 
@@ -147,6 +217,17 @@ const Chart: React.FunctionComponent<Props> = ({
 
 export default Chart;
 
+const Description = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  max-width: 80%;
+  color: #ffffff88;
+  position: relative;
+  font-size: 13px;
+  padding-top: 1px;
+`;
+
 const BottomWrapper = styled.div`
   display: flex;
   justify-content: space-between;
@@ -244,6 +325,7 @@ const IconWrapper = styled.div`
 `;
 
 const Title = styled.div`
+  display: flex;
   position: relative;
   text-decoration: none;
   padding: 12px 35px 12px 45px;

+ 36 - 0
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -16,6 +16,7 @@ import { PorterUrl } from "shared/routing";
 import Chart from "./Chart";
 import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
+import CronParser from "cron-parser";
 
 type Props = {
   currentCluster: ClusterType;
@@ -360,6 +361,41 @@ const ChartList: React.FunctionComponent<Props> = ({
       );
     } else if (sortType == "Alphabetical") {
       result.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+    } else if (sortType == "Next Run" && currentView === "jobs") {
+      const cronJobs = result.filter(
+        (chart) => chart?.config?.schedule?.enabled
+      );
+      const nonCronJobs = result.filter(
+        (chart) => !chart?.config?.schedule?.enabled
+      );
+      cronJobs.sort((a: any, b: any) => {
+        let firstInterval = null;
+        if (a?.config?.schedule?.enabled) {
+          firstInterval = CronParser.parseExpression(
+            a?.config?.schedule.value,
+            {
+              utc: true,
+            }
+          );
+        }
+
+        let secondInterval = null;
+        if (b?.config?.schedule?.enabled) {
+          secondInterval = CronParser.parseExpression(
+            b?.config?.schedule.value,
+            {
+              utc: true,
+            }
+          );
+        }
+
+        return Date.parse(firstInterval.next().toISOString()) >
+          Date.parse(secondInterval.next().toISOString())
+          ? 1
+          : -1;
+      });
+
+      return [...cronJobs, ...nonCronJobs];
     }
 
     return result;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -15,7 +15,7 @@ type Props = {
   sortType: "Newest" | "Oldest" | "Alphabetical";
 };
 
-const dateFormatter = (date: string) => {
+export const dateFormatter = (date: string | number) => {
   if (!date) {
     return "N/A";
   }

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -11,10 +11,10 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import EventsTab from "./events/EventsTab";
 import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
+import IncidentsTab from "./incidents/IncidentsTab";
 
 type TabEnum =
   | "preview_environments"
@@ -22,7 +22,7 @@ type TabEnum =
   | "settings"
   | "namespaces"
   | "metrics"
-  | "events";
+  | "incidents";
 
 const tabOptions: {
   label: string;
@@ -30,7 +30,7 @@ const tabOptions: {
 }[] = [
   { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
-  { label: "Events", value: "events" },
+  { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -53,8 +53,8 @@ export const Dashboard: React.FunctionComponent = () => {
           return <EnvironmentList />;
         }
         return <NodeList />;
-      case "events":
-        return <EventsTab />;
+      case "incidents":
+        return <IncidentsTab />;
       case "settings":
         return <ClusterSettings />;
       case "metrics":

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -2,6 +2,7 @@ import React, { useContext } from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
+import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
 import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
@@ -11,6 +12,9 @@ export const Routes = () => {
   return (
     <>
       <Switch>
+        <Route path={`${url}/incidents/:incident_id`}>
+          <IncidentPage />
+        </Route>
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>

+ 233 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx

@@ -0,0 +1,233 @@
+import Description from "components/Description";
+import useLastSeenPodStatus from "components/events/useLastSeenPodStatus";
+import Heading from "components/form-components/Heading";
+import Loading from "components/Loading";
+import { isEmpty } from "lodash";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import ExpandedContainer from "./ExpandedContainer";
+import { IncidentContainerEvent, IncidentEvent } from "./IncidentPage";
+
+const EventDrawer: React.FC<{
+  event: IncidentEvent;
+  closeDrawer: () => void;
+}> = ({ event, closeDrawer }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [containerLogs, setContainerLogs] = useState<{ [key: string]: string }>(
+    null
+  );
+
+  const {
+    status,
+    hasError: hasPodStatusErrored,
+    isLoading: isPodStatusLoading,
+  } = useLastSeenPodStatus({
+    podName: event?.pod_name,
+    namespace: event?.namespace,
+    resource_type: "pod",
+  });
+
+  const containers: IncidentContainerEvent[] = useMemo(() => {
+    if (isEmpty(event?.container_events)) {
+      return [];
+    }
+
+    return Object.values(event?.container_events || {});
+  }, [event]);
+
+  useEffect(() => {
+    if (!event) {
+      return () => {};
+    }
+
+    let isSubscribed = true;
+
+    const containersWithLogs = containers.filter(
+      (container) => container.log_id
+    );
+
+    const promises = containersWithLogs.map((container) => {
+      return api
+        .getIncidentLogsByLogId<{ contents: string }>(
+          "<token>",
+          {
+            log_id: container.log_id,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => ({
+          contents: res.data?.contents,
+          container_name: container.container_name,
+        }));
+    });
+
+    Promise.all(promises)
+      .then((data) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        const tmpContainerLogs = data.reduce<{ [key: string]: string }>(
+          (acc, c) => {
+            acc[c.container_name] = c.contents;
+            return acc;
+          },
+          {}
+        );
+
+        setContainerLogs(tmpContainerLogs);
+      })
+      .catch(() => console.log("nope"));
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [containers]);
+
+  if (!event) {
+    return null;
+  }
+
+  if (!containerLogs) {
+    return <Loading />;
+  }
+
+  return (
+    <EventDrawerContainer>
+      <EventDrawerTitleContainer>
+        <EventDrawerTitle>Pod: {event?.pod_name}</EventDrawerTitle>
+        <BackButton onClick={closeDrawer}>
+          <i className="material-icons">close</i>
+        </BackButton>
+      </EventDrawerTitleContainer>
+
+      <StyledHelper>
+        {hasPodStatusErrored ? (
+          "We couldn't retrieve last pod status, please try again later"
+        ) : (
+          <>
+            {isPodStatusLoading ? (
+              <Loading />
+            ) : (
+              <>
+                Latest pod status: {capitalize(status)}{" "}
+                <StatusColor status={status?.toLowerCase()}></StatusColor>
+              </>
+            )}
+          </>
+        )}
+      </StyledHelper>
+      <MetadataContainer>
+        <Heading>Overview</Heading>
+        <Description>
+          Event reported on{" "}
+          {Intl.DateTimeFormat([], {
+            // @ts-ignore
+            dateStyle: "full",
+            timeStyle: "long",
+          }).format(new Date(event?.timestamp))}
+        </Description>
+        <Description>{event?.message}</Description>
+        <Br />
+      </MetadataContainer>
+      {containers.map((container) => (
+        <ExpandedContainer
+          container={container}
+          logs={containerLogs[container.container_name]}
+        />
+      ))}
+    </EventDrawerContainer>
+  );
+};
+
+export default EventDrawer;
+
+const EventDrawerContainer = styled.div`
+  position: relative;
+  color: #ffffff;
+  padding: 25px 30px;
+`;
+
+const EventDrawerTitle = styled.span`
+  display: block;
+  font-size: 24px;
+  font-weight: bold;
+  color: #ffffff;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 20px;
+`;
+
+const MetadataContainer = styled.div`
+  border-radius: 6px;
+  background: #2e3135;
+  padding: 0 20px;
+  overflow-y: auto;
+  min-height: 100px;
+  font-size: 13px;
+  margin: 12px 0;
+`;
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+  margin-top: 6px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  color: #ffffffaa;
+
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  display: inline-block;
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "deleted"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const EventDrawerTitleContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+`;

+ 62 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx

@@ -0,0 +1,62 @@
+import Description from "components/Description";
+import Heading from "components/form-components/Heading";
+import React from "react";
+import styled from "styled-components";
+import { IncidentContainerEvent } from "./IncidentPage";
+
+type Props = {
+  container: IncidentContainerEvent;
+  logs: string;
+};
+
+const ExpandedContainer: React.FC<Props> = ({ container, logs }) => {
+  return (
+    <StyledCard>
+      <MetadataContainer>
+        <Heading>Container: {container.container_name}</Heading>
+        <Description>
+          Container exited with code {container.exit_code}, {container.message}
+        </Description>
+        <Description>
+          The following are the container logs from this application instance:
+        </Description>
+        <LogContainer>
+          {logs ? <>{logs}</> : <>No logs available for this container.</>}
+        </LogContainer>
+      </MetadataContainer>
+    </StyledCard>
+  );
+};
+
+export default ExpandedContainer;
+
+const StyledCard = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
+const MetadataContainer = styled.div`
+  margin-bottom: 3px;
+  border-radius: 6px;
+  background: #2e3135;
+  padding: 0 20px;
+  overflow-y: auto;
+  min-height: 100px;
+  font-size: 13px;
+  margin: 12px 0;
+`;
+
+const LogContainer = styled.div`
+  padding: 14px;
+  font-size: 13px;
+  background: #121318;
+  user-select: text;
+  overflow-wrap: break-word;
+  overflow-y: auto;
+  min-height: 55px;
+  color: #aaaabb;
+  height: 400px;
+  border-radius: 4px;
+  margin: 12px 0 24px 0;
+`;

+ 524 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx

@@ -0,0 +1,524 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useParams } from "react-router";
+import styled from "styled-components";
+
+import loading from "assets/loading.gif";
+import { Drawer, withStyles } from "@material-ui/core";
+import EventDrawer from "./EventDrawer";
+import { useRouting } from "shared/routing";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import DynamicLink from "components/DynamicLink";
+import Header from "components/expanded-object/Header";
+import { capitalize } from "shared/string_utils";
+import Description from "components/Description";
+import { dateFormatter } from "../../chart/JobRunTable";
+
+type IncidentPageParams = {
+  incident_id: string;
+};
+
+const IncidentPage = () => {
+  const { incident_id } = useParams<IncidentPageParams>();
+
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [incident, setIncident] = useState<Incident>(null);
+
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const [selectedEvent, setSelectedEvent] = useState<IncidentEvent>(null);
+
+  const { getQueryParam, pushFiltered } = useRouting();
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    setIncident(null);
+
+    api
+      .getIncidentById<Incident>(
+        "<token>",
+        { incident_id },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        let incident = res.data;
+
+        incident.events = convertEventsTimestampsToMilliseconds(
+          incident.events
+        );
+
+        setIncident(incident);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [incident_id]);
+
+  const refreshIncident = async () => {
+    setIsRefreshing(true);
+    try {
+      let incident = await api
+        .getIncidentById<Incident>(
+          "<token>",
+          { incident_id },
+          {
+            cluster_id: currentCluster.id,
+            project_id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      incident.events = convertEventsTimestampsToMilliseconds(incident.events);
+
+      setIncident(incident);
+    } catch (error) {
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const events = useMemo(() => {
+    return groupEventsByDate(incident?.events);
+  }, [incident]);
+
+  if (incident === null) {
+    return <Loading />;
+  }
+
+  const getBackLink = () => {
+    return (
+      getQueryParam("redirect_url") ||
+      "/cluster-dashboard?selected_tab=incidents"
+    );
+  };
+
+  const getResourceLink = () => {
+    let chartName = incident?.chart_name.split("-")[0] || "web";
+    let namespace = incident?.incident_id.split(":")[2] || "default";
+
+    if (chartName == "job") {
+      return `/jobs/${currentCluster.name}/${namespace}/${incident?.release_name}`;
+    }
+
+    return `/applications/${currentCluster.name}/${namespace}/${incident?.release_name}`;
+  };
+
+  return (
+    <StyledExpandedNodeView>
+      <HeaderWrapper>
+        <Header
+          last_updated={dateFormatter(incident.updated_at * 1000)}
+          back_link={getBackLink()}
+          name={"Incident for " + incident.release_name}
+          icon={"error"}
+          materialIconClass="material-icons"
+          inline_title_items={[
+            <ResourceLink
+              key="resource_link"
+              to={getResourceLink()}
+              target="_blank"
+              onClick={(e) => e.stopPropagation()}
+            >
+              {incident.release_name}
+              <i className="material-icons">open_in_new</i>
+            </ResourceLink>,
+          ]}
+          sub_title_items={[
+            <StatusContainer>
+              <Status>
+                <StatusDot status={incident.latest_state} />
+                {capitalize(incident.latest_state)}
+              </Status>
+              <StatusText>
+                - started {dateFormatter(incident.created_at * 1000)}, last
+                updated {dateFormatter(incident.updated_at * 1000)}
+              </StatusText>
+              <Description></Description>
+            </StatusContainer>,
+          ]}
+        />
+      </HeaderWrapper>
+      <LineBreak />
+      <BodyWrapper>
+        <RefreshButton onClick={refreshIncident} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+        {Object.entries(events).map(([date, events_list]) => (
+          <React.Fragment key={date}>
+            <StyledDate>{date}</StyledDate>
+
+            {events_list.map((event) => {
+              return (
+                <StyledCard
+                  key={event.event_id}
+                  onClick={() => setSelectedEvent(event)}
+                  active={selectedEvent?.event_id === event.event_id}
+                >
+                  <ContentContainer>
+                    <Icon status={"normal"} className="material-icons-outlined">
+                      info
+                    </Icon>
+                    <EventInformation>
+                      <EventName>
+                        <Helper>Pod:</Helper>
+                        {event.pod_name}
+                      </EventName>
+                      <EventReason>{event.message}</EventReason>
+                    </EventInformation>
+                  </ContentContainer>
+                  <ActionContainer>
+                    <TimestampContainer>
+                      <TimestampIcon className="material-icons-outlined">
+                        access_time
+                      </TimestampIcon>
+                      <span>
+                        {Intl.DateTimeFormat([], {
+                          // @ts-ignore
+                          dateStyle: "full",
+                          timeStyle: "long",
+                        }).format(new Date(event.timestamp))}
+                      </span>
+                    </TimestampContainer>
+                  </ActionContainer>
+                </StyledCard>
+              );
+            })}
+          </React.Fragment>
+        ))}
+      </BodyWrapper>
+      <StyledDrawer
+        anchor="right"
+        open={!!selectedEvent}
+        onClose={() => setSelectedEvent(null)}
+      >
+        <EventDrawer
+          event={selectedEvent}
+          closeDrawer={() => setSelectedEvent(null)}
+        />
+      </StyledDrawer>
+    </StyledExpandedNodeView>
+  );
+};
+
+export default IncidentPage;
+
+const convertEventsTimestampsToMilliseconds = (events: IncidentEvent[]) => {
+  return events.map((e) => {
+    let newEvent = e;
+
+    newEvent.timestamp = newEvent.timestamp * 1000;
+
+    return newEvent;
+  });
+};
+
+const groupEventsByDate = (
+  events: IncidentEvent[]
+): { [key: string]: IncidentEvent[] } => {
+  if (!events?.length) {
+    return {};
+  }
+
+  return events.reduce<{ [key: string]: IncidentEvent[] }>(
+    (accumulator, current) => {
+      // @ts-ignore
+      const date = Intl.DateTimeFormat([], { dateStyle: "full" }).format(
+        new Date(current.timestamp)
+      );
+
+      if (accumulator[date]?.length) {
+        accumulator[date].push(current);
+      } else {
+        accumulator[date] = [current];
+      }
+
+      return accumulator;
+    },
+    {}
+  );
+};
+
+export type IncidentContainerEvent = {
+  container_name: string;
+  reason: string;
+  message: string;
+  exit_code: number;
+  log_id: string;
+};
+
+export type IncidentEvent = {
+  event_id: string;
+  pod_name: string;
+  cluster: string;
+  namespace: string;
+  release_name: string;
+  release_type: string;
+  timestamp: number;
+  pod_phase: string;
+  pod_status: string;
+  reason: string;
+  message: string;
+  container_events: {
+    [key: string]: IncidentContainerEvent;
+  };
+};
+
+export type Incident = {
+  incident_id: string;
+  release_name: string; // eg: "sample-web"
+  latest_state: string; // "ONGOING" or "RESOLVED"
+  latest_reason: string; // eg: "Out of memory",
+  latest_message: string; // eg: "Application crash due to out of memory issue"
+  events: IncidentEvent[];
+  created_at: number;
+  updated_at: number;
+  chart_name: string;
+};
+
+const RefreshButton = styled.button`
+  position: absolute;
+  right: 0px;
+  top: 20px;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledExpandedNodeView = 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;
+    }
+  }
+`;
+
+const StyledDate = styled.div`
+  font-size: 18px;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 20px;
+  margin-top: 20px;
+  :first-child {
+    margin-top: 0px;
+  }
+`;
+
+const StyledCard = styled.div<{ active: boolean }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff66")};
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  :not(:last-child) {
+    margin-bottom: 15px;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+  color: #ffffff55;
+  margin-right: 10px;
+  font-size: 13px;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 7px;
+  font-size: 18px;
+`;
+
+const StyledDrawer = withStyles({
+  paperAnchorRight: {
+    background: "#202227",
+    minWidth: "700px",
+  },
+})(Drawer);
+
+const ResourceLink = styled(DynamicLink)`
+  font-size: 13px;
+  font-weight: 400;
+  margin-left: 20px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    text-decoration: underline;
+    color: white;
+  }
+
+  > i {
+    margin-left: 7px;
+    font-size: 17px;
+  }
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+  margin-right: 6px;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #aaaabb;
+  width: 100%;
+`;
+
+const StatusText = styled.div`
+  width: 100%;
+`;

+ 215 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -0,0 +1,215 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  margin-top: 35px;
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 209 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx

@@ -0,0 +1,209 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { useRouting } from "shared/routing";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { dateFormatter } from "../../chart/JobRunTable";
+import { Incident } from "./IncidentPage";
+
+export type IncidentsWithoutEvents = Omit<
+  Incident,
+  "events" | "incident_id"
+> & {
+  id: string;
+};
+
+const IncidentsTable = () => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidents<{ incidents: IncidentsWithoutEvents[] }>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data?.incidents || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const refreshIncidents = async () => {
+    setIsRefreshing(true);
+    try {
+      const incidents = await api
+        .getIncidents<{ incidents: IncidentsWithoutEvents[] }>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data?.incidents || []);
+
+      setIncidents(incidents);
+    } catch (err) {
+      setHasError(true);
+      setCurrentError(err);
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Release",
+        accessor: "release_name",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          let chartName = original?.chart_name.split("-")[0] || "web";
+
+          return (
+            <KindContainer>
+              <Icon src={hardcodedIcons[chartName] || hardcodedIcons["web"]} />
+              <Kind>{original.release_name}</Kind>
+            </KindContainer>
+          );
+        },
+      },
+      {
+        Header: "Status",
+        accessor: "latest_state",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return (
+            <Status>
+              <StatusDot status={original.latest_state} />
+              {capitalize(original.latest_state)}
+            </Status>
+          );
+        },
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return <Message>{original.latest_message}</Message>;
+        },
+      },
+      {
+        Header: "Started",
+        accessor: "created_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.created_at * 1000);
+        },
+      },
+      {
+        Header: "Last Updated",
+        accessor: "updated_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.updated_at * 1000);
+        },
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={incidents === null}
+      onRowClick={(row: any) => {
+        pushFiltered(`/cluster-dashboard/incidents/${row?.original?.id}`, []);
+      }}
+      hasError={hasError}
+      onRefresh={refreshIncidents}
+      isRefreshing={isRefreshing}
+    />
+  );
+};
+
+export default IncidentsTable;
+
+const KindContainer = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 200px;
+`;
+
+const Kind = styled.div`
+  margin-left: 8px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const Message = styled.div`
+  white-space: nowrap;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  max-width: 500px;
+`;

+ 20 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx

@@ -200,7 +200,7 @@ const EnvironmentList = () => {
         if (!Array.isArray(data)) {
           throw Error("Data is not an array");
         }
-        setEnvironmentList(data);
+        setDeploymentList(data);
       })
       .catch((err) => {
         setHasError(true);
@@ -226,7 +226,7 @@ const EnvironmentList = () => {
     );
   }
 
-  if (isLoading || !hasPermissionsLoaded) {
+  if (!hasPermissionsLoaded) {
     return (
       <Placeholder>
         <Loading />
@@ -252,6 +252,14 @@ const EnvironmentList = () => {
   }
 
   let renderDeploymentList = () => {
+    if (isLoading) {
+      return (
+        <Placeholder>
+          <Loading />
+        </Placeholder>
+      );
+    }
+
     if (!deploymentList.length) {
       return (
         <Placeholder>
@@ -262,7 +270,16 @@ const EnvironmentList = () => {
     }
 
     return deploymentList.map((d) => {
-      return <EnvironmentCard deployment={d} />;
+      const environment = environmentList?.find((e) => {
+        return e.id === d.environment_id;
+      });
+      return (
+        <EnvironmentCard
+          deployment={d}
+          environment={environment}
+          onDelete={handleRefresh}
+        />
+      );
     });
   };
 

+ 28 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx

@@ -12,15 +12,15 @@ import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-
-const porterYamlDocsLink =
-  "https://docs.porter.run/preview-environments/porter-yaml-reference";
+import { Environment } from "../EnvironmentList";
 
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
   const [repo, setRepo] = useState(null);
+  const [filteredRepos, setFilteredRepos] = useState<string[]>([]);
+
   const [status, setStatus] = useState(null);
   const { pushFiltered } = useRouting();
 
@@ -36,6 +36,30 @@ const ConnectNewRepo: React.FC = () => {
 
   const { url } = useRouteMatch();
 
+  useEffect(() => {
+    api
+      .listEnvironments<Environment[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        console.log("github account", data);
+
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+        const newFilteredRepos = data.map((env) => {
+          return `${env.git_repo_owner}/${env.git_repo_name}`;
+        });
+        setFilteredRepos(newFilteredRepos || []);
+      })
+      .catch(() => {});
+  }, []);
+
   const addRepo = () => {
     let [owner, repoName] = repo.split("/");
     setStatus("loading");
@@ -84,6 +108,7 @@ const ConnectNewRepo: React.FC = () => {
           setRepo(a.git_repo);
         }}
         readOnly={false}
+        filteredRepos={filteredRepos}
       />
       <HelperContainer>
         Note: you will need to add a <CodeBlock>porter.yaml</CodeBlock> file to

+ 121 - 20
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx

@@ -1,20 +1,51 @@
 import React, { useState } from "react";
-import styled from "styled-components";
-import { PRDeployment } from "../EnvironmentList";
+import styled, { keyframes } from "styled-components";
+import { Environment, PRDeployment } from "../EnvironmentList";
 import pr_icon from "assets/pull_request_icon.svg";
 import { integrationList } from "shared/common";
 import { useRouteMatch } from "react-router";
 import DynamicLink from "components/DynamicLink";
 import { capitalize, readableDate } from "shared/string_utils";
+import api from "shared/api";
+import { useContext } from "react";
+import { Context } from "shared/Context";
 
-const EnvironmentCard: React.FC<{ deployment: PRDeployment }> = ({
-  deployment,
-}) => {
+const EnvironmentCard: React.FC<{
+  deployment: PRDeployment;
+  environment: Environment;
+  onDelete: () => void;
+}> = ({ deployment, environment, onDelete }) => {
+  const { setCurrentOverlay } = useContext(Context);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
   const { url: currentUrl } = useRouteMatch();
 
   let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
 
+  const deleteDeployment = () => {
+    setIsDeleting(true);
+
+    api
+      .deletePRDeployment(
+        "<token>",
+        {
+          namespace: deployment.namespace,
+        },
+        {
+          cluster_id: environment.cluster_id,
+          project_id: environment.project_id,
+          git_installation_id: environment.git_installation_id,
+          git_repo_owner: environment.git_repo_owner,
+          git_repo_name: environment.git_repo_name,
+        }
+      )
+      .then(() => {
+        setIsDeleting(false);
+        onDelete();
+        setCurrentOverlay(null);
+      });
+  };
+
   return (
     <EnvironmentCardWrapper key={deployment.id}>
       <DataContainer>
@@ -52,27 +83,97 @@ const EnvironmentCard: React.FC<{ deployment: PRDeployment }> = ({
         </Flex>
       </DataContainer>
       <Flex>
-        <RowButton
-          to={`${currentUrl}/pr-env-detail/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-          key={deployment.id}
-        >
-          <i className="material-icons-outlined">info</i>
-          Details
-        </RowButton>
-        <RowButton
-          to={deployment.subdomain}
-          key={deployment.subdomain}
-          target="_blank"
-        >
-          <i className="material-icons">open_in_new</i>
-          View Live
-        </RowButton>
+        {!isDeleting ? (
+          <>
+            {deployment.status !== "creating" && (
+              <>
+                <RowButton
+                  to={`${currentUrl}/pr-env-detail/${deployment.namespace}?environment_id=${deployment.environment_id}`}
+                  key={deployment.id}
+                >
+                  <i className="material-icons-outlined">info</i>
+                  Details
+                </RowButton>
+                <RowButton
+                  to={deployment.subdomain}
+                  key={deployment.subdomain}
+                  target="_blank"
+                >
+                  <i className="material-icons">open_in_new</i>
+                  View Live
+                </RowButton>
+              </>
+            )}
+            <RowButton
+              to={"#"}
+              key={deployment.subdomain}
+              onClick={() =>
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete this deployment?`,
+                  onYes: deleteDeployment,
+                  onNo: () => setCurrentOverlay(null),
+                })
+              }
+            >
+              <i className="material-icons">delete</i>
+              Delete
+            </RowButton>
+          </>
+        ) : (
+          <DeleteMessage>
+            Deleting
+            <Dot delay="0s" />
+            <Dot delay="0.1s" />
+            <Dot delay="0.2s" />
+          </DeleteMessage>
+        )}
       </Flex>
     </EnvironmentCardWrapper>
   );
 };
 
 export default EnvironmentCard;
+
+const DeleteMessage = styled.div`
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+`;
+
+export const DissapearAnimation = keyframes`
+  0% { 
+    background-color: #ffffff; 
+  }
+
+  25% {
+    background-color: #ffffff50;
+  }
+
+  50% { 
+    background-color: none;
+  }
+
+  75% {
+    background-color: #ffffff50;
+  }
+
+  100% { 
+    background-color: #ffffff;
+  }
+`;
+
+const Dot = styled.div`
+  background-color: black;
+  border-radius: 50%;
+  width: 5px;
+  height: 5px;
+  margin: 0 0.25rem;
+  margin-bottom: 2px;
+  //Animation
+  animation: ${DissapearAnimation} 0.5s linear infinite;
+  animation-delay: ${(props: { delay: string }) => props.delay};
+`;
+
 const Flex = styled.div`
   display: flex;
   align-items: center;

+ 9 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -28,9 +28,8 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
-import EventsTab from "./events/EventsTab";
-import { PopulatedEnvGroup } from "components/porter-form/types";
 import { onlyInLeft } from "shared/array_utils";
+import IncidentsTab from "./incidents/IncidentsTab";
 
 type Props = {
   namespace: string;
@@ -426,8 +425,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
-      case "events":
-        return <EventsTab controllers={controllers} />;
+      case "incidents":
+        return (
+          <IncidentsTab
+            releaseName={chart?.name}
+            namespace={chart?.namespace}
+          />
+        );
       case "status":
         if (isLoadingChartData) {
           return (
@@ -520,7 +524,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
-    leftTabOptions.push({ label: "Events", value: "events" });
+    leftTabOptions.push({ label: "Incidents", value: "incidents" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 406 - 1064
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}

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx

@@ -0,0 +1,217 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = (props: {
+  releaseName: string;
+  namespace: string;
+}): JSX.Element => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable {...props} />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx

@@ -0,0 +1,217 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useLocation } from "react-router";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { useRouting } from "shared/routing";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { dateFormatter } from "../../chart/JobRunTable";
+import { IncidentsWithoutEvents } from "../../dashboard/incidents/IncidentsTable";
+
+const IncidentsTable = ({
+  releaseName,
+  namespace,
+}: {
+  releaseName: string;
+  namespace: string;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+  const location = useLocation();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidentsByReleaseName<{ incidents: IncidentsWithoutEvents[] }>(
+        "<token>",
+        {
+          namespace: namespace,
+          release_name: releaseName,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data?.incidents || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const refreshIncidents = async () => {
+    setIsRefreshing(true);
+    try {
+      const incidents = await api
+        .getIncidentsByReleaseName<{ incidents: IncidentsWithoutEvents[] }>(
+          "<token>",
+          {
+            namespace: namespace,
+            release_name: releaseName,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data?.incidents || []);
+
+      setIncidents(incidents);
+    } catch (err) {
+      setHasError(true);
+      setCurrentError(err);
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Status",
+        accessor: "latest_state",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return (
+            <Status>
+              <StatusDot status={original.latest_state} />
+              {capitalize(original.latest_state)}
+            </Status>
+          );
+        },
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return <Message>{original.latest_message}</Message>;
+        },
+      },
+      {
+        Header: "Started",
+        accessor: "created_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.created_at * 1000);
+        },
+      },
+      {
+        Header: "Last Updated",
+        accessor: "updated_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.updated_at * 1000);
+        },
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={incidents === null}
+      onRowClick={(row: any) => {
+        pushFiltered(`/cluster-dashboard/incidents/${row?.original?.id}/`, [], {
+          redirect_url: location.pathname,
+        });
+      }}
+      hasError={hasError}
+      onRefresh={refreshIncidents}
+      isRefreshing={isRefreshing}
+    />
+  );
+};
+
+export default IncidentsTable;
+
+const TableWrapper = styled.div``;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const KindContainer = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 200px;
+`;
+
+const Kind = styled.div`
+  margin-left: 8px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const Message = styled.div`
+  white-space: nowrap;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  max-width: 500px;
+`;

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

+ 27 - 22
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 = () => {
@@ -251,7 +252,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
                 onClick={() =>
                   replaceInvite(
                     row.values.email,
-                    row.values.id,
+                    row.original.id,
                     row.values.kind
                   )
                 }
@@ -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;

+ 19 - 10
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -9,6 +9,7 @@ import { ClusterType } from "shared/types";
 import Drawer from "./Drawer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered } from "shared/routing";
+import { NavLink } from "react-router-dom";
 
 type PropsType = RouteComponentProps & {
   forceCloseDrawer: boolean;
@@ -171,16 +172,19 @@ class ClusterSection extends Component<PropsType, StateType> {
 
     if (clusters.length > 0) {
       return (
-        <ClusterSelector isSelected={false}>
-          <LinkWrapper
-            onClick={() => pushFiltered(this.props, "/cluster-dashboard", [])}
-          >
+        <ClusterSelector to="/cluster-dashboard">
+          <LinkWrapper>
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
             </ClusterIcon>
             <ClusterName>{currentCluster && currentCluster.name}</ClusterName>
           </LinkWrapper>
-          <DrawerButton onClick={this.toggleDrawer}>
+          <DrawerButton
+            onClick={(e) => {
+              e.preventDefault();
+              this.toggleDrawer();
+            }}
+          >
             <BgAccent src={drawerBg} />
             <DropdownIcon showDrawer={showDrawer}>
               <i className="material-icons">arrow_drop_down</i>
@@ -332,7 +336,7 @@ const LinkWrapper = styled.div`
   width: 100%;
 `;
 
-const ClusterSelector = styled.div`
+const ClusterSelector = styled(NavLink)`
   position: relative;
   display: block;
   padding-left: 7px;
@@ -343,12 +347,17 @@ const ClusterSelector = styled.div`
   font-weight: 500;
   color: white;
   cursor: pointer;
-  background: ${(props: { isSelected: boolean }) =>
-    props.isSelected ? "#ffffff08" : ""};
   z-index: 1;
 
+  &.active {
+    background: #ffffff11;
+
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
   :hover {
-    background: ${(props: { isSelected: boolean }) =>
-      props.isSelected ? "" : "#ffffff08"};
+    background: #ffffff08;
   }
 `;

+ 47 - 242
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -15,6 +15,7 @@ import ProjectSectionContainer from "./ProjectSectionContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { NavLink } from "react-router-dom";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -98,103 +99,66 @@ class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderClusterContent = () => {
-    let { currentView } = this.props;
     let { currentCluster, currentProject } = this.context;
 
     if (currentCluster) {
       return (
         <>
           <NavButton
-            selected={currentView === "applications"}
-            onClick={() => {
+            to={(location) => {
               let params = this.props.match.params as any;
               let pathNamespace = params.namespace;
+              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
-              // If namespace is currently only in path (ex: ExpandedChart) set to param
               if (pathNamespace) {
-                pushFiltered(
-                  this.props,
-                  "/applications",
-                  ["project_id", "cluster", "namespace"],
-                  {
-                    cluster: currentCluster.name,
-                    namespace: pathNamespace,
-                  }
-                );
-              } else {
-                pushFiltered(
-                  this.props,
-                  "/applications",
-                  ["project_id", "cluster", "namespace"],
-                  {
-                    cluster: currentCluster.name,
-                  }
-                );
+                search.concat(`&namespace=${pathNamespace}`);
               }
+
+              return {
+                ...location,
+                pathname: "/applications",
+                search,
+              };
             }}
           >
             <Img src={monoweb} />
             Applications
           </NavButton>
           <NavButton
-            selected={currentView === "jobs"}
-            onClick={() => {
+            to={() => {
               let params = this.props.match.params as any;
               let pathNamespace = params.namespace;
+              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
-              // If namespace is currently only in path (ex: ExpandedChart) set to param
               if (pathNamespace) {
-                pushFiltered(
-                  this.props,
-                  "/jobs",
-                  ["project_id", "cluster", "namespace"],
-                  {
-                    cluster: currentCluster.name,
-                    namespace: pathNamespace,
-                  }
-                );
-              } else {
-                pushFiltered(
-                  this.props,
-                  "/jobs",
-                  ["project_id", "cluster", "namespace"],
-                  {
-                    cluster: currentCluster.name,
-                  }
-                );
+                search.concat(`&namespace=${pathNamespace}`);
               }
+
+              return {
+                ...location,
+                pathname: "/jobs",
+                search,
+              };
             }}
           >
             <Img src={monojob} />
             Jobs
           </NavButton>
           <NavButton
-            selected={currentView === "env-groups"}
-            onClick={() => {
+            to={() => {
               let params = this.props.match.params as any;
               let pathNamespace = params.namespace;
+              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
-              // If namespace is currently only in path (ex: ExpandedChart) set to param
               if (pathNamespace) {
-                pushFiltered(
-                  this.props,
-                  "/env-groups",
-                  ["project_id", "cluster", "namespace"],
-                  {
-                    cluster: currentCluster.name,
-                    namespace: pathNamespace,
-                  }
-                );
-              } else {
-                pushFiltered(
-                  this.props,
-                  "/env-groups",
-                  ["project_id", "cluster", "namespace"],
-                  {
-                    cluster: currentCluster.name,
-                  }
-                );
+                search.concat(`&namespace=${pathNamespace}`);
               }
+
+              return {
+                ...location,
+                pathname: "/env-groups",
+                search,
+              };
             }}
           >
             <Img src={sliders} />
@@ -203,12 +167,7 @@ class Sidebar extends Component<PropsType, StateType> {
           {currentCluster.service === "eks" &&
             currentCluster.infra_id > 0 &&
             currentProject.enable_rds_databases && (
-              <NavButton
-                selected={currentView === "databases"}
-                onClick={() => {
-                  pushFiltered(this.props, "/databases", [], {});
-                }}
-              >
+              <NavButton to={"/databases"}>
                 <Icon className="material-icons-outlined">storage</Icon>
                 Databases
               </NavButton>
@@ -219,39 +178,22 @@ class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderProjectContents = () => {
-    let { currentView, history, location } = this.props;
-    let { currentProject, setCurrentModal } = this.context;
+    let { currentView } = this.props;
+    let { currentProject } = this.context;
     if (currentProject) {
       return (
         <>
           <SidebarLabel>Home</SidebarLabel>
-          <NavButton
-            onClick={() =>
-              currentView !== "provisioner" &&
-              pushFiltered(this.props, "/dashboard", ["project_id"])
-            }
-            selected={
-              currentView === "dashboard" || currentView === "provisioner"
-            }
-          >
+          <NavButton to="/dashboard">
             <Img src={category} />
             Dashboard
           </NavButton>
-          <NavButton
-            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
-            selected={currentView === "launch"}
-          >
+          <NavButton to="/launch">
             <Img src={rocket} />
             Launch
           </NavButton>
           {currentProject && currentProject.managed_infra_enabled && (
-            <NavButton
-              onClick={() =>
-                currentView !== "infrastructure" &&
-                pushFiltered(this.props, "/infrastructure", ["project_id"])
-              }
-              selected={currentView === "infrastructure"}
-            >
+            <NavButton to={"/infrastructure"}>
               <i className="material-icons">build_circle</i>
               Infrastructure
             </NavButton>
@@ -262,12 +204,7 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-            <NavButton
-              selected={currentView === "integrations"}
-              onClick={() =>
-                pushFiltered(this.props, "/integrations", ["project_id"])
-              }
-            >
+            <NavButton to="/integrations">
               <Img src={integrations} />
               Integrations
             </NavButton>
@@ -277,12 +214,7 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-            <NavButton
-              onClick={() =>
-                pushFiltered(this.props, "/project-settings", ["project_id"])
-              }
-              selected={this.props.currentView === "project-settings"}
-            >
+            <NavButton to="/project-settings">
               <Img enlarge={true} src={settings} />
               Settings
             </NavButton>
@@ -338,12 +270,6 @@ class Sidebar extends Component<PropsType, StateType> {
           <br />
 
           {this.renderProjectContents()}
-          {/*
-          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
-            <Icon src={discordLogo} />
-            Join Our Discord
-          </DiscordButton>
-          */}
         </StyledSidebar>
       </>
     );
@@ -354,40 +280,6 @@ Sidebar.contextType = Context;
 
 export default withRouter(withAuth(Sidebar));
 
-const BranchPad = styled.div`
-  width: 20px;
-  height: 42px;
-  margin-left: 2px;
-  margin-right: 8px;
-`;
-
-const Rail = styled.div`
-  width: 2px;
-  background: ${(props: { lastTab?: boolean }) =>
-    props.lastTab ? "" : "#52545D"};
-  height: 50%;
-`;
-
-const Circle = styled.div`
-  min-width: 10px;
-  min-height: 2px;
-  margin-bottom: -2px;
-  margin-left: 8px;
-  background: #52545d;
-`;
-
-const Gutter = styled.div`
-  position: absolute;
-  top: 0px;
-  left: 22px;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  overflow: visible;
-`;
-
 const Icon = styled.span`
   padding: 4px;
   width: 23px;
@@ -416,7 +308,7 @@ const ProjectPlaceholder = styled.div`
   }
 `;
 
-const NavButton = styled.div`
+const NavButton = styled(NavLink)`
   display: flex;
   align-items: center;
   position: relative;
@@ -429,14 +321,19 @@ const NavButton = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  background: ${(props: { disabled?: boolean; selected?: boolean }) =>
-    props.selected ? "#ffffff11" : ""};
-  cursor: ${(props: { disabled?: boolean; selected?: boolean }) =>
+  cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
+  &.active {
+    background: #ffffff11;
+
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
   :hover {
-    background: ${(props: { disabled?: boolean; selected?: boolean }) =>
-      props.selected ? "" : "#ffffff08"};
+    background: #ffffff08;
   }
 
   > i {
@@ -456,56 +353,6 @@ const Img = styled.img<{ enlarge?: boolean }>`
   margin-right: 10px;
 `;
 
-const BottomSection = styled.div`
-  position: absolute;
-  width: 100%;
-  bottom: 10px;
-`;
-
-const DiscordButton = styled.a`
-  position: absolute;
-  text-decoration: none;
-  bottom: 17px;
-  display: flex;
-  align-items: center;
-  width: calc(100% - 30px);
-  left: 15px;
-  border: 2px solid #ffffff44;
-  border-radius: 3px;
-  color: #ffffff44;
-  height: 40px;
-  font-family: Work Sans, sans-serif;
-  font-size: 14px;
-  font-weight: bold;
-  cursor: pointer;
-  :hover {
-    > img {
-      opacity: 60%;
-    }
-    color: #ffffff88;
-    border-color: #ffffff88;
-  }
-`;
-
-const LogOutButton = styled(NavButton)`
-  width: calc(100% - 55px);
-  border-top-right-radius: 3px;
-  border-bottom-right-radius: 3px;
-  margin-left: -1px;
-  color: #ffffffaa;
-
-  > i {
-    background: none;
-    display: flex;
-    font-size: 12px;
-    top: 11px;
-    align-items: center;
-    justify-content: center;
-    color: #ffffffaa;
-    border: 1px solid #ffffffaa;
-  }
-`;
-
 const SidebarBg = styled.div`
   position: absolute;
   top: 0;
@@ -526,48 +373,6 @@ const SidebarLabel = styled.div`
   font-weight: 500;
 `;
 
-const UserSection = styled.div`
-  width: 100%;
-  height: 40px;
-  margin: 6px 0px 17px;
-  display: flex;
-  flex: 1;
-  flex-direction: row;
-  align-items: center;
-`;
-
-const RingWrapper = styled.div`
-  width: 28px;
-  border-radius: 30px;
-  :focus {
-    outline: 0;
-  }
-  height: 28px;
-  padding: 3px;
-  border: 2px solid #ffffff44;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin: 0px 10px 0px 18px;
-`;
-
-const UserIcon = styled.img`
-  width: 20px;
-  height: 20px;
-  background: blue;
-  border-radius: 50px;
-  box-shadow: 0 2px 4px 0px #00000044;
-`;
-
-const UserName = styled.div`
-  max-width: 120px;
-  color: #e5e5e5;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  font-size: 14px;
-`;
-
 const PullTab = styled.div`
   position: fixed;
   width: 30px;

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

@@ -1,4 +1,5 @@
 import { PolicyDocType } from "./auth/types";
+import { release } from "process";
 import { baseApi } from "./baseApi";
 
 import { FullActionConfigType, StorageType } from "./types";
@@ -338,6 +339,28 @@ const getPRDeployment = baseApi<
   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
 });
 
+const deletePRDeployment = baseApi<
+  {
+    namespace: string;
+  },
+  {
+    cluster_id: number;
+    project_id: number;
+    git_installation_id: number;
+    git_repo_owner: string;
+    git_repo_name: string;
+  }
+>("DELETE", (pathParams) => {
+  const {
+    cluster_id,
+    project_id,
+    git_installation_id,
+    git_repo_owner,
+    git_repo_name,
+  } = pathParams;
+  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
+});
+
 const getNotificationConfig = baseApi<
   {},
   {
@@ -1622,6 +1645,70 @@ const getPreviousLogsForContainer = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
 );
 
+const getIncidents = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentsByReleaseName = baseApi<
+  {
+    namespace: string;
+    release_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentById = baseApi<
+  {
+    incident_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentLogsByLogId = baseApi<
+  {
+    log_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents/logs`
+);
+
+const upgradePorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/upgrade`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1780,4 +1867,10 @@ export default {
   provisionDatabase,
   getDatabases,
   getPreviousLogsForContainer,
+  getIncidents,
+  getIncidentsByReleaseName,
+  getIncidentById,
+  getIncidentLogsByLogId,
+  upgradePorterAgent,
+  deletePRDeployment,
 };

+ 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,

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

@@ -109,6 +109,10 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
     showStartCommand: boolean;
     statefulset: { enabled: boolean };
     terminationGracePeriodSeconds: number;
+    schedule: {
+      enabled: boolean;
+      value: string;
+    };
   };
 }
 

+ 39 - 33
go.mod

@@ -8,13 +8,13 @@ require (
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation/v2 v2.0.3
-	github.com/buildpacks/pack v0.19.0
+	github.com/buildpacks/pack v0.24.1
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/digitalocean/godo v1.75.0
-	github.com/docker/cli v20.10.11+incompatible
-	github.com/docker/distribution v2.7.1+incompatible
-	github.com/docker/docker v20.10.12+incompatible
+	github.com/docker/cli v20.10.14+incompatible
+	github.com/docker/distribution v2.8.1+incompatible
+	github.com/docker/docker v20.10.14+incompatible
 	github.com/docker/docker-credential-helpers v0.6.4
 	github.com/docker/go-connections v0.4.0
 	github.com/fatih/color v1.13.0
@@ -36,7 +36,7 @@ require (
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/mitchellh/mapstructure v1.4.3
 	github.com/moby/moby v20.10.6+incompatible
-	github.com/moby/term v0.0.0-20210610120745-9d4ed1856297
+	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
 	github.com/opencontainers/image-spec v1.0.2
 	github.com/pkg/errors v0.9.1
 	github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb
@@ -47,17 +47,17 @@ require (
 	github.com/spf13/viper v1.10.0
 	github.com/stretchr/testify v1.7.0
 	golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
-	golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba
+	golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
 	google.golang.org/api v0.62.0
-	google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5
-	google.golang.org/grpc v1.44.0
-	google.golang.org/protobuf v1.27.1
+	google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac
+	google.golang.org/grpc v1.45.0
+	google.golang.org/protobuf v1.28.0
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.22.3
 	helm.sh/helm/v3 v3.8.0
 	k8s.io/api v0.23.1
-	k8s.io/apimachinery v0.23.1
+	k8s.io/apimachinery v0.23.5
 	k8s.io/cli-runtime v0.23.1
 	k8s.io/client-go v0.23.1
 	k8s.io/helm v2.17.0+incompatible
@@ -67,6 +67,7 @@ require (
 )
 
 require (
+	github.com/briandowns/spinner v1.18.1
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
 	gopkg.in/yaml.v2 v2.4.0
 	gorm.io/driver/postgres v1.2.3
@@ -80,46 +81,48 @@ require (
 	github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
 	github.com/Azure/go-autorest/logger v0.2.1 // indirect
 	github.com/Azure/go-autorest/tracing v0.6.0 // indirect
-	github.com/BurntSushi/toml v0.4.1 // indirect
+	github.com/BurntSushi/toml v1.1.0 // indirect
 	github.com/MakeNowJust/heredoc v1.0.0 // indirect
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/Masterminds/sprig/v3 v3.2.2 // indirect
 	github.com/Masterminds/squirrel v1.5.2 // indirect
-	github.com/Microsoft/go-winio v0.5.1 // indirect
-	github.com/Microsoft/hcsshim v0.9.1 // indirect
+	github.com/Microsoft/go-winio v0.5.2 // indirect
+	github.com/Microsoft/hcsshim v0.9.2 // indirect
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/apex/log v1.9.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bits-and-blooms/bitset v1.2.0 // indirect
-	github.com/briandowns/spinner v1.18.1 // indirect
-	github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 // indirect
-	github.com/buildpacks/lifecycle v0.11.3 // indirect
+	github.com/buildpacks/imgutil v0.0.0-20220310160537-4dd8bc60eaff // indirect
+	github.com/buildpacks/lifecycle v0.14.0 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 // indirect
 	github.com/cli/safeexec v1.0.0 // indirect
 	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
 	github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
-	github.com/containerd/cgroups v1.0.2 // indirect
-	github.com/containerd/containerd v1.5.9 // indirect
-	github.com/containerd/stargz-snapshotter/estargz v0.4.1 // indirect
+	github.com/containerd/cgroups v1.0.3 // indirect
+	github.com/containerd/containerd v1.6.2 // indirect
+	github.com/containerd/stargz-snapshotter/estargz v0.11.3 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/docker/go-units v0.4.0 // indirect
+	github.com/dustin/go-humanize v1.0.0 // indirect
 	github.com/emirpasic/gods v1.12.0 // indirect
 	github.com/envoyproxy/go-control-plane v0.10.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
 	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
+	github.com/gdamore/encoding v1.0.0 // indirect
+	github.com/gdamore/tcell/v2 v2.4.0 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect
 	github.com/go-errors/errors v1.0.1 // indirect
-	github.com/go-logr/logr v1.2.0 // indirect
+	github.com/go-logr/logr v1.2.2 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
 	github.com/go-openapi/jsonreference v0.19.5 // indirect
 	github.com/go-openapi/swag v0.19.14 // indirect
@@ -130,10 +133,10 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/btree v1.0.1 // indirect
-	github.com/google/go-cmp v0.5.6 // indirect
-	github.com/google/go-containerregistry v0.5.1 // indirect
+	github.com/google/go-cmp v0.5.7 // indirect
+	github.com/google/go-containerregistry v0.8.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
-	github.com/google/gofuzz v1.1.0 // indirect
+	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.2.0 // indirect
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
@@ -165,13 +168,14 @@ require (
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
-	github.com/klauspost/compress v1.13.6 // indirect
+	github.com/klauspost/compress v1.15.1 // indirect
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
 	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/lib/pq v1.10.4 // indirect
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mailru/easyjson v0.7.6 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
@@ -194,17 +198,18 @@ require (
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/onsi/ginkgo v1.16.4 // indirect
-	github.com/onsi/gomega v1.16.0 // indirect
+	github.com/onsi/gomega v1.18.1 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
-	github.com/opencontainers/runc v1.0.2 // indirect
-	github.com/opencontainers/selinux v1.8.2 // indirect
+	github.com/opencontainers/runc v1.1.0 // indirect
+	github.com/opencontainers/selinux v1.10.0 // indirect
 	github.com/pelletier/go-toml v1.9.4 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_golang v1.11.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
-	github.com/prometheus/common v0.28.0 // indirect
-	github.com/prometheus/procfs v0.6.0 // indirect
+	github.com/prometheus/common v0.30.0 // indirect
+	github.com/prometheus/procfs v0.7.3 // indirect
+	github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc // indirect
 	github.com/russross/blackfriday v1.5.2 // indirect
@@ -219,6 +224,7 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/src-d/gcfg v1.4.0 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
+	github.com/vbatts/tar-split v0.11.2 // indirect
 	github.com/xanzy/ssh-agent v0.3.0 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
@@ -227,9 +233,9 @@ require (
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
 	go.opencensus.io v0.23.0 // indirect
 	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
-	golang.org/x/mod v0.5.0 // indirect
+	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
-	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
+	golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
@@ -246,10 +252,10 @@ require (
 	k8s.io/component-base v0.23.1 // indirect
 	k8s.io/klog/v2 v2.30.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
-	k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b // indirect
+	k8s.io/utils v0.0.0-20211116205334-6203023598ed // indirect
 	oras.land/oras-go v1.1.0 // indirect
 	sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect
 	sigs.k8s.io/kustomize/api v0.10.1 // indirect
 	sigs.k8s.io/kustomize/kyaml v0.13.0 // indirect
-	sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect
+	sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect
 )

+ 121 - 4
go.sum

@@ -91,6 +91,10 @@ github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBp
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
 github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
+github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
+github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
 github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
@@ -125,6 +129,8 @@ github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JP
 github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
 github.com/Microsoft/go-winio v0.5.1 h1:aPJp2QD7OOrhO5tQXqQoGSJc+DjDtWTGLOmNyAm6FgY=
 github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
 github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
@@ -137,6 +143,8 @@ github.com/Microsoft/hcsshim v0.8.21/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwT
 github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg=
 github.com/Microsoft/hcsshim v0.9.1 h1:VfDCj+QnY19ktX5TsH22JHcjaZ05RWQiwDbOyEg5ziM=
 github.com/Microsoft/hcsshim v0.9.1/go.mod h1:Y/0uV2jUab5kBI7SQgl62at0AVX7uaruzADAVmxm3eM=
+github.com/Microsoft/hcsshim v0.9.2 h1:wB06W5aYFfUB3IvootYAY2WnOmIdgPGfqSI6tufQNnY=
+github.com/Microsoft/hcsshim v0.9.2/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc=
 github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU=
 github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
@@ -231,10 +239,22 @@ github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXe
 github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
 github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 h1:SzI5Uwnus3g/HQCFri+svWNiht4y8+jE2+QR8kzLPps=
 github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918/go.mod h1:ZQdcfsoyeqJvSdnUcCiS3Njhj0SZgBllJBnx5ojmgaQ=
+github.com/buildpacks/imgutil v0.0.0-20211203200417-76206845baac h1:XrKr6axRUBHEQdyyo7uffYDwWurOdeyH8MpNRJuBdIw=
+github.com/buildpacks/imgutil v0.0.0-20211203200417-76206845baac/go.mod h1:YZReWjuSxwyvuN92Vlcul+WgaCXylpecgFn7T3rNang=
+github.com/buildpacks/imgutil v0.0.0-20220310160537-4dd8bc60eaff h1:sP0G3fOfWMSDabqIuPY1o6aeiX35eQz9mWhYCMqgp08=
+github.com/buildpacks/imgutil v0.0.0-20220310160537-4dd8bc60eaff/go.mod h1:zjdTnysBSl9Jeiz2J/B7Nf621dsDaEGkMfySlPqXNtY=
 github.com/buildpacks/lifecycle v0.11.3 h1:FyvtzNxNjnBAdujzUiSpiCap3x+NzrqokGj69PiYvGk=
 github.com/buildpacks/lifecycle v0.11.3/go.mod h1:4anPUHYqREC3oh3qqKZwt7wqWR866E7BvtIxRE8xGLE=
+github.com/buildpacks/lifecycle v0.13.3 h1:vV2DGTPVQOELtrCSYpop8W9OF0m+l5gwxWDPmL9ZcOw=
+github.com/buildpacks/lifecycle v0.13.3/go.mod h1:4Kv6HljeDJ1ibUcRijvvC1/AHXMCpNddIqH2KYnboks=
+github.com/buildpacks/lifecycle v0.14.0 h1:GD0HIfyI8I9tlT193+P1EJeUXjka50+TnnwpC+Ki5Cg=
+github.com/buildpacks/lifecycle v0.14.0/go.mod h1:WVrbLGvWd3f96qWZglI+RlEcPZDXMDuJDK+i7G7hZjU=
 github.com/buildpacks/pack v0.19.0 h1:somWkTDEkR7LW0ZSGnO4WQw7Y3qTqqErzz57MlJPgRg=
 github.com/buildpacks/pack v0.19.0/go.mod h1:ITfkOnEmfIQW3TEXvze9sdE0Jk+AzQviQX022/EBj4o=
+github.com/buildpacks/pack v0.24.0 h1:Oeq7DImb7PLX5z/11h5kWJC/YZtgCAxJiEBTU/XsnNo=
+github.com/buildpacks/pack v0.24.0/go.mod h1:3BMdtlXEXTHUGAv31eeuPAebXq+JYZhFrAd7tEi6m0g=
+github.com/buildpacks/pack v0.24.1 h1:CkrdFCWCk/I71E3noNmKtcPha1s+1F9j8ykhbxHLV04=
+github.com/buildpacks/pack v0.24.1/go.mod h1:3BMdtlXEXTHUGAv31eeuPAebXq+JYZhFrAd7tEi6m0g=
 github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
@@ -250,6 +270,7 @@ github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1
 github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
 github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw=
 github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
+github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -258,6 +279,7 @@ github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLI
 github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs=
 github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
 github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
+github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
 github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
@@ -303,11 +325,14 @@ github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTF
 github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU=
 github.com/containerd/cgroups v1.0.2 h1:mZBclaSgNDfPWtfhj2xJY28LZ9nYIgzB0pwSURPl6JM=
 github.com/containerd/cgroups v1.0.2/go.mod h1:qpbpJ1jmlqsR9f2IyaLPsdkCdnt0rbDVqIDlhuu5tRY=
+github.com/containerd/cgroups v1.0.3 h1:ADZftAkglvCiD44c77s5YmMqaP2pzVCFZvBmAlBdAP4=
+github.com/containerd/cgroups v1.0.3/go.mod h1:/ofk34relqNjSGyqPrmEULrO4Sc8LJhvJmWbUCUKqj8=
 github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
 github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
 github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE=
 github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
 github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
@@ -324,8 +349,11 @@ github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09Zvgq
 github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s=
 github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g=
 github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c=
+github.com/containerd/containerd v1.5.8/go.mod h1:YdFSv5bTFLpG2HIYmfqDpSYYTDX+mc5qtSuYx1YUb/s=
 github.com/containerd/containerd v1.5.9 h1:rs6Xg1gtIxaeyG+Smsb/0xaSDu1VgFhOCKBXxMxbsF4=
 github.com/containerd/containerd v1.5.9/go.mod h1:fvQqCfadDGga5HZyn3j4+dx56qj2I9YwBrlSdalvJYQ=
+github.com/containerd/containerd v1.6.2 h1:pcaPUGbYW8kBw6OgIZwIVIeEhdWVrBzsoCfVJ5BjrLU=
+github.com/containerd/containerd v1.6.2/go.mod h1:sidY30/InSE1j2vdD1ihtKoJz+lWdaXMdiAeIupaf+s=
 github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
@@ -335,6 +363,7 @@ github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR
 github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ=
 github.com/containerd/continuity v0.1.0 h1:UFRRY5JemiAhPZrr/uE0n8fMTLcZsUvySPr1+D7pgr8=
 github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM=
+github.com/containerd/continuity v0.2.2 h1:QSqfxcn8c+12slxwu00AtzXrsami0MJb/MQs9lOLHLA=
 github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
 github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
 github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0=
@@ -357,6 +386,11 @@ github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oM
 github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
 github.com/containerd/stargz-snapshotter/estargz v0.4.1 h1:5e7heayhB7CcgdTkqfZqrNaNv15gABwr3Q2jBTbLlt4=
 github.com/containerd/stargz-snapshotter/estargz v0.4.1/go.mod h1:x7Q9dg9QYb4+ELgxmo4gBUeJB0tl5dqH1Sdz0nJU1QM=
+github.com/containerd/stargz-snapshotter/estargz v0.10.0/go.mod h1:aE5PCyhFMwR8sbrErO5eM2GcvkyXTTJremG883D4qF0=
+github.com/containerd/stargz-snapshotter/estargz v0.10.1 h1:hd1EoVjI2Ax8Cr64tdYqnJ4i4pZU49FkEf5kU8KxQng=
+github.com/containerd/stargz-snapshotter/estargz v0.10.1/go.mod h1:aE5PCyhFMwR8sbrErO5eM2GcvkyXTTJremG883D4qF0=
+github.com/containerd/stargz-snapshotter/estargz v0.11.3 h1:k2kN16Px6LYuv++qFqK+JTcYqc8bEVxzGpf8/gFBL5M=
+github.com/containerd/stargz-snapshotter/estargz v0.11.3/go.mod h1:7vRJIcImfY8bpifnMjt+HTJoQxASq7T28MYbP15/Nf0=
 github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
 github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
 github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8=
@@ -431,8 +465,6 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
-github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
 github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y=
 github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684 h1:DBZ2sN7CK6dgvHVpQsQj4sRMCbWTmd17l+5SUCjnQSY=
@@ -441,17 +473,27 @@ github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v0.0.0-20200312141509-ef2f64abbd37/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v20.10.10+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v20.10.11+incompatible h1:tXU1ezXcruZQRrMP8RN2z9N91h+6egZTS1gsPsKantc=
 github.com/docker/cli v20.10.11+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v20.10.12+incompatible h1:lZlz0uzG+GH+c0plStMUdF/qk3ppmgnswpR5EbqzVGA=
+github.com/docker/cli v20.10.12+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v20.10.14+incompatible h1:dSBKJOVesDgHo7rbxlYjYsXe7gPzrTT+/cKQgpDAazg=
+github.com/docker/cli v20.10.14+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
 github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68=
+github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v20.10.0-beta1.0.20201110211921-af34b94a78a1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.10+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v20.10.11+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v20.10.12+incompatible h1:CEeNmFM0QZIsJCZKMkZx0ZcahTiewkrgiwfYD+dfl1U=
 github.com/docker/docker v20.10.12+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.14+incompatible h1:+T9/PRYWNDo5SZl5qS1r9Mo/0Q8AwxKKPtu9S1yxM0w=
+github.com/docker/docker v20.10.14+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
 github.com/docker/docker-credential-helpers v0.6.4 h1:axCks+yV+2MR3/kZhAmy07yC56WZ2Pwu/fKWtKuZB0o=
 github.com/docker/docker-credential-helpers v0.6.4/go.mod h1:ofX3UI0Gz1TteYBjtgs07O36Pyasyp66D2uKT7H8W1c=
@@ -470,6 +512,7 @@ github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNE
 github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
 github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
@@ -522,6 +565,11 @@ github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui72
 github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.3.3/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
+github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM=
+github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
 github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
 github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
 github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
@@ -552,6 +600,8 @@ github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7
 github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
 github.com/go-logr/logr v1.2.0 h1:QK40JKJyMdUDz+h+xvCsru/bJhvG0UxvePV0ufL/AcE=
 github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.2 h1:ahHml/yUpnlb96Rp8HCvtYVPY8ZYpxq3g7UYchIYwbs=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/zapr v1.2.0/go.mod h1:Qa4Bsj2Vb+FAVeAKsLD8RLQ+YRJB8YDmOAKxaBQf7Ro=
 github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
 github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
@@ -604,6 +654,7 @@ github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblf
 github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
 github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godror/godror v0.24.2/go.mod h1:wZv/9vPiUib6tkoDl+AZ/QLf5YZgMravZ7jxH2eQWAE=
 github.com/gofrs/flock v0.7.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
@@ -685,9 +736,14 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
 github.com/google/go-containerregistry v0.4.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
 github.com/google/go-containerregistry v0.5.1 h1:/+mFTs4AlwsJ/mJe8NDtKb7BxLtbZFpcn8vDsneEkwQ=
 github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
+github.com/google/go-containerregistry v0.7.0/go.mod h1:2zaoelrL0d08gGbpdP3LqyUuBmhWbpD6IOe2s9nLS2k=
+github.com/google/go-containerregistry v0.8.0 h1:mtR24eN6rapCN+shds82qFEIWWmg64NPMuyCNT7/Ogc=
+github.com/google/go-containerregistry v0.8.0/go.mod h1:wW5v71NHGnQyb4k+gSshjxidrC7lN33MdWEn+Mz9TsI=
 github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
 github.com/google/go-github/v39 v39.0.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
 github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
@@ -701,6 +757,8 @@ github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSN
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -716,6 +774,7 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
@@ -954,6 +1013,8 @@ github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYs
 github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A=
+github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
 github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -996,6 +1057,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
 github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
+github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
@@ -1104,8 +1167,11 @@ github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdx
 github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ=
 github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
 github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
+github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
 github.com/moby/term v0.0.0-20210610120745-9d4ed1856297 h1:yH0SvLzcbZxcJXho2yh7CqdENGMQe73Cw3woZBpPli0=
 github.com/moby/term v0.0.0-20210610120745-9d4ed1856297/go.mod h1:vgPCkQMyxTZ7IDy8SXRufE172gr8+K/JE/7hHFxHW3A=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1154,6 +1220,7 @@ github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISq
 github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
 github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
 github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
 github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -1166,6 +1233,9 @@ github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7
 github.com/onsi/gomega v1.12.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
 github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
 github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
+github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
 github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -1174,6 +1244,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.2-0.20210730191737-8e42a01fb1b7/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.2-0.20211117181255-693428a734f5/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
 github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
@@ -1183,6 +1255,8 @@ github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rm
 github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0=
 github.com/opencontainers/runc v1.0.2 h1:opHZMaswlyxz1OuGpBE53Dwe4/xF7EZTY0A2L/FpCOg=
 github.com/opencontainers/runc v1.0.2/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
+github.com/opencontainers/runc v1.1.0 h1:O9+X96OcDjkmmZyfaG996kV7yq8HsoU2h1XRRQcefG8=
+github.com/opencontainers/runc v1.1.0/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc=
 github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
 github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
 github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
@@ -1194,6 +1268,8 @@ github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqi
 github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo=
 github.com/opencontainers/selinux v1.8.2 h1:c4ca10UMgRcvZ6h0K4HtS15UaVSBEaE+iln2LVpAuGc=
 github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8=
+github.com/opencontainers/selinux v1.10.0 h1:rAiKF8hTcgLI3w0DHm6i0ylVVcOrlgR1kK99DRLDhyU=
+github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
 github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
@@ -1219,8 +1295,6 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ
 github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034 h1:qzxRAL/HPfadofm5CX3zG3aPXOH77W3KwiW/zctUF7c=
-github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb h1:aNRIZcKkDkFhyROzmc5FCHgK6+ZbmzfTGudioPdtgmU=
 github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -1253,6 +1327,8 @@ github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB8
 github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
 github.com/prometheus/common v0.28.0 h1:vGVfV9KrDTvWt5boZO0I19g2E3CsWfpPPKZM9dt3mEw=
 github.com/prometheus/common v0.28.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.30.0 h1:JEkYlQnpzrzQFxi6gnukFPdQ+ac82oRhzMcIduJu/Ug=
+github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
@@ -1266,8 +1342,12 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
 github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
 github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
 github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
 github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
+github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 h1:I5N0WNMgPSq5NKUFspB4jMJ6n2P0ipz5FlOlB4BXviQ=
+github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2/go.mod h1:IxQujbYMAh4trWr0Dwa8jfciForjVmxyHpskZX6aydQ=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -1301,6 +1381,7 @@ github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
 github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
+github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
 github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE=
 github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
 github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
@@ -1411,11 +1492,14 @@ github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/
 github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
 github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
 github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
+github.com/vbatts/tar-split v0.11.2 h1:Via6XqJr0hceW4wff3QRzD5gAk/tatMw/4ZA7cTlIME=
+github.com/vbatts/tar-split v0.11.2/go.mod h1:vV3ZuO2yWSVsz+pfFzDG/upWH1JhjOiEaWq6kXyQ3VI=
 github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
 github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
 github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
@@ -1451,6 +1535,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
 github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
@@ -1506,6 +1591,7 @@ go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
@@ -1592,6 +1678,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.0 h1:UG21uOlmZabA4fW5i7ZX6bjw1xELEGg/ZLgZq9auk/Q=
 golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1654,11 +1742,17 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211111160137-58aab5ef257a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211203184738-4852103109b8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c=
+golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1784,6 +1878,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1804,16 +1899,24 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw=
+golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@@ -1919,6 +2022,7 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.6-0.20210820212750-d4cc65f0b2ff/go.mod h1:YD9qOF0M9xpSpdWTBbzEl5e/RnCefISl8E5Noe10jFM=
 golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
+golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2039,6 +2143,7 @@ google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211111162719-482062a4217b/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
@@ -2047,6 +2152,8 @@ google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ6
 google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5 h1:zzNejm+EgrbLfDZ6lu9Uud2IVvHySPl8vQzf04laR5Q=
 google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac h1:qSNTkEN+L2mvWcLgJOR+8bdHX9rN/IdU3A1Ghpfb1Rg=
+google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -2081,6 +2188,8 @@ google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
 google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
 google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -2096,6 +2205,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -2191,6 +2302,8 @@ k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRp
 k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc=
 k8s.io/apimachinery v0.23.1 h1:sfBjlDFwj2onG0Ijx5C+SrAoeUscPrmghm7wHP+uXlo=
 k8s.io/apimachinery v0.23.1/go.mod h1:SADt2Kl8/sttJ62RRsi9MIV4o8f5S3coArm0Iu3fBno=
+k8s.io/apimachinery v0.23.5 h1:Va7dwhp8wgkUPWsEXk6XglXWU4IKYLKNlv8VkX7SDM0=
+k8s.io/apimachinery v0.23.5/go.mod h1:BEuFMMBaIbcOqVIJqNZJXGFTP4W6AycEpb5+m/97hrM=
 k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q=
@@ -2250,6 +2363,8 @@ k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/
 k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b h1:wxEMGetGMur3J1xuGLQY7GEQYg9bZxKn3tKo5k/eYcs=
 k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed h1:ck1fRPWPJWsMd8ZRFsWc6mh/zHp5fZ/shhbrgPUxDAE=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw=
 modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk=
 modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k=
@@ -2279,6 +2394,8 @@ sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK
 sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
 sigs.k8s.io/structured-merge-diff/v4 v4.1.2 h1:Hr/htKFmJEbtMgS/UD0N+gtgctAqz81t3nu+sPzynno=
 sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.1 h1:bKCqE9GvQ5tiVHn5rfn1r+yao3aLQEaLzkkmAkf+A6Y=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
 sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=

+ 0 - 23
helm/.helmignore

@@ -1,23 +0,0 @@
-# Patterns to ignore when building packages.
-# This supports shell glob matching, relative path matching, and
-# negation (prefixed with !). Only one pattern per line.
-.DS_Store
-# Common VCS dirs
-.git/
-.gitignore
-.bzr/
-.bzrignore
-.hg/
-.hgignore
-.svn/
-# Common backup files
-*.swp
-*.bak
-*.tmp
-*.orig
-*~
-# Various IDEs
-.project
-.idea/
-*.tmproj
-.vscode/

+ 0 - 23
helm/Chart.yaml

@@ -1,23 +0,0 @@
-apiVersion: v2
-name: porter-prod
-description: A Helm chart for Kubernetes
-
-# A chart can be either an 'application' or a 'library' chart.
-#
-# Application charts are a collection of templates that can be packaged into versioned archives
-# to be deployed.
-#
-# Library charts provide useful utilities or functions for the chart developer. They're included as
-# a dependency of application charts to inject those utilities and functions into the rendering
-# pipeline. Library charts do not define any templates and therefore cannot be deployed.
-type: application
-
-# This is the chart version. This version number should be incremented each time you make changes
-# to the chart and its templates, including the app version.
-# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 0.1.0
-
-# This is the version number of the application being deployed. This version number should be
-# incremented each time you make changes to the application. Versions are not expected to
-# follow Semantic Versioning. They should reflect the version the application is using.
-appVersion: 1.16.0

+ 0 - 21
helm/templates/NOTES.txt

@@ -1,21 +0,0 @@
-1. Get the application URL by running these commands:
-{{- if .Values.ingress.enabled }}
-{{- range $host := .Values.ingress.hosts }}
-  {{- range .paths }}
-  http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}
-  {{- end }}
-{{- end }}
-{{- else if contains "NodePort" .Values.service.type }}
-  export NODE_PORT=$(kubectl get --namespace {{ .Release().Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "porter-prod.fullname" . }})
-  export NODE_IP=$(kubectl get nodes --namespace {{ .Release().Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
-  echo http://$NODE_IP:$NODE_PORT
-{{- else if contains "LoadBalancer" .Values.service.type }}
-     NOTE: It may take a few minutes for the LoadBalancer IP to be available.
-           You can watch the status of by running 'kubectl get --namespace {{ .Release().Namespace }} svc -w {{ include "porter-prod.fullname" . }}'
-  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release().Namespace }} {{ include "porter-prod.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
-  echo http://$SERVICE_IP:{{ .Values.service.port }}
-{{- else if contains "ClusterIP" .Values.service.type }}
-  export POD_NAME=$(kubectl get pods --namespace {{ .Release().Namespace }} -l "app.kubernetes.io/name={{ include "porter-prod.name" . }},app.kubernetes.io/instance={{ .Release().Name }}" -o jsonpath="{.items[0].metadata.name}")
-  echo "Visit http://127.0.0.1:8080 to use your application"
-  kubectl --namespace {{ .Release().Namespace }} port-forward $POD_NAME 8080:80
-{{- end }}

+ 0 - 63
helm/templates/_helpers.tpl

@@ -1,63 +0,0 @@
-{{/* vim: set filetype=mustache: */}}
-{{/*
-Expand the name of the chart.
-*/}}
-{{- define "porter-prod.name" -}}
-{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
-{{- end }}
-
-{{/*
-Create a default fully qualified app name.
-We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
-If release name contains chart name it will be used as a full name.
-*/}}
-{{- define "porter-prod.fullname" -}}
-{{- if .Values.fullnameOverride }}
-{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
-{{- else }}
-{{- $name := default .Chart.Name .Values.nameOverride }}
-{{- if contains $name .Release().Name }}
-{{- .Release().Name | trunc 63 | trimSuffix "-" }}
-{{- else }}
-{{- printf "%s-%s" .Release().Name $name | trunc 63 | trimSuffix "-" }}
-{{- end }}
-{{- end }}
-{{- end }}
-
-{{/*
-Create chart name and version as used by the chart label.
-*/}}
-{{- define "porter-prod.chart" -}}
-{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
-{{- end }}
-
-{{/*
-Common labels
-*/}}
-{{- define "porter-prod.labels" -}}
-helm.sh/chart: {{ include "porter-prod.chart" . }}
-{{ include "porter-prod.selectorLabels" . }}
-{{- if .Chart.AppVersion }}
-app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
-{{- end }}
-app.kubernetes.io/managed-by: {{ .Release().Service }}
-{{- end }}
-
-{{/*
-Selector labels
-*/}}
-{{- define "porter-prod.selectorLabels" -}}
-app.kubernetes.io/name: {{ include "porter-prod.name" . }}
-app.kubernetes.io/instance: {{ .Release().Name }}
-{{- end }}
-
-{{/*
-Create the name of the service account to use
-*/}}
-{{- define "porter-prod.serviceAccountName" -}}
-{{- if .Values.serviceAccount.create }}
-{{- default (include "porter-prod.fullname" .) .Values.serviceAccount.name }}
-{{- else }}
-{{- default "default" .Values.serviceAccount.name }}
-{{- end }}
-{{- end }}

+ 0 - 60
helm/templates/deployment.yaml

@@ -1,60 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: {{ include "porter-prod.fullname" . }}
-  labels:
-    {{- include "porter-prod.labels" . | nindent 4 }}
-spec:
-{{- if not .Values.autoscaling.enabled }}
-  replicas: {{ .Values.replicaCount }}
-{{- end }}
-  selector:
-    matchLabels:
-      {{- include "porter-prod.selectorLabels" . | nindent 6 }}
-  template:
-    metadata:
-    {{- with .Values.podAnnotations }}
-      annotations:
-        {{- toYaml . | nindent 8 }}
-    {{- end }}
-      labels:
-        {{- include "porter-prod.selectorLabels" . | nindent 8 }}
-    spec:
-      {{- with .Values.imagePullSecrets }}
-      imagePullSecrets:
-        {{- toYaml . | nindent 8 }}
-      {{- end }}
-      volumes:
-        - name: wss-ssl-certificate
-          secret:
-            secretName: ingress-dashboard
-      serviceAccountName: {{ include "porter-prod.serviceAccountName" . }}
-      securityContext:
-        {{- toYaml .Values.podSecurityContext | nindent 8 }}
-      containers:
-        - name: {{ .Chart.Name }}
-          securityContext:
-            {{- toYaml .Values.securityContext | nindent 12 }}
-          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
-          imagePullPolicy: {{ .Values.image.pullPolicy }}
-          ports:
-            - name: http
-              containerPort: 8080
-              protocol: TCP
-          resources:
-            {{- toYaml .Values.resources | nindent 12 }}
-          volumeMounts:
-            - name: wss-ssl-certificate
-              mountPath: /etc/wss
-      {{- with .Values.nodeSelector }}
-      nodeSelector:
-        {{- toYaml . | nindent 8 }}
-      {{- end }}
-      {{- with .Values.affinity }}
-      affinity:
-        {{- toYaml . | nindent 8 }}
-      {{- end }}
-      {{- with .Values.tolerations }}
-      tolerations:
-        {{- toYaml . | nindent 8 }}
-      {{- end }}

+ 0 - 28
helm/templates/hpa.yaml

@@ -1,28 +0,0 @@
-{{- if .Values.autoscaling.enabled }}
-apiVersion: autoscaling/v2beta1
-kind: HorizontalPodAutoscaler
-metadata:
-  name: {{ include "porter-prod.fullname" . }}
-  labels:
-    {{- include "porter-prod.labels" . | nindent 4 }}
-spec:
-  scaleTargetRef:
-    apiVersion: apps/v1
-    kind: Deployment
-    name: {{ include "porter-prod.fullname" . }}
-  minReplicas: {{ .Values.autoscaling.minReplicas }}
-  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
-  metrics:
-  {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
-    - type: Resource
-      resource:
-        name: cpu
-        targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
-  {{- end }}
-  {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
-    - type: Resource
-      resource:
-        name: memory
-        targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
-  {{- end }}
-{{- end }}

+ 0 - 43
helm/templates/ingress.yaml

@@ -1,43 +0,0 @@
-{{- if .Values.ingress.enabled -}}
-{{- $fullName := include "porter-prod.fullname" . -}}
-{{- $svcPort := .Values.service.port -}}
-{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
-apiVersion: networking.k8s.io/v1beta1
-{{- else -}}
-apiVersion: extensions/v1beta1
-{{- end }}
-kind: Ingress
-metadata:
-  name: {{ $fullName }}
-  labels:
-    {{- include "porter-prod.labels" . | nindent 4 }}
-  annotations:
-    kubernetes.io/ingress.global-static-ip-name: porter-hosted
-    cert-manager.io/cluster-issuer: letsencrypt-prod
-spec:
-  {{- if .Values.ingress.tls }}
-  tls:
-    {{- range .Values.ingress.tls }}
-    - hosts:
-        {{- range .hosts }}
-        - {{ . | quote }}
-        {{- end }}
-      secretName: {{ .secretName }}
-    {{- end }}
-  {{- end }}
-  backend:
-    serviceName: {{ $fullName }}
-    servicePort: {{ $svcPort }}
-  rules:
-    {{- range .Values.ingress.hosts }}
-    - host: {{ .host | quote }}
-      http:
-        paths:
-          {{- range .paths }}
-          - path: {{ . }}
-            backend:
-              serviceName: {{ $fullName }}
-              servicePort: {{ $svcPort }}
-          {{- end }}
-    {{- end }}
-  {{- end }}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio