Переглянути джерело

Merge branch 'master' into nafees/preview-env-improvements

Mohammed Nafees 3 роки тому
батько
коміт
b482cabd45
100 змінених файлів з 17131 додано та 1420 видалено
  1. 48 0
      .air.worker.toml
  2. 10 1
      .github/workflows/dev.yaml
  3. 1 1
      .github/workflows/prerelease.yaml
  4. 3 0
      Makefile
  5. 0 4
      README.md
  6. 55 0
      api/client/api.go
  7. 35 1
      api/client/registry.go
  8. 63 0
      api/client/v1_stack.go
  9. 78 0
      api/server/handlers/helmrepo/delete.go
  10. 85 12
      api/server/handlers/infra/forms.go
  11. 12 17
      api/server/handlers/namespace/stream_pod_logs.go
  12. 17 0
      api/server/handlers/release/upgrade.go
  13. 7 0
      api/server/handlers/release/upgrade_webhook.go
  14. 21 0
      api/server/handlers/stack/add_application.go
  15. 12 0
      api/server/handlers/stack/add_env_group.go
  16. 25 0
      api/server/handlers/stack/create.go
  17. 11 0
      api/server/handlers/stack/helpers.go
  18. 7 2
      api/server/handlers/user/welcome_webhook.go
  19. 79 0
      api/server/handlers/user/welcome_webhook_test.go
  20. 1 1
      api/server/router/cluster.go
  21. 29 0
      api/server/router/helm_repo.go
  22. 1 1
      api/server/shared/config/env/envconfs.go
  23. 6 12
      api/server/shared/requestutils/validator.go
  24. 35 0
      api/types/monitor.go
  25. 5 5
      api/types/namespace.go
  26. 1 1
      api/types/release.go
  27. 4 4
      api/types/stacks.go
  28. 10 6
      cli/cmd/connect/dockerhub.go
  29. 49 0
      cli/cmd/delete.go
  30. 83 19
      cli/cmd/deploy.go
  31. 3 0
      cli/cmd/deploy/create.go
  32. 5 1
      cli/cmd/docker/agent.go
  33. 13 0
      cli/cmd/docker/builder.go
  34. 60 0
      cli/cmd/helm.go
  35. 85 0
      cli/cmd/kubectl.go
  36. 101 38
      cli/cmd/run.go
  37. 217 0
      cli/cmd/stack.go
  38. 5 1
      dashboard/babel.config.json
  39. 2 2
      dashboard/docker/dev.Dockerfile
  40. 14275 1
      dashboard/package-lock.json
  41. 8 0
      dashboard/src/assets/cluster.svg
  42. BIN
      dashboard/src/assets/gradient.png
  43. 2 2
      dashboard/src/components/form-components/CheckboxRow.tsx
  44. 1 0
      dashboard/src/components/repo-selector/RepoList.tsx
  45. 142 123
      dashboard/src/hosted.index.html
  46. 1 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  47. 67 76
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  48. 2 2
      dashboard/src/main/home/cluster-dashboard/TagFilter.tsx
  49. 3 3
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  50. 8 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  51. 4 6
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  52. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  53. 53 85
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  54. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  55. 18 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  56. 4 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  57. 4 244
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  58. 15 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx
  59. 19 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/types.ts
  60. 218 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/useLogs.ts
  61. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  62. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx
  63. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  64. 2 4
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx
  65. 1 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  66. 0 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  67. 2 2
      dashboard/src/main/home/integrations/create-integration/GARForm.tsx
  68. 0 1
      dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx
  69. 13 13
      dashboard/src/main/home/launch/TemplateList.tsx
  70. 1 1
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  71. 3 5
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  72. 2 6
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  73. 2 2
      dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx
  74. 2 2
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  75. 0 1
      dashboard/src/main/home/new-project/NewProject.tsx
  76. 4 0
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  77. 16 0
      dashboard/src/main/home/onboarding/constants.ts
  78. 3 7
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx
  79. 9 5
      dashboard/src/main/home/project-settings/InviteList.tsx
  80. 5 5
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  81. 1 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  82. 314 290
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  83. 207 0
      dashboard/src/main/home/sidebar/Clusters.tsx
  84. 0 242
      dashboard/src/main/home/sidebar/Drawer.tsx
  85. 6 6
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  86. 25 121
      dashboard/src/main/home/sidebar/Sidebar.tsx
  87. 4 6
      dashboard/src/main/home/sidebar/SidebarLink.tsx
  88. 5 1
      dashboard/src/shared/hooks/useWebsockets.ts
  89. 1 1
      docker-compose.dev-secure.yaml
  90. 1 1
      docker-compose.dev.yaml
  91. 4 4
      docker/dev.Dockerfile
  92. 15 9
      go.mod
  93. 33 0
      go.sum
  94. 9 0
      internal/helm/agent.go
  95. 3 0
      internal/helm/config.go
  96. 3 0
      internal/kubernetes/config.go
  97. 1 1
      internal/kubernetes/prometheus/metrics.go
  98. 67 0
      internal/models/monitor.go
  99. 151 0
      internal/opa/config.yaml
  100. 82 0
      internal/opa/loader.go

+ 48 - 0
.air.worker.toml

@@ -0,0 +1,48 @@
+# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
+
+# Working directory
+# . or absolute path, please note that the directories following must be under root.
+root = "."
+tmp_dir = "tmp"
+
+[build]
+# Just plain old shell command. You could use `make` as well.
+cmd = "go build -o ./tmp/workers -tags ee -ldflags=\"-X 'main.Version=dev-ee'\" ./workers"
+
+# Binary file yields from `cmd`.
+bin = "tmp/workers"
+# Customize binary.
+full_bin = "tmp/workers"
+# Watch these filename extensions.
+include_ext = ["go", "mod", "sum", "html"]
+# Ignore these filename extensions or directories.
+exclude_dir = ["tmp", "dashboard"]
+# Watch these directories if you specified.
+include_dir = []
+# Exclude files.
+exclude_file = []
+# This log file places in your tmp_dir.
+log = "air.log"
+# It's not necessary to trigger build each time file changes if it's too frequent.
+delay = 1000 # ms
+# Stop running old binary when build errors occur.
+stop_on_error = true
+# Send Interrupt signal before killing process (windows does not support this feature)
+send_interrupt = false
+# Delay after sending Interrupt signal
+kill_delay = 500 # ms
+
+[log]
+# Show log time
+time = false
+
+[color]
+# Customize each part's color. If no color found, use the raw app log.
+main = "magenta"
+watcher = "cyan"
+build = "yellow"
+runner = "green"
+
+[misc]
+# Delete tmp directory on exit
+clean_on_exit = true

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

@@ -162,13 +162,22 @@ jobs:
           aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
           aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
           aws-region: us-east-2
+      - 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: Log in to gcloud CLI
+        run: gcloud auth configure-docker
       - name: Login to ECR
         id: login-ecr
         run: |
           aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }} -f ./workers/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }} -t gcr.io/porter-dev-273614/worker-pool:dev -f ./workers/Dockerfile
       - name: Push to ECR
         run: |
           docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }}
+          docker push gcr.io/porter-dev-273614/worker-pool:dev

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

@@ -134,7 +134,7 @@ jobs:
         run: |
           mkdir -p ./release/static
           cd dashboard
-          npm i --production=false
+          npm i --production=false --legacy-peer-deps
           npm run build
           cd ..
           zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*

+ 3 - 0
Makefile

@@ -21,3 +21,6 @@ build-cli-dev:
 
 start-provisioner-dev: install setup-env-files
 	bash ./scripts/dev-environment/StartProvisionerServer.sh
+
+start-worker-dev: install setup-env-files
+	bash ./scripts/dev-environment/StartWorkerServer.sh

+ 0 - 4
README.md

@@ -63,10 +63,6 @@ Below are instructions for a quickstart. For full documentation, please visit ou
 
 3. 🚀 Deploy your applications from a [git repository](https://docs.getporter.dev/docs/applications) or [Docker image registry](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure).
 
-## Running Porter Locally
-
-While it requires a few additional steps, it is possible to run Porter locally. Follow [this guide](https://docs.getporter.dev/docs/running-porter-locally) to run the local version of Porter.
-
 ## Want to Help?
 
 We welcome all contributions. If you're interested in contributing, please read our [contributing guide](https://github.com/porter-dev/porter/blob/master/CONTRIBUTING.md) and [join our Discord community](https://discord.gg/GJynMR3KXK).

+ 55 - 0
api/client/api.go

@@ -164,6 +164,61 @@ func (c *Client) postRequest(relPath string, data interface{}, response interfac
 	return err
 }
 
+type patchRequestOpts struct {
+	retryCount uint
+}
+
+func (c *Client) patchRequest(relPath string, data interface{}, response interface{}, opts ...patchRequestOpts) error {
+	var retryCount uint = 1
+
+	if len(opts) > 0 {
+		for _, opt := range opts {
+			retryCount = opt.retryCount
+		}
+	}
+
+	var httpErr *types.ExternalError
+	var err error
+
+	for i := 0; i < int(retryCount); i++ {
+		strData, err := json.Marshal(data)
+
+		if err != nil {
+			return nil
+		}
+
+		req, err := http.NewRequest(
+			"PATCH",
+			fmt.Sprintf("%s%s", c.BaseURL, relPath),
+			strings.NewReader(string(strData)),
+		)
+
+		if err != nil {
+			return err
+		}
+
+		httpErr, err = c.sendRequest(req, response, true)
+
+		if httpErr == nil && err == nil {
+			return nil
+		}
+
+		if i != int(retryCount)-1 {
+			if httpErr != nil {
+				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+			} else {
+				fmt.Printf("Error: %v, retrying request...\n", err)
+			}
+		}
+	}
+
+	if httpErr != nil {
+		return fmt.Errorf("%v", httpErr.Error)
+	}
+
+	return err
+}
+
 func (c *Client) deleteRequest(relPath string, data interface{}, response interface{}) error {
 	strData, err := json.Marshal(data)
 

+ 35 - 1
api/client/registry.go

@@ -27,7 +27,7 @@ func (c *Client) CreateRegistry(
 	return resp, err
 }
 
-// CreateRegistry creates a new registry integration
+// CreateHelmRepo creates a new helm repo in the project
 func (c *Client) CreateHelmRepo(
 	ctx context.Context,
 	projectID uint,
@@ -47,6 +47,40 @@ func (c *Client) CreateHelmRepo(
 	return resp, err
 }
 
+// ListHelmRepos list helm repos in the project
+func (c *Client) ListHelmRepos(
+	ctx context.Context,
+	projectID uint,
+) ([]*types.HelmRepo, error) {
+	var resp []*types.HelmRepo
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/helmrepos",
+			projectID,
+		),
+		nil,
+		&resp,
+	)
+
+	return resp, err
+}
+
+// DeleteHelmRepo deletes a helm repo from the project
+func (c *Client) DeleteHelmRepo(
+	ctx context.Context,
+	projectID, helmRepoID uint,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d/helmrepos/%d",
+			projectID, helmRepoID,
+		),
+		nil,
+		nil,
+	)
+}
+
 // ListRegistries returns a list of registries for a project
 func (c *Client) ListRegistries(
 	ctx context.Context,

+ 63 - 0
api/client/v1_stack.go

@@ -0,0 +1,63 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// ListStacks retrieves the list of stacks
+func (c *Client) ListStacks(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace string,
+) (*types.StackListResponse, error) {
+	resp := &types.StackListResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/v1/projects/%d/clusters/%d/namespaces/%s/stacks",
+			projectID, clusterID, namespace,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) AddEnvGroupToStack(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, stackID string,
+	req *types.CreateStackEnvGroupRequest,
+) error {
+	err := c.patchRequest(
+		fmt.Sprintf(
+			"/v1/projects/%d/clusters/%d/namespaces/%s/stacks/%s/add_env_group",
+			projectID, clusterID, namespace, stackID,
+		),
+		req,
+		nil,
+	)
+
+	return err
+}
+
+func (c *Client) RemoveEnvGroupFromStack(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, stackID, envGroupName string,
+) error {
+	err := c.deleteRequest(
+		fmt.Sprintf(
+			"/v1/projects/%d/clusters/%d/namespaces/%s/stacks/%s/remove_env_group/%s",
+			projectID, clusterID, namespace, stackID, envGroupName,
+		),
+		nil,
+		nil,
+	)
+
+	return err
+}

+ 78 - 0
api/server/handlers/helmrepo/delete.go

@@ -0,0 +1,78 @@
+package helmrepo
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type HelmRepoDeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewHelmRepoDeleteHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *HelmRepoDeleteHandler {
+	return &HelmRepoDeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *HelmRepoDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	helmRepoID, reqErr := requestutils.GetURLParamUint(r, "helm_repo_id")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	helmRepo, err := p.Repo().HelmRepo().ReadHelmRepo(proj.ID, helmRepoID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such helm repo")))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if helmRepo.BasicAuthIntegrationID != 0 {
+		basicAuthInt, err := p.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, helmRepo.BasicAuthIntegrationID)
+
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		} else if err == nil {
+			_, err = p.Repo().BasicIntegration().DeleteBasicIntegration(basicAuthInt)
+
+			if err != nil {
+				p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	}
+
+	err = p.Repo().HelmRepo().DeleteHelmRepo(helmRepo)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, helmRepo.ToHelmRepoType())
+}

+ 85 - 12
api/server/handlers/infra/forms.go

@@ -91,6 +91,12 @@ tabs:
           value: db.t3.xlarge
         - label: db.t3.2xlarge
           value: db.t3.2xlarge
+        - label: db.r5.large
+          value: db.r5.large
+        - label: db.r5.xlarge
+          value: db.r5.xlarge
+        - label: db.r5.2xlarge
+          value: db.r5.2xlarge
   - name: family-versions
     contents:
     - type: select
@@ -396,8 +402,16 @@ tabs:
           value: t3.xlarge
         - label: t3.2xlarge
           value: t3.2xlarge
+        - label: c6i.large
+          value: c6i.large
+        - label: c6i.xlarge
+          value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: r5.large
+          value: r5.large
+        - value: r5.xlarge
+          value: r5.xlarge
     - type: string-input
       label: 👤 Issuer Email
       required: true
@@ -625,6 +639,27 @@ tabs:
       label: Add an additional prometheus node group to ensure monitoring stability.
       settings:
         default: false
+  - name: prometheus_machine_settings
+    show_if: additional_prometheus_node_group
+    contents:
+    - type: select
+      label: ⚙️ AWS Prometheus Machine Type
+      variable: additional_prometheus_machine_type
+      settings:
+        default: t2.medium
+        options:
+        - label: t2.medium
+          value: t2.medium
+        - label: t2.large
+          value: t2.large
+        - label: t2.xlarge
+          value: t2.xlarge
+        - label: t3.medium
+          value: t3.medium
+        - label: t3.large
+          value: t3.large
+        - label: t3.xlarge
+          value: t3.xlarge
 `
 
 const gcrForm = `name: GCR
@@ -656,12 +691,18 @@ tabs:
           value: asia-northeast3
         - label: asia-south1
           value: asia-south1
+        - label: asia-south2
+          value: asia-south2
         - label: asia-southeast1
           value: asia-southeast1
         - label: asia-southeast2
           value: asia-southeast2
         - label: australia-southeast1
           value: australia-southeast1
+        - label: australia-southeast2
+          value: australia-southeast2
+        - label: europe-central2
+          value: europe-central2
         - label: europe-north1
           value: europe-north1
         - label: europe-west1
@@ -674,23 +715,33 @@ tabs:
           value: europe-west4
         - label: europe-west6
           value: europe-west6
+        - label: europe-west8
+          value: europe-west8
+        - label: europe-west9
+          value: europe-west9
+        - label: europe-southwest1
+          value: europe-southwest1
         - label: northamerica-northeast1
           value: northamerica-northeast1
+        - label: northamerica-northeast2
+          value: northamerica-northeast2
         - label: southamerica-east1
           value: southamerica-east1
+        - label: southamerica-west1
+          value: southamerica-west1
         - label: us-central1
           value: us-central1
         - label: us-east1
           value: us-east1
         - label: us-east4
           value: us-east4
-        - label: us-east1
-          value: us-east1
-        - label: us-east1
-          value: us-east1
+        - label: us-east5
+          value: us-east5
+        - label: us-south1
+          value: us-south1
         - label: us-west1
           value: us-west1
-        - label: us-east1
+        - label: us-west2
           value: us-west2
         - label: us-west3
           value: us-west3
@@ -741,8 +792,6 @@ tabs:
           value: europe-central2
         - label: europe-north1
           value: europe-north1
-        - label: europe-southwest1
-          value: europe-southwest1
         - label: europe-west1
           value: europe-west1
         - label: europe-west2
@@ -757,6 +806,8 @@ tabs:
           value: europe-west8
         - label: europe-west9
           value: europe-west9
+        - label: europe-southwest1
+          value: europe-southwest1
         - label: northamerica-northeast1
           value: northamerica-northeast1
         - label: northamerica-northeast2
@@ -783,6 +834,12 @@ tabs:
           value: us-west3
         - label: us-west4
           value: us-west4
+        - label: us (multi-region)
+          value: us
+        - label: europe (multi-region)
+          value: europe
+        - label: asia (multi-region)
+          value: asia
 `
 
 const gkeForm = `name: GKE
@@ -814,12 +871,18 @@ tabs:
           value: asia-northeast3
         - label: asia-south1
           value: asia-south1
+        - label: asia-south2
+          value: asia-south2
         - label: asia-southeast1
           value: asia-southeast1
         - label: asia-southeast2
           value: asia-southeast2
         - label: australia-southeast1
           value: australia-southeast1
+        - label: australia-southeast2
+          value: australia-southeast2
+        - label: europe-central2
+          value: europe-central2
         - label: europe-north1
           value: europe-north1
         - label: europe-west1
@@ -832,23 +895,33 @@ tabs:
           value: europe-west4
         - label: europe-west6
           value: europe-west6
+        - label: europe-west8
+          value: europe-west8
+        - label: europe-west9
+          value: europe-west9
+        - label: europe-southwest1
+          value: europe-southwest1
         - label: northamerica-northeast1
           value: northamerica-northeast1
+        - label: northamerica-northeast2
+          value: northamerica-northeast2
         - label: southamerica-east1
           value: southamerica-east1
+        - label: southamerica-west1
+          value: southamerica-west1
         - label: us-central1
           value: us-central1
         - label: us-east1
           value: us-east1
         - label: us-east4
           value: us-east4
-        - label: us-east1
-          value: us-east1
-        - label: us-east1
-          value: us-east1
+        - label: us-east5
+          value: us-east5
+        - label: us-south1
+          value: us-south1
         - label: us-west1
           value: us-west1
-        - label: us-east1
+        - label: us-west2
           value: us-west2
         - label: us-west3
           value: us-west3

+ 12 - 17
api/server/handlers/namespace/stream_pod_logs.go

@@ -55,22 +55,17 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	err = agent.GetPodLogs(namespace, name, request.Container, safeRW)
 
-	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("pod %s/%s was not found", namespace, name),
-			http.StatusNotFound,
-		))
-
-		return
-	} else if brErr := (kubernetes.BadRequestError{}); errors.As(err, &targetErr) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			&brErr,
-			http.StatusBadRequest,
-		))
-
-		return
-	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+	if err != nil {
+		if errors.Is(err, kubernetes.IsNotFoundError) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("pod %s/%s was not found", namespace, name),
+				http.StatusNotFound))
+			return
+		} else if _, ok := err.(*kubernetes.BadRequestError); ok {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		} else {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 }

+ 17 - 0
api/server/handlers/release/upgrade.go

@@ -141,6 +141,23 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 	}
 
+	// check if release is part of a stack
+	stacks, err := c.Repo().Stack().ListStacks(cluster.ProjectID, cluster.ID, helmRelease.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, stk := range stacks {
+		for _, res := range stk.Revisions[0].Resources {
+			if res.Name == helmRelease.Name {
+				conf.Stack = stk
+				break
+			}
+		}
+	}
+
 	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
 
 	if upgradeErr == nil && newHelmRelease != nil {

+ 7 - 0
api/server/handlers/release/upgrade_webhook.go

@@ -95,6 +95,7 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	// repository is set to current repository by default
 	repository := rel.Config["image"].(map[string]interface{})["repository"]
+	currTag := rel.Config["image"].(map[string]interface{})["tag"]
 
 	gitAction := release.GitActionConfig
 
@@ -106,7 +107,13 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	image := map[string]interface{}{}
 	image["repository"] = repository
+
 	image["tag"] = request.Commit
+
+	if request.Commit == "" {
+		image["tag"] = currTag
+	}
+
 	rel.Config["image"] = image
 
 	if rel.Config["auto_deploy"] == false {

+ 21 - 0
api/server/handlers/stack/add_application.go

@@ -82,6 +82,18 @@ func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 
 	appResources = append(appResources, newResources...)
 
+	nameValidator := make(map[string]bool)
+
+	for _, res := range appResources {
+		if _, ok := nameValidator[res.Name]; ok {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("duplicate app resource name: %s", res.Name),
+				http.StatusBadRequest))
+			return
+		}
+
+		nameValidator[res.Name] = true
+	}
+
 	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
 
 	if err != nil {
@@ -105,6 +117,14 @@ func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		return
 	}
 
+	// re-read the stack to get the most upto date information
+	stack, err = p.Repo().Stack().ReadStackByID(proj.ID, stack.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 
 	if err != nil {
@@ -132,6 +152,7 @@ func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 			registries: registries,
 			helmAgent:  helmAgent,
 			request:    req,
+			stack:      stack,
 		})
 
 		if err != nil {

+ 12 - 0
api/server/handlers/stack/add_env_group.go

@@ -88,6 +88,18 @@ func (p *StackAddEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	envGroups = append(envGroups, newEnvGroups...)
 
+	nameValidator := make(map[string]bool)
+
+	for _, eg := range envGroups {
+		if _, ok := nameValidator[eg.Name]; ok {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("duplicate env group name: %s", eg.Name),
+				http.StatusBadRequest))
+			return
+		}
+
+		nameValidator[eg.Name] = true
+	}
+
 	newRevision := &models.StackRevision{
 		StackID:        stack.ID,
 		RevisionNumber: latestRevision.RevisionNumber + 1,

+ 25 - 0
api/server/handlers/stack/create.go

@@ -67,6 +67,18 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	nameValidator := make(map[string]bool)
+
+	for _, res := range resources {
+		if _, ok := nameValidator[res.Name]; ok {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("duplicate app resource name: %s", res.Name),
+				http.StatusBadRequest))
+			return
+		}
+
+		nameValidator[res.Name] = true
+	}
+
 	envGroups, err := getEnvGroupModels(req.EnvGroups, proj.ID, cluster.ID, namespace)
 
 	if err != nil {
@@ -74,6 +86,18 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	nameValidator = make(map[string]bool)
+
+	for _, eg := range envGroups {
+		if _, ok := nameValidator[eg.Name]; ok {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("duplicate env group name: %s", eg.Name),
+				http.StatusBadRequest))
+			return
+		}
+
+		nameValidator[eg.Name] = true
+	}
+
 	// write stack to the database with creating status
 	stack := &models.Stack{
 		ProjectID: proj.ID,
@@ -174,6 +198,7 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				registries: registries,
 				helmAgent:  helmAgent,
 				request:    appResource,
+				stack:      stack,
 			})
 
 			if err != nil {

+ 11 - 0
api/server/handlers/stack/helpers.go

@@ -17,6 +17,7 @@ type applyAppResourceOpts struct {
 	helmAgent  *helm.Agent
 	request    *types.CreateStackAppResourceRequest
 	registries []*models.Registry
+	stack      *models.Stack
 }
 
 func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
@@ -40,6 +41,16 @@ func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
 		Registries: opts.registries,
 	}
 
+	if conf.Values == nil {
+		conf.Values = make(map[string]interface{})
+	}
+
+	conf.Values["stack"] = map[string]interface{}{
+		"enabled":  true,
+		"name":     opts.stack.Name,
+		"revision": opts.stack.Revisions[0].RevisionNumber,
+	}
+
 	return opts.helmAgent.InstallChart(conf, opts.config.DOConf)
 }
 

+ 7 - 2
api/server/handlers/user/welcome_webhook.go

@@ -27,14 +27,19 @@ func NewUserWelcomeHandler(
 }
 
 func (u *UserWelcomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Skip if no welcome hook is configured.
+	welcomeFormWebhook := u.Config().ServerConf.WelcomeFormWebhook
+	if welcomeFormWebhook == "" {
+		return
+	}
+
 	reqVals := &types.WelcomeWebhookRequest{}
 
 	if ok := u.DecodeAndValidate(w, r, reqVals); !ok {
 		return
 	}
 
-	req, err := http.NewRequest("GET", u.Config().ServerConf.WelcomeFormWebhook, nil)
-
+	req, err := http.NewRequest("GET", welcomeFormWebhook, nil)
 	if err != nil {
 		return
 	}

+ 79 - 0
api/server/handlers/user/welcome_webhook_test.go

@@ -0,0 +1,79 @@
+package user_test
+
+import (
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/porter-dev/porter/api/server/handlers/user"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func TestWelcomeWebhookWithoutURL(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(
+		t,
+		string(types.HTTPVerbPost),
+		"/api/welcome",
+		&types.WelcomeWebhookRequest{
+			Email:     "test@test.it",
+			IsCompany: true,
+			Company:   "Awesome Company",
+			Role:      "Founder",
+			Name:      "John Doe",
+		},
+	)
+
+	config := apitest.LoadConfig(t)
+	config.ServerConf.WelcomeFormWebhook = ""
+
+	handler := user.NewUserWelcomeHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	assert.Equal(t, rr.Result().StatusCode, 200, "incorrect status code")
+}
+
+func helloWebhook(w http.ResponseWriter, r *http.Request) {
+	io.WriteString(w, "Hello!\n")
+}
+
+func TestWelcomeWebhookWithURL(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(
+		t,
+		string(types.HTTPVerbPost),
+		"/api/welcome",
+		&types.WelcomeWebhookRequest{
+			Email:     "test@test.it",
+			IsCompany: true,
+			Company:   "Awesome Company",
+			Role:      "Founder",
+			Name:      "John Doe",
+		},
+	)
+
+	go func() {
+		http.HandleFunc("/hello", helloWebhook)
+		http.ListenAndServe(":10044", nil)
+	}()
+
+	config := apitest.LoadConfig(t)
+	config.ServerConf.WelcomeFormWebhook = "http://localhost:10044/hello"
+
+	handler := user.NewUserWelcomeHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	assert.Equal(t, rr.Result().StatusCode, 200, "incorrect status code")
+}

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

@@ -727,7 +727,7 @@ func getClusterRoutes(
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/kubeconfig -> cluster.NewGetTemporaryKubeconfigHandler
 	getTemporaryKubeconfigEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
+			Verb:   types.APIVerbUpdate, // we do not want users with no-write access to be able to use this
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,

+ 29 - 0
api/server/router/helm_repo.go

@@ -81,6 +81,35 @@ func getHelmRepoRoutes(
 		Router:   r,
 	})
 
+	// DELETE /api/projects/{project_id}/helmrepos/{helm_repo_id} -> registry.NewHelmRepoDeleteHandler
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.HelmRepoScope,
+			},
+		},
+	)
+
+	deleteHandler := helmrepo.NewHelmRepoDeleteHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
 	//  GET /api/projects/{project_id}/helmrepos/{helm_repo_id}/charts -> helmrepo.NewChartListHandler
 	hrListEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 1
api/server/shared/config/env/envconfs.go

@@ -100,7 +100,7 @@ type ServerConf struct {
 	ProvisionerTest bool `env:"PROVISIONER_TEST,default=false"`
 
 	// Disable filtering for project creation
-	DisableAllowlist bool `env:"DISABLE_ALLOWLIST,default=false"`
+	DisableAllowlist bool `env:"DISABLE_ALLOWLIST,default=true"`
 
 	// Enable gitlab integration
 	EnableGitlab bool `env:"ENABLE_GITLAB,default=false"`

+ 6 - 12
api/server/shared/requestutils/validator.go

@@ -5,9 +5,9 @@ import (
 	"net/http"
 	"strings"
 
+	v10Validator "github.com/go-playground/validator/v10"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
-
-	"github.com/go-playground/validator/v10"
+	"github.com/porter-dev/porter/internal/validator"
 )
 
 // Validator will validate the fields for a request object to ensure that
@@ -22,19 +22,13 @@ type Validator interface {
 // DefaultValidator uses the go-playground v10 validator for verifying that
 // request objects are well-formed
 type DefaultValidator struct {
-	v10 *validator.Validate
+	v10 *v10Validator.Validate
 }
 
 // NewDefaultValidator returns a Validator constructed from the go-playground v10
 // validator
 func NewDefaultValidator() Validator {
-	v10 := validator.New()
-
-	// set tag name to "form" since the request structs are used on both
-	// the client and server side
-	v10.SetTagName("form")
-
-	return &DefaultValidator{v10}
+	return &DefaultValidator{validator.New()}
 }
 
 // Validate uses the go-playground v10 validator and checks struct fields against
@@ -47,7 +41,7 @@ func (v *DefaultValidator) Validate(s interface{}) apierrors.RequestError {
 	}
 
 	// translate all validator errors
-	errs, ok := err.(validator.ValidationErrors)
+	errs, ok := err.(v10Validator.ValidationErrors)
 
 	if !ok {
 		return apierrors.NewErrInternal(fmt.Errorf("could not cast err to validator.ValidationErrors"))
@@ -93,7 +87,7 @@ type ValidationErrObject struct {
 
 // NewValidationErrObject simply returns a ValidationErrObject from a go-playground v10
 // validator `FieldError`
-func NewValidationErrObject(fieldErr validator.FieldError) *ValidationErrObject {
+func NewValidationErrObject(fieldErr v10Validator.FieldError) *ValidationErrObject {
 	return &ValidationErrObject{
 		Field:       fieldErr.Field(),
 		Condition:   fieldErr.ActualTag(),

+ 35 - 0
api/types/monitor.go

@@ -0,0 +1,35 @@
+package types
+
+import "time"
+
+type MonitorTestStatus string
+
+const (
+	MonitorTestStatusSuccess MonitorTestStatus = "success"
+	MonitorTestStatusFailed  MonitorTestStatus = "failed"
+)
+
+type MonitorTestSeverity string
+
+const (
+	MonitorTestSeverityCritical MonitorTestSeverity = "critical"
+	MonitorTestSeverityHigh     MonitorTestSeverity = "high"
+	MonitorTestSeverityLow      MonitorTestSeverity = "low"
+)
+
+type MonitorTestResult struct {
+	ProjectID uint   `json:"project_id"`
+	ClusterID uint   `json:"cluster_id"`
+	Category  string `json:"category"`
+	ObjectID  string `json:"object_id"`
+
+	LastStatusChange *time.Time `json:"last_status_change"`
+
+	LastTested    *time.Time        `json:"last_tested"`
+	LastRunResult MonitorTestStatus `json:"last_run_result"`
+
+	Title   string `json:"title"`
+	Message string `json:"message"`
+
+	Severity MonitorTestSeverity `json:"severity"`
+}

+ 5 - 5
api/types/namespace.go

@@ -135,8 +135,8 @@ type GetEnvGroupRequest struct {
 
 type CloneEnvGroupRequest struct {
 	Namespace string `json:"namespace" form:"required"`
-	Name      string `json:"name" form:"required"`
-	CloneName string `json:"clone_name"`
+	Name      string `json:"name" form:"required,dns1123"`
+	CloneName string `json:"clone_name,dns1123"`
 	Version   uint   `json:"version"`
 }
 
@@ -149,7 +149,7 @@ type DeleteEnvGroupRequest struct {
 }
 
 type AddEnvGroupApplicationRequest struct {
-	Name            string `json:"name" form:"required"`
+	Name            string `json:"name" form:"required,dns1123"`
 	ApplicationName string `json:"app_name" form:"required"`
 }
 
@@ -161,7 +161,7 @@ type ListEnvGroupsResponse []*EnvGroupMeta
 type CreateEnvGroupRequest struct {
 	// the name of the env group to create or update
 	// example: prod-env-group
-	Name string `json:"name" form:"required"`
+	Name string `json:"name" form:"required,dns1123"`
 
 	// the variables to include in the env group
 	Variables map[string]string `json:"variables" form:"required"`
@@ -231,7 +231,7 @@ type GetEnvGroupResponse struct {
 //
 // swagger:model
 type V1EnvGroupReleaseRequest struct {
-	ReleaseName string `json:"release_name" form:"required"`
+	ReleaseName string `json:"release_name" form:"required,dns1123"`
 }
 
 // V1EnvGroupResponse defines an env group

+ 1 - 1
api/types/release.go

@@ -68,7 +68,7 @@ type CreateReleaseBaseRequest struct {
 
 	// The name of this release
 	// required: true
-	Name string `json:"name" form:"required"`
+	Name string `json:"name" form:"required,dns1123"`
 }
 
 // swagger:model

+ 4 - 4
api/types/stacks.go

@@ -56,7 +56,7 @@ type CreateStackAppResourceRequest struct {
 
 	// The name of the resource.
 	// required: true
-	Name string `json:"name" form:"required"`
+	Name string `json:"name" form:"required,dns1123"`
 
 	// The name of the source config (must exist inside `source_configs`).
 	// required: true
@@ -235,15 +235,15 @@ type StackSourceConfig struct {
 type CreateStackEnvGroupRequest struct {
 	// The name of the env group
 	// required: true
-	Name string `json:"name" form:"required"`
+	Name string `json:"name" form:"required,dns1123"`
 
 	// The non-secret variables to set in the env group
 	// required: true
-	Variables map[string]string `json:"variables,required" form:"required"`
+	Variables map[string]string `json:"variables" form:"required"`
 
 	// The secret variables to set in the env group
 	// required: true
-	SecretVariables map[string]string `json:"secret_variables,required" form:"required"`
+	SecretVariables map[string]string `json:"secret_variables" form:"required"`
 
 	// The list of applications that this env group should be synced to. These applications **must** be present
 	// in the stack - if an env group is created from a stack, syncing to applications which are not in the stack

+ 10 - 6
cli/cmd/connect/dockerhub.go

@@ -3,6 +3,7 @@ package connect
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/porter-dev/porter/api/types"
 
@@ -22,21 +23,24 @@ func Dockerhub(
 
 	// query for dockerhub name
 
-	repoName, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the Docker Hub image path, in the form of ${org_name}/${repo_name}. For example, porter1/porter.
-Image path: `))
-
+	repoName, err := utils.PromptPlaintext("Provide the Docker Hub repository, in the form of ${org_name}/${repo_name}. For example, porter1/porter.\nRepository: ")
 	if err != nil {
 		return 0, err
 	}
 
-	username, err := utils.PromptPlaintext(fmt.Sprintf(`Docker Hub username: `))
+	orgRepo := strings.Split(repoName, "/")
+
+	if len(orgRepo) != 2 || orgRepo[0] == "" || orgRepo[1] == "" {
+		return 0, fmt.Errorf("invalid Docker Hub repository: %s", repoName)
+	}
+
+	username, err := utils.PromptPlaintext("Docker Hub username: ")
 
 	if err != nil {
 		return 0, err
 	}
 
-	password, err := utils.PromptPassword(`Provide the Docker Hub personal access token.
-Token:`)
+	password, err := utils.PromptPassword("Provide the Docker Hub personal access token.\nToken: ")
 
 	if err != nil {
 		return 0, err

+ 49 - 0
cli/cmd/delete.go

@@ -85,6 +85,21 @@ var deleteAddonsCmd = &cobra.Command{
 	},
 }
 
+// deleteHelmCmd represents the "porter delete helm" subcommand
+var deleteHelmCmd = &cobra.Command{
+	Use:     "helm",
+	Aliases: []string{"helmrepo", "helmrepos"},
+	Short:   "Deletes an existing helm repo",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteHelm)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 	deleteCmd.PersistentFlags().StringVar(
 		&namespace,
@@ -96,6 +111,7 @@ func init() {
 	deleteCmd.AddCommand(deleteAppsCmd)
 	deleteCmd.AddCommand(deleteJobsCmd)
 	deleteCmd.AddCommand(deleteAddonsCmd)
+	deleteCmd.AddCommand(deleteHelmCmd)
 
 	rootCmd.AddCommand(deleteCmd)
 }
@@ -221,3 +237,36 @@ func deleteAddon(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 
 	return nil
 }
+
+func deleteHelm(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.ListHelmRepos(context.Background(), cliConf.Project)
+
+	if err != nil {
+		return err
+	}
+
+	var repo *types.HelmRepo
+
+	for _, r := range resp {
+		if r.Name == name {
+			repo = r
+			break
+		}
+	}
+
+	if repo == nil {
+		return fmt.Errorf("no helm repo found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting helm repo: %s\n", name)
+
+	err = client.DeleteHelmRepo(context.Background(), cliConf.Project, repo.ID)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 83 - 19
cli/cmd/deploy.go

@@ -231,6 +231,7 @@ var updateEnvGroupCmd = &cobra.Command{
 var updateSetEnvGroupCmd = &cobra.Command{
 	Use:   "set",
 	Short: "Sets the desired value of an environment variable in an env group in the form VAR=VALUE.",
+	Args:  cobra.MaximumNArgs(1),
 	Run: func(cmd *cobra.Command, args []string) {
 		err := checkLoginAndRun(args, updateSetEnvGroup)
 
@@ -243,6 +244,7 @@ var updateSetEnvGroupCmd = &cobra.Command{
 var updateUnsetEnvGroupCmd = &cobra.Command{
 	Use:   "unset",
 	Short: "Removes an environment variable from an env group.",
+	Args:  cobra.MinimumNArgs(1),
 	Run: func(cmd *cobra.Command, args []string) {
 		err := checkLoginAndRun(args, updateUnsetEnvGroup)
 
@@ -262,9 +264,10 @@ var stream bool
 var buildFlagsEnv []string
 var forcePush bool
 var useCache bool
-var value string
 var version uint
 var varType string
+var normalEnvGroupVars []string
+var secretEnvGroupVars []string
 
 func init() {
 	buildFlagsEnv = []string{}
@@ -407,6 +410,22 @@ func init() {
 		"the type of environment variable (either \"normal\" or \"secret\")",
 	)
 
+	updateSetEnvGroupCmd.PersistentFlags().StringArrayVarP(
+		&normalEnvGroupVars,
+		"normal",
+		"n",
+		[]string{},
+		"list of variables to set, in the form VAR=VALUE",
+	)
+
+	updateSetEnvGroupCmd.PersistentFlags().StringArrayVarP(
+		&secretEnvGroupVars,
+		"secret",
+		"s",
+		[]string{},
+		"list of secret variables to set, in the form VAR=VALUE",
+	)
+
 	updateEnvGroupCmd.AddCommand(updateSetEnvGroupCmd)
 	updateEnvGroupCmd.AddCommand(updateUnsetEnvGroupCmd)
 
@@ -573,14 +592,8 @@ func updateUpgrade(_ *types.GetAuthenticatedUserResponse, client *api.Client, ar
 }
 
 func updateSetEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	if len(args) == 0 {
-		return fmt.Errorf("required variable in the form of VAR=VALUE")
-	}
-
-	key, value, found := strings.Cut(args[0], "=")
-
-	if !found {
-		return fmt.Errorf("variable should be in the form of VAR=VALUE")
+	if len(normalEnvGroupVars) == 0 && len(secretEnvGroupVars) == 0 && len(args) == 0 {
+		return fmt.Errorf("please provide one or more variables to update")
 	}
 
 	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
@@ -606,17 +619,56 @@ func updateSetEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client
 		Variables: envGroupResp.Variables,
 	}
 
-	delete(newEnvGroup.Variables, key)
+	// first check for multiple variables being set using the -e or -s flags
+	if len(normalEnvGroupVars) > 0 || len(secretEnvGroupVars) > 0 {
+		for _, v := range normalEnvGroupVars {
+			delete(newEnvGroup.Variables, v)
+
+			key, value, err := validateVarValue(v)
+
+			if err != nil {
+				return err
+			}
+
+			newEnvGroup.Variables[key] = value
+		}
+
+		if len(secretEnvGroupVars) > 0 {
+			newEnvGroup.SecretVariables = make(map[string]string)
+		}
+
+		for _, v := range secretEnvGroupVars {
+			delete(newEnvGroup.Variables, v)
+
+			key, value, err := validateVarValue(v)
+
+			if err != nil {
+				return err
+			}
+
+			newEnvGroup.SecretVariables[key] = value
+		}
+
+		s.Suffix = fmt.Sprintf(" Updating env group '%s' in namespace '%s'", name, namespace)
+	} else { // legacy usage
+		key, value, err := validateVarValue(args[0])
+
+		if err != nil {
+			return err
+		}
+
+		delete(newEnvGroup.Variables, key)
 
-	if varType == "secret" {
-		newEnvGroup.SecretVariables = make(map[string]string)
-		newEnvGroup.SecretVariables[key] = value
+		if varType == "secret" {
+			newEnvGroup.SecretVariables = make(map[string]string)
+			newEnvGroup.SecretVariables[key] = value
 
-		s.Suffix = fmt.Sprintf(" Adding new secret variable '%s' to env group '%s' in namespace '%s'", key, name, namespace)
-	} else {
-		newEnvGroup.Variables[key] = value
+			s.Suffix = fmt.Sprintf(" Adding new secret variable '%s' to env group '%s' in namespace '%s'", key, name, namespace)
+		} else {
+			newEnvGroup.Variables[key] = value
 
-		s.Suffix = fmt.Sprintf(" Adding new variable '%s' to env group '%s' in namespace '%s'", key, name, namespace)
+			s.Suffix = fmt.Sprintf(" Adding new variable '%s' to env group '%s' in namespace '%s'", key, name, namespace)
+		}
 	}
 
 	s.Start()
@@ -636,6 +688,16 @@ func updateSetEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client
 	return nil
 }
 
+func validateVarValue(in string) (string, string, error) {
+	key, value, found := strings.Cut(in, "=")
+
+	if !found {
+		return "", "", fmt.Errorf("%s is not in the form of VAR=VALUE", in)
+	}
+
+	return key, value, nil
+}
+
 func updateUnsetEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 	if len(args) == 0 {
 		return fmt.Errorf("required variable name")
@@ -664,9 +726,11 @@ func updateUnsetEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Clie
 		Variables: envGroupResp.Variables,
 	}
 
-	delete(newEnvGroup.Variables, args[0])
+	for _, v := range args {
+		delete(newEnvGroup.Variables, v)
+	}
 
-	s.Suffix = fmt.Sprintf(" Removing variable '%s' from env group '%s' in namespace '%s'", args[0], name, namespace)
+	s.Suffix = fmt.Sprintf(" Removing variables from env group '%s' in namespace '%s'", name, namespace)
 
 	s.Start()
 

+ 3 - 0
cli/cmd/deploy/create.go

@@ -438,6 +438,9 @@ func (c *CreateAgent) GetImageRepoURL(name, namespace string) (uint, string, err
 	if strings.Contains(imageURI, "pkg.dev") {
 		repoSlice := strings.Split(imageURI, "/")
 		imageURI = fmt.Sprintf("%s/%s", imageURI, repoSlice[len(repoSlice)-1])
+	} else if strings.Contains(imageURI, "index.docker.io") {
+		repoSlice := strings.Split(imageURI, "/")
+		imageURI = strings.Join(repoSlice[:len(repoSlice)-1], "/")
 	}
 
 	return regID, imageURI, nil

+ 5 - 1
cli/cmd/docker/agent.go

@@ -401,7 +401,7 @@ func (a *Agent) getPushOptions(image string) (types.ImagePushOptions, error) {
 }
 
 func GetServerURLFromTag(image string) (string, error) {
-	named, err := reference.ParseNamed(image)
+	named, err := reference.ParseNormalizedNamed(image)
 
 	if err != nil {
 		return "", err
@@ -432,6 +432,10 @@ func GetServerURLFromTag(image string) (string, error) {
 		return "", err
 	}
 
+	if domain == "docker.io" {
+		domain = "index.docker.io"
+	}
+
 	return fmt.Sprintf("%s/%s", domain, nonImagePath), nil
 }
 

+ 13 - 0
cli/cmd/docker/builder.go

@@ -12,6 +12,7 @@ import (
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
+	"github.com/docker/docker/pkg/fileutils"
 	"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
 	"github.com/moby/moby/pkg/jsonmessage"
 	"github.com/moby/moby/pkg/stringid"
@@ -47,6 +48,8 @@ func (a *Agent) BuildLocal(opts *BuildOpts) (err error) {
 		}
 	}
 
+	excludes = trimBuildFilesFromExcludes(excludes, dockerfilePath)
+
 	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{
 		ExcludePatterns: excludes,
 	})
@@ -107,6 +110,16 @@ func (a *Agent) BuildLocal(opts *BuildOpts) (err error) {
 	return jsonmessage.DisplayJSONMessagesStream(out.Body, os.Stderr, termFd, isTerm, nil)
 }
 
+func trimBuildFilesFromExcludes(excludes []string, dockerfile string) []string {
+	if keep, _ := fileutils.Matches(".dockerignore", excludes); keep {
+		excludes = append(excludes, "!.dockerignore")
+	}
+	if keep, _ := fileutils.Matches(dockerfile, excludes); keep {
+		excludes = append(excludes, "!"+dockerfile)
+	}
+	return excludes
+}
+
 // AddDockerfileToBuildContext from a ReadCloser, returns a new archive and
 // the relative path to the dockerfile in the context.
 func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) {

+ 60 - 0
cli/cmd/helm.go

@@ -0,0 +1,60 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+)
+
+var helmCmd = &cobra.Command{
+	Use:   "helm",
+	Short: "Use helm to interact with a Porter cluster",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runHelm)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(helmCmd)
+}
+
+func runHelm(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	_, err := exec.LookPath("helm")
+
+	if err != nil {
+		return fmt.Errorf("error finding helm: %w", err)
+	}
+
+	tmpFile, err := downloadTempKubeconfig(client)
+
+	if err != nil {
+		return err
+	}
+
+	defer func() {
+		os.Remove(tmpFile)
+	}()
+
+	os.Setenv("KUBECONFIG", tmpFile)
+
+	cmd := exec.Command("helm", args...)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err = cmd.Run()
+
+	if err != nil {
+		return fmt.Errorf("error running helm: %w", err)
+	}
+
+	return nil
+}

+ 85 - 0
cli/cmd/kubectl.go

@@ -0,0 +1,85 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+)
+
+var kubectlCmd = &cobra.Command{
+	Use:   "kubectl",
+	Short: "Use kubectl to interact with a Porter cluster",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runKubectl)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(kubectlCmd)
+}
+
+func runKubectl(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	_, err := exec.LookPath("kubectl")
+
+	if err != nil {
+		return fmt.Errorf("error finding kubectl: %w", err)
+	}
+
+	tmpFile, err := downloadTempKubeconfig(client)
+
+	if err != nil {
+		return err
+	}
+
+	defer func() {
+		os.Remove(tmpFile)
+	}()
+
+	os.Setenv("KUBECONFIG", tmpFile)
+
+	cmd := exec.Command("kubectl", args...)
+
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err = cmd.Run()
+
+	if err != nil {
+		return fmt.Errorf("error running helm: %w", err)
+	}
+
+	return nil
+}
+
+func downloadTempKubeconfig(client *api.Client) (string, error) {
+	tmpFile, err := os.CreateTemp("", "porter_kubeconfig_*.yaml")
+
+	if err != nil {
+		return "", fmt.Errorf("error creating temp file for kubeconfig: %w", err)
+	}
+
+	defer tmpFile.Close()
+
+	resp, err := client.GetKubeconfig(context.Background(), cliConf.Project, cliConf.Cluster, cliConf.Kubeconfig)
+
+	if err != nil {
+		return "", fmt.Errorf("error fetching kubeconfig for cluster: %w", err)
+	}
+
+	_, err = tmpFile.Write(resp.Kubeconfig)
+
+	if err != nil {
+		return "", fmt.Errorf("error writing kubeconfig to temp file: %w", err)
+	}
+
+	return tmpFile.Name(), nil
+}

+ 101 - 38
cli/cmd/run.go

@@ -33,6 +33,9 @@ import (
 
 var namespace string
 var verbose bool
+var existingPod bool
+var nonInteractive bool
+var containerName string
 
 // runCmd represents the "porter run" base command when called
 // without any subcommands
@@ -63,8 +66,6 @@ var cleanupCmd = &cobra.Command{
 	},
 }
 
-var existingPod bool
-
 func init() {
 	rootCmd.AddCommand(runCmd)
 
@@ -91,12 +92,31 @@ func init() {
 		"whether to print verbose output",
 	)
 
+	runCmd.PersistentFlags().BoolVar(
+		&nonInteractive,
+		"non-interactive",
+		false,
+		"whether to run in non-interactive mode",
+	)
+
+	runCmd.PersistentFlags().StringVarP(
+		&containerName,
+		"container",
+		"c",
+		"",
+		"name of the container inside pod to run the command in",
+	)
+
 	runCmd.AddCommand(cleanupCmd)
 }
 
 func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 
+	if nonInteractive {
+		color.New(color.FgBlue).Println("Using non-interactive mode. The first available pod will be used to run the command.")
+	}
+
 	podsSimple, err := getPods(client, namespace, args[0])
 
 	if err != nil {
@@ -108,7 +128,7 @@ func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 
 	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
-	} else if len(podsSimple) == 1 || !existingPod {
+	} else if nonInteractive || len(podsSimple) == 1 || !existingPod {
 		selectedPod = podsSimple[0]
 	} else {
 		podNames := make([]string, 0)
@@ -133,12 +153,35 @@ func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 
 	var selectedContainerName string
 
-	// if the selected pod has multiple container, spawn selector
 	if len(selectedPod.ContainerNames) == 0 {
-		return fmt.Errorf("At least one pod must exist in this deployment.")
+		return fmt.Errorf("At least one container must exist in the selected pod.")
 	} else if len(selectedPod.ContainerNames) == 1 {
+		if containerName != "" && containerName != selectedPod.ContainerNames[0] {
+			return fmt.Errorf("provided container %s does not exist in pod %s", containerName, selectedPod.Name)
+		}
+
 		selectedContainerName = selectedPod.ContainerNames[0]
-	} else {
+	}
+
+	if containerName != "" && selectedContainerName == "" {
+		// check if provided container name exists in the pod
+		for _, name := range selectedPod.ContainerNames {
+			if name == containerName {
+				selectedContainerName = name
+				break
+			}
+		}
+
+		if selectedContainerName == "" {
+			return fmt.Errorf("provided container %s does not exist in pod %s", containerName, selectedPod.Name)
+		}
+	}
+
+	if selectedContainerName == "" {
+		if nonInteractive {
+			return fmt.Errorf("container name must be specified using the --container flag when using non-interactive mode")
+		}
+
 		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
 
 		if err != nil {
@@ -485,6 +528,24 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 }
 
 func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
+	// try and create the cron job and all of the other required resources as necessary,
+	// starting with the service account, then role and then a role binding
+
+	err := checkForServiceAccount(config)
+	if err != nil {
+		return err
+	}
+
+	err = checkForClusterRole(config)
+	if err != nil {
+		return err
+	}
+
+	err = checkForRoleBinding(config)
+	if err != nil {
+		return err
+	}
+
 	namespaces, err := config.Clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
 	if err != nil {
 		return err
@@ -498,7 +559,13 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 			return err
 		}
 
-		if namespace.Name != "default" {
+		if namespace.Name == "default" {
+			for _, cronJob := range cronJobs.Items {
+				if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
+					return nil
+				}
+			}
+		} else {
 			for _, cronJob := range cronJobs.Items {
 				if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
 					err = config.Clientset.BatchV1beta1().CronJobs(namespace.Name).Delete(
@@ -510,30 +577,6 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 				}
 			}
 		}
-
-		for _, cronJob := range cronJobs.Items {
-			if namespace.Name == "default" && cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
-				return nil
-			}
-		}
-	}
-
-	// try and create the cron job and all of the other required resources as necessary,
-	// starting with the service account, then role and then a role binding
-
-	err = checkForServiceAccount(config)
-	if err != nil {
-		return err
-	}
-
-	err = checkForClusterRole(config)
-	if err != nil {
-		return err
-	}
-
-	err = checkForRoleBinding(config)
-	if err != nil {
-		return err
 	}
 
 	// create the cronjob
@@ -575,16 +618,36 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 }
 
 func checkForServiceAccount(config *PorterRunSharedConfig) error {
-	serviceAccounts, err := config.Clientset.CoreV1().ServiceAccounts(namespace).List(
-		context.Background(), metav1.ListOptions{},
-	)
+	namespaces, err := config.Clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
 	if err != nil {
 		return err
 	}
 
-	for _, serviceAccount := range serviceAccounts.Items {
-		if serviceAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
-			return nil
+	for _, namespace := range namespaces.Items {
+		serviceAccounts, err := config.Clientset.CoreV1().ServiceAccounts(namespace.Name).List(
+			context.Background(), metav1.ListOptions{},
+		)
+		if err != nil {
+			return err
+		}
+
+		if namespace.Name == "default" {
+			for _, svcAccount := range serviceAccounts.Items {
+				if svcAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
+					return nil
+				}
+			}
+		} else {
+			for _, svcAccount := range serviceAccounts.Items {
+				if svcAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
+					err = config.Clientset.CoreV1().ServiceAccounts(namespace.Name).Delete(
+						context.Background(), svcAccount.Name, metav1.DeleteOptions{},
+					)
+					if err != nil {
+						return err
+					}
+				}
+			}
 		}
 	}
 
@@ -593,7 +656,7 @@ func checkForServiceAccount(config *PorterRunSharedConfig) error {
 			Name: "porter-ephemeral-pod-deletion-service-account",
 		},
 	}
-	_, err = config.Clientset.CoreV1().ServiceAccounts(namespace).Create(
+	_, err = config.Clientset.CoreV1().ServiceAccounts("default").Create(
 		context.Background(), serviceAccount, metav1.CreateOptions{},
 	)
 	if err != nil {

+ 217 - 0
cli/cmd/stack.go

@@ -0,0 +1,217 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+)
+
+var linkedApps []string
+
+// stackCmd represents the "porter stack" base command when called
+// without any subcommands
+var stackCmd = &cobra.Command{
+	Use:     "stack",
+	Aliases: []string{"stacks"},
+	Short:   "Commands that control Porter Stacks",
+}
+
+var stackEnvGroupCmd = &cobra.Command{
+	Use:     "env-group",
+	Aliases: []string{"eg", "envgroup", "env-groups", "envgroups"},
+	Short:   "Commands to add or remove an env group in a stack",
+	Run: func(cmd *cobra.Command, args []string) {
+		color.New(color.FgRed).Println("need to specify an operation to continue")
+	},
+}
+
+var stackEnvGroupAddCmd = &cobra.Command{
+	Use:   "add [name]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Add an env group to a stack",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, stackAddEnvGroup)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var stackEnvGroupRemoveCmd = &cobra.Command{
+	Use:   "remove [name]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Remove an existing env group from a stack",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, stackRemoveEnvGroup)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(stackCmd)
+
+	stackCmd.AddCommand(stackEnvGroupCmd)
+
+	stackCmd.PersistentFlags().StringVar(
+		&name,
+		"name",
+		"",
+		"the name of the stack",
+	)
+
+	stackCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"the namespace of the stack",
+	)
+
+	stackEnvGroupAddCmd.PersistentFlags().StringArrayVarP(
+		&normalEnvGroupVars,
+		"normal",
+		"n",
+		[]string{},
+		"list of variables to set, in the form VAR=VALUE",
+	)
+
+	stackEnvGroupAddCmd.PersistentFlags().StringArrayVarP(
+		&secretEnvGroupVars,
+		"secret",
+		"s",
+		[]string{},
+		"list of secret variables to set, in the form VAR=VALUE",
+	)
+
+	stackEnvGroupAddCmd.PersistentFlags().StringArrayVar(
+		&linkedApps,
+		"linked-apps",
+		[]string{},
+		"list of stack apps to link this env group with",
+	)
+
+	stackEnvGroupCmd.AddCommand(stackEnvGroupAddCmd)
+	stackEnvGroupCmd.AddCommand(stackEnvGroupRemoveCmd)
+}
+
+func stackAddEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	envGroupName := args[0]
+
+	if len(envGroupName) == 0 {
+		return fmt.Errorf("empty env group name")
+	} else if len(name) == 0 {
+		return fmt.Errorf("empty stack name")
+	} else if len(normalEnvGroupVars) == 0 && len(secretEnvGroupVars) == 0 {
+		return fmt.Errorf("one or more variables are required to create the env group")
+	}
+
+	listStacks, err := client.ListStacks(context.Background(), cliConf.Project, cliConf.Cluster, namespace)
+
+	if err != nil {
+		return err
+	}
+
+	stacks := *listStacks
+
+	var stackID string
+
+	for _, stk := range stacks {
+		if stk.Name == name {
+			stackID = stk.ID
+		}
+	}
+
+	if len(stackID) == 0 {
+		return fmt.Errorf("stack not found")
+	}
+
+	normalVariables := make(map[string]string)
+	secretVariables := make(map[string]string)
+
+	for _, v := range normalEnvGroupVars {
+		key, val, err := validateVarValue(v)
+
+		if err != nil {
+			return err
+		}
+
+		normalVariables[key] = val
+	}
+
+	for _, v := range secretEnvGroupVars {
+		key, val, err := validateVarValue(v)
+
+		if err != nil {
+			return err
+		}
+
+		secretVariables[key] = val
+	}
+
+	err = client.AddEnvGroupToStack(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, stackID,
+		&types.CreateStackEnvGroupRequest{
+			Name:               envGroupName,
+			Variables:          normalVariables,
+			SecretVariables:    secretVariables,
+			LinkedApplications: linkedApps,
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Println("successfully added env group")
+
+	return nil
+}
+
+func stackRemoveEnvGroup(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	envGroupName := args[0]
+
+	if len(envGroupName) == 0 {
+		return fmt.Errorf("empty env group name")
+	} else if len(name) == 0 {
+		return fmt.Errorf("empty stack name")
+	}
+
+	listStacks, err := client.ListStacks(context.Background(), cliConf.Project, cliConf.Cluster, namespace)
+
+	if err != nil {
+		return err
+	}
+
+	stacks := *listStacks
+
+	var stackID string
+
+	for _, stk := range stacks {
+		if stk.Name == name {
+			stackID = stk.ID
+		}
+	}
+
+	if len(stackID) == 0 {
+		return fmt.Errorf("stack not found")
+	}
+
+	err = client.RemoveEnvGroupFromStack(context.Background(), cliConf.Project, cliConf.Cluster, namespace, stackID,
+		envGroupName)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Println("successfully removed env group")
+
+	return nil
+}

+ 5 - 1
dashboard/babel.config.json

@@ -1,5 +1,9 @@
 {
-  "plugins": ["lodash", "babel-plugin-styled-components", "@babel/plugin-syntax-dynamic-import"],
+  "plugins": [
+    "lodash",
+    "babel-plugin-styled-components",
+    "@babel/plugin-syntax-dynamic-import"
+  ],
   "presets": [
     "@babel/preset-env",
     "@babel/preset-react",

+ 2 - 2
dashboard/docker/dev.Dockerfile

@@ -7,9 +7,9 @@ COPY package*.json ./
 
 ENV NODE_ENV=development
 
-RUN npm install
+RUN npm ci --legacy-peer-deps
 RUN npm i -g http-parser-js
 
 COPY . ./
 
-CMD npm start
+CMD npm start

Різницю між файлами не показано, бо вона завелика
+ 14275 - 1
dashboard/package-lock.json


+ 8 - 0
dashboard/src/assets/cluster.svg

@@ -0,0 +1,8 @@
+<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M9.15631 13.4409C11.4924 13.4409 13.3656 11.5569 13.3656 9.20736C13.3656 6.85691 11.4924 4.97385 9.15631 4.97385C6.8202 4.97385 4.94702 6.85691 4.94702 9.20736C4.94702 11.5569 6.8202 13.4409 9.15631 13.4409" fill="white"/>
+<path opacity="0.4" d="M18.2952 10.1931C18.8996 7.81564 17.1276 5.68042 14.8712 5.68042C14.6259 5.68042 14.3913 5.70744 14.162 5.75336C14.1316 5.76057 14.0976 5.77588 14.0797 5.8029C14.0591 5.83712 14.0743 5.88305 14.0967 5.91276C14.7745 6.86915 15.164 8.03357 15.164 9.28354C15.164 10.4813 14.8067 11.598 14.1799 12.5246C14.1155 12.6201 14.1728 12.7489 14.2865 12.7687C14.4441 12.7966 14.6053 12.811 14.77 12.8155C16.4131 12.8588 17.8878 11.7952 18.2952 10.1931" fill="white"/>
+<path opacity="0.4" d="M4.25211 5.75364C4.02378 5.70681 3.78829 5.68069 3.54295 5.68069C1.28653 5.68069 -0.485469 7.81591 0.119824 10.1934C0.526336 11.7955 2.00106 12.859 3.64413 12.8158C3.80888 12.8113 3.97095 12.796 4.12765 12.769C4.24136 12.7492 4.29867 12.6204 4.2342 12.5249C3.60742 11.5973 3.25015 10.4816 3.25015 9.28382C3.25015 8.03295 3.64055 6.86853 4.31837 5.91304C4.33986 5.88332 4.35597 5.83739 4.33448 5.80317C4.31658 5.77525 4.28345 5.76084 4.25211 5.75364" fill="white"/>
+<path d="M13.4409 9.25842C13.4409 6.92232 11.5569 5.04914 9.20739 5.04914C6.85694 5.04914 4.97388 6.92232 4.97388 9.25842C4.97388 11.5945 6.85694 13.4677 9.20739 13.4677C11.5569 13.4677 13.4409 11.5945 13.4409 9.25842" fill="white"/>
+<path opacity="0.4" d="M10.1931 0.119514C7.81564 -0.484882 5.68042 1.28712 5.68042 3.54353C5.68042 3.78887 5.70744 4.02347 5.75336 4.25269C5.76057 4.28314 5.77588 4.31716 5.8029 4.33507C5.83712 4.35566 5.88305 4.34044 5.91276 4.31806C6.86915 3.64024 8.03357 3.25074 9.28354 3.25074C10.4813 3.25074 11.598 3.608 12.5246 4.23479C12.6201 4.29925 12.7489 4.24195 12.7687 4.12823C12.7966 3.97064 12.811 3.80947 12.8155 3.64471C12.8588 2.00165 11.7952 0.526922 10.1931 0.119514" fill="white"/>
+<path opacity="0.4" d="M5.75361 14.1626C5.70678 14.391 5.68066 14.6264 5.68066 14.8718C5.68066 17.1282 7.81588 18.9002 10.1934 18.2949C11.7954 17.8884 12.859 16.4137 12.8158 14.7706C12.8113 14.6059 12.796 14.4438 12.7689 14.2871C12.7491 14.1734 12.6203 14.1161 12.5249 14.1805C11.5973 14.8073 10.4815 15.1646 9.28379 15.1646C8.03292 15.1646 6.8685 14.7742 5.91301 14.0964C5.88329 14.0749 5.83736 14.0588 5.80314 14.0802C5.77522 14.0982 5.76081 14.1313 5.75361 14.1626" fill="white"/>
+</svg>

BIN
dashboard/src/assets/gradient.png


+ 2 - 2
dashboard/src/components/form-components/CheckboxRow.tsx

@@ -53,8 +53,8 @@ const CheckboxWrapper = styled.div<{ disabled?: boolean }>`
 `;
 
 const Checkbox = styled.div<{ checked: boolean }>`
-  width: 16px;
-  height: 16px;
+  width: 12px;
+  height: 12px;
   border: 1px solid #ffffff55;
   margin: 1px 10px 0px 1px;
   border-radius: 3px;

+ 1 - 0
dashboard/src/components/repo-selector/RepoList.tsx

@@ -442,6 +442,7 @@ const ProviderSelectorStyles = {
     overflow-y: auto;
     width: calc(100% - 4px);
     box-shadow: 0 8px 20px 0px #00000088;
+    z-index: 999;
   `,
   Option: styled.div`
     display: flex;

+ 142 - 123
dashboard/src/hosted.index.html

@@ -1,133 +1,152 @@
 <!DOCTYPE html>
 <html lang="en">
+  <head>
+    <title>Porter | Dashboard</title>
 
-<head>
-  <title>Porter | Dashboard</title>
+    <script>
+      window.intercomSettings = {
+        app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
+        custom_launcher_selector: "#intercom_help",
+      };
+    </script>
 
-  <script>
-    window.intercomSettings = {
-      app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
-      custom_launcher_selector: "#intercom_help",
-    };
-  </script>
-
-  <script>
-    // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-    (function () {
-      var w = window;
-      var ic = w.Intercom;
-      if (typeof ic === "function") {
-        ic("reattach_activator");
-        ic("update", w.intercomSettings);
-      } else {
-        var d = document;
-        var i = function () {
-          i.c(arguments);
-        };
-        i.q = [];
-        i.c = function (args) {
-          i.q.push(args);
-        };
-        w.Intercom = i;
-        var l = function () {
-          var s = d.createElement("script");
-          s.type = "text/javascript";
-          s.async = true;
-          s.src = "<%= htmlWebpackPlugin.options.intercomSrc %>";
-          var x = d.getElementsByTagName("script")[0];
-          x.parentNode.insertBefore(s, x);
-        };
-        if (document.readyState === "complete") {
-          l();
-        } else if (w.attachEvent) {
-          w.attachEvent("onload", l);
+    <script>
+      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+      (function () {
+        var w = window;
+        var ic = w.Intercom;
+        if (typeof ic === "function") {
+          ic("reattach_activator");
+          ic("update", w.intercomSettings);
         } else {
-          w.addEventListener("load", l, false);
-        }
-      }
-    })();
-  </script>
-
-  <script>
-    !(function () {
-      var analytics = (window.analytics = window.analytics || []);
-      if (!analytics.initialize)
-        if (analytics.invoked)
-          window.console &&
-            console.error &&
-            console.error("Segment snippet included twice.");
-        else {
-          analytics.invoked = !0;
-          analytics.methods = [
-            "trackSubmit",
-            "trackClick",
-            "trackLink",
-            "trackForm",
-            "pageview",
-            "identify",
-            "reset",
-            "group",
-            "track",
-            "ready",
-            "alias",
-            "debug",
-            "page",
-            "once",
-            "off",
-            "on",
-            "addSourceMiddleware",
-            "addIntegrationMiddleware",
-            "setAnonymousId",
-            "addDestinationMiddleware",
-          ];
-          analytics.factory = function (e) {
-            return function () {
-              var t = Array.prototype.slice.call(arguments);
-              t.unshift(e);
-              analytics.push(t);
-              return analytics;
-            };
+          var d = document;
+          var i = function () {
+            i.c(arguments);
           };
-          for (var e = 0; e < analytics.methods.length; e++) {
-            var key = analytics.methods[e];
-            analytics[key] = analytics.factory(key);
-          }
-          analytics.load = function (key, e) {
-            var t = document.createElement("script");
-            t.type = "text/javascript";
-            t.async = !0;
-            t.src =
-              "https://cdn.segment.com/analytics.js/v1/" +
-              key +
-              "/analytics.min.js";
-            var n = document.getElementsByTagName("script")[0];
-            n.parentNode.insertBefore(t, n);
-            analytics._loadOptions = e;
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
           };
-          analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
-          analytics.SNIPPET_VERSION = "4.13.2";
-          analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
-          analytics.page();
+          w.Intercom = i;
+          var l = function () {
+            var s = d.createElement("script");
+            s.type = "text/javascript";
+            s.async = true;
+            s.src = "<%= htmlWebpackPlugin.options.intercomSrc %>";
+            var x = d.getElementsByTagName("script")[0];
+            x.parentNode.insertBefore(s, x);
+          };
+          if (document.readyState === "complete") {
+            l();
+          } else if (w.attachEvent) {
+            w.attachEvent("onload", l);
+          } else {
+            w.addEventListener("load", l, false);
+          }
         }
-    })();
-  </script>
-  <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
-  <meta name="description" content="Kubernetes powered PaaS that runs in your own cloud." />
-  <meta property="og:title" content="Porter" />
-  <meta property="og:image" content="https://i.ibb.co/52g2g7C/porter-wide.png" />
-  <meta property="og:description" content="Kubernetes powered PaaS that runs in your own cloud." />
-  <meta property="og:url" content="https://porter.run" />
-  <link href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600" rel="stylesheet" />
-  <link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
-  <link href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
-    rel="stylesheet" />
-  <!-- Coding languages icons -->
-  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css" />
-</head>
+      })();
+    </script>
 
-<body>
-  <div id="output"></div>
-  <div id="modal-root"></div>
-</body>
+    <script>
+      !(function () {
+        var analytics = (window.analytics = window.analytics || []);
+        if (!analytics.initialize)
+          if (analytics.invoked)
+            window.console &&
+              console.error &&
+              console.error("Segment snippet included twice.");
+          else {
+            analytics.invoked = !0;
+            analytics.methods = [
+              "trackSubmit",
+              "trackClick",
+              "trackLink",
+              "trackForm",
+              "pageview",
+              "identify",
+              "reset",
+              "group",
+              "track",
+              "ready",
+              "alias",
+              "debug",
+              "page",
+              "once",
+              "off",
+              "on",
+              "addSourceMiddleware",
+              "addIntegrationMiddleware",
+              "setAnonymousId",
+              "addDestinationMiddleware",
+            ];
+            analytics.factory = function (e) {
+              return function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(e);
+                analytics.push(t);
+                return analytics;
+              };
+            };
+            for (var e = 0; e < analytics.methods.length; e++) {
+              var key = analytics.methods[e];
+              analytics[key] = analytics.factory(key);
+            }
+            analytics.load = function (key, e) {
+              var t = document.createElement("script");
+              t.type = "text/javascript";
+              t.async = !0;
+              t.src =
+                "https://cdn.segment.com/analytics.js/v1/" +
+                key +
+                "/analytics.min.js";
+              var n = document.getElementsByTagName("script")[0];
+              n.parentNode.insertBefore(t, n);
+              analytics._loadOptions = e;
+            };
+            analytics._writeKey =
+              "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
+            analytics.SNIPPET_VERSION = "4.13.2";
+            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
+            analytics.page();
+          }
+      })();
+    </script>
+    <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
+    <meta
+      name="description"
+      content="Kubernetes powered PaaS that runs in your own cloud."
+    />
+    <meta property="og:title" content="Porter" />
+    <meta
+      property="og:image"
+      content="https://i.ibb.co/52g2g7C/porter-wide.png"
+    />
+    <meta
+      property="og:description"
+      content="Kubernetes powered PaaS that runs in your own cloud."
+    />
+    <meta property="og:url" content="https://porter.run" />
+    <link
+      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
+      rel="stylesheet"
+    />
+    <link
+      href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css"
+      rel="stylesheet"
+    />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
+      rel="stylesheet"
+    />
+    <!-- Coding languages icons -->
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
+    />
+  </head>
 
-</html>
+  <body>
+    <div id="output"></div>
+    <div id="modal-root"></div>
+  </body>
+</html>

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

@@ -16,7 +16,7 @@ import {
 import DashboardHeader from "./DashboardHeader";
 import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
-import NamespaceSelector from "./NamespaceSelector";
+import { NamespaceSelector } from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";

+ 67 - 76
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -6,7 +6,7 @@ import api from "shared/api";
 
 import Selector from "components/Selector";
 
-type PropsType = {
+type Props = {
   setNamespace: (x: string) => void;
   namespace: string;
 };
@@ -16,15 +16,22 @@ type StateType = {
 };
 
 // TODO: fix update to unmounted component
-export default class NamespaceSelector extends Component<PropsType, StateType> {
-  _isMounted = false;
-
-  state = {
-    namespaceOptions: [] as { label: string; value: string }[],
-  };
-
-  updateOptions = () => {
-    let { currentCluster, currentProject } = this.context;
+export const NamespaceSelector: React.FunctionComponent<Props> = ({
+  setNamespace,
+  namespace,
+}) => {
+  const context = useContext(Context);
+  let _isMounted = true;
+  const [namespaceOptions, setNamespaceOptions] = useState<
+    {
+      label: string;
+      value: string;
+    }[]
+  >([]);
+  const [defaultNamespace, setDefaultNamespace] = useState<string>("default");
+
+  const updateOptions = () => {
+    let { currentCluster, currentProject } = context;
 
     api
       .getNamespaces(
@@ -36,7 +43,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        if (this._isMounted) {
+        if (_isMounted) {
           let namespaceOptions: { label: string; value: string }[] = [
             { label: "All", value: "ALL" },
           ];
@@ -49,84 +56,68 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
             urlNamespace = "ALL";
           }
 
-          let defaultNamespace = "default";
-          const availableNamespaces = res.data.filter(
-            (namespace: any) => {
-              return namespace.status !== "Terminating";
-            }
-          );
-          availableNamespaces.forEach(
-            (x: { name: string }, i: number) => {
-              namespaceOptions.push({
-                label: x.name,
-                value: x.name,
-              });
-              if (x.name === urlNamespace) {
-                defaultNamespace = urlNamespace;
-              }
-            }
-          );
-          this.setState({ namespaceOptions }, () => {
-            if (
-              urlNamespace === "" ||
-              defaultNamespace === "" ||
-              urlNamespace === "ALL"
-            ) {
-              this.props.setNamespace("ALL");
-            } else if (this.props.namespace !== defaultNamespace) {
-              this.props.setNamespace(defaultNamespace);
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
+          setDefaultNamespace("default");
+          availableNamespaces.forEach((x: { name: string }, i: number) => {
+            namespaceOptions.push({
+              label: x.name,
+              value: x.name,
+            });
+            if (x.name === urlNamespace) {
+              setDefaultNamespace(urlNamespace);
             }
           });
+          setNamespaceOptions(namespaceOptions);
         }
       })
       .catch((err) => {
-        if (this._isMounted) {
-          this.setState({ namespaceOptions: [{ label: "All", value: "ALL" }] });
+        if (_isMounted) {
+          setNamespaceOptions([{ label: "All", value: "ALL" }]);
         }
       });
   };
 
-  componentDidMount() {
-    this._isMounted = true;
-    this.updateOptions();
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.namespace !== this.props.namespace) {
-      this.updateOptions();
+  useEffect(() => {
+    let urlParams = new URLSearchParams(window.location.search);
+    let urlNamespace = urlParams.get("namespace");
+    if (
+      urlNamespace === "" ||
+      defaultNamespace === "" ||
+      urlNamespace === "ALL"
+    ) {
+      setNamespace("ALL");
+    } else if (namespace !== defaultNamespace) {
+      setNamespace(defaultNamespace);
     }
-  }
+  }, [namespaceOptions]);
 
-  componentWillUnmount() {
-    this._isMounted = false;
-  }
+  useEffect(() => {
+    updateOptions();
+  }, [namespace, context.currentCluster]);
 
-  handleSetActive = (namespace: any) => {
-    // console.log("SELECTED", namespace);
-    this.props.setNamespace(namespace);
+  const handleSetActive = (namespace: any) => {
+    setNamespace(namespace);
   };
 
-  render() {
-    return (
-      <StyledNamespaceSelector>
-        <Label>
-          <i className="material-icons">filter_alt</i> Namespace
-        </Label>
-        <Selector
-          activeValue={this.props.namespace}
-          setActiveValue={this.handleSetActive}
-          options={this.state.namespaceOptions}
-          dropdownLabel="Namespace"
-          width="150px"
-          dropdownWidth="230px"
-          closeOverlay={true}
-        />
-      </StyledNamespaceSelector>
-    );
-  }
-}
-
-NamespaceSelector.contextType = Context;
+  return (
+    <StyledNamespaceSelector>
+      <Label>
+        <i className="material-icons">filter_alt</i> Namespace
+      </Label>
+      <Selector
+        activeValue={namespace}
+        setActiveValue={handleSetActive}
+        options={namespaceOptions}
+        dropdownLabel="Namespace"
+        width="150px"
+        dropdownWidth="230px"
+        closeOverlay={true}
+      />
+    </StyledNamespaceSelector>
+  );
+};
 
 const Label = styled.div`
   display: flex;

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

@@ -5,7 +5,7 @@ import { Context } from "shared/Context";
 import styled from "styled-components";
 
 const TagFilter = ({ onSelect }: { onSelect: (tag: any) => void }) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, currentCluster } = useContext(Context);
   const [selectedTag, setSelectedTag] = useState("none");
   const [tags, setTags] = useState([]);
 
@@ -22,7 +22,7 @@ const TagFilter = ({ onSelect }: { onSelect: (tag: any) => void }) => {
     return () => {
       isSubscribed = false;
     };
-  }, [currentProject]);
+  }, [currentProject, currentCluster]);
 
   useEffect(() => {
     const currentTag = tags.find((tag) => tag.name === selectedTag);

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

@@ -297,7 +297,7 @@ const ChartList: React.FunctionComponent<Props> = ({
       controllers.map((controller) => closeWebsocket(controller));
       closeWebsocket(jobWebsocketID);
     };
-  }, []);
+  }, [context.currentCluster]);
 
   useEffect(() => {
     const websocketID = "helm_releases";
@@ -307,7 +307,7 @@ const ChartList: React.FunctionComponent<Props> = ({
     return () => {
       closeWebsocket(websocketID);
     };
-  }, [namespace]);
+  }, [namespace, context.currentCluster]);
 
   useEffect(() => {
     let isSubscribed = true;
@@ -323,7 +323,7 @@ const ChartList: React.FunctionComponent<Props> = ({
     return () => {
       isSubscribed = false;
     };
-  }, [namespace, currentView]);
+  }, [namespace, currentView, context.currentCluster]);
 
   const filteredCharts = useMemo(() => {
     if (!Array.isArray(charts)) {

+ 8 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -22,7 +22,9 @@ const tabOptions: {
   value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
+  /*
   { label: "Incidents", value: "incidents" },
+  */
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -69,6 +71,11 @@ export const Dashboard: React.FunctionComponent = () => {
     }
   }, [location]);
 
+  // Need to reset tab to reset views that don't auto-update on cluster switch (esp namespaces + settings)
+  useEffect(() => {
+    setCurrentTab("nodes");
+  }, [context.currentCluster]);
+
   return (
     <>
       <TitleSection>
@@ -85,7 +92,7 @@ export const Dashboard: React.FunctionComponent = () => {
           </InfoLabel>
         </TopRow>
         <Description>
-          Cluster dashboard for {context.currentCluster.name}
+          Cluster settings for {context.currentCluster.name}
         </Description>
       </InfoSection>
 

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

@@ -122,11 +122,9 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.filter(
-            (namespace: any) => {
-              return namespace.status !== "Terminating";
-            }
-          );
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
           const namespaceOptions = availableNamespaces.map(
             (x: { name: string }) => {
               return { label: x.name, value: x.name };
@@ -341,7 +339,7 @@ const HeaderSection = styled.div`
   > i {
     cursor: pointer;
     font-size: 20px;
-    color: #969Fbbaa;
+    color: #969fbbaa;
     padding: 2px;
     border: 2px solid #969fbbaa;
     border-radius: 100px;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -7,7 +7,7 @@ import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
 import DashboardHeader from "../DashboardHeader";
-import NamespaceSelector from "../NamespaceSelector";
+import { NamespaceSelector } from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
 import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";

+ 53 - 85
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -10,49 +10,47 @@ import Loading from "components/Loading";
 import { getQueryParam, pushQueryParams } from "shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
 
-type PropsType = RouteComponentProps & {
+type Props = RouteComponentProps & {
   currentCluster: ClusterType;
   namespace: string;
   sortType: string;
   setExpandedEnvGroup: (envGroup: any) => void;
 };
 
-type StateType = {
+type State = {
   envGroups: any[];
   loading: boolean;
   error: boolean;
 };
 
-const dummyEnvGroups = [
-  { name: "sapporo", last_updated: "12", namespace: "default" },
-  { name: "backend-staging", last_updated: "4", namespace: "default" },
-  { name: "backend-production", last_updated: "7", namespace: "default" },
-];
-
-class EnvGroupList extends Component<PropsType, StateType> {
-  state = {
-    envGroups: [] as any[],
-    loading: false,
-    error: false,
-  };
+const EnvGroupList: React.FunctionComponent<Props> = (props) => {
+  const context = useContext(Context);
+
+  const { currentCluster, namespace, sortType, setExpandedEnvGroup } = props;
+
+  const [envGroups, setEnvGroups] = useState<any[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [hasError, setHasError] = useState<boolean>(false);
 
-  updateEnvGroups = async () => {
-    const { currentCluster, namespace, sortType } = this.props;
+  const updateEnvGroups = async () => {
+    let { currentProject, currentCluster } = context;
     try {
       const envGroups = await api
         .listEnvGroups(
           "<token>",
           {},
           {
-            id: this.context.currentProject.id,
-            namespace: this.props.namespace,
-            cluster_id: this.props.currentCluster.id,
+            id: currentProject.id,
+            namespace: namespace,
+            cluster_id: currentCluster.id,
           }
         )
-        .then((res) => res.data);
+        .then((res) => {
+          return res.data;
+        });
 
       let sortedGroups = envGroups;
-      switch (this.props.sortType) {
+      switch (sortType) {
         case "Oldest":
           sortedGroups.sort((a: any, b: any) =>
             Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
@@ -69,76 +67,50 @@ class EnvGroupList extends Component<PropsType, StateType> {
 
       return sortedGroups;
     } catch (error) {
-      console.log(error);
-      this.setState({ loading: false, error: true });
+      setIsLoading(false);
+      setHasError(true);
     }
   };
 
-  componentDidMount() {
-    this.setState({ loading: true });
-    this.updateEnvGroups().then((envGroups) => {
-      const selectedEnvGroup = getQueryParam(this.props, "selected_env_group");
-
-      if (selectedEnvGroup) {
-        // find env group by selectedEnvGroup
-        const envGroup = envGroups.find(
-          (envGroup: any) => envGroup.name === selectedEnvGroup
-        );
-        if (envGroup) {
-          this.props.setExpandedEnvGroup(envGroup);
-          return;
+  useEffect(() => {
+    // Prevents reload when opening ClusterConfigModal
+    (namespace || namespace === "") &&
+      updateEnvGroups().then((envGroups) => {
+        const selectedEnvGroup = getQueryParam(props, "selected_env_group");
+
+        setEnvGroups(envGroups);
+        if (envGroups && envGroups.length > 0) {
+          setHasError(false);
         }
-      }
-      this.setState({ envGroups, loading: false });
-    });
-  }
+        setIsLoading(false);
 
-  componentDidUpdate(prevProps: PropsType) {
-    // Prevents reload when opening ClusterConfigModal
-    if (
-      prevProps.currentCluster !== this.props.currentCluster ||
-      prevProps.namespace !== this.props.namespace ||
-      prevProps.sortType !== this.props.sortType
-    ) {
-      (this.props.namespace || this.props.namespace === "") &&
-        this.updateEnvGroups().then((envGroups) => {
-          const selectedEnvGroup = getQueryParam(
-            this.props,
-            "selected_env_group"
+        if (selectedEnvGroup) {
+          // find env group by selectedEnvGroup
+          const envGroup = envGroups.find(
+            (envGroup: any) => envGroup.name === selectedEnvGroup
           );
-
-          this.setState({ envGroups, loading: false });
-
-          if (selectedEnvGroup) {
-            // find env group by selectedEnvGroup
-            const envGroup = envGroups.find(
-              (envGroup: any) => envGroup.name === selectedEnvGroup
-            );
-            if (envGroup) {
-              this.props.setExpandedEnvGroup(envGroup);
-            } else {
-              pushQueryParams(this.props, {}, ["selected_env_group"]);
-            }
+          if (envGroup) {
+            setExpandedEnvGroup(envGroup);
+          } else {
+            pushQueryParams(props, {}, ["selected_env_group"]);
           }
-        });
-    }
-  }
+        }
+      });
+  }, [currentCluster, namespace, sortType]);
 
-  handleExpand = (envGroup: any) => {
-    pushQueryParams(this.props, { selected_env_group: envGroup.name }, []);
-    this.props.setExpandedEnvGroup(envGroup);
+  const handleExpand = (envGroup: any) => {
+    pushQueryParams(props, { selected_env_group: envGroup.name }, []);
+    props.setExpandedEnvGroup(envGroup);
   };
 
-  renderEnvGroupList = () => {
-    let { loading, error, envGroups } = this.state;
-
-    if (loading || (!this.props.namespace && this.props.namespace !== "")) {
+  const renderEnvGroupList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
-    } else if (error) {
+    } else if (hasError) {
       return (
         <Placeholder>
           <i className="material-icons">error</i> Error connecting to cluster.
@@ -153,23 +125,19 @@ class EnvGroupList extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.envGroups.map((envGroup: any, i: number) => {
+    return envGroups.map((envGroup: any, i: number) => {
       return (
         <EnvGroup
           key={i}
           envGroup={envGroup}
-          setExpanded={() => this.handleExpand(envGroup)}
+          setExpanded={() => handleExpand(envGroup)}
         />
       );
     });
   };
 
-  render() {
-    return <StyledEnvGroupList>{this.renderEnvGroupList()}</StyledEnvGroupList>;
-  }
-}
-
-EnvGroupList.contextType = Context;
+  return <StyledEnvGroupList>{renderEnvGroupList()}</StyledEnvGroupList>;
+};
 
 export default withRouter(EnvGroupList);
 

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

@@ -530,9 +530,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
 
+    /* Temporarily disable incident detection
     if (!DisabledNamespacesForIncidents.includes(currentChart.namespace)) {
       leftTabOptions.push({ label: "Incidents", value: "incidents" });
     }
+    */
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });

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

@@ -109,9 +109,25 @@ export default class JobResource extends Component<PropsType, StateType> {
       }
     });
 
+    if (!completeCondition) {
+      // otherwise look for a failed reason
+      this.props.job.status?.conditions?.forEach(
+        (condition: any, i: number) => {
+          if (condition.type == "Failed") {
+            completeCondition = condition;
+          }
+        }
+      );
+    }
+
+    // if still no complete condition, return unknown
+    if (!completeCondition) {
+      return "Succeeded";
+    }
+
     return (
-      completeCondition.reason ||
-      `Completed at ${readableDate(completeCondition.lastTransitionTime)}`
+      completeCondition?.reason ||
+      `Completed at ${readableDate(completeCondition?.lastTransitionTime)}`
     );
   };
 

+ 4 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -28,6 +28,7 @@ export type ControllerTabPodType = {
   restartCount: number | string;
   podAge: string;
   revisionNumber?: number;
+  containerStatus: any;
 };
 
 const formatCreationTimestamp = timeFormat("%H:%M:%S %b %d, '%y");
@@ -125,6 +126,7 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
             status: pod?.status,
             replicaSetName,
             restartCount,
+            containerStatus,
             podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
             revisionNumber:
               (pod?.metadata?.annotations &&
@@ -233,8 +235,8 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
         {},
         {
           cluster_id: currentCluster.id,
-          name: pod.metadata?.name,
-          namespace: pod.metadata?.namespace,
+          name: pod?.name,
+          namespace: pod?.namespace,
           id: currentProject.id,
         }
       )

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

@@ -1,40 +1,10 @@
-import React, {
-  Component,
-  useContext,
-  useEffect,
-  useMemo,
-  useRef,
-  useState,
-} from "react";
+import React, { useEffect, useRef, useState } from "react";
 import styled from "styled-components";
-import { Context } from "shared/Context";
-import * as Anser from "anser";
-import api from "shared/api";
-import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import Anser from "anser";
 import CommandLineIcon from "assets/command-line-icon";
 import ConnectToLogsInstructionModal from "./ConnectToLogsInstructionModal";
-
-const MAX_LOGS = 250;
-
-type SelectedPodType = {
-  spec: {
-    [key: string]: any;
-    containers: {
-      [key: string]: any;
-      name: string;
-    }[];
-  };
-  metadata: {
-    name: string;
-    namespace: string;
-    labels: {
-      [key: string]: string;
-    };
-  };
-  status: {
-    phase: string;
-  };
-};
+import { SelectedPodType } from "./types";
+import { useLogs } from "./useLogs";
 
 const LogsFC: React.FC<{
   selectedPod: SelectedPodType;
@@ -253,216 +223,6 @@ const LogsFC: React.FC<{
 
 export default LogsFC;
 
-const useLogs = (
-  currentPod: SelectedPodType,
-  scroll?: (smooth: boolean) => void
-) => {
-  const currentPodName = useRef<string>();
-
-  const { currentCluster, currentProject } = useContext(Context);
-  const [containers, setContainers] = useState<string[]>([]);
-  const [currentContainer, setCurrentContainer] = useState<string>("");
-  const [logs, setLogs] = useState<{
-    [key: string]: Anser.AnserJsonEntry[][];
-  }>({});
-
-  const [prevLogs, setPrevLogs] = useState<{
-    [key: string]: Anser.AnserJsonEntry[][];
-  }>({});
-
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeAllWebsockets,
-    getWebsocket,
-    closeWebsocket,
-  } = useWebsockets();
-
-  const getSystemLogs = async () => {
-    const events = await api
-      .getPodEvents(
-        "<token>",
-        {},
-        {
-          name: currentPod?.metadata?.name,
-          namespace: currentPod?.metadata?.namespace,
-          cluster_id: currentCluster?.id,
-          id: currentProject?.id,
-        }
-      )
-      .then((res) => res.data);
-
-    let processedLogs = [] as Anser.AnserJsonEntry[][];
-
-    events.items.forEach((evt: any) => {
-      let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
-      let ansiLog = Anser.ansiToJson(
-        `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
-      );
-      processedLogs.push(ansiLog);
-    });
-
-    // SET LOGS FOR SYSTEM
-    setLogs((prevState) => ({
-      ...prevState,
-      system: processedLogs,
-    }));
-  };
-
-  const getContainerPreviousLogs = async (containerName: string) => {
-    try {
-      const logs = await api
-        .getPreviousLogsForContainer<{ previous_logs: string[] }>(
-          "<token>",
-          {
-            container_name: containerName,
-          },
-          {
-            pod_name: currentPod?.metadata?.name,
-            namespace: currentPod?.metadata?.namespace,
-            cluster_id: currentCluster?.id,
-            project_id: currentProject?.id,
-          }
-        )
-        .then((res) => res.data);
-      // Process logs
-      const processedLogs: Anser.AnserJsonEntry[][] = logs.previous_logs.map(
-        (currentLog) => {
-          let ansiLog = Anser.ansiToJson(currentLog);
-          return ansiLog;
-        }
-      );
-
-      setPrevLogs((pl) => ({
-        ...pl,
-        [containerName]: processedLogs,
-      }));
-    } catch (error) {}
-  };
-
-  const setupWebsocket = (containerName: string, websocketKey: string) => {
-    if (!currentPod?.metadata?.name) return;
-
-    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${currentPod?.metadata?.namespace}/pod/${currentPod?.metadata?.name}/logs?container_name=${containerName}`;
-
-    const config: NewWebsocketOptions = {
-      onopen: () => {
-        console.log("Opened websocket:", websocketKey);
-      },
-      onmessage: (evt: MessageEvent) => {
-        let ansiLog = Anser.ansiToJson(evt.data);
-        setLogs((logs) => {
-          const tmpLogs = { ...logs };
-          let containerLogs = tmpLogs[containerName] || [];
-
-          containerLogs.push(ansiLog);
-          // this is technically not as efficient as things could be
-          // if there are performance issues, a deque can be used in place of a list
-          // for storing logs
-          if (containerLogs.length > MAX_LOGS) {
-            containerLogs.shift();
-          }
-          if (typeof scroll === "function") {
-            scroll(true);
-          }
-          return {
-            ...logs,
-            [containerName]: containerLogs,
-          };
-        });
-      },
-      onclose: () => {
-        console.log("Closed websocket:", websocketKey);
-      },
-    };
-
-    newWebsocket(websocketKey, endpoint, config);
-    openWebsocket(websocketKey);
-  };
-
-  const refresh = () => {
-    const websocketKey = `${currentPodName.current}-${currentContainer}-websocket`;
-    closeWebsocket(websocketKey);
-
-    setPrevLogs((prev) => ({ ...prev, [currentContainer]: [] }));
-    setLogs((prev) => ({ ...prev, [currentContainer]: [] }));
-
-    if (!Array.isArray(containers)) {
-      return;
-    }
-
-    if (currentContainer === "system") {
-      getSystemLogs();
-    } else {
-      getContainerPreviousLogs(currentContainer);
-      setupWebsocket(currentContainer, websocketKey);
-    }
-  };
-
-  useEffect(() => {
-    // console.log("Selected pod updated");
-    if (currentPod?.metadata?.name === currentPodName.current) {
-      return () => {};
-    }
-    currentPodName.current = currentPod?.metadata?.name;
-    const currentContainers =
-      currentPod?.spec?.containers?.map((container) => container?.name) || [];
-
-    setContainers(currentContainers);
-    setCurrentContainer(currentContainers[0]);
-  }, [currentPod]);
-
-  // Retrieve all previous logs for containers
-  useEffect(() => {
-    if (!Array.isArray(containers)) {
-      return;
-    }
-
-    closeAllWebsockets();
-
-    setPrevLogs({});
-    setLogs({});
-
-    getSystemLogs();
-    containers.forEach((containerName) => {
-      const websocketKey = `${currentPodName.current}-${containerName}-websocket`;
-
-      getContainerPreviousLogs(containerName);
-
-      if (!getWebsocket(websocketKey)) {
-        setupWebsocket(containerName, websocketKey);
-      }
-    });
-
-    return () => {
-      closeAllWebsockets();
-    };
-  }, [containers]);
-
-  useEffect(() => {
-    return () => {
-      closeAllWebsockets();
-    };
-  }, []);
-
-  const currentLogs = useMemo(() => {
-    return logs[currentContainer] || [];
-  }, [currentContainer, logs]);
-
-  const currentPreviousLogs = useMemo(() => {
-    return prevLogs[currentContainer] || [];
-  }, [currentContainer, prevLogs]);
-
-  return {
-    containers,
-    currentContainer,
-    setCurrentContainer,
-    logs: currentLogs,
-    previousLogs: currentPreviousLogs,
-    refresh,
-  };
-};
-
 const Highlight = styled.div`
   display: flex;
   align-items: center;

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

@@ -43,6 +43,14 @@ const PodRow: React.FunctionComponent<PodRowProps> = ({
           {pod?.name}
           <Grey>Restart count: {pod.restartCount}</Grey>
           <Grey>Created on: {pod.podAge}</Grey>
+          {podStatus === "failed" ? (
+            <FailedStatusContainer>
+              <Grey>
+                Failure Reason: {pod?.containerStatus?.state?.waiting?.reason}
+              </Grey>
+              <Grey>{pod?.containerStatus?.state?.waiting?.message}</Grey>
+            </FailedStatusContainer>
+          ) : null}
         </Tooltip>
       )}
 
@@ -73,6 +81,13 @@ const Grey = styled.div`
   color: #aaaabb;
 `;
 
+const FailedStatusContainer = styled.div`
+  width: 100%;
+  border: 1px solid hsl(0deg, 100%, 30%);
+  padding: 5px;
+  margin-block: 5px;
+`;
+
 const Tooltip = styled.div`
   position: absolute;
   left: 35px;

+ 19 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/types.ts

@@ -0,0 +1,19 @@
+export type SelectedPodType = {
+  spec: {
+    [key: string]: any;
+    containers: {
+      [key: string]: any;
+      name: string;
+    }[];
+  };
+  metadata: {
+    name: string;
+    namespace: string;
+    labels: {
+      [key: string]: string;
+    };
+  };
+  status: {
+    phase: string;
+  };
+};

+ 218 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/useLogs.ts

@@ -0,0 +1,218 @@
+import Anser from "anser";
+import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
+import { SelectedPodType } from "./types";
+
+const MAX_LOGS = 250;
+
+export const useLogs = (
+  currentPod: SelectedPodType,
+  scroll?: (smooth: boolean) => void
+) => {
+  const currentPodName = useRef<string>();
+
+  const { currentCluster, currentProject } = useContext(Context);
+  const [containers, setContainers] = useState<string[]>([]);
+  const [currentContainer, setCurrentContainer] = useState<string>("");
+  const [logs, setLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
+
+  const [prevLogs, setPrevLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    getWebsocket,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const getSystemLogs = async () => {
+    const events = await api
+      .getPodEvents(
+        "<token>",
+        {},
+        {
+          name: currentPod?.metadata?.name,
+          namespace: currentPod?.metadata?.namespace,
+          cluster_id: currentCluster?.id,
+          id: currentProject?.id,
+        }
+      )
+      .then((res) => res.data);
+
+    let processedLogs = [] as Anser.AnserJsonEntry[][];
+
+    events.items.forEach((evt: any) => {
+      let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
+      let ansiLog = Anser.ansiToJson(
+        `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
+      );
+      processedLogs.push(ansiLog);
+    });
+
+    // SET LOGS FOR SYSTEM
+    setLogs((prevState) => ({
+      ...prevState,
+      system: processedLogs,
+    }));
+  };
+
+  const getContainerPreviousLogs = async (containerName: string) => {
+    try {
+      const logs = await api
+        .getPreviousLogsForContainer<{ previous_logs: string[] }>(
+          "<token>",
+          {
+            container_name: containerName,
+          },
+          {
+            pod_name: currentPod?.metadata?.name,
+            namespace: currentPod?.metadata?.namespace,
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+          }
+        )
+        .then((res) => res.data);
+      // Process logs
+      const processedLogs: Anser.AnserJsonEntry[][] = logs.previous_logs.map(
+        (currentLog) => {
+          let ansiLog = Anser.ansiToJson(currentLog);
+          return ansiLog;
+        }
+      );
+
+      setPrevLogs((pl) => ({
+        ...pl,
+        [containerName]: processedLogs,
+      }));
+    } catch (error) {}
+  };
+
+  const setupWebsocket = (containerName: string, websocketKey: string) => {
+    if (!currentPod?.metadata?.name) return;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${currentPod?.metadata?.namespace}/pod/${currentPod?.metadata?.name}/logs?container_name=${containerName}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let ansiLog = Anser.ansiToJson(evt.data);
+        setLogs((logs) => {
+          const tmpLogs = { ...logs };
+          let containerLogs = tmpLogs[containerName] || [];
+
+          containerLogs.push(ansiLog);
+          // this is technically not as efficient as things could be
+          // if there are performance issues, a deque can be used in place of a list
+          // for storing logs
+          if (containerLogs.length > MAX_LOGS) {
+            containerLogs.shift();
+          }
+          if (typeof scroll === "function") {
+            scroll(true);
+          }
+          return {
+            ...logs,
+            [containerName]: containerLogs,
+          };
+        });
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
+  };
+
+  const refresh = () => {
+    const websocketKey = `${currentPodName.current}-${currentContainer}-websocket`;
+    closeWebsocket(websocketKey);
+
+    setPrevLogs((prev) => ({ ...prev, [currentContainer]: [] }));
+    setLogs((prev) => ({ ...prev, [currentContainer]: [] }));
+
+    if (!Array.isArray(containers)) {
+      return;
+    }
+
+    if (currentContainer === "system") {
+      getSystemLogs();
+    } else {
+      getContainerPreviousLogs(currentContainer);
+      setupWebsocket(currentContainer, websocketKey);
+    }
+  };
+
+  useEffect(() => {
+    // console.log("Selected pod updated");
+    if (currentPod?.metadata?.name === currentPodName.current) {
+      return () => {};
+    }
+    currentPodName.current = currentPod?.metadata?.name;
+    const currentContainers =
+      currentPod?.spec?.containers?.map((container) => container?.name) || [];
+
+    setContainers(currentContainers);
+    setCurrentContainer(currentContainers[0]);
+  }, [currentPod]);
+
+  // Retrieve all previous logs for containers
+  useEffect(() => {
+    if (!Array.isArray(containers)) {
+      return;
+    }
+
+    closeAllWebsockets();
+
+    setPrevLogs({});
+    setLogs({});
+
+    getSystemLogs();
+    containers.forEach((containerName) => {
+      const websocketKey = `${currentPodName.current}-${containerName}-websocket`;
+
+      getContainerPreviousLogs(containerName);
+
+      if (!getWebsocket(websocketKey)) {
+        setupWebsocket(containerName, websocketKey);
+      }
+    });
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, [containers]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  const currentLogs = useMemo(() => {
+    return logs[currentContainer] || [];
+  }, [currentContainer, logs]);
+
+  const currentPreviousLogs = useMemo(() => {
+    return prevLogs[currentContainer] || [];
+  }, [currentContainer, prevLogs]);
+
+  return {
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    logs: currentLogs,
+    previousLogs: currentPreviousLogs,
+    refresh,
+  };
+};

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
 import DashboardHeader from "../DashboardHeader";
-import NamespaceSelector from "../NamespaceSelector";
+import { NamespaceSelector } from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
 import { Action } from "./components/styles";
 import StackList from "./_StackList";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx

@@ -153,7 +153,7 @@ export const SelectStyles = {
     height: 35px;
     border: 1px solid #ffffff55;
     font-size: 13px;
-    color: ${props => props.readOnly ? "#ffffff44" : ""};
+    color: ${(props) => (props.readOnly ? "#ffffff44" : "")};
     padding: 5px 10px;
     padding-left: 15px;
     border-radius: 3px;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -91,7 +91,7 @@ const StackList = ({
     return () => {
       isSubscribed = false;
     };
-  }, [namespace]);
+  }, [namespace, currentCluster]);
 
   const sortedStacks = useMemo(() => {
     return (

+ 2 - 4
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx

@@ -62,9 +62,7 @@ export const AddResourceButton = () => {
       <AddResourceButtonStyles.Flex>
         <LinkMask
           to={`/stacks/launch/new-app/${currentTemplate?.name}/${currentVersion}`}
-        >
-          
-        </LinkMask>
+        ></LinkMask>
         <Icon>
           <i className="material-icons">add</i>
         </Icon>
@@ -103,4 +101,4 @@ const Icon = styled.div`
     font-size: 20px;
     color: #aaaabb;
   }
-`;
+`;

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

@@ -162,7 +162,7 @@ class Templates extends Component<PropsType, StateType> {
           <TemplateBlock
             onClick={() => {
               this.context.setCurrentCluster(cluster);
-              pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
+              pushFiltered(this.props, "/applications", ["project_id"], {
                 cluster: cluster.name,
               });
             }}

+ 0 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -293,7 +293,6 @@ const Overlay = styled.div`
   height: 100%;
   width: 100%;
   position: absolute;
-  background: #00000028;
   top: 0;
   left: 0;
   border-radius: 5px;

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

@@ -4,7 +4,7 @@ import InputRow from "components/form-components/InputRow";
 import SelectRow from "components/form-components/SelectRow";
 import UploadArea from "components/form-components/UploadArea";
 import SaveButton from "components/SaveButton";
-import { GCP_REGION_OPTIONS } from "main/home/onboarding/constants";
+import { GAR_REGION_OPTIONS } from "main/home/onboarding/constants";
 import React, { useContext, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -109,7 +109,7 @@ const GARForm = (props: { closeForm: () => void }) => {
         />
         <Helper>GAR Region</Helper>
         <SelectRow
-          options={GCP_REGION_OPTIONS}
+          options={GAR_REGION_OPTIONS}
           width="100%"
           value={region}
           scrollBuffer={true}

+ 0 - 1
dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx

@@ -126,7 +126,6 @@ const GitlabForm: React.FC<Props> = () => {
           makeFlush={true}
           text="Save Gitlab Settings"
           status={buttonStatus || error?.message}
-          
         />
       </StyledForm>
     </>

+ 13 - 13
dashboard/src/main/home/launch/TemplateList.tsx

@@ -49,19 +49,19 @@ const TemplateList: React.FC<Props> = ({
           throw Error("Data is not an array");
         }
 
-        let sortedVersionData = data.map((template: any) => {
-          let versions = template.versions.reverse();
-
-          versions = template.versions.sort(semver.rcompare);
-
-          return {
-            ...template,
-            versions,
-            currentVersion: versions[0],
-          };
-        }).sort((a: any, b: any) =>
-          a.name > b.name ? 1 : -1
-        );
+        let sortedVersionData = data
+          .map((template: any) => {
+            let versions = template.versions.reverse();
+
+            versions = template.versions.sort(semver.rcompare);
+
+            return {
+              ...template,
+              versions,
+              currentVersion: versions[0],
+            };
+          })
+          .sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
 
         setTemplateList(sortedVersionData);
         setIsLoading(false);

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

@@ -364,7 +364,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   const renderCurrentPage = () => {
     let { form, currentTab } = props;
 
-    if (currentPage === "source" && currentTab === "porter") {
+    if (currentPage === "source" && form?.hasSource) {
       return (
         <SourcePage
           sourceType={sourceType}

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

@@ -99,11 +99,9 @@ class SettingsPage extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.filter(
-            (namespace: any) => {
-              return namespace.status !== "Terminating";
-            }
-          );
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
           const namespaceOptions = availableNamespaces.map(
             (x: { name: string }) => {
               return { label: x.name, value: x.name };

+ 2 - 6
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -29,13 +29,9 @@ export default class ClusterInstructionsModal extends Component<
         return (
           <Placeholder>
             1. To install the Porter CLI, run the following in your terminal:
-            <Code>
-              /bin/bash -c "$(curl -fsSL https://install.porter.run)"
-            </Code>
+            <Code>/bin/bash -c "$(curl -fsSL https://install.porter.run)"</Code>
             Alternatively, on macOS you can use Homebrew:
-            <Code>
-              brew install porter-dev/porter/porter
-            </Code>
+            <Code>brew install porter-dev/porter/porter</Code>
             2. Log in to the Porter CLI:
             <Code>
               porter config set-host {location.protocol + "//" + location.host}

+ 2 - 2
dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx

@@ -10,8 +10,8 @@ const ConnectToDatabaseInstructionsModal = () => {
     <Container>
       In order to get connection credentials for your RDS Postgres database,
       select <b>Load from Env Group</b> when launching or updating your
-      application. Then, select the rds-credentials-{currentModalData?.name}{" "}
-      env group.
+      application. Then, select the rds-credentials-{currentModalData?.name} env
+      group.
       <p>
         This will set the following environment variables in your application:
       </p>

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

@@ -89,7 +89,7 @@ const EditCollaboratorModal = () => {
   return (
     <>
       <ModalTitle>
-        Update {isInvite ? "Invite for" : "Collaborator"} {user?.email}
+        Update {isInvite ? "invite for" : "collaborator"} {user?.email}
       </ModalTitle>
       <Subtitle>Specify a different role for this user.</Subtitle>
       <RoleSelectorWrapper>
@@ -101,7 +101,7 @@ const EditCollaboratorModal = () => {
       </RoleSelectorWrapper>
 
       <SaveButton
-        text={`Update ${isInvite ? "Invite" : "Collaborator"}`}
+        text={`Update ${isInvite ? "invite" : "collaborator"}`}
         color="#616FEEcc"
         onClick={() => handleUpdate()}
         status={status}

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

@@ -217,7 +217,6 @@ const Letter = styled.div`
   height: 100%;
   width: 100%;
   position: absolute;
-  background: #00000028;
   top: 0;
   left: 0;
   display: flex;

+ 4 - 0
dashboard/src/main/home/onboarding/components/ProviderSelector.tsx

@@ -37,11 +37,13 @@ export const registryOptions = [
     icon: integrationList["gcr"]?.icon,
     label: "Google Artifact Registry (GAR)",
   },
+  /*
   {
     value: "do",
     icon: integrationList["do"]?.icon,
     label: "DigitalOcean Container Registry (DOCR)",
   },
+  */
 ];
 
 export const provisionerOptions = [
@@ -56,11 +58,13 @@ export const provisionerOptions = [
     label: "Google Cloud Platform (GCP)",
   },
 
+  /*
   {
     value: "do",
     icon: integrationList["do"]?.icon,
     label: "DigitalOcean (DO)",
   },
+  */
 ];
 
 export const provisionerOptionsWithExternal = [

+ 16 - 0
dashboard/src/main/home/onboarding/constants.ts

@@ -5,22 +5,38 @@ export const GCP_REGION_OPTIONS = [
   { value: "asia-northeast2", label: "asia-northeast2" },
   { value: "asia-northeast3", label: "asia-northeast3" },
   { value: "asia-south1", label: "asia-south1" },
+  { value: "asia-south2", label: "asia-south2" },
   { value: "asia-southeast1", label: "asia-southeast1" },
   { value: "asia-southeast2", label: "asia-southeast2" },
   { value: "australia-southeast1", label: "australia-southeast1" },
+  { value: "australia-southeast2", label: "australia-southeast2" },
+  { value: "europe-central2", label: "europe-central2" },
   { value: "europe-north1", label: "europe-north1" },
   { value: "europe-west1", label: "europe-west1" },
   { value: "europe-west2", label: "europe-west2" },
   { value: "europe-west3", label: "europe-west3" },
   { value: "europe-west4", label: "europe-west4" },
   { value: "europe-west6", label: "europe-west6" },
+  { value: "europe-west8", label: "europe-west8" },
+  { value: "europe-west9", label: "europe-west9" },
+  { value: "europe-southwest1", label: "europe-southwest1" },
   { value: "northamerica-northeast1", label: "northamerica-northeast1" },
+  { value: "northamerica-northeast2", label: "northamerica-northeast2" },
   { value: "southamerica-east1", label: "southamerica-east1" },
+  { value: "southamerica-west1", label: "southamerica-west1" },
   { value: "us-central1", label: "us-central1" },
   { value: "us-east1", label: "us-east1" },
   { value: "us-east4", label: "us-east4" },
+  { value: "us-east5", label: "us-east5" },
   { value: "us-west1", label: "us-west1" },
   { value: "us-west2", label: "us-west2" },
   { value: "us-west3", label: "us-west3" },
   { value: "us-west4", label: "us-west4" },
+  { value: "us-south1", label: "us-south1" },
 ];
+
+export const GAR_REGION_OPTIONS = GCP_REGION_OPTIONS.concat([
+  { value: "us", label: "us (multi-region)" },
+  { value: "europe", label: "europe (multi-region)" },
+  { value: "asia", label: "asia (multi-region)" },
+]);

+ 3 - 7
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx

@@ -48,7 +48,7 @@ const ConnectExternalCluster: React.FC<Props> = ({
           }
         }
       });
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -65,13 +65,9 @@ const ConnectExternalCluster: React.FC<Props> = ({
         return (
           <Placeholder>
             1. To install the Porter CLI, run the following in your terminal:
-            <Code>
-              /bin/bash -c "$(curl -fsSL https://install.porter.run)"
-            </Code>
+            <Code>/bin/bash -c "$(curl -fsSL https://install.porter.run)"</Code>
             Alternatively, on macOS you can use Homebrew:
-            <Code>
-              brew install porter-dev/porter/porter
-            </Code>
+            <Code>brew install porter-dev/porter/porter</Code>
           </Placeholder>
         );
       case 1:

+ 9 - 5
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -119,7 +119,11 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
   const createInvite = () => {
     api
-      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
+      .createInvite(
+        "<token>",
+        { email: email.toLowerCase(), kind: role },
+        { id: currentProject.id }
+      )
       .then(() => {
         getData();
         setEmail("");
@@ -395,7 +399,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   return (
     <>
       <>
-        <Heading isAtTop={true}>Share Project</Heading>
+        <Heading isAtTop={true}>Share project</Heading>
         <Helper>Generate a project invite for another user.</Helper>
         <InputRowWrapper>
           <InputRow
@@ -403,7 +407,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
             type="text"
             setValue={(newEmail: string) => setEmail(newEmail)}
             width="100%"
-            placeholder="ex: mrp@getporter.dev"
+            placeholder="ex: mrp@porter.run"
           />
         </InputRowWrapper>
         <Helper>Specify a role for this user.</Helper>
@@ -416,7 +420,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         </RoleSelectorWrapper>
         <ButtonWrapper>
           <InviteButton disabled={!hasSeats} onClick={() => validateEmail()}>
-            Create Invite
+            Create invite
           </InviteButton>
           {isInvalidEmail && (
             <Invalid>Invalid email address. Please try again.</Invalid>
@@ -429,7 +433,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         </ButtonWrapper>
       </>
 
-      <Heading>Invites & Collaborators</Heading>
+      <Heading>Invites & collaborators</Heading>
       <Helper>Manage pending invites and view collaborators.</Helper>
       {isLoading && <Loading height={"30%"} />}
       {data?.length && !isLoading ? (

+ 5 - 5
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -64,7 +64,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
     const tabOptions = [];
-    tabOptions.push({ value: "manage-access", label: "Manage Access" });
+    tabOptions.push({ value: "manage-access", label: "Manage access" });
     tabOptions.push({
       value: "billing",
       label: "Billing",
@@ -87,7 +87,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
       tabOptions.push({
         value: "additional-settings",
-        label: "Additional Settings",
+        label: "Additional settings",
       });
     }
 
@@ -132,7 +132,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
     } else {
       return (
         <>
-          <Heading isAtTop={true}>Delete Project</Heading>
+          <Heading isAtTop={true}>Delete project</Heading>
           <Helper>
             Permanently delete this project. This will destroy all clusters tied
             to this project that have been provisioned by Porter. Note that this
@@ -164,7 +164,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
               });
             }}
           >
-            Delete Project
+            Delete project
           </DeleteButton>
         </>
       );
@@ -174,7 +174,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledProjectSettings>
-        <TitleSection>Project Settings</TitleSection>
+        <TitleSection>Project settings</TitleSection>
         <TabRegion
           currentTab={this.state.currentTab}
           setCurrentTab={(x: string) => this.setState({ currentTab: x })}

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

@@ -27,7 +27,7 @@ type Props = {
   provisioner?: boolean;
 };
 
-const providers = ["aws", "gcp", "do"];
+const providers = ["aws", "gcp"];
 
 const ProvisionerSettings: React.FC<Props> = ({
   provisioner,

+ 314 - 290
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -1,331 +1,356 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import drawerBg from "assets/drawer-bg.png";
-
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { ClusterType } from "shared/types";
+import React, { useEffect, useState } from "react";
 
-import Drawer from "./Drawer";
-import { RouteComponentProps, withRouter } from "react-router";
+import styled from "styled-components";
+import { ClusterType, ProjectType } from "shared/types";
 import { Tooltip } from "@material-ui/core";
-import SidebarLink from "./SidebarLink";
+import settings from "assets/settings.svg";
 
-type PropsType = RouteComponentProps & {
-  forceCloseDrawer: boolean;
-  releaseDrawer: () => void;
-  setWelcome: (x: boolean) => void;
-  currentView: string;
-  isSelected: boolean;
-  forceRefreshClusters: boolean;
-  setRefreshClusters: (x: boolean) => void;
-};
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
+import sliders from "assets/sliders.svg";
+import cluster from "assets/cluster.svg";
 
-type StateType = {
-  showDrawer: boolean;
-  initializedDrawer: boolean;
-  clusters: ClusterType[];
+import SidebarLink from "./SidebarLink";
 
-  // Track last project id for refreshing clusters on project change
-  prevProjectId: number;
+type Props = {
+  cluster: ClusterType;
+  currentCluster: ClusterType;
+  currentProject: ProjectType;
+  setCurrentCluster: (x: ClusterType, callback?: any) => void;
+  navToClusterDashboard: () => void;
 };
 
-class ClusterSection extends Component<PropsType, StateType> {
-  // Need to track initialized for animation mounting
-  state = {
-    showDrawer: false,
-    initializedDrawer: false,
-    clusters: [] as ClusterType[],
-    prevProjectId: this.context.currentProject.id,
-  };
-
-  updateClusters = () => {
-    let {
-      user,
-      currentProject,
-      setCurrentCluster,
-      currentCluster,
-    } = this.context;
-
-    // TODO: query with selected filter once implemented
-    api
-      .getClusters("<token>", {}, { id: currentProject.id })
-      .then((res) => {
-        window.analytics?.identify(user.userId, {
-          currentProject,
-          clusters: res.data,
-        });
+export const ClusterSection: React.FC<Props> = ({
+  cluster,
+  currentCluster,
+  currentProject,
+  setCurrentCluster,
+  navToClusterDashboard,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useEffect(() => {
+    if (!isExpanded) {
+      currentCluster.id === cluster.id && setIsExpanded(true);
+    }
+  }, [currentCluster]);
 
-        this.props.setWelcome(false);
-        // TODO: handle uninitialized kubeconfig
-        if (res.data) {
-          let clusters = res.data;
-          clusters.sort((a: any, b: any) => a.id - b.id);
-          if (clusters.length > 0) {
-            let queryString = window.location.search;
-            let urlParams = new URLSearchParams(queryString);
-            let paramClusterName = urlParams.get("cluster");
-            let params = this.props.match.params as any;
-            let pathClusterName = params.cluster;
+  const renderClusterContent = (cluster: any) => {
+    let clusterId = cluster.id;
 
-            // Set cluster from URL if in path or params
-            let defaultCluster = null as ClusterType;
-            if (paramClusterName || pathClusterName) {
-              clusters.forEach((cluster: ClusterType) => {
-                if (!defaultCluster) {
-                  if (cluster.name === pathClusterName) {
-                    defaultCluster = cluster;
-                  } else if (cluster.name === paramClusterName) {
-                    defaultCluster = cluster;
-                  }
-                }
-              });
+    if (currentCluster && isExpanded) {
+      return (
+        <Relative>
+          <SideLine />
+          <NavButton
+            path="/applications"
+            targetClusterName={cluster?.name}
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname.startsWith("/applications")
             }
-
-            this.setState({ clusters });
-            let saved = JSON.parse(
-              localStorage.getItem(currentProject.id + "-cluster")
-            );
-            if (!defaultCluster && saved && saved !== "null") {
-              // Ensures currentCluster isn't prematurely set (causes issues downstream)
-              let loaded = false;
-              for (let i = 0; i < clusters.length; i++) {
-                if (
-                  clusters[i].id === saved.id &&
-                  clusters[i].project_id === saved.project_id &&
-                  clusters[i].name === saved.name
-                ) {
-                  loaded = true;
-                  setCurrentCluster(clusters[i]);
-                  break;
+          >
+            <Img src={monoweb} />
+            Applications
+          </NavButton>
+          <NavButton
+            path="/jobs"
+            targetClusterName={cluster?.name}
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname.startsWith("/jobs")
+            }
+          >
+            <Img src={monojob} />
+            Jobs
+          </NavButton>
+          <NavButton
+            path="/env-groups"
+            targetClusterName={cluster?.name}
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname.startsWith("/env-groups")
+            }
+          >
+            <Img src={sliders} />
+            Env groups
+          </NavButton>
+          {cluster.service === "eks" &&
+            cluster.infra_id > 0 &&
+            currentProject.enable_rds_databases && (
+              <NavButton
+                path="/databases"
+                targetClusterName={cluster?.name}
+                active={
+                  currentCluster.id === clusterId &&
+                  window.location.pathname.startsWith("/databases")
                 }
+              >
+                <Icon className="material-icons-outlined">storage</Icon>
+                Databases
+              </NavButton>
+            )}
+          {currentProject?.stacks_enabled ? (
+            <NavButton
+              path="/stacks"
+              targetClusterName={cluster?.name}
+              active={
+                currentCluster.id === clusterId &&
+                window.location.pathname.startsWith("/stacks")
               }
-              if (!loaded) {
-                setCurrentCluster(clusters[0]);
+            >
+              <Icon className="material-icons-outlined">lan</Icon>
+              Stacks
+            </NavButton>
+          ) : null}
+          {currentProject?.preview_envs_enabled && (
+            <NavButton
+              path="/preview-environments"
+              targetClusterName={cluster?.name}
+              active={
+                currentCluster.id === clusterId &&
+                window.location.pathname.startsWith("/preview-environments")
               }
-            } else {
-              setCurrentCluster(defaultCluster || clusters[0]);
+            >
+              <InlineSVGWrapper
+                id="Flat"
+                fill="#FFFFFF"
+                xmlns="http://www.w3.org/2000/svg"
+                viewBox="0 0 256 256"
+              >
+                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
+              </InlineSVGWrapper>
+              Preview envs
+            </NavButton>
+          )}
+          <NavButton
+            path={"/cluster-dashboard"}
+            targetClusterName={cluster?.name}
+            active={
+              currentCluster.id === clusterId &&
+              window.location.pathname.startsWith("/cluster-dashboard")
             }
-          } else if (
-            this.props.currentView !== "provisioner" &&
-            this.props.currentView !== "new-project"
-          ) {
-            this.setState({ clusters: [] });
-            setCurrentCluster(null);
-          }
-        }
-      })
-      .catch((err) => this.props.setWelcome(true));
-  };
-
-  componentDidMount() {
-    this.updateClusters();
-  }
-
-  // Need to override showDrawer when the sidebar is closed
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props) {
-      // Refresh clusters on project change
-      if (this.state.prevProjectId !== this.context.currentProject.id) {
-        this.updateClusters();
-        this.setState({ prevProjectId: this.context.currentProject.id });
-      } else if (this.props.forceRefreshClusters === true) {
-        this.updateClusters();
-        this.props.setRefreshClusters(false);
-      }
-
-      if (this.props.forceCloseDrawer && this.state.showDrawer) {
-        this.setState({ showDrawer: false });
-        this.props.releaseDrawer();
-      }
-    }
-  }
-
-  toggleDrawer = (): void => {
-    if (!this.state.initializedDrawer) {
-      this.setState({ initializedDrawer: true });
-    }
-    this.setState({ showDrawer: !this.state.showDrawer });
-  };
-
-  renderDrawer = (): JSX.Element | undefined => {
-    if (this.state.initializedDrawer) {
-      return (
-        <Drawer
-          toggleDrawer={this.toggleDrawer}
-          showDrawer={this.state.showDrawer}
-          clusters={this.state.clusters}
-        />
-      );
-    }
-  };
-
-  showClusterConfigModal = () => {
-    this.context.setCurrentModal("ClusterConfigModal", {
-      updateClusters: this.updateClusters,
-    });
-  };
-
-  renderContents = (): JSX.Element => {
-    let { clusters, showDrawer } = this.state;
-    let { currentCluster } = this.context;
-
-    if (clusters.length > 0) {
-      return (
-        <ClusterSelector path="/cluster-dashboard">
-          <LinkWrapper>
-            <ClusterIcon>
-              <i className="material-icons">device_hub</i>
-            </ClusterIcon>
-            <Tooltip title={currentCluster?.name}>
-              <ClusterName>{currentCluster?.name}</ClusterName>
-            </Tooltip>
-          </LinkWrapper>
-          <DrawerButton
-            onClick={(e) => {
-              e.preventDefault();
-              this.toggleDrawer();
-            }}
           >
-            <BgAccent src={drawerBg} />
-            <DropdownIcon showDrawer={showDrawer}>
-              <i className="material-icons">arrow_drop_down</i>
-            </DropdownIcon>
-          </DrawerButton>
-        </ClusterSelector>
+            <Icon className="material-icons">device_hub</Icon>
+            Cluster settings
+          </NavButton>
+        </Relative>
       );
     }
+  };
 
-    return (
-      <InitializeButton
-        onClick={() =>
-          this.context.setCurrentModal("ClusterInstructionsModal", {})
+  return (
+    <>
+      <ClusterSelector
+        onClick={() => setIsExpanded(!isExpanded)}
+        active={
+          !isExpanded &&
+          cluster.id === currentCluster.id && (
+            window.location.pathname.startsWith("/cluster-dashboard") ||
+            window.location.pathname.startsWith("/preview-environments") ||
+            window.location.pathname.startsWith("/stacks") ||
+            window.location.pathname.startsWith("/databases") ||
+            window.location.pathname.startsWith("/env-groups") ||
+            window.location.pathname.startsWith("/jobs") ||
+            window.location.pathname.startsWith("/applications")
+          )
         }
       >
-        <Plus>+</Plus> Connect a Cluster
-      </InitializeButton>
-    );
-  };
+        <LinkWrapper>
+          <ClusterIcon>
+            <svg
+              width="19"
+              height="19"
+              viewBox="0 0 19 19"
+              fill="none"
+              xmlns="http://www.w3.org/2000/svg"
+            >
+              <path
+                d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                fill-rule="evenodd"
+                clip-rule="evenodd"
+                d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+              <path
+                fill-rule="evenodd"
+                clip-rule="evenodd"
+                d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+                stroke="white"
+                stroke-width="1.5"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+            </svg>
+          </ClusterIcon>
+          <Tooltip title={cluster?.name}>
+            <ClusterName>{cluster?.name}</ClusterName>
+          </Tooltip>
+          <I isExpanded={isExpanded} className="material-icons">
+            arrow_drop_down
+          </I>
+          <Spacer />
+        </LinkWrapper>
+      </ClusterSelector>
+      <div onClick={() => setCurrentCluster(cluster)}>
+        {renderClusterContent(cluster)}
+      </div>
+    </>
+  );
+};
 
-  render() {
-    return (
-      <>
-        {this.renderDrawer()}
-        {this.renderContents()}
-      </>
-    );
+const InlineSVGWrapper = styled.svg`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+  padding-left: 0;
+
+  > path {
+    fill: #ffffff;
   }
-}
+`;
 
-ClusterSection.contextType = Context;
+const Spacer = styled.div`
+  flex: 1;
+`;
 
-export default withRouter(ClusterSection);
+const Settings = styled.p`
+  color: #ffffff44;
+  width: 16px;
+  padding-right: 7px;
+  height: 100%;
+  border-radius: 3px;
+  cursor: pointer;
+  margin-left: 1px;
+  :hover {
+    color: #ffffff;
+  }
+  > i {
+    font-size: 16px;
+    display: flex;
+    height: 100%;
+    align-items: center;
+    justify-content: center;
+  }
+`;
 
-const Plus = styled.div`
-  margin-right: 10px;
-  font-size: 15px;
+const I = styled.i`
+  color: #ffffff99;
+  font-size: 20px;
+  border-radius: 100px;
+  transform: ${(props: { isExpanded: boolean }) =>
+    props.isExpanded ? "" : "rotate(-90deg)"};
 `;
 
-const InitializeButton = styled.div`
+const Relative = styled.div`
   position: relative;
+`;
+
+const SideLine = styled.div`
+  position: absolute;
+  left: 32px;
+  width: 1px;
+  top: 5px;
+  height: calc(100% - 12px);
+  background: #383a3f;
+`;
+
+const Icon = styled.span`
+  padding: 4px;
+  width: 22px;
+  padding-top: 4px;
+  border-radius: 3px;
+  margin-right: 8px;
+  font-size: 16px;
+`;
+
+const NavButton = styled(SidebarLink)`
   display: flex;
   align-items: center;
-  justify-content: center;
-  width: calc(100% - 30px);
-  height: 38px;
-  margin: 10px 15px 12px;
+  border-radius: 5px;
+  position: relative;
+  text-decoration: none;
+  height: 34px;
+  margin: 5px 15px;
+  margin-left: 39px;
+  padding: 0 30px 2px 8px;
   font-size: 13px;
-  font-weight: 500;
-  border-radius: 3px;
+  font-family: "Work Sans", sans-serif;
   color: #ffffff;
-  padding-bottom: 1px;
-  cursor: pointer;
-  background: #ffffff11;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: any) => (props.active ? "#ffffff11" : "")};
 
   :hover {
-    background: #ffffff22;
+    background: ${(props: any) => (props.active ? "#ffffff11" : "#ffffff08")};
   }
-`;
 
-const BgAccent = styled.img`
-  height: 30px;
-  background: #819bfd;
-  width: 30px;
-  border-top-left-radius: 100px;
-  max-width: 30px;
-  border-bottom-left-radius: 100px;
-  position: absolute;
-  top: 6px;
-  right: -8px;
-  border: none;
-  outline: none;
+  > i {
+    font-size: 20px;
+    padding-top: 4px;
+    border-radius: 3px;
+    margin-right: 10px;
+  }
 `;
 
-const DrawerButton = styled.div`
-  position: absolute;
-  height: 42px;
+const Img = styled.img<{ enlarge?: boolean }>`
+  padding: ${(props) => (props.enlarge ? "0 0 0 1px" : "4px")};
+  height: 22px;
   width: 22px;
-  top: 0px;
-  right: 0px;
-  z-index: 0;
-  overflow: hidden;
-  border: none;
-  outline: none;
+  padding-top: 4px;
+  border-radius: 3px;
+  margin-right: 8px;
 `;
 
 const ClusterName = styled.div`
   white-space: nowrap;
   overflow: hidden;
-  padding-right: 15px;
   text-overflow: ellipsis;
   display: inline-block;
-  width: 130px;
   margin-left: 3px;
+  margin-right: 4px;
   font-weight: 400;
   color: #ffffff;
 `;
 
-const DropdownIcon = styled.span`
-  position: absolute;
-  right: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "-2px" : "2px"};
-  top: 10px;
-  > i {
-    font-size: 18px;
-  }
-  -webkit-transform: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "rotate(-90deg)" : "rotate(90deg)"};
-  transform: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "rotate(-90deg)" : "rotate(90deg)"};
-  animation: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "rotateLeft 0.5s" : "rotateRight 0.5s"};
-  animation-fill-mode: forwards;
-
-  @keyframes rotateLeft {
-    100% {
-      right: 2px;
-      -webkit-transform: rotate(90deg);
-      transform: rotate(90deg);
-    }
-  }
-
-  @keyframes rotateRight {
-    100% {
-      right: -2px;
-      -webkit-transform: rotate(-90deg);
-      transform: rotate(-90deg);
-    }
-  }
-`;
-
 const ClusterIcon = styled.div`
-  > i {
-    font-size: 16px;
+  > svg {
+    width: 13px;
     display: flex;
     align-items: center;
-    margin-bottom: 0px;
-    margin-left: 17px;
-    margin-right: 10px;
+    margin-bottom: -1x;
+    margin-right: 9px;
     color: #ffffff;
   }
 `;
@@ -335,31 +360,30 @@ const LinkWrapper = styled.div`
   height: 100%;
   display: flex;
   align-items: center;
+  justify-content: space-between;
   width: 100%;
 `;
 
-const ClusterSelector = styled(SidebarLink)`
+const ClusterSelector = styled.div`
   position: relative;
   display: block;
-  padding-left: 7px;
-  width: 100%;
-  height: 42px;
-  margin: 0 auto 0 auto;
-  font-size: 14px;
+  border-radius: 5px;
+  width: calc(100% - 30px);
+  height: 34px;
+  padding: 0 6px 2px 11px;
+  font-size: 13px;
+  margin: 5px 15px;
   font-weight: 500;
   color: white;
   cursor: pointer;
   z-index: 1;
-
-  &.active {
-    background: #ffffff11;
-
-    :hover {
-      background: #ffffff11;
-    }
-  }
-
+  background: ${(props: { active?: boolean }) =>
+    props.active ? "#ffffff11" : ""};
   :hover {
-    background: #ffffff08;
+    > div {
+      > i {
+        background: #ffffff11;
+      }
+    }
   }
 `;

+ 207 - 0
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -0,0 +1,207 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { pushFiltered } from "shared/routing";
+import { Context } from "shared/Context";
+import { ClusterType } from "shared/types";
+import { ClusterSection } from "./ClusterSection";
+
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = RouteComponentProps & {
+  setWelcome: (x: boolean) => void;
+  currentView: string;
+  isSelected: boolean;
+  forceRefreshClusters: boolean;
+  setRefreshClusters: (x: boolean) => void;
+};
+
+type StateType = {
+  clusters: ClusterType[];
+
+  // Track last project id for refreshing clusters on project change
+  prevProjectId: number;
+};
+
+class Clusters extends Component<PropsType, StateType> {
+  // Need to track initialized for animation mounting
+  state = {
+    clusters: [] as ClusterType[],
+    prevProjectId: this.context.currentProject.id,
+  };
+
+  updateClusters = () => {
+    let {
+      user,
+      currentProject,
+      setCurrentCluster,
+      currentCluster,
+    } = this.context;
+
+    // TODO: query with selected filter once implemented
+    api
+      .getClusters("<token>", {}, { id: currentProject.id })
+      .then((res) => {
+        window.analytics?.identify(user.userId, {
+          currentProject,
+          clusters: res.data,
+        });
+
+        this.props.setWelcome(false);
+        // TODO: handle uninitialized kubeconfig
+        if (res.data) {
+          let clusters = res.data;
+          clusters.sort((a: any, b: any) => a.id - b.id);
+          if (clusters.length > 0) {
+            let queryString = window.location.search;
+            let urlParams = new URLSearchParams(queryString);
+            let paramClusterName = urlParams.get("cluster");
+            let params = this.props.match.params as any;
+            let pathClusterName = params.cluster;
+
+            // Set cluster from URL if in path or params
+            let defaultCluster = null as ClusterType;
+            if (paramClusterName || pathClusterName) {
+              clusters.forEach((cluster: ClusterType) => {
+                if (!defaultCluster) {
+                  if (cluster.name === pathClusterName) {
+                    defaultCluster = cluster;
+                  } else if (cluster.name === paramClusterName) {
+                    defaultCluster = cluster;
+                  }
+                }
+              });
+            }
+
+            this.setState({ clusters });
+            let saved = JSON.parse(
+              localStorage.getItem(currentProject.id + "-cluster")
+            );
+            if (!defaultCluster && saved && saved !== "null") {
+              // Ensures currentCluster isn't prematurely set (causes issues downstream)
+              let loaded = false;
+              for (let i = 0; i < clusters.length; i++) {
+                if (
+                  clusters[i].id === saved.id &&
+                  clusters[i].project_id === saved.project_id &&
+                  clusters[i].name === saved.name
+                ) {
+                  loaded = true;
+                  setCurrentCluster(clusters[i]);
+                  break;
+                }
+              }
+              if (!loaded) {
+                setCurrentCluster(clusters[0]);
+              }
+            } else {
+              setCurrentCluster(defaultCluster || clusters[0]);
+            }
+          } else if (
+            this.props.currentView !== "provisioner" &&
+            this.props.currentView !== "new-project"
+          ) {
+            this.setState({ clusters: [] });
+            setCurrentCluster(null);
+          }
+        }
+      })
+      .catch((err) => this.props.setWelcome(true));
+  };
+
+  componentDidMount() {
+    this.updateClusters();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps !== this.props) {
+      // Refresh clusters on project change
+      if (this.state.prevProjectId !== this.context.currentProject.id) {
+        this.updateClusters();
+        this.setState({ prevProjectId: this.context.currentProject.id });
+      } else if (this.props.forceRefreshClusters === true) {
+        this.updateClusters();
+        this.props.setRefreshClusters(false);
+      }
+    }
+  }
+
+  showClusterConfigModal = () => {
+    this.context.setCurrentModal("ClusterConfigModal", {
+      updateClusters: this.updateClusters,
+    });
+  };
+
+  renderContents = (): JSX.Element[] | JSX.Element => {
+    let { clusters } = this.state;
+    let { currentCluster, setCurrentCluster, currentProject } = this.context;
+
+    if (clusters.length > 0 && currentCluster) {
+      clusters.sort((a, b) => a.id - b.id);
+
+      return clusters.map((cluster: ClusterType, i: number) => {
+        return (
+          <ClusterSection
+            key={i}
+            cluster={cluster}
+            currentCluster={currentCluster}
+            currentProject={currentProject}
+            setCurrentCluster={setCurrentCluster}
+            navToClusterDashboard={() => {
+              setCurrentCluster(cluster, () => {
+                pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              });
+            }}
+          />
+        );
+      });
+    }
+
+    return (
+      <InitializeButton
+        onClick={() =>
+          this.context.setCurrentModal("ClusterInstructionsModal", {})
+        }
+      >
+        <Plus>+</Plus> Connect a Cluster
+      </InitializeButton>
+    );
+  };
+
+  render() {
+    return <>{this.renderContents()}</>;
+  }
+}
+
+Clusters.contextType = Context;
+
+export default withRouter(Clusters);
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
+const InitializeButton = styled.div`
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 30px);
+  height: 38px;
+  margin: 10px 15px 12px;
+  font-size: 13px;
+  font-weight: 500;
+  border-radius: 3px;
+  color: #ffffff;
+  padding-bottom: 1px;
+  cursor: pointer;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;

+ 0 - 242
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -1,242 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import close from "assets/close.png";
-
-import { Context } from "shared/Context";
-import { ClusterType } from "shared/types";
-import { RouteComponentProps, withRouter } from "react-router";
-import { pushFiltered } from "shared/routing";
-import { Tooltip } from "@material-ui/core";
-
-type PropsType = RouteComponentProps & {
-  toggleDrawer: () => void;
-  showDrawer: boolean;
-  clusters: ClusterType[];
-};
-
-type StateType = {};
-
-class Drawer extends Component<PropsType, StateType> {
-  renderClusterList = (): JSX.Element[] | JSX.Element => {
-    let { clusters } = this.props;
-    let { currentCluster, setCurrentCluster } = this.context;
-
-    if (clusters.length > 0 && currentCluster) {
-      clusters.sort((a, b) => a.id - b.id);
-
-      return clusters.map((cluster: ClusterType, i: number) => {
-        /*
-        let active = this.context.activeProject &&
-          this.context.activeProject.namespace == val.namespace; 
-        */
-
-        return (
-          <ClusterOption
-            key={i}
-            active={cluster.name === currentCluster.name}
-            onClick={() => {
-              setCurrentCluster(cluster, () => {
-                pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
-                  cluster: cluster.name,
-                });
-              });
-            }}
-          >
-            <ClusterIcon>
-              <i className="material-icons">device_hub</i>
-            </ClusterIcon>
-            <Tooltip title={cluster?.name}>
-              <ClusterName>{cluster.name}</ClusterName>
-            </Tooltip>
-          </ClusterOption>
-        );
-      });
-    }
-
-    return <Placeholder>No clusters selected</Placeholder>;
-  };
-
-  renderCloseOverlay = (): JSX.Element | undefined => {
-    if (this.props.showDrawer) {
-      return <CloseOverlay onClick={this.props.toggleDrawer} />;
-    }
-  };
-
-  render() {
-    return (
-      <div>
-        {this.renderCloseOverlay()}
-        <StyledDrawer showDrawer={this.props.showDrawer}>
-          <CloseButton onClick={this.props.toggleDrawer}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-
-          {this.renderClusterList()}
-
-          <InitializeButton
-            onClick={() => {
-              this.context.setCurrentModal("ClusterInstructionsModal", {});
-            }}
-          >
-            <Plus>+</Plus> Connect a Cluster
-          </InitializeButton>
-        </StyledDrawer>
-      </div>
-    );
-  }
-}
-
-Drawer.contextType = Context;
-
-export default withRouter(Drawer);
-
-const Plus = styled.div`
-  margin-right: 10px;
-  font-size: 15px;
-`;
-
-const ButtonLabel = styled.div`
-  display: inline-block;
-  font-size: 14px;
-  position: absolute;
-  top: 11px;
-  left: 61px;
-`;
-
-const InitializeButton = styled.div`
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: calc(100% - 30px);
-  height: 38px;
-  margin: 45px 15px 12px;
-  font-size: 13px;
-  font-weight: 500;
-  border-radius: 3px;
-  color: #ffffff;
-  padding-bottom: 3px;
-  cursor: pointer;
-  background: #ffffff22;
-
-  :hover {
-    background: #ffffff33;
-  }
-`;
-
-const ClusterOption = styled.div`
-  width: 100%;
-  padding: 2px 7px;
-  padding-right: 30px;
-  display: flex;
-  align-items: center;
-  height: 42px;
-  text-decoration: none;
-  color: white;
-  font-size: 14px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  cursor: pointer;
-  background: ${(props: { active?: boolean }) =>
-    props.active ? "#ffffff18" : ""};
-  :hover {
-    background: #ffffff22;
-  }
-`;
-
-const Placeholder = styled(ClusterOption)`
-  color: #ffffff99;
-  justify-content: center;
-  padding: 0;
-  cursor: default;
-  :hover {
-    background: none;
-  }
-`;
-
-const CloseOverlay = styled.div`
-  background: transparent;
-  width: 100vw;
-  height: 100vh;
-  position: absolute;
-  top: 0;
-  left: 0;
-  z-index: -2;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  right: 10px;
-  top: 7px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff20;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 12px;
-  margin: 0 auto;
-`;
-
-const ClusterIcon = styled.div`
-  > i {
-    font-size: 16px;
-    display: flex;
-    align-items: center;
-    margin-bottom: 0px;
-    margin-left: 17px;
-    margin-right: 10px;
-  }
-`;
-
-const StyledDrawer = styled.div`
-  position: absolute;
-  height: 100%;
-  padding-top: 41px;
-  width: 230px;
-  overflow-y: auto;
-  padding-bottom: 40px;
-  top: 0;
-  left: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "-30px" : "200px"};
-  z-index: -2;
-  background: #00000fd4;
-  animation: ${(props: { showDrawer: boolean }) =>
-    props.showDrawer ? "slideDrawerRight 0.4s" : "slideDrawerLeft 0.4s"};
-  animation-fill-mode: forwards;
-  @keyframes slideDrawerRight {
-    from {
-      left: -30px;
-      opacity: 0;
-    }
-    to {
-      left: 200px;
-      opacity: 1;
-    }
-  }
-  @keyframes slideDrawerLeft {
-    from {
-      left: 200px;
-      opacity: 1;
-    }
-    to {
-      left: -30px;
-      opacity: 0;
-    }
-  }
-`;
-
-const ClusterName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-left: 3px;
-`;

+ 6 - 6
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -74,7 +74,7 @@ class ProjectSection extends Component<PropsType, StateType> {
         <div>
           <Dropdown>
             {this.renderOptionList()}
-            {this.context.canCreateProject && (
+            {this.context.user?.email.includes("porter.run") && (
               <Option
                 selected={false}
                 lastItem={true}
@@ -83,7 +83,7 @@ class ProjectSection extends Component<PropsType, StateType> {
                 }
               >
                 <ProjectIconAlt>+</ProjectIconAlt>
-                <ProjectLabel>Create a Project</ProjectLabel>
+                <ProjectLabel>Create a project</ProjectLabel>
               </Option>
             )}
           </Dropdown>
@@ -124,7 +124,7 @@ class ProjectSection extends Component<PropsType, StateType> {
           })
         }
       >
-        <Plus>+</Plus> Create a Project
+        <Plus>+</Plus> Create a project
       </InitializeButton>
     );
   }
@@ -197,10 +197,10 @@ const Option = styled.div`
 
 const Dropdown = styled.div`
   position: absolute;
-  right: 10px;
+  right: 13px;
   top: calc(100% + 5px);
   background: #26282f;
-  width: 180px;
+  width: 210px;
   max-height: 500px;
   border-radius: 3px;
   z-index: 999;
@@ -221,7 +221,6 @@ const Letter = styled.div`
   position: absolute;
   padding-bottom: 2px;
   font-weight: 500;
-  background: #00000028;
   top: 0;
   left: 0;
   display: flex;
@@ -254,6 +253,7 @@ const ProjectIconAlt = styled(ProjectIcon)`
 
 const StyledProjectSection = styled.div`
   position: relative;
+  margin-left: 3px;
 `;
 
 const MainSelector = styled.div`

+ 25 - 121
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -3,14 +3,11 @@ import styled from "styled-components";
 import category from "assets/category.svg";
 import integrations from "assets/integrations.svg";
 import rocket from "assets/rocket.png";
-import monojob from "assets/monojob.png";
-import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
-import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";
 
-import ClusterSection from "./ClusterSection";
+import Clusters from "./Clusters";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, pushFiltered } from "shared/routing";
@@ -102,83 +99,12 @@ class Sidebar extends Component<PropsType, StateType> {
     }
   };
 
-  renderClusterContent = () => {
-    let { currentCluster, currentProject } = this.context;
-
-    if (currentCluster) {
-      return (
-        <>
-          <NavButton path="/applications">
-            <Img src={monoweb} />
-            Applications
-          </NavButton>
-          <NavButton path="/jobs">
-            <Img src={monojob} />
-            Jobs
-          </NavButton>
-          <NavButton path="/env-groups">
-            <Img src={sliders} />
-            Env Groups
-          </NavButton>
-          {currentCluster.service === "eks" &&
-            currentCluster.infra_id > 0 &&
-            currentProject.enable_rds_databases && (
-              <NavButton path="/databases">
-                <Icon className="material-icons-outlined">storage</Icon>
-                Databases
-              </NavButton>
-            )}
-          {currentProject?.preview_envs_enabled && (
-            <NavButton path="/preview-environments">
-              <InlineSVGWrapper
-                id="Flat"
-                fill="#FFFFFF"
-                xmlns="http://www.w3.org/2000/svg"
-                viewBox="0 0 256 256"
-              >
-                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
-              </InlineSVGWrapper>
-              <EllipsisTextWrapper
-                onMouseOver={() => {
-                  this.setState((prev) => ({
-                    ...prev,
-                    showLinkTooltip: {
-                      ...prev.showLinkTooltip,
-                      prev_envs: true,
-                    },
-                  }));
-                }}
-                onMouseOut={() => {
-                  this.setState((prev) => ({
-                    ...prev,
-                    showLinkTooltip: {
-                      ...prev.showLinkTooltip,
-                      prev_envs: false,
-                    },
-                  }));
-                }}
-              >
-                Preview Envs
-              </EllipsisTextWrapper>
-            </NavButton>
-          )}
-          {currentProject?.stacks_enabled ? (
-            <NavButton path={"/stacks"}>
-              <Icon className="material-icons-outlined">lan</Icon>
-              Stacks
-            </NavButton>
-          ) : null}
-        </>
-      );
-    }
-  };
-
   renderProjectContents = () => {
     let { currentView } = this.props;
     let { currentProject } = this.context;
     if (currentProject) {
       return (
-        <>
+        <ScrollWrapper>
           <SidebarLabel>Home</SidebarLabel>
           <NavButton path={"/dashboard"}>
             <Img src={category} />
@@ -212,7 +138,7 @@ class Sidebar extends Component<PropsType, StateType> {
           ]) && (
             <NavButton path={"/project-settings"}>
               <Img enlarge={true} src={settings} />
-              Settings
+              Project settings
             </NavButton>
           )}
 
@@ -220,20 +146,17 @@ class Sidebar extends Component<PropsType, StateType> {
 
           {this.context.hasFinishedOnboarding && (
             <>
-              <SidebarLabel>Current Cluster</SidebarLabel>
-              <ClusterSection
-                forceCloseDrawer={this.state.forceCloseDrawer}
-                releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
+              <SidebarLabel>Clusters</SidebarLabel>
+              <Clusters
                 setWelcome={this.props.setWelcome}
                 currentView={currentView}
                 isSelected={false}
                 forceRefreshClusters={this.props.forceRefreshClusters}
                 setRefreshClusters={this.props.setRefreshClusters}
               />
-              {this.renderClusterContent()}
             </>
           )}
-        </>
+        </ScrollWrapper>
       );
     }
 
@@ -276,13 +199,10 @@ Sidebar.contextType = Context;
 
 export default withRouter(withAuth(Sidebar));
 
-const Icon = styled.span`
-  padding: 4px;
-  width: 23px;
-  padding-top: 4px;
-  border-radius: 3px;
-  margin-right: 10px;
-  font-size: 18px;
+const ScrollWrapper = styled.div`
+  overflow-y: auto;
+  padding-bottom: 25px;
+  max-height: calc(100vh - 95px);
 `;
 
 const ProjectPlaceholder = styled.div`
@@ -307,11 +227,13 @@ const ProjectPlaceholder = styled.div`
 const NavButton = styled(SidebarLink)`
   display: flex;
   align-items: center;
+  border-radius: 5px;
   position: relative;
   text-decoration: none;
-  height: 42px;
-  padding: 0 30px 2px 20px;
-  font-size: 14px;
+  height: 34px;
+  margin: 5px 15px;
+  padding: 0 30px 2px 6px;
+  font-size: 13px;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
   cursor: ${(props: { disabled?: boolean }) =>
@@ -339,29 +261,11 @@ const NavButton = styled(SidebarLink)`
 
 const Img = styled.img<{ enlarge?: boolean }>`
   padding: ${(props) => (props.enlarge ? "0 0 0 1px" : "4px")};
-  height: 23px;
-  width: 23px;
+  height: 22px;
+  width: 22px;
   padding-top: 4px;
   border-radius: 3px;
-  margin-right: 10px;
-`;
-
-const InlineSVGWrapper = styled.svg`
-  width: 32px;
-  height: 32px;
-  padding: 8px;
-  padding-left: 0;
-
-  > path {
-    fill: #ffffff;
-  }
-`;
-
-const EllipsisTextWrapper = styled.span`
-  display: block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  margin-right: 8px;
 `;
 
 const SidebarBg = styled.div`
@@ -369,17 +273,17 @@ const SidebarBg = styled.div`
   top: 0;
   left: 0;
   width: 100%;
-  background-color: #292c35;
+  background-color: #202227;
   height: 100%;
   z-index: -1;
-  box-shadow: 8px 0px 8px 0px #00000010;
+  border-right: 1px solid #383a3f;
 `;
 
 const SidebarLabel = styled.div`
   color: #ffffff99;
-  padding: 5px 16px;
+  padding: 5px 23px;
   margin-bottom: 5px;
-  font-size: 14px;
+  font-size: 13px;
   z-index: 1;
   font-weight: 500;
 `;
@@ -465,7 +369,7 @@ const CollapseButton = styled.div`
 
 const StyledSidebar = styled.section`
   font-family: "Work Sans", sans-serif;
-  width: 200px;
+  width: 235px;
   position: relative;
   padding-top: 20px;
   height: 100vh;
@@ -475,7 +379,7 @@ const StyledSidebar = styled.section`
   animation-fill-mode: forwards;
   @keyframes showSidebar {
     from {
-      margin-left: -200px;
+      margin-left: -235px;
     }
     to {
       margin-left: 0px;
@@ -486,7 +390,7 @@ const StyledSidebar = styled.section`
       margin-left: 0px;
     }
     to {
-      margin-left: -200px;
+      margin-left: -235px;
     }
   }
 `;

+ 4 - 6
dashboard/src/main/home/sidebar/SidebarLink.tsx

@@ -3,11 +3,9 @@ import { NavLink, NavLinkProps, useParams } from "react-router-dom";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
 
-const SidebarLink: React.FC<{ path: string } & Omit<NavLinkProps, "to">> = ({
-  children,
-  path,
-  ...props
-}) => {
+const SidebarLink: React.FC<
+  { path: string; targetClusterName?: string } & Omit<NavLinkProps, "to">
+> = ({ children, path, ...props }) => {
   const params = useParams<{ namespace: string }>();
   const { getQueryParam } = useRouting();
   const { currentCluster, currentProject } = useContext(Context);
@@ -20,7 +18,7 @@ const SidebarLink: React.FC<{ path: string } & Omit<NavLinkProps, "to">> = ({
     let pathNamespace = params.namespace;
     const search = new URLSearchParams();
     if (currentCluster?.name) {
-      search.append("cluster", currentCluster.name);
+      search.append("cluster", props.targetClusterName || currentCluster.name);
     }
 
     if (currentProject?.id) {

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

@@ -99,7 +99,11 @@ export const useWebsockets = () => {
   /**
    * Close specific websocket
    */
-  const closeWebsocket = (id: string, code: number =  4000, reason: string = "User closed the websocket connection") => {
+  const closeWebsocket = (
+    id: string,
+    code: number = 4000,
+    reason: string = "User closed the websocket connection"
+  ) => {
     const ws = websocketMap.current[id];
 
     if (!ws) {

+ 1 - 1
docker-compose.dev-secure.yaml

@@ -18,7 +18,7 @@ services:
       - postgres
     env_file:
       - ./docker/.env
-    command: /bin/sh -c '/porter/bin/migrate; air -c .air.toml;'
+    command:  air -c .air.toml
     restart: on-failure
     volumes:
       - ./cmd:/porter/cmd

+ 1 - 1
docker-compose.dev.yaml

@@ -18,7 +18,7 @@ services:
       - postgres
     env_file:
       - ./docker/.env
-    command: /bin/sh -c '/porter/bin/migrate; air -c .air.toml;'
+    command: air -c .air.toml
     restart: on-failure
     volumes:
       - ./cmd:/porter/cmd

+ 4 - 4
docker/dev.Dockerfile

@@ -5,6 +5,9 @@ WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git
 
+# for live reloading of go container
+RUN go install github.com/cosmtrek/air@latest
+
 COPY go.mod go.sum ./
 RUN go mod download
 
@@ -12,7 +15,4 @@ COPY . ./
 
 RUN chmod +x /porter/docker/bin/*
 
-# for live reloading of go container
-RUN go get github.com/cosmtrek/air
-
-CMD air -c .air.toml
+CMD air -c .air.toml

+ 15 - 9
go.mod

@@ -51,8 +51,8 @@ require (
 	golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26
 	google.golang.org/api v0.88.0
 	google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03
-	google.golang.org/grpc v1.47.0
-	google.golang.org/protobuf v1.28.0
+	google.golang.org/grpc v1.49.0
+	google.golang.org/protobuf v1.28.1
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.22.3
 	helm.sh/helm/v3 v3.9.0
@@ -84,7 +84,9 @@ require (
 	github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect
 	github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
+	github.com/OneOfOne/xxhash v1.2.8 // indirect
 	github.com/PuerkitoBio/goquery v1.5.1 // indirect
+	github.com/agnivade/levenshtein v1.1.1 // indirect
 	github.com/andybalholm/cascadia v1.1.0 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.16.4 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.15.9 // indirect
@@ -114,8 +116,12 @@ require (
 	github.com/mmcdole/gofeed v1.1.3 // indirect
 	github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/open-policy-agent/opa v0.44.0 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
+	github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
+	github.com/tchap/go-patricia/v2 v2.3.1 // indirect
 	github.com/xanzy/go-gitlab v0.68.0 // indirect
+	github.com/yashtewari/glob-intersection v0.1.0 // indirect
 	go.uber.org/goleak v1.1.12 // indirect
 )
 
@@ -135,7 +141,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.2.2 // indirect
 	github.com/Masterminds/squirrel v1.5.3 // indirect
 	github.com/Microsoft/go-winio v0.5.2 // indirect
-	github.com/Microsoft/hcsshim v0.9.3 // indirect
+	github.com/Microsoft/hcsshim v0.9.4 // 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
@@ -150,7 +156,7 @@ require (
 	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.3 // indirect
-	github.com/containerd/containerd v1.6.6 // indirect
+	github.com/containerd/containerd v1.6.8 // indirect
 	github.com/containerd/stargz-snapshotter/estargz v0.11.4 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
@@ -252,10 +258,10 @@ require (
 	github.com/pelletier/go-toml v1.9.5 // 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.12.2 // indirect
+	github.com/prometheus/client_golang v1.13.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
-	github.com/prometheus/common v0.35.0 // indirect
-	github.com/prometheus/procfs v0.7.3 // indirect
+	github.com/prometheus/common v0.37.0 // indirect
+	github.com/prometheus/procfs v0.8.0 // indirect
 	github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rubenv/sql-migrate v1.1.2 // indirect
@@ -265,7 +271,7 @@ require (
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
 	github.com/shopspring/decimal v1.3.1 // indirect
-	github.com/sirupsen/logrus v1.8.1 // indirect
+	github.com/sirupsen/logrus v1.9.0 // indirect
 	github.com/spf13/afero v1.6.0 // indirect
 	github.com/spf13/cast v1.5.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -282,7 +288,7 @@ require (
 	go.starlark.net v0.0.0-20220328144851-d1966c6b9fcd // indirect
 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
 	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
-	golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b // indirect
+	golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect

+ 33 - 0
go.sum

@@ -207,6 +207,8 @@ github.com/Microsoft/hcsshim v0.9.2 h1:wB06W5aYFfUB3IvootYAY2WnOmIdgPGfqSI6tufQN
 github.com/Microsoft/hcsshim v0.9.2/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc=
 github.com/Microsoft/hcsshim v0.9.3 h1:k371PzBuRrz2b+ebGuI2nVgVhgsVX60jMfSw80NECxo=
 github.com/Microsoft/hcsshim v0.9.3/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc=
+github.com/Microsoft/hcsshim v0.9.4 h1:mnUj0ivWy6UzbB1uLFqKR6F+ZyiDc7j4iGgHTpO+5+I=
+github.com/Microsoft/hcsshim v0.9.4/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=
@@ -214,6 +216,8 @@ github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMo
 github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
 github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
+github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
 github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM=
 github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
 github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
@@ -230,6 +234,8 @@ github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:H
 github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
 github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
+github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
 github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
 github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
 github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
@@ -265,6 +271,7 @@ github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3st
 github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
 github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
 github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
+github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@@ -495,6 +502,8 @@ github.com/containerd/containerd v1.6.3 h1:JfgUEIAH07xDWk6kqz0P3ArZt+KJ9YeihSC9u
 github.com/containerd/containerd v1.6.3/go.mod h1:gCVGrYRYFm2E8GmuUIbj/NGD7DLZQLzSJQazjVKDOig=
 github.com/containerd/containerd v1.6.6 h1:xJNPhbrmz8xAMDNoVjHy9YHtWwEQNS+CDkcIRh7t8Y0=
 github.com/containerd/containerd v1.6.6/go.mod h1:ZoP1geJldzCVY3Tonoz7b1IXk8rIX0Nltt5QE4OMNk0=
+github.com/containerd/containerd v1.6.8 h1:h4dOFDwzHmqFEP754PgfgTeVXFnLiRc6kiqC7tplDJs=
+github.com/containerd/containerd v1.6.8/go.mod h1:By6p5KqPK0/7/CgO/A6t/Gz+CUYUu2zf1hUaaymVXB0=
 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=
@@ -621,6 +630,7 @@ 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/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
 github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y=
 github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
@@ -1637,6 +1647,8 @@ github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl
 github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
 github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
 github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
+github.com/open-policy-agent/opa v0.44.0 h1:sEZthsrWBqIN+ShTMJ0Hcz6a3GkYsY4FaB2S/ou2hZk=
+github.com/open-policy-agent/opa v0.44.0/go.mod h1:YpJaFIk5pq89n/k72c1lVvfvR5uopdJft2tMg1CW/yU=
 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=
@@ -1736,6 +1748,8 @@ github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVD
 github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
 github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34=
 github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
+github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU=
+github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ=
 github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -1758,6 +1772,8 @@ github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuI
 github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/common v0.35.0 h1:Eyr+Pw2VymWejHqCugNaQXkAi6KayVNxaHeu6khmFBE=
 github.com/prometheus/common v0.35.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
+github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
+github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
 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=
@@ -1772,6 +1788,8 @@ github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
 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/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
+github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
 github.com/pseudomuto/protoc-gen-doc v1.3.2/go.mod h1:y5+P6n3iGrbKG+9O04V5ld71in3v/bX88wUwgt+U8EA=
 github.com/pseudomuto/protokit v0.2.0/go.mod h1:2PdH30hxVHsup8KpBTOXTBeMVhJZVio3Q8ViKSAXT0Q=
@@ -1783,6 +1801,8 @@ github.com/quasilyte/go-ruleguard/dsl v0.3.10/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQ
 github.com/quasilyte/go-ruleguard/rules v0.0.0-20201231183845-9e62ed36efe1/go.mod h1:7JTjp89EGyU1d6XfBiXihJNG37wB2VRkd125Q1u7Plc=
 github.com/quasilyte/go-ruleguard/rules v0.0.0-20210428214800-545e0d2e0bf7/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
 github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
+github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
+github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
 github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 h1:xe+mmCnDN82KhC010l3NfYlA8ZbOuzbXAzSYBa6wbMc=
 github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk=
@@ -1866,6 +1886,8 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
 github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/sivchari/tenv v1.4.7/go.mod h1:5nF+bITvkebQVanjU6IuMbvIot/7ReNsUV7I5NbprB0=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
@@ -1952,7 +1974,10 @@ github.com/sylvia7788/contextcheck v1.0.4/go.mod h1:vuPKJMQ7MQ91ZTqfdyreNKwZjyUg
 github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
 github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
 github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/tchap/go-patricia v2.2.6+incompatible h1:JvoDL7JSoIP2HDE8AbDH3zC8QBPxmzYe32HHy5yQ+Ck=
 github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I=
+github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
+github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
 github.com/tdakkota/asciicheck v0.0.0-20200416200610-e657995f937b/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM=
 github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
 github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
@@ -2039,6 +2064,8 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
 github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
 github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
+github.com/yashtewari/glob-intersection v0.1.0 h1:6gJvMYQlTDOL3dMsPF6J0+26vwX9MB8/1q3uAdhmTrg=
+github.com/yashtewari/glob-intersection v0.1.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
 github.com/yeya24/promlinter v0.1.0/go.mod h1:rs5vtZzeBHqqMwXqFScncpCF6u06lezhZepno9AB1Oc=
 github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
 github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
@@ -2515,6 +2542,8 @@ golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8=
 golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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=
@@ -2885,6 +2914,8 @@ google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACu
 google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
 google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
 google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
+google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
 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=
@@ -2901,6 +2932,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
 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=
+google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
+google.golang.org/protobuf v1.28.1/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=

+ 9 - 0
internal/helm/agent.go

@@ -166,6 +166,7 @@ type UpgradeReleaseConfig struct {
 	Cluster    *models.Cluster
 	Repo       repository.Repository
 	Registries []*models.Registry
+	Stack      *models.Stack
 
 	// Optional, if chart should be overriden
 	Chart *chart.Chart
@@ -222,6 +223,14 @@ func (a *Agent) UpgradeReleaseByValues(
 		return nil, err
 	}
 
+	if conf.Stack != nil {
+		conf.Values["stack"] = map[string]interface{}{
+			"enabled":  true,
+			"name":     conf.Stack.Name,
+			"revision": conf.Stack.Revisions[0].RevisionNumber,
+		}
+	}
+
 	res, err := cmd.Run(conf.Name, ch, conf.Values)
 
 	if err != nil {

+ 3 - 0
internal/helm/config.go

@@ -3,6 +3,7 @@ package helm
 import (
 	"errors"
 	"io/ioutil"
+	"time"
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
@@ -26,6 +27,7 @@ type Form struct {
 	Storage                   string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
 	Namespace                 string `json:"namespace"`
 	AllowInClusterConnections bool
+	Timeout                   time.Duration // optional
 }
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -38,6 +40,7 @@ func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 		Repo:                      form.Repo,
 		DigitalOceanOAuth:         form.DigitalOceanOAuth,
 		AllowInClusterConnections: form.AllowInClusterConnections,
+		Timeout:                   form.Timeout,
 	}
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)

+ 3 - 0
internal/kubernetes/config.go

@@ -114,6 +114,7 @@ type OutOfClusterConfig struct {
 	Repo                      repository.Repository
 	DefaultNamespace          string // optional
 	AllowInClusterConnections bool
+	Timeout                   time.Duration // optional
 
 	// Only required if using DigitalOcean OAuth as an auth mechanism
 	DigitalOceanOAuth *oauth2.Config
@@ -135,6 +136,8 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 		return nil, err
 	}
 
+	restConf.Timeout = conf.Timeout
+
 	rest.SetKubernetesDefaults(restConf)
 	return restConf, nil
 }

+ 1 - 1
internal/kubernetes/prometheus/metrics.go

@@ -301,7 +301,7 @@ func getSelectionRegex(kind, name string) (string, error) {
 
 	switch strings.ToLower(kind) {
 	case "deployment":
-		suffix = "[a-z0-9]+"
+		suffix = "[a-z0-9]+(-[a-z0-9]+)*"
 	case "statefulset":
 		suffix = "[0-9]+"
 	case "job":

+ 67 - 0
internal/models/monitor.go

@@ -0,0 +1,67 @@
+package models
+
+import (
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type MonitorTestResult struct {
+	gorm.Model
+
+	ProjectID uint
+	ClusterID uint
+	Category  string
+	ObjectID  string
+
+	LastStatusChange  *time.Time
+	LastTested        *time.Time
+	LastRunResult     string
+	LastRunResultEnum uint
+
+	LastRecommenderRunID string
+	Archived             bool
+
+	Title   string
+	Message string
+
+	Severity     string
+	SeverityEnum uint
+}
+
+func (m *MonitorTestResult) ToMonitorTestResultType() *types.MonitorTestResult {
+	return &types.MonitorTestResult{
+		ProjectID:        m.ProjectID,
+		ClusterID:        m.ClusterID,
+		Category:         m.Category,
+		ObjectID:         m.ObjectID,
+		LastStatusChange: m.LastStatusChange,
+		LastTested:       m.LastTested,
+		LastRunResult:    types.MonitorTestStatus(m.LastRunResult),
+		Title:            m.Title,
+		Message:          m.Message,
+		Severity:         types.MonitorTestSeverity(m.Severity),
+	}
+}
+
+func GetSeverityEnum(severity string) uint {
+	switch strings.ToLower(severity) {
+	case string(types.MonitorTestSeverityCritical):
+		return 2
+	case string(types.MonitorTestSeverityHigh):
+		return 1
+	default:
+		return 0
+	}
+}
+
+func GetLastRunResultEnum(lastRunResult string) uint {
+	switch strings.ToLower(lastRunResult) {
+	case string(types.MonitorTestStatusFailed):
+		return 1
+	default:
+		return 0
+	}
+}

+ 151 - 0
internal/opa/config.yaml

@@ -0,0 +1,151 @@
+web:
+  kind: "helm_release"
+  match:
+    chart_name: "web"
+  policies:
+  - path: "./policies/web/web_version.rego"
+    name: "web.version"
+nginx:
+  kind: "helm_release"
+  match:
+    name: nginx-ingress
+    namespace: ingress-nginx
+  mustExist: true
+  policies:
+  - path: "./policies/nginx/nginx_version.rego"
+    name: "nginx.version"
+  - path: "./policies/nginx/nginx_topology_spread_constraints.rego"
+    name: "nginx.topology_spread_constraints"
+  - path: "./policies/nginx/memory_limits.rego"
+    name: "nginx.memory_limits"
+  - path: "./policies/nginx/wait_shutdown.rego"
+    name: "nginx.wait_shutdown"
+cert-manager:
+  kind: "helm_release"
+  match:
+    name: cert-manager
+    namespace: cert-manager
+  mustExist: true
+  policies:
+  - path: "./policies/cert-manager/cert_manager_version.rego"
+    name: "cert_manager.version"
+  - path: "./policies/cert-manager/cainjector_memory_limits.rego"
+    name: "cert_manager.cainjector_memory_limits"
+  - path: "./policies/cert-manager/controller_memory_limits.rego"
+    name: "cert_manager.controller_memory_limits"
+  - path: "./policies/cert-manager/webhook_memory_limits.rego"
+    name: "cert_manager.webhook_memory_limits"
+prometheus:
+  kind: "helm_release"
+  match:
+    name: prometheus
+    namespace: monitoring
+  mustExist: true
+  policies:
+  - path: "./policies/prometheus/server_memory_limits.rego"
+    name: "prometheus.server_memory_limits"
+  - path: "./policies/prometheus/alertmanager_memory_limits.rego"
+    name: "prometheus.alertmanager_memory_limits"
+  - path: "./policies/prometheus/kubestatemetrics_memory_limits.rego"
+    name: "prometheus.kubestatemetrics_memory_limits"
+  - path: "./policies/prometheus/pushgateway_memory_limits.rego"
+    name: "prometheus.pushgateway_memory_limits"
+  - path: "./policies/prometheus/nodeexporter_memory_limits.rego"
+    name: "prometheus.nodeexporter_memory_limits"
+  - path: "./policies/prometheus/prometheus_version.rego"
+    name: "prometheus.version"
+nginx_pod:
+  kind: "pod"
+  overrideSeverity: "critical"
+  match:
+    namespace: ingress-nginx
+    labels:
+      app.kubernetes.io/component: "controller"
+      app.kubernetes.io/instance: "nginx-ingress"
+      app.kubernetes.io/name: "ingress-nginx"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+prometheus_server_pod:
+  kind: "pod"
+  match:
+    namespace: monitoring
+    labels:
+      app: "prometheus"
+      component: "server"
+      release: "prometheus"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+prometheus_alertmanager_pod:
+  kind: "pod"
+  match:
+    namespace: monitoring
+    labels:
+      app: "prometheus"
+      component: "alertmanager"
+      release: "prometheus"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+porter_agent_pod:
+  kind: "pod"
+  match:
+    namespace: porter-agent-system
+    labels:
+      control-plane: "controller-manager"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+porter_agent_redis_pod:
+  kind: "pod"
+  match:
+    namespace: porter-agent-system
+    labels:
+      app.kubernetes.io/component: "master"
+      app.kubernetes.io/instance: "porter-agent"
+      app.kubernetes.io/managed-by: "Helm"
+      app.kubernetes.io/name: "redis"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+certificates:
+  kind: "crd_list"
+  match:
+    group: cert-manager.io
+    version: v1
+    resource: certificates
+  policies:
+  - path: "./policies/certificates/expiry_two_weeks.rego"
+    name: "certificates.expiry_two_weeks"
+  - path: "./policies/certificates/expired.rego"
+    name: "certificates.expired"
+node:
+  kind: "crd_list"
+  match:
+    group: core
+    version: v1
+    resource: nodes
+  policies:
+  - path: "./policies/node/k8s_version.rego"
+    name: "node.k8s_version"
+  - path: "./policies/node/porter_run_taints.rego"
+    name: "node.porter_run_taints"
+  - path: "./policies/node/porter_run_labels.rego"
+    name: "node.porter_run_labels"
+  - path: "./policies/node/healthy.rego"
+    name: "node.healthy"
+descheduler:
+  kind: "helm_release"
+  match:
+    name: descheduler
+    namespace: kube-system
+  mustExist: true
+  policies: []
+vpa:
+  kind: "helm_release"
+  match:
+    name: vpa
+    namespace: kube-system
+  mustExist: true
+  policies: []

+ 82 - 0
internal/opa/loader.go

@@ -0,0 +1,82 @@
+package opa
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"path/filepath"
+
+	"github.com/open-policy-agent/opa/rego"
+	"sigs.k8s.io/yaml"
+)
+
+type ConfigFile map[string]ConfigFilePolicyCollection
+
+type ConfigFilePolicyCollection struct {
+	Kind             string             `json:"kind"`
+	Match            MatchParameters    `json:"match"`
+	MustExist        bool               `json:"mustExist"`
+	OverrideSeverity string             `json:"overrideSeverity"`
+	Policies         []ConfigFilePolicy `json:"policies"`
+}
+
+type ConfigFilePolicy struct {
+	Path string
+	Name string
+}
+
+func LoadPolicies(configFilePathDir string) (*KubernetesPolicies, error) {
+	// read and parse the config file
+	fileBytes, err := ioutil.ReadFile(filepath.Join(configFilePathDir, "config.yaml"))
+
+	if err != nil {
+		return nil, err
+	}
+
+	configFile := make(map[string]ConfigFilePolicyCollection)
+
+	err = yaml.Unmarshal(fileBytes, &configFile)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// load each map entry
+	policies := make(map[string]KubernetesOPAQueryCollection)
+
+	for name, cfPolicyCollection := range configFile {
+		queries := make([]rego.PreparedEvalQuery, 0)
+
+		for _, cfPolicy := range cfPolicyCollection.Policies {
+			fileBytes, err := ioutil.ReadFile(filepath.Join(configFilePathDir, cfPolicy.Path))
+
+			if err != nil {
+				return nil, err
+			}
+
+			query, err := rego.New(
+				rego.Query(fmt.Sprintf("data.%s", cfPolicy.Name)),
+				rego.Module(cfPolicy.Name, string(fileBytes)),
+			).PrepareForEval(context.Background())
+
+			if err != nil {
+				// Handle error.
+				return nil, err
+			}
+
+			queries = append(queries, query)
+		}
+
+		policies[name] = KubernetesOPAQueryCollection{
+			Kind:             KubernetesBuiltInKind(cfPolicyCollection.Kind),
+			Queries:          queries,
+			Match:            cfPolicyCollection.Match,
+			OverrideSeverity: cfPolicyCollection.OverrideSeverity,
+			MustExist:        cfPolicyCollection.MustExist,
+		}
+	}
+
+	return &KubernetesPolicies{
+		Policies: policies,
+	}, nil
+}

Деякі файли не було показано, через те що забагато файлів було змінено