Browse Source

merge with master and fix merge conflicts

Alexander Belanger 4 years ago
parent
commit
53d38ea455
69 changed files with 3846 additions and 432 deletions
  1. 127 2
      .github/workflows/prerelease.yaml
  2. 16 0
      api/client/api.go
  3. 20 0
      api/client/registry.go
  4. 4 3
      api/server/authz/cluster.go
  5. 76 0
      api/server/handlers/helmrepo/create.go
  6. 81 0
      api/server/handlers/helmrepo/get_chart.go
  7. 44 0
      api/server/handlers/helmrepo/list.go
  8. 63 0
      api/server/handlers/helmrepo/list_charts.go
  9. 22 5
      api/server/handlers/namespace/clone_env_group.go
  10. 56 0
      api/server/handlers/namespace/stream_job_runs.go
  11. 50 1
      api/server/handlers/release/create_addon.go
  12. 18 2
      api/server/handlers/release/get_jobs.go
  13. 62 0
      api/server/handlers/release/get_latest_job_run.go
  14. 11 6
      api/server/handlers/release/ugprade.go
  15. 1 1
      api/server/handlers/template/list.go
  16. 38 0
      api/server/handlers/user/create.go
  17. 6 0
      api/server/handlers/user/github_callback.go
  18. 6 0
      api/server/handlers/user/google_callback.go
  19. 58 0
      api/server/router/helm_repo.go
  20. 35 1
      api/server/router/namespace.go
  21. 56 0
      api/server/router/project.go
  22. 32 0
      api/server/router/release.go
  23. 2 0
      api/server/shared/config/env/envconfs.go
  24. 6 0
      api/types/helm_repo.go
  25. 9 0
      api/types/namespace.go
  26. 2 0
      api/types/release.go
  27. 2 0
      api/types/template.go
  28. 185 31
      cli/cmd/config.go
  29. 26 0
      cli/cmd/connect.go
  30. 94 0
      cli/cmd/connect/helmrepo.go
  31. 1 1
      cli/cmd/deploy/create.go
  32. 1 1
      cli/cmd/deploy/deploy.go
  33. 31 45
      cli/cmd/docker/agent.go
  34. 48 2
      cli/cmd/get.go
  35. 9 10
      cli/cmd/job.go
  36. 6 0
      cli/cmd/login/server.go
  37. 63 0
      cmd/app/main.go
  38. 35 0
      dashboard/src/assets/command-line-icon.tsx
  39. 133 14
      dashboard/src/components/Table.tsx
  40. 70 42
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  41. 1 0
      dashboard/src/index.html
  42. 119 15
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  43. 610 0
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  44. 5 3
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  45. 38 17
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  46. 52 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ConnectToJobInstructionsModal.tsx
  47. 4 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  48. 65 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  49. 604 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx
  50. 52 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ConnectToLogsInstructionModal.tsx
  51. 85 19
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  52. 67 141
      dashboard/src/main/home/launch/Launch.tsx
  53. 236 0
      dashboard/src/main/home/launch/TemplateList.tsx
  54. 56 23
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  55. 3 4
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  56. 1 1
      dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx
  57. 44 11
      dashboard/src/main/home/modals/Modal.tsx
  58. 29 0
      dashboard/src/shared/api.tsx
  59. 1 0
      dashboard/src/shared/types.tsx
  60. 2 1
      go.mod
  61. 5 0
      go.sum
  62. 11 9
      internal/helm/config.go
  63. 2 1
      internal/helm/loader/loader.go
  64. 10 4
      internal/helm/postrenderer.go
  65. 1 1
      internal/helm/repo/repo.go
  66. 109 0
      internal/kubernetes/agent.go
  67. 16 4
      internal/kubernetes/config.go
  68. 9 8
      internal/models/cluster.go
  69. 4 3
      internal/usage/usage.go

+ 127 - 2
.github/workflows/prerelease.yaml

@@ -250,7 +250,7 @@ jobs:
   release:
     name: Zip binaries, create release and upload assets
     runs-on: ubuntu-latest
-    needs: 
+    needs:
     - notarize
     - build-linux
     steps:
@@ -395,4 +395,129 @@ jobs:
             --build-arg VERSION=${{steps.tag_name.outputs.tag}}
       - name: Push
         run: |
-          docker push public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+          docker push public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+  update-porter-update-action:
+    name: Update porter-update-action
+    runs-on: ubuntu-latest
+    needs: build-push-docker-cli
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Push new branch with updated CLI
+        run: |
+          cd $GITHUB_WORKSPACE
+
+          git clone https://abelanger5:${{ secrets.PORTER_DEV_GITHUB_TOKEN }}@github.com/porter-dev/porter-update-action
+
+          cd porter-update-action
+
+          git checkout -B "${{steps.tag_name.outputs.tag}}"
+
+          cat >Dockerfile <<EOL
+          FROM public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+
+          COPY entrypoint.sh /action/
+
+          ENTRYPOINT ["/action/entrypoint.sh"]
+          EOL
+
+          git config user.name "Update Bot"
+          git config user.email "support@porter.run"
+
+          git add .
+
+          git commit -m "Update to CLI version ${{steps.tag_name.outputs.tag}}"
+
+          git push --set-upstream origin ${{steps.tag_name.outputs.tag}} -f
+  update-porter-cli-action:
+    name: Update porter-cli-action
+    runs-on: ubuntu-latest
+    needs: build-push-docker-cli
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Push new branch with updated CLI
+        run: |
+          cd $GITHUB_WORKSPACE
+
+          git clone https://abelanger5:${{ secrets.PORTER_DEV_GITHUB_TOKEN }}@github.com/porter-dev/porter-cli-action
+
+          cd porter-cli-action
+
+          git checkout -B "${{steps.tag_name.outputs.tag}}"
+
+          cat >Dockerfile <<EOL
+          FROM public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+
+          COPY entrypoint.sh /action/
+
+          ENTRYPOINT ["/action/entrypoint.sh"]
+          EOL
+
+          git config user.name "Update Bot"
+          git config user.email "support@porter.run"
+
+          git add .
+
+          git commit -m "Update to CLI version ${{steps.tag_name.outputs.tag}}"
+
+          git push --set-upstream origin ${{steps.tag_name.outputs.tag}} -f
+  update-new-release-tests:
+    name: Update new-release-tests
+    runs-on: ubuntu-latest
+    needs: [update-porter-update-action, update-porter-cli-action]
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Update new-release-tests
+        run: |
+          cd $GITHUB_WORKSPACE
+
+          git clone https://abelanger5:${{ secrets.PORTER_DEV_GITHUB_TOKEN }}@github.com/porter-dev/new-release-tests
+
+          cd new-release-tests/.github/workflows
+
+          sed -i 's/uses: porter-dev\/porter-update-action.*/uses: porter-dev\/porter-update-action@${{ steps.tag_name.outputs.tag }}/g' porter_test_pack_production.yml
+          sed -i 's/uses: porter-dev\/porter-cli-action.*/uses: porter-dev\/porter-cli-action@${{ steps.tag_name.outputs.tag }}/g' porter_test_pack_production.yml
+
+          sed -i 's/uses: porter-dev\/porter-update-action.*/uses: porter-dev\/porter-update-action@${{ steps.tag_name.outputs.tag }}/g' porter_test_docker_production.yml
+          sed -i 's/uses: porter-dev\/porter-cli-action.*/uses: porter-dev\/porter-cli-action@${{ steps.tag_name.outputs.tag }}/g' porter_test_docker_production.yml
+
+          cd ../..
+
+          git config user.name "Update Bot"
+          git config user.email "support@porter.run"
+
+          git add .
+
+          git diff --quiet --exit-code || git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}"
+
+          git push -f
+  run-new-release-tests-workflows:
+    name: Run new-release-tests Porter workflows
+    runs-on: ubuntu-latest
+    needs: update-new-release-tests
+    steps:
+      - name: Run porter_test_pack_production.yml workflow
+        run: gh workflow run porter_test_pack_production.yml --repo porter-dev/new-release-tests
+        env:
+          GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}
+      - name: Run porter_test_docker_production.yml workflow
+        run: gh workflow run porter_test_docker_production.yml --repo porter-dev/new-release-tests
+        env:
+          GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}

+ 16 - 0
api/client/api.go

@@ -24,6 +24,8 @@ type Client struct {
 	Cookie         *http.Cookie
 	CookieFilePath string
 	Token          string
+
+	cfToken string
 }
 
 // NewClient constructs a new client based on a set of options
@@ -45,6 +47,11 @@ func NewClient(baseURL string, cookieFileName string) *Client {
 		client.Cookie = cookie
 	}
 
+	// look for a cloudflare access token specifically for Porter
+	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
+		client.cfToken = cfToken
+	}
+
 	return client
 }
 
@@ -57,6 +64,11 @@ func NewClientWithToken(baseURL, token string) *Client {
 		},
 	}
 
+	// look for a cloudflare access token specifically for Porter
+	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
+		client.cfToken = cfToken
+	}
+
 	return client
 }
 
@@ -191,6 +203,10 @@ func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (
 		req.AddCookie(c.Cookie)
 	}
 
+	if c.cfToken != "" {
+		req.Header.Set("cf-access-token", c.cfToken)
+	}
+
 	res, err := c.HTTPClient.Do(req)
 
 	if err != nil {

+ 20 - 0
api/client/registry.go

@@ -27,6 +27,26 @@ func (c *Client) CreateRegistry(
 	return resp, err
 }
 
+// CreateRegistry creates a new registry integration
+func (c *Client) CreateHelmRepo(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateHelmRepoRequest,
+) (*types.HelmRepo, error) {
+	resp := &types.HelmRepo{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/helmrepos",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 // ListRegistries returns a list of registries for a project
 func (c *Client) ListRegistries(
 	ctx context.Context,

+ 4 - 3
api/server/authz/cluster.go

@@ -86,9 +86,10 @@ func NewOutOfClusterAgentGetter(config *config.Config) KubernetesAgentGetter {
 
 func (d *OutOfClusterAgentGetter) GetOutOfClusterConfig(cluster *models.Cluster) *kubernetes.OutOfClusterConfig {
 	return &kubernetes.OutOfClusterConfig{
-		Repo:              d.config.Repo,
-		DigitalOceanOAuth: d.config.DOConf,
-		Cluster:           cluster,
+		Repo:                      d.config.Repo,
+		DigitalOceanOAuth:         d.config.DOConf,
+		Cluster:                   cluster,
+		AllowInClusterConnections: d.config.ServerConf.InitInCluster,
 	}
 }
 

+ 76 - 0
api/server/handlers/helmrepo/create.go

@@ -0,0 +1,76 @@
+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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type HelmRepoCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewHelmRepoCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *HelmRepoCreateHandler {
+	return &HelmRepoCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *HelmRepoCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateHelmRepoRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	// if a basic integration is specified, verify that it exists in the project
+	if request.BasicIntegrationID != 0 {
+		_, err := p.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, request.BasicIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("basic integration with id %d not found in project %d", request.BasicIntegrationID, proj.ID),
+				))
+
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	hr := &models.HelmRepo{
+		Name:                   request.Name,
+		ProjectID:              proj.ID,
+		RepoURL:                request.URL,
+		BasicAuthIntegrationID: request.BasicIntegrationID,
+	}
+
+	// handle write to the database
+	hr, err := p.Repo().HelmRepo().CreateHelmRepo(hr)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, hr.ToHelmRepoType())
+}

+ 81 - 0
api/server/handlers/helmrepo/get_chart.go

@@ -0,0 +1,81 @@
+package helmrepo
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"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"
+	"github.com/porter-dev/porter/internal/templater/parser"
+)
+
+type ChartGetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewChartGetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ChartGetHandler {
+	return &ChartGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (t *ChartGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	helmRepo, _ := r.Context().Value(types.HelmRepoScope).(*models.HelmRepo)
+
+	name, _ := requestutils.GetURLParamString(r, types.URLParamTemplateName)
+	version, _ := requestutils.GetURLParamString(r, types.URLParamTemplateVersion)
+
+	// if version passed as latest, pass empty string to loader to get latest
+	if version == "latest" {
+		version = ""
+	}
+
+	chart, err := release.LoadChart(t.Config(), &release.LoadAddonChartOpts{
+		ProjectID:       proj.ID,
+		RepoURL:         helmRepo.RepoURL,
+		TemplateName:    name,
+		TemplateVersion: version,
+	})
+
+	if err != nil {
+		t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	parserDef := &parser.ClientConfigDefault{
+		HelmChart: chart,
+	}
+
+	res := &types.GetTemplateResponse{
+		RepoURL: helmRepo.RepoURL,
+	}
+	res.Metadata = chart.Metadata
+	res.Values = chart.Values
+
+	for _, file := range chart.Files {
+		if strings.Contains(file.Name, "form.yaml") {
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data, "declared", "")
+
+			if err != nil {
+				break
+			}
+
+			res.Form = formYAML
+		} else if strings.Contains(file.Name, "README.md") {
+			res.Markdown = string(file.Data)
+		}
+	}
+
+	t.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/helmrepo/list.go

@@ -0,0 +1,44 @@
+package helmrepo
+
+import (
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type HelmRepoListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewHelmRepoListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *HelmRepoListHandler {
+	return &HelmRepoListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *HelmRepoListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	hrs, err := c.Repo().HelmRepo().ListHelmReposByProjectID(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.HelmRepo, 0)
+
+	for _, hr := range hrs {
+		res = append(res, hr.ToHelmRepoType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 63 - 0
api/server/handlers/helmrepo/list_charts.go

@@ -0,0 +1,63 @@
+package helmrepo
+
+import (
+	"net/http"
+
+	"k8s.io/helm/pkg/repo"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ChartListHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewChartListHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ChartListHandler {
+	return &ChartListHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (t *ChartListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	helmRepo, _ := r.Context().Value(types.HelmRepoScope).(*models.HelmRepo)
+
+	var repoIndex *repo.IndexFile
+	var err error
+
+	if helmRepo.BasicAuthIntegrationID != 0 {
+		// read the basic integration id
+		basic, err := t.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, helmRepo.BasicAuthIntegrationID)
+
+		if err != nil {
+			t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		repoIndex, err = loader.LoadRepoIndex(&loader.BasicAuthClient{
+			Username: string(basic.Username),
+			Password: string(basic.Password),
+		}, helmRepo.RepoURL)
+	} else {
+		repoIndex, err = loader.LoadRepoIndexPublic(helmRepo.RepoURL)
+	}
+
+	if err != nil {
+		t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	charts := loader.RepoIndexToPorterChartList(repoIndex, helmRepo.RepoURL)
+
+	t.WriteResult(w, r, charts)
+}

+ 22 - 5
api/server/handlers/namespace/clone_env_group.go

@@ -2,6 +2,7 @@ package namespace
 
 import (
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,21 +47,37 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, request.Version)
+	cm, _, err := agent.GetLatestVersionedConfigMap(request.Name, namespace)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	secret, _, err := agent.GetLatestVersionedSecret(request.Name, namespace)
+
 	if request.CloneName == "" {
 		request.CloneName = request.Name
 	}
 
+	vars := make(map[string]string)
+	secretVars := make(map[string]string)
+
+	for key, val := range cm.Data {
+		if !strings.Contains(val, "PORTERSECRET") {
+			vars[key] = val
+		}
+	}
+
+	for key, val := range secret.Data {
+		secretVars[key] = string(val)
+	}
+
 	configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
-		Name:      request.CloneName,
-		Namespace: request.Namespace,
-		Variables: envGroup.Variables,
+		Name:            request.CloneName,
+		Namespace:       request.Namespace,
+		Variables:       vars,
+		SecretVariables: secretVars,
 	})
 
 	if err != nil {
@@ -68,7 +85,7 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	envGroup, err = envgroup.ToEnvGroup(configMap)
+	envGroup, err := envgroup.ToEnvGroup(configMap)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 56 - 0
api/server/handlers/namespace/stream_job_runs.go

@@ -0,0 +1,56 @@
+package namespace
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StreamJobRunsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamJobRunsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamJobRunsHandler {
+	return &StreamJobRunsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StreamJobRunsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if strings.ToLower(namespace) == "all" {
+		namespace = ""
+	}
+
+	err = agent.StreamJobs(namespace, "", safeRW)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 50 - 1
api/server/handlers/release/create_addon.go

@@ -15,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
+	"helm.sh/helm/v3/pkg/chart"
 )
 
 type CreateAddonHandler struct {
@@ -35,6 +36,7 @@ func NewCreateAddonHandler(
 
 func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	operationID := oauth.CreateRandomState()
@@ -63,7 +65,12 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		request.TemplateVersion = ""
 	}
 
-	chart, err := loader.LoadChartPublic(request.RepoURL, request.TemplateName, request.TemplateVersion)
+	chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+		ProjectID:       proj.ID,
+		RepoURL:         request.RepoURL,
+		TemplateName:    request.TemplateName,
+		TemplateVersion: request.TemplateVersion,
+	})
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -112,3 +119,45 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		},
 	))
 }
+
+type LoadAddonChartOpts struct {
+	ProjectID                              uint
+	RepoURL, TemplateName, TemplateVersion string
+}
+
+func LoadChart(config *config.Config, opts *LoadAddonChartOpts) (*chart.Chart, error) {
+	// if the chart repo url is one of the specified application/addon charts, just load public
+	if opts.RepoURL == config.ServerConf.DefaultAddonHelmRepoURL || opts.RepoURL == config.ServerConf.DefaultApplicationHelmRepoURL {
+		return loader.LoadChartPublic(opts.RepoURL, opts.TemplateName, opts.TemplateVersion)
+	} else {
+		// load the helm repos in the project
+		hrs, err := config.Repo.HelmRepo().ListHelmReposByProjectID(opts.ProjectID)
+
+		if err != nil {
+			return nil, err
+		}
+
+		for _, hr := range hrs {
+			if hr.RepoURL == opts.RepoURL {
+				if hr.BasicAuthIntegrationID != 0 {
+					// read the basic integration id
+					basic, err := config.Repo.BasicIntegration().ReadBasicIntegration(opts.ProjectID, hr.BasicAuthIntegrationID)
+
+					if err != nil {
+
+						return nil, err
+					}
+
+					return loader.LoadChart(&loader.BasicAuthClient{
+						Username: string(basic.Username),
+						Password: string(basic.Password),
+					}, hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
+				} else {
+					return loader.LoadChartPublic(hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
+				}
+			}
+		}
+	}
+
+	return nil, fmt.Errorf("chart repo not found")
+}

+ 18 - 2
api/server/handlers/release/get_jobs.go

@@ -22,15 +22,22 @@ type GetJobsHandler struct {
 
 func NewGetJobsHandler(
 	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
 ) *GetJobsHandler {
 	return &GetJobsHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
 func (c *GetJobsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetJobsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
 	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	agent, err := c.GetAgent(r, cluster, "")
@@ -40,7 +47,16 @@ func (c *GetJobsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	jobs, err := agent.ListJobsByLabel(helmRelease.Namespace, getJobLabels(helmRelease)...)
+	labels := getJobLabels(helmRelease)
+
+	if request.Revision != 0 {
+		labels = append(labels, kubernetes.Label{
+			Key: "helm.sh/revision",
+			Val: fmt.Sprintf("%d", request.Revision),
+		})
+	}
+
+	jobs, err := agent.ListJobsByLabel(helmRelease.Namespace, labels...)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 62 - 0
api/server/handlers/release/get_latest_job_run.go

@@ -0,0 +1,62 @@
+package release
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type GetLatestJobRunHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetLatestJobRunHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetLatestJobRunHandler {
+	return &GetLatestJobRunHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetLatestJobRunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	jobs, err := agent.ListJobsByLabel(helmRelease.Namespace, getJobLabels(helmRelease)...)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get the most recent job
+	if len(jobs) > 0 {
+		mostRecentJob := jobs[0]
+
+		for _, job := range jobs {
+			createdAt := job.ObjectMeta.CreationTimestamp
+
+			if mostRecentJob.CreationTimestamp.Before(&createdAt) {
+				mostRecentJob = job
+			}
+		}
+
+		c.WriteResult(w, r, mostRecentJob)
+	}
+}

+ 11 - 6
api/server/handlers/release/ugprade.go

@@ -14,7 +14,6 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/slack"
 	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/release"
@@ -94,11 +93,17 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 
-		chart, err := loader.LoadChartPublic(
-			chartRepoURL,
-			helmRelease.Chart.Metadata.Name,
-			request.ChartVersion,
-		)
+		chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+			ProjectID:       cluster.ProjectID,
+			RepoURL:         chartRepoURL,
+			TemplateName:    helmRelease.Chart.Metadata.Name,
+			TemplateVersion: request.ChartVersion,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 1 - 1
api/server/handlers/template/list.go

@@ -47,7 +47,7 @@ func (t *TemplateListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	porterCharts := loader.RepoIndexToPorterChartList(repoIndex)
+	porterCharts := loader.RepoIndexToPorterChartList(repoIndex, repoURL)
 
 	t.WriteResult(w, r, porterCharts)
 }

+ 38 - 0
api/server/handlers/user/create.go

@@ -76,6 +76,13 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	err = addUserToDefaultProject(u.Config(), user)
+
+	if err != nil {
+		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// save the user as authenticated in the session
 	redirect, err := authn.SaveUserAuthenticated(w, r, u.Config(), user)
 
@@ -113,3 +120,34 @@ func doesUserExist(userRepo repository.UserRepository, user *models.User) bool {
 
 	return user != nil && err == nil
 }
+
+// addUserToDefaultProject adds the created user to any default projects if required by
+// config variables.
+func addUserToDefaultProject(config *config.Config, user *models.User) error {
+	if config.ServerConf.InitInCluster {
+		// if this is the first user, add the user to the default project
+		if user.ID == 1 {
+			// read the default project
+			project, err := config.Repo.Project().ReadProject(1)
+
+			if err != nil {
+				return err
+			}
+
+			// create a new Role with the user as the admin
+			_, err = config.Repo.Project().CreateProjectRole(project, &models.Role{
+				Role: types.Role{
+					UserID:    user.ID,
+					ProjectID: project.ID,
+					Kind:      types.RoleAdmin,
+				},
+			})
+
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}

+ 6 - 0
api/server/handlers/user/github_callback.go

@@ -158,6 +158,12 @@ func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User
 				return nil, err
 			}
 
+			err = addUserToDefaultProject(config, user)
+
+			if err != nil {
+				return nil, err
+			}
+
 			config.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 				UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 				Email:               user.Email,

+ 6 - 0
api/server/handlers/user/google_callback.go

@@ -143,6 +143,12 @@ func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*model
 				return nil, err
 			}
 
+			err = addUserToDefaultProject(config, user)
+
+			if err != nil {
+				return nil, err
+			}
+
 			config.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 				UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 				Email:               user.Email,

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

@@ -80,5 +80,63 @@ func getHelmRepoRoutes(
 		Router:   r,
 	})
 
+	//  GET /api/projects/{project_id}/helmrepos/{helm_repo_id}/charts -> helmrepo.NewChartListHandler
+	hrListEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/charts",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.HelmRepoScope,
+			},
+		},
+	)
+
+	hrListHandler := helmrepo.NewChartListHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: hrListEndpoint,
+		Handler:  hrListHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/helmrepos/{helm_repo_id}/charts/{name}/{version} -> helmrepo.NewChartGetHandler
+	chartGetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/charts/{name}/{version}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.HelmRepoScope,
+			},
+		},
+	)
+
+	chartGetHandler := helmrepo.NewChartGetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: chartGetEndpoint,
+		Handler:  chartGetHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 35 - 1
api/server/router/namespace.go

@@ -419,6 +419,40 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/jobs/stream -> namespace.NewStreamJobRunsHandler
+	streamJobRunsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/jobs/stream",
+					relPath,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamJobRunsHandler := namespace.NewStreamJobRunsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: streamJobRunsEndpoint,
+		Handler:  streamJobRunsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pod/{name}/previous_logs
 	getPreviousLogsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -649,7 +683,7 @@ func getNamespaceRoutes(
 	})
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/ingresses/{name} ->
-	// release.NewGetJobsHandler
+	// namespace.NewGetIngressHandler
 	getIngressEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,

+ 56 - 0
api/server/router/project.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
+	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
@@ -890,5 +891,60 @@ func getProjectRoutes(
 	// 	Router:   r,
 	// })
 
+	//  POST /api/projects/{project_id}/helmrepos -> helmrepo.NewHelmRepoCreateHandler
+	hrCreateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/helmrepos",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	hrCreateHandler := helmrepo.NewHelmRepoCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: hrCreateEndpoint,
+		Handler:  hrCreateHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/helmrepos -> helmrepo.NewHelmRepoListHandler
+	hrListEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/helmrepos",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	hrListHandler := helmrepo.NewHelmRepoListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: hrListEndpoint,
+		Handler:  hrListHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 32 - 0
api/server/router/release.go

@@ -681,6 +681,7 @@ func getReleaseRoutes(
 
 	getJobsHandler := release.NewGetJobsHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
@@ -690,6 +691,37 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/latest_job_run ->
+	// release.NewGetLatestJobRunHandler
+	getLatestJobRunEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/latest_job_run",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	getLatestJobRunHandler := release.NewGetLatestJobRunHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getLatestJobRunEndpoint,
+		Handler:  getLatestJobRunHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/jobs/status ->
 	// release.NewGetJobsHandler
 	getJobsStatusEndpoint := factory.NewAPIEndpoint(

+ 2 - 0
api/server/shared/config/env/envconfs.go

@@ -83,6 +83,8 @@ type ServerConf struct {
 	SentryDSN string `env:"SENTRY_DSN"`
 	SentryEnv string `env:"SENTRY_ENV,default=dev"`
 
+	InitInCluster bool `env:"INIT_IN_CLUSTER,default=false"`
+
 	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
 
 	// Token for internal retool to authenticate to internal API endpoints

+ 6 - 0
api/types/helm_repo.go

@@ -13,3 +13,9 @@ type HelmRepo struct {
 }
 
 type GetHelmRepoResponse HelmRepo
+
+type CreateHelmRepoRequest struct {
+	URL                string `json:"url"`
+	Name               string `json:"name" form:"required"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
+}

+ 9 - 0
api/types/namespace.go

@@ -176,3 +176,12 @@ type GetPreviousPodLogsRequest struct {
 type GetPreviousPodLogsResponse struct {
 	PrevLogs []string `json:"previous_logs"`
 }
+
+type GetJobsRequest struct {
+	Revision uint `schema:"revision"`
+}
+
+type GetJobRunsRequest struct {
+	Status string `schema:"status"`
+	Sort   string `schema:"sort"`
+}

+ 2 - 0
api/types/release.go

@@ -51,6 +51,8 @@ type CreateReleaseRequest struct {
 
 type CreateAddonRequest struct {
 	*CreateReleaseBaseRequest
+
+	HelmRepoID uint `json:"helm_repo_id"`
 }
 
 type RollbackReleaseRequest struct {

+ 2 - 0
api/types/template.go

@@ -23,6 +23,7 @@ type PorterTemplateSimple struct {
 	Versions    []string `json:"versions"`
 	Description string   `json:"description"`
 	Icon        string   `json:"icon"`
+	RepoURL     string   `json:"repo_url,omitempty"`
 }
 
 // ListTemplatesResponse is how a chart gets displayed when listed
@@ -38,6 +39,7 @@ type GetTemplateResponse struct {
 	Metadata *chart.Metadata        `json:"metadata"`
 	Values   map[string]interface{} `json:"values"`
 	Form     *FormYAML              `json:"form"`
+	RepoURL  string                 `json:"repo_url,omitempty"`
 }
 
 type GetTemplateUpgradeNotesRequest struct {

+ 185 - 31
cli/cmd/config.go

@@ -1,14 +1,20 @@
 package cmd
 
 import (
+	"context"
 	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
+	"time"
 
+	"github.com/briandowns/spinner"
 	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 
@@ -278,63 +284,87 @@ var configCmd = &cobra.Command{
 
 var configSetProjectCmd = &cobra.Command{
 	Use:   "set-project [id]",
-	Args:  cobra.ExactArgs(1),
+	Args:  cobra.MaximumNArgs(1),
 	Short: "Saves the project id in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		projID, err := strconv.ParseUint(args[0], 10, 64)
+		if len(args) == 0 {
+			err := checkLoginAndRun(args, listAndSetProject)
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
-		}
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			projID, err := strconv.ParseUint(args[0], 10, 64)
 
-		err = config.SetProject(uint(projID))
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
+			err = config.SetProject(uint(projID))
+
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 		}
 	},
 }
 
 var configSetClusterCmd = &cobra.Command{
 	Use:   "set-cluster [id]",
-	Args:  cobra.ExactArgs(1),
+	Args:  cobra.MaximumNArgs(1),
 	Short: "Saves the cluster id in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		clusterID, err := strconv.ParseUint(args[0], 10, 64)
+		if len(args) == 0 {
+			err := checkLoginAndRun(args, listAndSetCluster)
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
-		}
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			clusterID, err := strconv.ParseUint(args[0], 10, 64)
+
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 
-		err = config.SetCluster(uint(clusterID))
+			err = config.SetCluster(uint(clusterID))
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 		}
 	},
 }
 
 var configSetRegistryCmd = &cobra.Command{
 	Use:   "set-registry [id]",
-	Args:  cobra.ExactArgs(1),
+	Args:  cobra.MaximumNArgs(1),
 	Short: "Saves the registry id in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		registryID, err := strconv.ParseUint(args[0], 10, 64)
+		if len(args) == 0 {
+			err := checkLoginAndRun(args, listAndSetRegistry)
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
-		}
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			registryID, err := strconv.ParseUint(args[0], 10, 64)
 
-		err = config.SetRegistry(uint(registryID))
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
+			err = config.SetRegistry(uint(registryID))
+
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 		}
 	},
 }
@@ -391,7 +421,131 @@ func printConfig() error {
 		return err
 	}
 
-	fmt.Printf(string(config))
+	fmt.Println(string(config))
+
+	return nil
+}
+
+func listAndSetProject(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = " Loading list of projects"
+	s.Start()
+
+	resp, err := client.ListUserProjects(context.Background())
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	var projID uint64
+
+	if len(*resp) > 1 {
+		// only give the option to select when more than one option exists
+		projName, err := utils.PromptSelect("Select a project with ID", func() []string {
+			var names []string
+
+			for _, proj := range *resp {
+				names = append(names, fmt.Sprintf("%s - %d", proj.Name, proj.ID))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		projID, _ = strconv.ParseUint(strings.Split(projName, " - ")[1], 10, 64)
+	} else {
+		projID = uint64((*resp)[0].ID)
+	}
+
+	config.SetProject(uint(projID))
+
+	return nil
+}
+
+func listAndSetCluster(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = " Loading list of clusters"
+	s.Start()
+
+	resp, err := client.ListProjectClusters(context.Background(), config.Project)
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	var clusterID uint64
+
+	if len(*resp) > 1 {
+		clusterName, err := utils.PromptSelect("Select a cluster with ID", func() []string {
+			var names []string
+
+			for _, cluster := range *resp {
+				names = append(names, fmt.Sprintf("%s - %d", cluster.Name, cluster.ID))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		clusterID, _ = strconv.ParseUint(strings.Split(clusterName, " - ")[1], 10, 64)
+	} else {
+		clusterID = uint64((*resp)[0].ID)
+	}
+
+	config.SetCluster(uint(clusterID))
+
+	return nil
+}
+
+func listAndSetRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = " Loading list of registries"
+	s.Start()
+
+	resp, err := client.ListRegistries(context.Background(), config.Project)
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	var regID uint64
+
+	if len(*resp) > 1 {
+		regName, err := utils.PromptSelect("Select a registry with ID", func() []string {
+			var names []string
+
+			for _, cluster := range *resp {
+				names = append(names, fmt.Sprintf("%s - %d", cluster.Name, cluster.ID))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		regID, _ = strconv.ParseUint(strings.Split(regName, " - ")[1], 10, 64)
+	} else {
+		regID = uint64((*resp)[0].ID)
+	}
+
+	config.SetRegistry(uint(regID))
 
 	return nil
 }

+ 26 - 0
cli/cmd/connect.go

@@ -68,6 +68,18 @@ var connectRegistryCmd = &cobra.Command{
 	},
 }
 
+var connectHelmRepoCmd = &cobra.Command{
+	Use:   "helm",
+	Short: "Adds a custom Helm registry to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectHelmRepo)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectGCRCmd = &cobra.Command{
 	Use:   "gcr",
 	Short: "Adds a GCR instance to a project",
@@ -116,6 +128,7 @@ func init() {
 	connectCmd.AddCommand(connectDockerhubCmd)
 	connectCmd.AddCommand(connectGCRCmd)
 	connectCmd.AddCommand(connectDOCRCmd)
+	connectCmd.AddCommand(connectHelmRepoCmd)
 }
 
 func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
@@ -204,3 +217,16 @@ func runConnectRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Clien
 
 	return config.SetRegistry(regID)
 }
+
+func runConnectHelmRepo(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+	hrID, err := connect.HelmRepo(
+		client,
+		config.Project,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return config.SetHelmRepo(hrID)
+}

+ 94 - 0
cli/cmd/connect/helmrepo.go

@@ -0,0 +1,94 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	"github.com/porter-dev/porter/api/types"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func HelmRepo(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	repoName, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the name that you would like to give this Helm registry. 
+Name: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	repoURL, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the Helm registry URL, make sure to include the protocol. For example, https://charts.bitnami.com/bitnami.
+Registry URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	if _, err := url.Parse(repoURL); err != nil {
+		return 0, fmt.Errorf("not a valid url: %s", err)
+	}
+
+	username, err := utils.PromptPlaintext(fmt.Sprintf(`Helm repo username (press enter for a public registry):`))
+
+	if err != nil {
+		return 0, err
+	}
+
+	password, err := utils.PromptPassword(`Helm registry password (press enter for a public registry).
+Password:`)
+
+	if err != nil {
+		return 0, err
+	}
+
+	var basicIntegrationID uint = 0
+
+	if username != "" && password != "" {
+		// create the basic auth integration
+		integration, err := client.CreateBasicAuthIntegration(
+			context.Background(),
+			projectID,
+			&types.CreateBasicRequest{
+				Username: username,
+				Password: password,
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created basic auth integration with id %d\n", integration.ID)
+
+		basicIntegrationID = integration.ID
+	}
+
+	reg, err := client.CreateHelmRepo(
+		context.Background(),
+		projectID,
+		&types.CreateHelmRepoRequest{
+			URL:                repoURL,
+			Name:               repoName,
+			BasicIntegrationID: basicIntegrationID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created helm registry integration with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 1 - 1
cli/cmd/deploy/create.go

@@ -273,7 +273,7 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 	}
 
-	imageExists := agent.CheckIfImageExists(fmt.Sprintf("%s:%s", imageURL, imageTag))
+	imageExists := agent.CheckIfImageExists(imageURL, imageTag)
 
 	if imageExists && imageTag != "latest" && !forceBuild {
 		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, imageTag)

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

@@ -140,7 +140,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	err = coalesceEnvGroups(deployAgent.client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
 		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
 
-	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(fmt.Sprintf("%s:%s", deployAgent.imageRepo, deployAgent.tag))
+	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
 
 	return deployAgent, err
 }

+ 31 - 45
cli/cmd/docker/agent.go

@@ -11,6 +11,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/digitalocean/godo"
 	"github.com/docker/distribution/reference"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
@@ -159,17 +160,21 @@ var PullImageErrNotFound = fmt.Errorf("Requested image not found")
 
 var PullImageErrUnauthorized = fmt.Errorf("Could not pull image: unauthorized")
 
-func getRegistryRepositoryPair(image, prefix string) []string {
-	if !strings.HasSuffix(prefix, "/") {
-		prefix = prefix + "/"
+func getRegistryRepositoryPair(imageRepo string) ([]string, error) {
+	named, err := reference.ParseNamed(imageRepo)
+
+	if err != nil {
+		return nil, err
 	}
 
-	return strings.Split(strings.TrimPrefix(strings.Split(image, ":")[0], prefix), "/")
+	path := reference.Path(named)
+
+	return strings.SplitN(path, "/", 2), nil
 }
 
 // CheckIfImageExists checks if the image exists in the registry
-func (a *Agent) CheckIfImageExists(image string) bool {
-	registryToken, err := a.getContainerRegistryToken(image)
+func (a *Agent) CheckIfImageExists(imageRepo, imageTag string) bool {
+	registryToken, err := a.getContainerRegistryToken(imageRepo)
 
 	if err != nil {
 		return false
@@ -178,15 +183,21 @@ func (a *Agent) CheckIfImageExists(image string) bool {
 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
 	defer cancel()
 
-	if strings.Contains(image, "gcr.io") {
-		gcrRegRepo := getRegistryRepositoryPair(image, "gcr.io")
+	if strings.Contains(imageRepo, "gcr.io") {
+		gcrRegRepo, err := getRegistryRepositoryPair(imageRepo)
 
-		if len(gcrRegRepo) != 2 {
+		if err != nil {
+			return false
+		}
+
+		named, err := reference.ParseNamed(imageRepo)
+
+		if err != nil {
 			return false
 		}
 
 		req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(
-			"https://gcr.io/v2/%s/%s/tags/list", gcrRegRepo[0], gcrRegRepo[1],
+			"https://%s/v2/%s/%s/tags/list", reference.Domain(named), gcrRegRepo[0], gcrRegRepo[1],
 		), nil)
 
 		if err != nil {
@@ -214,59 +225,33 @@ func (a *Agent) CheckIfImageExists(image string) bool {
 			return false
 		}
 
-		reqTag := strings.Split(image, ":")[1]
-
 		for _, tag := range tags.Tags {
-			if tag == reqTag {
+			if tag == imageTag {
 				return true
 			}
 		}
 
 		return false
-	} else if strings.Contains(image, "registry.digitalocean.com") {
-		doRegRepo := getRegistryRepositoryPair(image, "registry.digitalocean.com")
-
-		if len(doRegRepo) != 2 {
-			return false
-		}
-
-		req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(
-			"https://api.digitalocean.com/v2/registry/%s/repositories/%s/digests", doRegRepo[0], doRegRepo[1],
-		), nil)
-
-		if err != nil {
-			return false
-		}
-
-		req.Header.Add("Content-Type", "application/json")
-		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", registryToken))
-
-		resp, err := http.DefaultClient.Do(req)
+	} else if strings.Contains(imageRepo, "registry.digitalocean.com") {
+		doRegRepo, err := getRegistryRepositoryPair(imageRepo)
 
 		if err != nil {
 			return false
 		}
 
-		defer resp.Body.Close()
+		doClient := godo.NewFromToken(registryToken)
 
-		// refer https://github.com/digitalocean/godo/blob/main/registry.go#L106
-		var digest struct {
-			Manifests []struct {
-				Tags []string `json:"tags,omitempty"`
-			} `json:"manifests,omitempty"`
-		}
-
-		err = json.NewDecoder(resp.Body).Decode(&digest)
+		manifests, _, err := doClient.Registry.ListRepositoryManifests(
+			ctx, doRegRepo[0], doRegRepo[1], &godo.ListOptions{},
+		)
 
 		if err != nil {
 			return false
 		}
 
-		reqTag := strings.Split(image, ":")[1]
-
-		for _, manifest := range digest.Manifests {
+		for _, manifest := range manifests {
 			for _, tag := range manifest.Tags {
-				if tag == reqTag {
+				if tag == imageTag {
 					return true
 				}
 			}
@@ -275,6 +260,7 @@ func (a *Agent) CheckIfImageExists(image string) bool {
 		return false
 	}
 
+	image := imageRepo + ":" + imageTag
 	encodedRegistryAuth, err := a.getEncodedRegistryAuth(image)
 
 	if err != nil {

+ 48 - 2
cli/cmd/get.go

@@ -28,11 +28,23 @@ var getCmd = &cobra.Command{
 	},
 }
 
+// getValuesCmd represents the "porter get values" command
+var getValuesCmd = &cobra.Command{
+	Use:   "values [release]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Fetches the Helm values for a release.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, getValues)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var output string
 
 func init() {
-	rootCmd.AddCommand(getCmd)
-
 	getCmd.PersistentFlags().StringVar(
 		&namespace,
 		"namespace",
@@ -46,6 +58,10 @@ func init() {
 		"",
 		"the output format to use (\"yaml\" or \"json\")",
 	)
+
+	getCmd.AddCommand(getValuesCmd)
+
+	rootCmd.AddCommand(getCmd)
 }
 
 type getReleaseInfo struct {
@@ -94,3 +110,33 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 
 	return nil
 }
+
+func getValues(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	rel, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, args[0])
+
+	if err != nil {
+		return err
+	}
+
+	values := rel.Config
+
+	if output == "json" {
+		bytes, err := json.Marshal(values)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(string(bytes))
+	} else { // yaml is the default
+		bytes, err := yaml.Marshal(values)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(string(bytes))
+	}
+
+	return nil
+}

+ 9 - 10
cli/cmd/job.go

@@ -22,17 +22,17 @@ var batchImageUpdateCmd = &cobra.Command{
 	Use:   "update-images",
 	Short: "Updates the image tag of all jobs in a namespace which use a specific image.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Updates the image tag of all jobs in a namespace which use a specific image. Note that for all
 jobs with version <= v0.4.0, this will trigger a new run of a manual job. However, for versions
->= v0.5.0, this will not create a new run of the job. 
+>= v0.5.0, this will not create a new run of the job.
 
 Example commands:
 
   %s
 
-This command is namespace-scoped and uses the default namespace. To specify a different namespace, 
+This command is namespace-scoped and uses the default namespace. To specify a different namespace,
 use the --namespace flag:
 
   %s
@@ -54,16 +54,16 @@ var waitCmd = &cobra.Command{
 	Use:   "wait",
 	Short: "Waits for a job to complete.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Waits for a job with a given name and namespace to complete a run. If the job completes successfully,
-this command exits with exit code 0. Otherwise, this command exits with exit code 1. 
+this command exits with exit code 0. Otherwise, this command exits with exit code 1.
 
 Example commands:
 
   %s
 
-This command is namespace-scoped and uses the default namespace. To specify a different namespace, 
+This command is namespace-scoped and uses the default namespace. To specify a different namespace,
 use the --namespace flag:
 
   %s
@@ -166,10 +166,10 @@ func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		return pausedErr
 	}
 
-	// if no job exists with the given revision, wait up to 5 minutes
-	timeWait := time.Now().Add(5 * time.Minute)
+	// if no job exists with the given revision, wait up to 30 minutes
+	timeWait := time.Now().Add(30 * time.Minute)
 
-	for timeNow := time.Now(); timeNow.Before(timeWait); {
+	for time.Now().Before(timeWait) {
 		// get the jobs for that job chart
 		jobs, err := client.GetJobs(context.Background(), config.Project, config.Cluster, namespace, name)
 
@@ -196,7 +196,6 @@ func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 
 		// otherwise, return no error
 		time.Sleep(10 * time.Second)
-		continue
 	}
 
 	return fmt.Errorf("timed out waiting for job")

+ 6 - 0
cli/cmd/login/server.go

@@ -6,6 +6,7 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"os"
 	"strings"
 	"time"
 
@@ -99,6 +100,11 @@ func ExchangeToken(host, code string) (string, error) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	req.Header.Set("Accept", "application/json; charset=utf-8")
 
+	// look for a cloudflare access token specifically for Porter
+	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
+		req.Header.Set("cf-access-token", cfToken)
+	}
+
 	client := &http.Client{
 		Timeout: time.Minute,
 	}

+ 63 - 0
cmd/app/main.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"flag"
 	"fmt"
 	"log"
@@ -8,7 +9,10 @@ import (
 	"os"
 
 	"github.com/porter-dev/porter/api/server/router"
+	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
 )
 
 // Version will be linked by an ldflag during build
@@ -33,6 +37,12 @@ func main() {
 		log.Fatal("Config loading failed: ", err)
 	}
 
+	err = initData(config)
+
+	if err != nil {
+		log.Fatal("Data initialization failed: ", err)
+	}
+
 	appRouter := router.NewAPIRouter(config)
 
 	address := fmt.Sprintf(":%d", config.ServerConf.Port)
@@ -51,3 +61,56 @@ func main() {
 		config.Logger.Fatal().Err(err).Msg("Server startup failed")
 	}
 }
+
+const defaultProjectName = "default"
+const defaultClusterName = "cluster-1"
+
+func initData(conf *config.Config) error {
+	// if the config specifies in-cluster connections are permitted, create a new project with a
+	// cluster that uses the in-cluster config. this will be the default project for this instance.
+	if conf.ServerConf.InitInCluster {
+		l := conf.Logger
+		l.Debug().Msg("in-cluster config variable set: checking for default project and cluster")
+
+		// look for a project with id 1 with name of defaultProjectName
+		_, err := conf.Repo.Project().ReadProject(1)
+
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			l.Debug().Msg("default project not found: attempting creation")
+
+			_, err = conf.Repo.Project().CreateProject(&models.Project{
+				Name: defaultProjectName,
+			})
+
+			if err != nil {
+				return err
+			}
+
+			l.Debug().Msg("successfully created default project")
+		} else if err != nil {
+			return err
+		}
+
+		_, err = conf.Repo.Cluster().ReadCluster(1, 1)
+
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			l.Debug().Msg("default cluster not found: attempting creation")
+
+			_, err = conf.Repo.Cluster().CreateCluster(&models.Cluster{
+				Name:          defaultClusterName,
+				AuthMechanism: models.InCluster,
+				ProjectID:     1,
+			})
+
+			if err != nil {
+				return err
+			}
+
+			l.Debug().Msg("successfully created default cluster")
+		} else if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 35 - 0
dashboard/src/assets/command-line-icon.tsx

@@ -0,0 +1,35 @@
+import React, { SVGProps } from "react";
+import styled from "styled-components";
+
+function CommandLineIcon(props: SVGProps<SVGElement>) {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      x="0"
+      y="0"
+      version="1.1"
+      viewBox="0 0 24 24"
+      xmlSpace="preserve"
+      className={props.className}
+      onClick={props.onClick}
+    >
+      <linearGradient
+        x1="825.344"
+        x2="825.344"
+        y1="-528.502"
+        y2="-529.502"
+        gradientUnits="userSpaceOnUse"
+      >
+        <stop offset="0" stopColor="#656565"></stop>
+        <stop offset="0.618" stopColor="#1b1b1b"></stop>
+        <stop offset="0.629" stopColor="#545454"></stop>
+        <stop offset="0.983" stopColor="#3e3e3e"></stop>
+      </linearGradient>
+      <path d="M3.2 17.3L2 15.9c-.2-.2-.2-.6.1-.7l5.4-4.5c.3-.2.3-.6 0-.8L2 5.4c-.2-.2-.2-.5 0-.8l1.2-1.5c.2-.1.5-.2.7 0l7.6 6.3c.5.4.5 1.3 0 1.7l-7.6 6.3c-.2.2-.5.2-.7-.1zM21.6 21H9.4c-.3 0-.6-.2-.6-.5v-1.9c0-.3.2-.5.6-.5h12.2c.3 0 .6.2.6.5v1.9c-.1.3-.3.5-.6.5z"></path>
+    </svg>
+  );
+}
+
+export default CommandLineIcon;
+
+const SVG = styled.svg``;

+ 133 - 14
dashboard/src/components/Table.tsx

@@ -1,7 +1,14 @@
-import React from "react";
+import React, { useEffect } from "react";
 import styled from "styled-components";
-import { Column, Row, useGlobalFilter, useTable } from "react-table";
+import {
+  Column,
+  Row,
+  useGlobalFilter,
+  usePagination,
+  useTable,
+} from "react-table";
 import Loading from "components/Loading";
+import Selector from "./Selector";
 
 const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
   const [value, setValue] = React.useState("");
@@ -31,8 +38,11 @@ export type TableProps = {
   isLoading: boolean;
   disableGlobalFilter?: boolean;
   disableHover?: boolean;
+  enablePagination?: boolean;
 };
 
+const MIN_PAGE_SIZE = 1;
+
 const Table: React.FC<TableProps> = ({
   columns: columnsData,
   data,
@@ -40,23 +50,42 @@ const Table: React.FC<TableProps> = ({
   isLoading,
   disableGlobalFilter = false,
   disableHover,
+  enablePagination,
 }) => {
   const {
     getTableProps,
     getTableBodyProps,
-    rows,
+    page,
     setGlobalFilter,
     prepareRow,
     headerGroups,
     visibleColumns,
+
+    // Pagination options
+    canPreviousPage,
+    canNextPage,
+    pageOptions,
+    pageCount,
+    gotoPage,
+    nextPage,
+    previousPage,
+    setPageSize,
+    state: { pageIndex, pageSize },
   } = useTable(
     {
       columns: columnsData,
       data,
     },
-    useGlobalFilter
+    useGlobalFilter,
+    usePagination
   );
 
+  useEffect(() => {
+    if (!enablePagination) {
+      setPageSize(data.length || MIN_PAGE_SIZE);
+    }
+  }, [data, enablePagination]);
+
   const renderRows = () => {
     if (isLoading) {
       return (
@@ -68,7 +97,7 @@ const Table: React.FC<TableProps> = ({
       );
     }
 
-    if (!rows.length) {
+    if (!page.length) {
       return (
         <StyledTr disableHover={true} selected={false}>
           <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
@@ -77,7 +106,7 @@ const Table: React.FC<TableProps> = ({
     }
     return (
       <>
-        {rows.map((row) => {
+        {page.map((row) => {
           prepareRow(row);
 
           return (
@@ -89,14 +118,18 @@ const Table: React.FC<TableProps> = ({
               selected={false}
             >
               {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
-              {row.cells.map((cell) => (
-                <StyledTd
-                  {...cell.getCellProps()}
-                  width={cell.column.totalWidth}
-                >
-                  {cell.render("Cell")}
-                </StyledTd>
-              ))}
+              {row.cells.map((cell) => {
+                return (
+                  <StyledTd
+                    {...cell.getCellProps()}
+                    style={{
+                      width: cell.column.totalWidth,
+                    }}
+                  >
+                    {cell.render("Cell")}
+                  </StyledTd>
+                );
+              })}
             </StyledTr>
           );
         })}
@@ -126,6 +159,50 @@ const Table: React.FC<TableProps> = ({
         </StyledTHead>
         <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
       </StyledTable>
+      {enablePagination && (
+        <FlexEnd style={{ marginTop: "15px" }}>
+          <PageCountWrapper>
+            Page size:
+            <Selector
+              activeValue={String(pageSize)}
+              options={[
+                {
+                  label: "10",
+                  value: "10",
+                },
+                {
+                  label: "20",
+                  value: "20",
+                },
+                {
+                  label: "50",
+                  value: "50",
+                },
+                {
+                  label: "100",
+                  value: "100",
+                },
+              ]}
+              setActiveValue={(val) => setPageSize(Number(val))}
+              width="70px"
+            ></Selector>
+          </PageCountWrapper>
+          <PaginationActionsWrapper>
+            <PaginationAction
+              disabled={!canPreviousPage}
+              onClick={previousPage}
+            >
+              {"<"}
+            </PaginationAction>
+            <PageCounter>
+              {pageIndex + 1} of {pageCount}
+            </PageCounter>
+            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+              {">"}
+            </PaginationAction>
+          </PaginationActionsWrapper>
+        </FlexEnd>
+      )}
     </TableWrapper>
   );
 };
@@ -136,6 +213,47 @@ const TableWrapper = styled.div`
   padding-bottom: 20px;
 `;
 
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
+  }
+
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
+
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
+
 type StyledTrProps = {
   enablePointer?: boolean;
   disableHover?: boolean;
@@ -169,6 +287,7 @@ export const StyledTHead = styled.thead`
   width: 100%;
   border-top: 1px solid #aaaabb22;
   border-bottom: 1px solid #aaaabb22;
+  position: sticky;
 `;
 
 export const StyledTh = styled.th`

+ 70 - 42
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -485,52 +485,80 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   }
 
-  let obj = {
-    normal: {},
-  } as any;
-  const rg = /(?:^|[^\\])(\\n)/g;
-  const fixNewlines = (s: string) => {
-    while (rg.test(s)) {
-      s = s.replace(rg, (str) => {
-        if (str.length == 2) return "\n";
-        if (str[0] != "\\") return str[0] + "\n";
-        return "\\n";
-      });
-    }
-    return s;
-  };
-  const isNumber = (s: string) => {
-    return !isNaN(!s ? NaN : Number(String(s).trim()));
-  };
-  state.values.forEach((entry: any, i: number) => {
-    if (isNumber(entry.value)) {
-      obj.normal[entry.key] = entry.value;
-    } else {
-      obj.normal[entry.key] = fixNewlines(entry.value);
+  if (props.variable.includes("env")) {
+    let obj = {
+      normal: {},
+    } as any;
+    const rg = /(?:^|[^\\])(\\n)/g;
+    const fixNewlines = (s: string) => {
+      while (rg.test(s)) {
+        s = s.replace(rg, (str) => {
+          if (str.length == 2) return "\n";
+          if (str[0] != "\\") return str[0] + "\n";
+          return "\\n";
+        });
+      }
+      return s;
+    };
+    const isNumber = (s: string) => {
+      return !isNaN(!s ? NaN : Number(String(s).trim()));
+    };
+    state.values.forEach((entry: any, i: number) => {
+      if (isNumber(entry.value)) {
+        obj.normal[entry.key] = entry.value;
+      } else {
+        obj.normal[entry.key] = fixNewlines(entry.value);
+      }
+    });
+
+    if (state.synced_env_groups?.length) {
+      obj.synced = state.synced_env_groups.map((envGroup) => ({
+        name: envGroup?.name,
+        version: envGroup?.version,
+        keys: Object.entries(envGroup?.variables || {}).map(([key, val]) => ({
+          name: key,
+          secret: val.includes("PORTERSECRET"),
+        })),
+      }));
     }
-  });
-
-  if (state.synced_env_groups?.length) {
-    obj.synced = state.synced_env_groups.map((envGroup) => ({
-      name: envGroup?.name,
-      version: envGroup?.version,
-      keys: Object.entries(envGroup?.variables || {}).map(([key, val]) => ({
-        name: key,
-        secret: val.includes("PORTERSECRET"),
-      })),
-    }));
-  }
 
-  const variableContent = props.variable.split(".");
-  let variable = props.variable;
+    const variableContent = props.variable.split(".");
+    let variable = props.variable;
 
-  if (variable.includes("normal")) {
-    variable = `${variableContent[0]}.${variableContent[1]}`;
-  }
+    if (variable.includes("normal")) {
+      variable = `${variableContent[0]}.${variableContent[1]}`;
+    }
 
-  return {
-    [variable]: obj,
-  };
+    return {
+      [variable]: obj,
+    };
+  } else {
+    let obj = {} as any;
+    const rg = /(?:^|[^\\])(\\n)/g;
+    const fixNewlines = (s: string) => {
+      while (rg.test(s)) {
+        s = s.replace(rg, (str) => {
+          if (str.length == 2) return "\n";
+          if (str[0] != "\\") return str[0] + "\n";
+          return "\\n";
+        });
+      }
+      return s;
+    };
+    const isNumber = (s: string) => {
+      return !isNaN(!s ? NaN : Number(String(s).trim()));
+    };
+    state.values.forEach((entry: any, i: number) => {
+      if (isNumber(entry.value)) {
+        obj[entry.key] = entry.value;
+      } else {
+        obj[entry.key] = fixNewlines(entry.value);
+      }
+    });
+    return {
+      [props.variable]: obj,
+    };
+  }
 };
 
 export default KeyValueArray;

+ 1 - 0
dashboard/src/index.html

@@ -181,5 +181,6 @@
   </head>
   <body>
     <div id="output"></div>
+    <div id="modal-root"></div>
   </body>
 </html>

+ 119 - 15
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -28,6 +28,10 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import LastRunStatusSelector from "./LastRunStatusSelector";
 import loadable from "@loadable/component";
 import Loading from "components/Loading";
+import JobRunTable from "./chart/JobRunTable";
+import SwitchBase from "@material-ui/core/internal/SwitchBase";
+import Selector from "components/Selector";
+import TabSelector from "components/TabSelector";
 
 // @ts-ignore
 const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
@@ -47,6 +51,7 @@ type StateType = {
   lastRunStatus: JobStatusType | null;
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
+  showRuns: boolean;
 };
 
 // TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
@@ -59,6 +64,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     lastRunStatus: "all" as null,
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
+    showRuns: false,
   };
 
   componentDidMount() {
@@ -130,7 +136,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
   };
 
-  renderBody = () => {
+  renderBodyForApps = () => {
     let { currentCluster, currentView } = this.props;
     const isAuthorizedToAdd = this.props.isAuthorized(
       "namespace",
@@ -186,25 +192,104 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
   };
 
-  renderContents = () => {
-    let { currentCluster, setSidebar, currentView } = this.props;
-    if (currentView === "env-groups") {
-      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-    }
-
+  renderBodyForJobs = () => {
+    let { currentCluster, currentView } = this.props;
+    const isAuthorizedToAdd = this.props.isAuthorized(
+      "namespace",
+      [],
+      ["get", "create"]
+    );
     return (
       <>
-        <DashboardHeader
-          image={currentView === "jobs" ? monojob : monoweb}
-          title={currentView}
-          description={this.getDescription(currentView)}
+        <TabSelector
+          currentTab={this.state.showRuns ? "job_runs" : "chart_list"}
+          options={[
+            { label: "Jobs", value: "chart_list" },
+            { label: "Runs", value: "job_runs" },
+          ]}
+          setCurrentTab={(value) => {
+            if (value === "job_runs") {
+              this.setState({ showRuns: true });
+            } else {
+              this.setState({ showRuns: false });
+            }
+          }}
         />
-        {this.renderBody()}
+        <ControlRow style={{ marginTop: "35px" }}>
+          {isAuthorizedToAdd && (
+            <Button
+              onClick={() =>
+                pushFiltered(this.props, "/launch", ["project_id"])
+              }
+            >
+              <i className="material-icons">add</i> Launch Template
+            </Button>
+          )}
+          <SortFilterWrapper>
+            {currentView === "jobs" && (
+              <LastRunStatusSelector
+                lastRunStatus={this.state.lastRunStatus}
+                setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                  this.setState({ lastRunStatus });
+                }}
+              />
+            )}
+            <NamespaceSelector
+              setNamespace={(namespace) =>
+                this.setState({ namespace }, () => {
+                  pushQueryParams(this.props, {
+                    namespace: this.state.namespace || "ALL",
+                  });
+                })
+              }
+              namespace={this.state.namespace}
+            />
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
+          </SortFilterWrapper>
+        </ControlRow>
+        <HidableElement show={this.state.showRuns}>
+          <JobRunTable
+            lastRunStatus={this.state.lastRunStatus}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType as any}
+          />
+        </HidableElement>
+        <HidableElement show={!this.state.showRuns}>
+          <ChartList
+            currentView={currentView}
+            currentCluster={currentCluster}
+            lastRunStatus={this.state.lastRunStatus}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType}
+          />
+        </HidableElement>
       </>
     );
   };
 
+  // renderContents = () => {
+  //   let { currentCluster, setSidebar, currentView } = this.props;
+  //   if (currentView === "env-groups") {
+  //     return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
+  //   }
+
+  //   return (
+  //     <>
+  //       <DashboardHeader
+  //         image={currentView === "jobs" ? monojob : monoweb}
+  //         title={currentView}
+  //         description={this.getDescription(currentView)}
+  //       />
+  //       {this.renderBody()}
+  //     </>
+  //   );
+  // };
+
   render() {
+    let { currentView } = this.props;
     let { setSidebar } = this.props;
     return (
       <Switch>
@@ -220,7 +305,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {this.renderContents()}
+          <DashboardHeader
+            image={monojob}
+            title={currentView}
+            description={this.getDescription(currentView)}
+            disableLineBreak
+          />
+
+          {this.renderBodyForJobs()}
         </GuardedRoute>
         <GuardedRoute
           path={"/applications"}
@@ -228,7 +320,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {this.renderContents()}
+          {/* {this.renderContents()} */}
+          <DashboardHeader
+            image={monoweb}
+            title={currentView}
+            description={this.getDescription(currentView)}
+          />
+
+          {this.renderBodyForApps()}
         </GuardedRoute>
         <GuardedRoute
           path={"/env-groups"}
@@ -236,7 +335,8 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {this.renderContents()}
+          {/* {this.renderContents()} */}
+          <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
         <Route path={"/databases"}>
           <LazyDatabasesRoutes />
@@ -253,6 +353,10 @@ ClusterDashboard.contextType = Context;
 
 export default withRouter(withAuth(ClusterDashboard));
 
+const HidableElement = styled.div<{ show: boolean }>`
+  display: ${(props) => (props.show ? "unset" : "none")};
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 1px;

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

@@ -0,0 +1,610 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { CellProps, Column, Row } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+
+type Props = {
+  lastRunStatus: "failed" | "succeeded" | "active" | "all";
+  namespace: string;
+  sortType: "Newest" | "Oldest" | "Alphabetical";
+};
+
+const dateFormatter = (date: string) => {
+  if (!date) {
+    return "N/A";
+  }
+
+  // @ts-ignore
+  const rtf = new Intl.RelativeTimeFormat("en", {
+    localeMatcher: "best fit", // other values: "lookup"
+    numeric: "auto", // other values: "auto"
+    style: "long", // other values: "short" or "narrow"
+  });
+
+  const time = timeFrom(date);
+  if (!time) {
+    return "N/A";
+  }
+
+  return rtf.format(-time.time, time.unitOfTime);
+};
+
+const runnedFor = (start: string | number, end?: string | number) => {
+  const duration = timeFrom(start, end);
+
+  const unit =
+    duration.time === 1
+      ? duration.unitOfTime.substring(0, duration.unitOfTime.length - 1)
+      : duration.unitOfTime;
+
+  return `${duration.time} ${unit}`;
+};
+
+function timeFrom(time: string | number, secondTime?: string | number) {
+  // Get timestamps
+  let unixTime = new Date(time).getTime();
+  if (!unixTime) return;
+
+  let now = new Date().getTime();
+
+  if (secondTime) {
+    now = new Date(secondTime).getTime();
+  }
+
+  // Calculate difference
+  let difference = unixTime / 1000 - now / 1000;
+
+  // Setup return object
+  let tfn: any = {};
+
+  // Check if time is in the past, present, or future
+  tfn.when = "now";
+  if (difference > 0) {
+    tfn.when = "future";
+  } else if (difference < -1) {
+    tfn.when = "past";
+  }
+
+  // Convert difference to absolute
+  difference = Math.abs(difference);
+
+  // Calculate time unit
+  if (difference / (60 * 60 * 24 * 365) > 1) {
+    // Years
+    tfn.unitOfTime = "years";
+    tfn.time = Math.floor(difference / (60 * 60 * 24 * 365));
+  } else if (difference / (60 * 60 * 24 * 45) > 1) {
+    // Months
+    tfn.unitOfTime = "months";
+    tfn.time = Math.floor(difference / (60 * 60 * 24 * 45));
+  } else if (difference / (60 * 60 * 24) > 1) {
+    // Days
+    tfn.unitOfTime = "days";
+    tfn.time = Math.floor(difference / (60 * 60 * 24));
+  } else if (difference / (60 * 60) > 1) {
+    // Hours
+    tfn.unitOfTime = "hours";
+    tfn.time = Math.floor(difference / (60 * 60));
+  } else if (difference / 60 > 1) {
+    // Minutes
+    tfn.unitOfTime = "minutes";
+    tfn.time = Math.floor(difference / 60);
+  } else {
+    // Seconds
+    tfn.unitOfTime = "seconds";
+    tfn.time = Math.floor(difference);
+  }
+
+  // Return time from now data
+  return tfn;
+}
+
+const JobRunTable: React.FC<Props> = ({
+  lastRunStatus,
+  namespace,
+  sortType,
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [jobRuns, setJobRuns] = useState<JobRun[]>(null);
+  const [hasError, setHasError] = useState(false);
+  const tmpJobRuns = useRef([]);
+  const lastStreamStatus = useRef("");
+  const { openWebsocket, newWebsocket, closeAllWebsockets } = useWebsockets();
+
+  const getJobRuns = () => {
+    closeAllWebsockets();
+    tmpJobRuns.current = [];
+    lastStreamStatus.current = "";
+    setJobRuns(null);
+    setHasError(false);
+    const websocketId = `job-runs-for-all-charts-ws`;
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/jobs/stream`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (message) => {
+        const data = JSON.parse(message.data);
+
+        if (data.streamStatus === "finished") {
+          setHasError(false);
+          setJobRuns(tmpJobRuns.current);
+          lastStreamStatus.current = data.streamStatus;
+          return;
+        }
+
+        if (data.streamStatus === "errored") {
+          setHasError(true);
+          tmpJobRuns.current = [];
+          setJobRuns([]);
+          return;
+        }
+
+        tmpJobRuns.current = [...tmpJobRuns.current, data];
+      },
+      onclose: (event) => {
+        console.log(event);
+        closeAllWebsockets();
+      },
+      onerror: (error) => {
+        setHasError(true);
+        console.log(error);
+        closeAllWebsockets();
+      },
+    };
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+  };
+
+  useEffect(() => {
+    if (!namespace) {
+      return;
+    }
+
+    getJobRuns();
+  }, [currentCluster, currentProject, namespace]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  const columns = useMemo<Column<JobRun>[]>(
+    () => [
+      {
+        Header: "Namespace / Name",
+        accessor: (originalRow) => {
+          const owners = originalRow.metadata.ownerReferences;
+          let name = "N/A";
+          if (Array.isArray(owners)) {
+            name = owners[0]?.name;
+          }
+          if (originalRow?.metadata?.labels["meta.helm.sh/release-name"]) {
+            name = originalRow.metadata.labels["meta.helm.sh/release-name"];
+          }
+
+          if (name !== "N/A") {
+            return originalRow.metadata?.namespace + "/" + name;
+          }
+
+          return name;
+        },
+        width: "max-content",
+      },
+      {
+        Header: "Run at",
+        accessor: (originalRow) => dateFormatter(originalRow.status.startTime),
+      },
+      {
+        Header: "Run for",
+        accessor: (originalRow) => {
+          if (originalRow.status?.completionTime) {
+            return originalRow.status?.completionTime;
+          } else if (
+            Array.isArray(originalRow.status?.conditions) &&
+            originalRow.status?.conditions[0]?.lastTransitionTime
+          ) {
+            return originalRow.status?.conditions[0]?.lastTransitionTime;
+          } else {
+            return "Still running...";
+          }
+        },
+        Cell: ({ row }: CellProps<JobRun>) => {
+          if (row.original.status?.completionTime) {
+            return runnedFor(
+              row.original.status?.startTime,
+              row.original.status?.completionTime
+            );
+          } else if (
+            Array.isArray(row.original.status?.conditions) &&
+            row.original.status?.conditions[0]?.lastTransitionTime
+          ) {
+            return runnedFor(
+              row.original.status?.startTime,
+              row.original.status?.conditions[0]?.lastTransitionTime
+            );
+          } else {
+            return "Still running...";
+          }
+        },
+        styles: {
+          padding: "10px",
+        },
+      },
+      {
+        Header: "Status",
+        id: "status",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          if (row.original.status?.succeeded >= 1) {
+            return <Status color="#38a88a">Succeeded</Status>;
+          }
+
+          if (row.original.status?.failed >= 1) {
+            return <Status color="#cc3d42">Failed</Status>;
+          }
+
+          return <Status color="#ffffff11">Running</Status>;
+        },
+      },
+      {
+        Header: "Commit/Image tag",
+        id: "commit_or_image_tag",
+        accessor: (originalRow) => {
+          const container = originalRow.spec?.template?.spec?.containers[0];
+          return container?.image?.split(":")[1] || "N/A";
+        },
+        Cell: ({ row }: CellProps<JobRun>) => {
+          const container = row.original.spec?.template?.spec?.containers[0];
+
+          const tag = container?.image?.split(":")[1];
+          return tag;
+        },
+      },
+      {
+        Header: "Command",
+        id: "command",
+        accessor: (originalRow) => {
+          const container = originalRow.spec?.template?.spec?.containers[0];
+          return container?.command?.join(" ") || "N/A";
+        },
+        Cell: ({ row }: CellProps<JobRun>) => {
+          const container = row.original.spec?.template?.spec?.containers[0];
+
+          return (
+            <CommandString>
+              {container?.command?.join(" ") || "N/A"}
+            </CommandString>
+          );
+        },
+      },
+      {
+        id: "expand",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          /**
+           * project_id: currentProject.id,
+          chart_revision: 0,
+          job: row.original?.metadata?.name,
+           */
+          const urlParams = new URLSearchParams();
+          urlParams.append("project_id", String(currentProject.id));
+          urlParams.append("chart_revision", String(0));
+          urlParams.append("job", row.original.metadata.name);
+
+          return (
+            <RedirectButton
+              to={{
+                pathname: `/jobs/${currentCluster.name}/${row.original?.metadata?.namespace}/${row.original?.metadata?.labels["meta.helm.sh/release-name"]}`,
+                search: urlParams.toString(),
+              }}
+            >
+              <i className="material-icons">open_in_new</i>
+            </RedirectButton>
+          );
+        },
+        maxWidth: 40,
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    if (jobRuns === null) {
+      return [];
+    }
+    let tmp = [...tmpJobRuns.current];
+    const filter = new JobRunsFilter(tmp);
+    switch (lastRunStatus) {
+      case "active":
+        tmp = filter.filterByActive();
+        break;
+      case "failed":
+        tmp = filter.filterByFailed();
+        break;
+      case "succeeded":
+        tmp = filter.filterBySucceded();
+        break;
+      default:
+        tmp = filter.dontFilter();
+        break;
+    }
+
+    const sorter = new JobRunsSorter(tmp);
+    switch (sortType) {
+      case "Alphabetical":
+        tmp = sorter.sortByAlphabetical();
+        break;
+      case "Newest":
+        tmp = sorter.sortByNewest();
+        break;
+      case "Oldest":
+        tmp = sorter.sortByOldest();
+        break;
+      default:
+        break;
+    }
+
+    return tmp;
+  }, [jobRuns, lastRunStatus, sortType]);
+
+  if (hasError && lastStreamStatus.current !== "finished") {
+    return (
+      <ErrorWrapper>
+        Couldn't retrieve jobs, please try again.{" "}
+        <RetryButton onClick={() => getJobRuns()}>Retry</RetryButton>
+      </ErrorWrapper>
+    );
+  }
+
+  if (jobRuns === null) {
+    return <Loading />;
+  }
+
+  if (!jobRuns?.length) {
+    return <>No job runs found</>;
+  }
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={jobRuns === null}
+      enablePagination
+    />
+  );
+};
+
+export default JobRunTable;
+
+const RetryButton = styled.button`
+  margin-left: 10px;
+  border: none;
+  background: #5460c6;
+  color: white;
+  padding: 5px 10px;
+  border-radius: 25px;
+  min-height: 35px;
+  min-width: 65px;
+  cursor: pointer;
+`;
+
+const ErrorWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 300px;
+  width: 100%;
+  color: #ffffff88;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  background: ${(props) => props.color};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: min-content;
+  height: 25px;
+  min-width: 90px;
+`;
+
+const CommandString = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 300px;
+  color: #ffffff55;
+  margin-right: 27px;
+  font-family: monospace;
+`;
+
+const RedirectButton = styled(DynamicLink)`
+  user-select: none;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+type JobRun = {
+  metadata: {
+    name: string;
+    namespace: string;
+    selfLink: string;
+    uid: string;
+    resourceVersion: string;
+    creationTimestamp: string;
+    labels: {
+      [key: string]: string;
+      "app.kubernetes.io/instance": string;
+      "app.kubernetes.io/managed-by": string;
+      "app.kubernetes.io/version": string;
+      "helm.sh/chart": string;
+      "helm.sh/revision": string;
+      "meta.helm.sh/release-name": string;
+    };
+    ownerReferences: {
+      apiVersion: string;
+      kind: string;
+      name: string;
+      uid: string;
+      controller: boolean;
+      blockOwnerDeletion: boolean;
+    }[];
+    managedFields: unknown[];
+  };
+  spec: {
+    [key: string]: unknown;
+    parallelism: number;
+    completions: number;
+    backOffLimit?: number;
+    selector: {
+      [key: string]: unknown;
+      matchLabels: {
+        [key: string]: unknown;
+        "controller-uid": string;
+      };
+    };
+    template: {
+      [key: string]: unknown;
+      metadata: {
+        creationTimestamp: string | null;
+        labels: {
+          [key: string]: unknown;
+          "controller-uid": string;
+          "job-name": string;
+        };
+      };
+      spec: {
+        containers: {
+          name: string;
+          image: string;
+          command: string[];
+          env?: {
+            [key: string]: unknown;
+            name: string;
+            value?: string;
+            valueFrom?: {
+              secretKeyRef?: { name: string; key: string };
+              configMapKeyRef?: { name: string; key: string };
+            };
+          }[];
+          resources: {
+            [key: string]: unknown;
+            limits: { [key: string]: unknown; memory: string };
+            requests: { [key: string]: unknown; cpu: string; memory: string };
+          };
+          terminationMessagePath: string;
+          terminationMessagePolicy: string;
+          imagePullPolicy: string;
+        }[];
+
+        restartPolicy: string;
+        terminationGracePeriodSeconds: number;
+        dnsPolicy: string;
+        shareProcessNamespace: boolean;
+        securityContext: unknown;
+        schedulerName: string;
+        tolerations: {
+          [key: string]: unknown;
+          key: string;
+          operator: string;
+          value: string;
+          effect: string;
+        }[];
+      };
+    };
+  };
+  status: {
+    [key: string]: unknown;
+    conditions: {
+      [key: string]: unknown;
+      type: string;
+      status: string;
+      lastProbeTime: string;
+      lastTransitionTime: string;
+    }[];
+    startTime: string;
+    completionTime: string | undefined | null;
+    succeeded?: number;
+    failed?: number;
+    active?: number;
+  };
+};
+
+class JobRunsFilter {
+  jobRuns: JobRun[];
+
+  constructor(newJobRuns: JobRun[]) {
+    this.jobRuns = newJobRuns;
+  }
+
+  filterByFailed() {
+    return this.jobRuns.filter((jobRun) => jobRun?.status?.failed);
+  }
+
+  filterByActive() {
+    return this.jobRuns.filter((jobRun) => jobRun?.status?.active);
+  }
+
+  filterBySucceded() {
+    return this.jobRuns.filter(
+      (jobRun) =>
+        jobRun?.status?.succeeded &&
+        !jobRun?.status?.active &&
+        !jobRun?.status?.failed
+    );
+  }
+
+  dontFilter() {
+    return this.jobRuns;
+  }
+}
+
+class JobRunsSorter {
+  jobRuns: JobRun[];
+
+  constructor(newJobRuns: JobRun[]) {
+    this.jobRuns = newJobRuns;
+  }
+
+  sortByNewest() {
+    return this.jobRuns.sort((a, b) => {
+      return Date.parse(a?.metadata?.creationTimestamp) >
+        Date.parse(b?.metadata?.creationTimestamp)
+        ? -1
+        : 1;
+    });
+  }
+
+  sortByOldest() {
+    return this.jobRuns.sort((a, b) => {
+      return Date.parse(a?.metadata?.creationTimestamp) >
+        Date.parse(b?.metadata?.creationTimestamp)
+        ? 1
+        : -1;
+    });
+  }
+
+  sortByAlphabetical() {
+    return this.jobRuns.sort((a, b) =>
+      a?.metadata?.name > b?.metadata?.name ? 1 : -1
+    );
+  }
+}

+ 5 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -21,8 +21,9 @@ const NodeList: React.FC = () => {
         Header: "Node Name",
         accessor: "name",
         Cell: ({ row }) => {
-          return <NodeName>{row.values.name}</NodeName>;
+          return <NameWrapper>{row.values.name}</NameWrapper>;
         },
+        width: "max-content",
       },
       {
         Header: "Machine Type",
@@ -189,6 +190,7 @@ const StatusButton = styled.div`
   }
 `;
 
-const NodeName = styled.div`
-  min-width: 250px;
+const NameWrapper = styled.span`
+  white-space: nowrap;
+  margin-right: 10px;
 `;

+ 38 - 17
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -27,6 +27,8 @@ import Banner from "components/Banner";
 import KeyValueArray from "components/form-components/KeyValueArray";
 import { onlyInLeft } from "shared/array_utils";
 import { readableDate } from "shared/string_utils";
+import MetricsSection from "./metrics/MetricsSection";
+import JobMetricsSection from "./metrics/JobMetricsSection";
 
 type PropsType = WithAuthProps &
   RouteComponentProps & {
@@ -55,6 +57,8 @@ type StateType = {
   upgradeVersion: string;
   expandedJobRun: any;
   pods: any;
+  showConnectionModal: boolean;
+  loadingJobs: boolean;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -77,6 +81,8 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
     expandedJobRun: null as any,
     pods: null as any,
+    showConnectionModal: false,
+    loadingJobs: true,
   };
 
   getPods = (job: any, callback?: () => void) => {
@@ -106,7 +112,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     let { currentCluster, currentChart } = this.props;
 
     this.setState({ loading: true });
-    api
+    return api
       .getChart(
         "<token>",
         {},
@@ -506,9 +512,9 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
   getJobs = async (chart: ChartType) => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    api
-      .getJobs(
+    this.setState({ loadingJobs: true });
+    try {
+      const res = await api.getJobs(
         "<token>",
         {},
         {
@@ -517,12 +523,13 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
           namespace: chart.namespace,
           release_name: chart.name,
         }
-      )
-      .then((res) => {
-        // sort jobs by started timestamp
-        this.sortJobsAndSave(res.data);
-      })
-      .catch((err) => setCurrentError(err));
+      );
+      // sort jobs by started timestamp
+      this.sortJobsAndSave(res.data);
+      this.setState({ loadingJobs: false });
+    } catch (err) {
+      return setCurrentError(err);
+    }
   };
 
   sortJobsAndSave = (jobs: any[]) => {
@@ -593,6 +600,8 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               isAuthorized={this.props.isAuthorized}
               saveValuesStatus={this.state.saveValuesStatus}
               expandJob={(job: any) => this.setJobRun(job)}
+              chartName={this.state.currentChart?.name}
+              isLoading={this.state.loadingJobs}
             />
           </TabWrapper>
         );
@@ -661,10 +670,12 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       chart: currentChart.name,
     });
 
-    this.getChartData(currentChart, currentChart.version);
-    this.getJobs(currentChart);
-    this.setupJobWebsocket(currentChart);
-    this.setupCronJobWebsocket(currentChart);
+    this.getChartData(currentChart, currentChart.version).then(() => {
+      this.getJobs(currentChart).then(() => {
+        this.setupJobWebsocket(currentChart);
+        this.setupCronJobWebsocket(currentChart);
+      });
+    });
   }
 
   componentDidUpdate(
@@ -956,7 +967,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     let { currentChart } = this.state;
     let chart = currentChart;
     let run = this.state.expandedJobRun;
-
     return (
       <StyledExpandedChart>
         <HeaderWrapper>
@@ -994,13 +1004,17 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 label: "Logs",
                 value: "logs",
               },
+              {
+                label: "Metrics",
+                value: "metrics",
+              },
               {
                 label: "Config",
                 value: "config",
               },
             ]}
           >
-            {this.state.currentTab === "logs" ? (
+            {this.state.currentTab === "logs" && (
               <JobLogsWrapper>
                 <Logs
                   selectedPod={this.state.pods[0]}
@@ -1008,9 +1022,16 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                   rawText={true}
                 />
               </JobLogsWrapper>
-            ) : (
+            )}
+            {this.state.currentTab === "config" && (
               <>{this.renderConfigSection(run)}</>
             )}
+            {this.state.currentTab === "metrics" && (
+              <JobMetricsSection
+                jobChart={this.state.currentChart}
+                jobRun={run}
+              />
+            )}
           </TabRegion>
         </BodyWrapper>
       </StyledExpandedChart>

+ 52 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ConnectToJobInstructionsModal.tsx

@@ -0,0 +1,52 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import { ChartType } from "shared/types";
+import styled from "styled-components";
+
+const ConnectToJobInstructionsModal: React.FC<{
+  show: boolean;
+  onClose: () => void;
+  chartName: string;
+}> = ({ show, chartName, onClose }) => {
+  if (!show) {
+    return null;
+  }
+
+  return (
+    <Modal
+      onRequestClose={() => onClose()}
+      width="700px"
+      height="300px"
+      title="Shell Access Instructions"
+    >
+      To get shell access to this job run, make sure you have the Porter CLI
+      installed (installation instructions&nbsp;
+      <a href={"https://docs.porter.run/cli/installation"} target="_blank">
+        here
+      </a>
+      ).
+      <br />
+      <br />
+      Run the following line of code, and make sure to change the command to
+      something your container can run:
+      <Code>porter run {chartName || "[APP-NAME]"} -- [COMMAND]</Code>
+      Note that this will create a copy of the most recent job run for this
+      template.
+    </Modal>
+  );
+};
+
+export default ConnectToJobInstructionsModal;
+
+const Code = styled.div`
+  background: #181b21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  line-height: 1em;
+  font-family: monospace;
+`;

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -9,6 +9,8 @@ import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
 import KeyValueArray from "components/form-components/KeyValueArray";
 import { readableDate } from "shared/string_utils";
+import CommandLineIcon from "assets/command-line-icon";
+import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
 
 type PropsType = {
   job: any;
@@ -22,6 +24,7 @@ type StateType = {
   expanded: boolean;
   configIsExpanded: boolean;
   pods: any[];
+  showConnectionModal: boolean;
 };
 
 export default class JobResource extends Component<PropsType, StateType> {
@@ -29,6 +32,7 @@ export default class JobResource extends Component<PropsType, StateType> {
     expanded: false,
     configIsExpanded: false,
     pods: [] as any[],
+    showConnectionModal: false,
   };
 
   expandJob = (event: MouseEvent) => {

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

@@ -4,6 +4,9 @@ import styled from "styled-components";
 import { PorterFormContext } from "components/porter-form/PorterFormContextProvider";
 import JobList from "./JobList";
 import SaveButton from "components/SaveButton";
+import CommandLineIcon from "assets/command-line-icon";
+import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
+import Loading from "components/Loading";
 
 interface Props {
   isAuthorized: any;
@@ -12,6 +15,8 @@ interface Props {
   jobs: any;
   handleSaveValues: any;
   expandJob: any;
+  chartName: string;
+  isLoading: boolean;
 }
 
 /**
@@ -20,6 +25,7 @@ interface Props {
  */
 const TempJobList: React.FC<Props> = (props) => {
   const { getSubmitValues } = useContext(PorterFormContext);
+  const [showConnectionModal, setShowConnectionModal] = useState(false);
   const [searchInput, setSearchInput] = useState("");
 
   let saveButton = (
@@ -36,6 +42,15 @@ const TempJobList: React.FC<Props> = (props) => {
       >
         <i className="material-icons">play_arrow</i> Run Job
       </SaveButton>
+      <CLIModalIconWrapper
+        onClick={(e) => {
+          e.preventDefault();
+          setShowConnectionModal(true);
+        }}
+      >
+        <CLIModalIcon />
+        Shell Access
+      </CLIModalIconWrapper>
     </ButtonWrapper>
   );
 
@@ -43,6 +58,10 @@ const TempJobList: React.FC<Props> = (props) => {
     saveButton = null;
   }
 
+  if (props.isLoading) {
+    return <Loading height="500px"></Loading>;
+  }
+
   return (
     <>
       {saveButton}
@@ -51,6 +70,11 @@ const TempJobList: React.FC<Props> = (props) => {
         setJobs={props.setJobs}
         expandJob={props.expandJob}
       />
+      <ConnectToJobInstructionsModal
+        show={showConnectionModal}
+        onClose={() => setShowConnectionModal(false)}
+        chartName={props.chartName}
+      />
     </>
   );
 };
@@ -58,5 +82,46 @@ const TempJobList: React.FC<Props> = (props) => {
 export default TempJobList;
 
 const ButtonWrapper = styled.div`
+  display: flex;
   margin: 5px 0 35px;
+  justify-content: space-between;
+`;
+
+const CLIModalIconWrapper = styled.div`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 20px 6px 10px;
+  text-align: left;
+  border: 1px solid #ffffff55;
+  border-radius: 8px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+
+  :hover {
+    cursor: pointer;
+    background: #ffffff22;
+    > path {
+      fill: #ffffff77;
+    }
+  }
+
+  > path {
+    fill: #ffffff99;
+  }
+`;
+
+const CLIModalIcon = styled(CommandLineIcon)`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+
+  > path {
+    fill: #ffffff99;
+  }
 `;

+ 604 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx

@@ -0,0 +1,604 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
+
+import settings from "assets/settings.svg";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartTypeWithExtendedConfig, StorageType } from "shared/types";
+
+import TabSelector from "components/TabSelector";
+import Loading from "components/Loading";
+import SelectRow from "components/form-components/SelectRow";
+import AreaChart from "./AreaChart";
+import { MetricNormalizer } from "./MetricNormalizer";
+import { AvailableMetrics, NormalizedMetricsData } from "./types";
+import CheckboxRow from "components/form-components/CheckboxRow";
+
+type PropsType = {
+  jobChart: ChartTypeWithExtendedConfig;
+  jobRun: any;
+};
+
+export const resolutions: { [range: string]: string } = {
+  "1H": "1s",
+  "6H": "15s",
+  "1D": "15s",
+  "1M": "5h",
+};
+
+export const secondsBeforeNow: { [range: string]: number } = {
+  "1H": 60 * 60,
+  "6H": 60 * 60 * 6,
+  "1D": 60 * 60 * 24,
+  "1M": 60 * 60 * 24 * 30,
+};
+
+const JobMetricsSection: React.FunctionComponent<PropsType> = ({
+  jobChart: currentChart,
+  jobRun,
+}) => {
+  const [pods, setPods] = useState([]);
+  const [selectedPod, setSelectedPod] = useState("");
+  const [controllerOptions, setControllerOptions] = useState([]);
+  const [selectedController, setSelectedController] = useState(null);
+  const [ingressOptions, setIngressOptions] = useState([]);
+  const [selectedIngress, setSelectedIngress] = useState(null);
+  const [selectedRange, setSelectedRange] = useState("1H");
+  const [selectedMetric, setSelectedMetric] = useState("cpu");
+  const [selectedMetricLabel, setSelectedMetricLabel] = useState(
+    "CPU Utilization (vCPUs)"
+  );
+  const [dropdownExpanded, setDropdownExpanded] = useState(false);
+  const [data, setData] = useState<NormalizedMetricsData[]>([]);
+  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
+  const [metricsOptions, setMetricsOptions] = useState([
+    { value: "cpu", label: "CPU Utilization (vCPUs)" },
+    { value: "memory", label: "RAM Utilization (Mi)" },
+  ]);
+  const [isLoading, setIsLoading] = useState(0);
+  const [hpaData, setHpaData] = useState([]);
+  const [hpaEnabled, setHpaEnabled] = useState(
+    currentChart?.config?.autoscaling?.enabled
+  );
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    setIsLoading((prev) => prev + 1);
+
+    api
+      .getChartControllers(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        const controllerOptions = res.data.map((controller: any) => {
+          let name = controller?.metadata?.name;
+          return { value: controller, label: name };
+        });
+
+        setControllerOptions(controllerOptions);
+        setSelectedController(controllerOptions[0]?.value);
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        setControllerOptions([]);
+      })
+      .finally(() => {
+        setIsLoading((prev) => prev - 1);
+      });
+  }, [currentChart, currentCluster, currentProject]);
+
+  useEffect(() => {
+    getPods();
+  }, [selectedController]);
+
+  const getPods = () => {
+    const jobName = jobRun?.metadata?.name;
+    const selector = `job-name=${jobName}`;
+
+    setIsLoading((prev) => prev + 1);
+
+    api
+      .getMatchingPods(
+        "<token>",
+        {
+          namespace: selectedController?.metadata?.namespace,
+          selectors: [selector],
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        const pods = res?.data?.map((pod: any) => {
+          let name = pod?.metadata?.name;
+          return { value: name, label: name };
+        });
+
+        setPods(pods);
+        setSelectedPod(pods[0].value);
+
+        getMetrics();
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        return;
+      })
+      .finally(() => {
+        setIsLoading((prev) => prev - 1);
+      });
+  };
+
+  const getAutoscalingThreshold = async (
+    metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
+    shouldsum: boolean,
+    namespace: string,
+    start: number,
+    end: number
+  ) => {
+    setIsLoading((prev) => prev + 1);
+    setHpaData([]);
+    try {
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          metric: metricType,
+          shouldsum: shouldsum,
+          kind: selectedController?.kind,
+          name: selectedController?.metadata.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: [],
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      if (!Array.isArray(res.data) || !res.data[0]?.results) {
+        return;
+      }
+      const autoscalingMetrics = new MetricNormalizer(res.data, metricType);
+      setHpaData(autoscalingMetrics.getParsedData());
+      return;
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  const getMetrics = async () => {
+    if (pods?.length == 0) {
+      return;
+    }
+    try {
+      let namespace = currentChart.namespace;
+
+      const start = Math.round(
+        new Date(jobRun?.status?.startTime).getTime() / 1000
+      );
+
+      let end = Math.round(
+        new Date(jobRun?.status?.completionTime).getTime() / 1000
+      );
+
+      if (!jobRun?.status?.completionTime) {
+        end = Math.round(new Date().getTime() / 1000);
+      }
+
+      let podNames = [selectedPod] as string[];
+
+      setIsLoading((prev) => prev + 1);
+      setData([]);
+
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          metric: selectedMetric,
+          shouldsum: false,
+          kind: selectedController?.kind,
+          name: selectedController?.metadata.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: podNames,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const metrics = new MetricNormalizer(
+        res.data,
+        selectedMetric as AvailableMetrics
+      );
+
+      // transform the metrics to expected form
+      setData(metrics.getParsedData());
+    } catch (error) {
+      setCurrentError(JSON.stringify(error));
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedPod && selectedController) {
+      getMetrics();
+    }
+  }, [
+    selectedMetric,
+    selectedRange,
+    selectedPod,
+    selectedController,
+    selectedIngress,
+  ]);
+
+  const renderMetricsSettings = () => {
+    if (showMetricsSettings && true) {
+      if (selectedMetric == "nginx:errors") {
+        return (
+          <>
+            <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
+            <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
+              <Label>Additional Settings</Label>
+              <SelectRow
+                label="Target Ingress"
+                value={selectedIngress}
+                setActiveValue={(x: any) => setSelectedIngress(x)}
+                options={ingressOptions}
+                width="100%"
+              />
+            </DropdownAlt>
+          </>
+        );
+      }
+
+      return (
+        <>
+          <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
+          <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
+            <Label>Additional Settings</Label>
+            <SelectRow
+              label="Target Controller"
+              value={selectedController}
+              setActiveValue={(x: any) => setSelectedController(x)}
+              options={controllerOptions}
+              width="100%"
+            />
+            <SelectRow
+              label="Target Pod"
+              value={selectedPod}
+              setActiveValue={(x: any) => setSelectedPod(x)}
+              options={pods}
+              width="100%"
+            />
+          </DropdownAlt>
+        </>
+      );
+    }
+  };
+
+  const renderDropdown = () => {
+    if (dropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setDropdownExpanded(false)} />
+          <Dropdown
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return metricsOptions.map(
+      (option: { value: string; label: string }, i: number) => {
+        return (
+          <Option
+            key={i}
+            selected={option.value === selectedMetric}
+            onClick={() => {
+              setSelectedMetric(option.value);
+              setSelectedMetricLabel(option.label);
+            }}
+            lastItem={i === metricsOptions.length - 1}
+          >
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
+  const hasJobRunnedForMoreThan5m = () => {
+    const firstDate = new Date(jobRun.status.startTime);
+    const secondDate = jobRun?.status?.completionTime
+      ? new Date(jobRun?.status?.completionTime)
+      : new Date();
+    const _5M_IN_MILISECONDS = 60000;
+    return secondDate.getTime() - firstDate.getTime() > _5M_IN_MILISECONDS;
+  };
+
+  return (
+    <StyledMetricsSection>
+      <MetricsHeader>
+        <Flex>
+          <MetricSelector
+            onClick={() => setDropdownExpanded(!dropdownExpanded)}
+          >
+            <MetricsLabel>{selectedMetricLabel}</MetricsLabel>
+            <i className="material-icons">arrow_drop_down</i>
+            {renderDropdown()}
+          </MetricSelector>
+
+          <Highlight color={"#7d7d81"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+          </Highlight>
+        </Flex>
+      </MetricsHeader>
+      {isLoading > 0 && <Loading />}
+      {data.length === 0 && isLoading === 0 && (
+        <>
+          {selectedMetric === "cpu" && hasJobRunnedForMoreThan5m() ? (
+            <Message>
+              No data available yet.
+              <Highlight color={"#8590ff"} onClick={getMetrics}>
+                <i className="material-icons">autorenew</i>
+                Refresh
+              </Highlight>
+            </Message>
+          ) : (
+            <Message>
+              <Highlight color={"#8590ff"} disableHover>
+                CPU data is not available for jobs that ran for less than 5
+                minutes.
+              </Highlight>
+            </Message>
+          )}
+        </>
+      )}
+      {data.length > 0 && isLoading === 0 && (
+        <>
+          {currentChart?.config?.autoscaling?.enabled &&
+            ["cpu", "memory"].includes(selectedMetric) && (
+              <CheckboxRow
+                toggle={() => setHpaEnabled((prev: any) => !prev)}
+                checked={hpaEnabled}
+                label="Show Autoscaling Threshold"
+              />
+            )}
+          <ParentSize>
+            {({ width, height }) => (
+              <AreaChart
+                dataKey={selectedMetricLabel}
+                data={data}
+                hpaData={hpaData}
+                hpaEnabled={
+                  hpaEnabled && ["cpu", "memory"].includes(selectedMetric)
+                }
+                width={width}
+                height={height - 10}
+                resolution={selectedRange}
+                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+              />
+            )}
+          </ParentSize>
+        </>
+      )}
+    </StyledMetricsSection>
+  );
+};
+
+export default JobMetricsSection;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: ${(props: { color: string; disableHover?: boolean }) => props.color};
+  cursor: ${(props) => (props.disableHover ? "unset" : "pointer")};
+
+  > i {
+    font-size: 20px;
+    margin-right: 3px;
+  }
+`;
+
+const Label = styled.div`
+  font-weight: bold;
+`;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const IconWrapper = styled.div`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-top: 2px;
+  border-radius: 30px;
+  height: 25px;
+  width: 25px;
+  margin-left: 8px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const SettingsIcon = styled.img`
+  opacity: 0.4;
+  width: 20px;
+  height: 20px;
+  margin-left: -1px;
+  margin-bottom: -2px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const DropdownAlt = styled(Dropdown)`
+  padding: 20px 20px 7px;
+  overflow: visible;
+`;
+
+const RangeWrapper = styled.div`
+  float: right;
+  font-weight: bold;
+  width: 156px;
+  margin-top: -8px;
+`;
+
+const MetricSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const MetricsLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const StyledMetricsSection = styled.div`
+  width: 100%;
+  min-height: 400px;
+  height: 50vh;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  padding: 18px 22px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 52 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ConnectToLogsInstructionModal.tsx

@@ -0,0 +1,52 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import styled from "styled-components";
+
+const ConnectToLogsInstructionModal: React.FC<{
+  show: boolean;
+  onClose: () => void;
+  chartName: string;
+  namespace: string;
+}> = ({ show, chartName, namespace, onClose }) => {
+  if (!show) {
+    return null;
+  }
+
+  return (
+    <Modal
+      onRequestClose={() => onClose()}
+      width="700px"
+      height="300px"
+      title="Shell Access Instructions"
+    >
+      To get shell live logs for this pod, make sure you have the Porter CLI
+      installed (installation instructions&nbsp;
+      <a href={"https://docs.porter.run/cli/installation"} target="_blank">
+        here
+      </a>
+      ).
+      <br />
+      <br />
+      Run the following line of code:
+      <Code>
+        porter logs {chartName || "[APP-NAME]"} --follow --namespace{" "}
+        {namespace || "[NAMESPACE]"}
+      </Code>
+    </Modal>
+  );
+};
+
+export default ConnectToLogsInstructionModal;
+
+const Code = styled.div`
+  background: #181b21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  line-height: 1em;
+  font-family: monospace;
+`;

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

@@ -11,8 +11,10 @@ import { Context } from "shared/Context";
 import * as Anser from "anser";
 import api from "shared/api";
 import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import CommandLineIcon from "assets/command-line-icon";
+import ConnectToLogsInstructionModal from "./ConnectToLogsInstructionModal";
 
-const MAX_LOGS = 1000;
+const MAX_LOGS = 250;
 
 type SelectedPodType = {
   spec: {
@@ -25,6 +27,9 @@ type SelectedPodType = {
   metadata: {
     name: string;
     namespace: string;
+    labels: {
+      [key: string]: string;
+    };
   };
   status: {
     phase: string;
@@ -36,23 +41,15 @@ const LogsFC: React.FC<{
   podError: string;
   rawText?: boolean;
 }> = ({ selectedPod, podError, rawText }) => {
-  const {
-    logs,
-    previousLogs,
-    containers,
-    currentContainer,
-    setCurrentContainer,
-    refresh,
-  } = useLogs(selectedPod);
-
-  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
-
   const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(true);
 
+  const [showConnectionModal, setShowConnectionModal] = useState(false);
+
+  const shouldScroll = useRef<boolean>(true);
   const wrapperRef = useRef<HTMLDivElement>();
 
   const scrollToBottom = (smooth: boolean) => {
-    if (!wrapperRef.current) {
+    if (!wrapperRef.current || !shouldScroll.current) {
       return;
     }
 
@@ -71,11 +68,20 @@ const LogsFC: React.FC<{
     }
   };
 
+  const {
+    logs,
+    previousLogs,
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    refresh,
+  } = useLogs(selectedPod, scrollToBottom);
+
+  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
+
   useEffect(() => {
-    if (isScrollToBottomEnabled) {
-      scrollToBottom(true);
-    }
-  }, [isScrollToBottomEnabled, logs]);
+    shouldScroll.current = isScrollToBottomEnabled;
+  }, [isScrollToBottomEnabled]);
 
   const renderLogs = () => {
     if (podError && podError != "") {
@@ -151,6 +157,21 @@ const LogsFC: React.FC<{
 
   const renderContent = () => (
     <>
+      {/* <ConnectToLogsInstructionModal
+        show={showConnectionModal}
+        onClose={() => setShowConnectionModal(false)}
+        chartName={selectedPod?.metadata?.labels["app.kubernetes.io/instance"]}
+        namespace={selectedPod?.metadata?.namespace}
+      />
+      <CLIModalIconWrapper
+        onClick={(e) => {
+          e.preventDefault();
+          setShowConnectionModal(true);
+        }}
+      >
+        <CLIModalIcon />
+        CLI Logs Instructions
+      </CLIModalIconWrapper> */}
       <Wrapper ref={wrapperRef}>{renderLogs()}</Wrapper>
       <LogTabs>
         {containers.map((containerName, _i, arr) => {
@@ -232,7 +253,10 @@ const LogsFC: React.FC<{
 
 export default LogsFC;
 
-const useLogs = (currentPod: SelectedPodType) => {
+const useLogs = (
+  currentPod: SelectedPodType,
+  scroll?: (smooth: boolean) => void
+) => {
   const currentPodName = useRef<string>();
 
   const { currentCluster, currentProject } = useContext(Context);
@@ -338,7 +362,9 @@ const useLogs = (currentPod: SelectedPodType) => {
           if (containerLogs.length > MAX_LOGS) {
             containerLogs.shift();
           }
-
+          if (typeof scroll === "function") {
+            scroll(true);
+          }
           return {
             ...logs,
             [containerName]: containerLogs,
@@ -572,3 +598,43 @@ const LogSpan = styled.span`
   background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
     props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
 `;
+
+const CLIModalIconWrapper = styled.div`
+  max-width: 200px;
+  height: 35px;
+  margin: 10px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 20px 6px 10px;
+  text-align: left;
+  border: 1px solid #ffffff55;
+  border-radius: 8px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+  :hover {
+    cursor: pointer;
+    background: #ffffff22;
+    > path {
+      fill: #ffffff77;
+    }
+  }
+
+  > path {
+    fill: #ffffff99;
+  }
+`;
+
+const CLIModalIcon = styled(CommandLineIcon)`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+
+  > path {
+    fill: #ffffff99;
+  }
+`;

+ 67 - 141
dashboard/src/main/home/launch/Launch.tsx

@@ -3,11 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import {
-  ChartTypeWithExtendedConfig,
-  PorterTemplate,
-  StorageType,
-} from "shared/types";
+import { ChartTypeWithExtendedConfig, PorterTemplate } from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
@@ -16,16 +12,22 @@ import LaunchFlow from "./launch-flow/LaunchFlow";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 import TitleSection from "components/TitleSection";
 
-import { hardcodedNames } from "shared/hardcodedNameDict";
 import semver from "semver";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, getQueryParams } from "shared/routing";
+import TemplateList from "./TemplateList";
+import { capitalize } from "lodash";
 
-const tabOptions = [
+const initialTabOptions = [
   { label: "New Application", value: "porter" },
   { label: "Community Add-ons", value: "community" },
 ];
 
+type TabOption = {
+  label: string;
+  value: string;
+};
+
 const HIDDEN_CHARTS = ["porter-agent"];
 
 type PropsType = RouteComponentProps & {};
@@ -40,8 +42,8 @@ type StateType = {
   error: boolean;
   isOnLaunchFlow: boolean;
   clonedChart: ChartTypeWithExtendedConfig;
+  tabOptions: TabOption[];
 };
-
 class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
@@ -53,6 +55,7 @@ class Templates extends Component<PropsType, StateType> {
     error: false,
     isOnLaunchFlow: false,
     clonedChart: null as ChartTypeWithExtendedConfig,
+    tabOptions: initialTabOptions,
   };
 
   async componentDidMount() {
@@ -163,6 +166,29 @@ class Templates extends Component<PropsType, StateType> {
     } catch (error) {
       this.setState({ loading: false, error: true });
     }
+
+    try {
+      const res = await api.getHelmRepos(
+        "<token>",
+        {},
+        {
+          project_id: this.context.currentProject.id,
+        }
+      );
+
+      let tabOptions = this.state.tabOptions.concat(
+        ...res.data.map((val: any) => {
+          return {
+            value: `${val.id}`,
+            label: capitalize(val.name),
+          };
+        })
+      );
+
+      this.setState({ tabOptions });
+    } catch (error) {
+      this.setState({ loading: false, error: true });
+    }
   }
 
   isTryingToClone = () => {
@@ -222,48 +248,37 @@ class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderTemplateList = (templates: any) => {
-    let { loading, error } = this.state;
-
-    if (loading) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (error) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error retrieving templates.
-        </Placeholder>
-      );
-    } else if (templates.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No templates found.
-        </Placeholder>
-      );
+  renderTemplateList = (templates?: any, helm_repo_id?: number) => {
+    if (!helm_repo_id && templates) {
+      if (this.state.loading) {
+        return (
+          <LoadingWrapper>
+            <Loading />
+          </LoadingWrapper>
+        );
+      } else if (this.state.error) {
+        return (
+          <Placeholder>
+            <i className="material-icons">error</i> Error retrieving templates.
+          </Placeholder>
+        );
+      } else if (templates.length === 0) {
+        return (
+          <Placeholder>
+            <i className="material-icons">category</i> No templates found.
+          </Placeholder>
+        );
+      }
     }
 
     return (
-      <TemplateList>
-        {templates.map((template: PorterTemplate, i: number) => {
-          let { name, icon, description } = template;
-          if (hardcodedNames[name]) {
-            name = hardcodedNames[name];
-          }
-          return (
-            <TemplateBlock
-              key={name}
-              onClick={() => this.setState({ currentTemplate: template })}
-            >
-              {this.renderIcon(icon)}
-              <TemplateTitle>{name}</TemplateTitle>
-              <TemplateDescription>{description}</TemplateDescription>
-            </TemplateBlock>
-          );
-        })}
-      </TemplateList>
+      <TemplateList
+        helm_repo_id={helm_repo_id}
+        templates={templates}
+        setCurrentTemplate={(template) =>
+          this.setState({ currentTemplate: template })
+        }
+      />
     );
   };
 
@@ -278,13 +293,16 @@ class Templates extends Component<PropsType, StateType> {
           setCurrentTemplate={(currentTemplate: PorterTemplate) => {
             this.setState({ currentTemplate });
           }}
+          helm_repo_id={parseInt(this.state.currentTab)}
         />
       );
     }
     if (this.state.currentTab === "porter") {
       return this.renderTemplateList(this.state.applicationTemplates);
-    } else {
+    } else if (this.state.currentTab == "community") {
       return this.renderTemplateList(this.state.addonTemplates);
+    } else {
+      return this.renderTemplateList(null, parseInt(this.state.currentTab));
     }
   };
 
@@ -293,7 +311,7 @@ class Templates extends Component<PropsType, StateType> {
       return (
         <>
           <TabSelector
-            options={tabOptions}
+            options={this.state.tabOptions}
             currentTab={this.state.currentTab}
             setCurrentTab={(value: string) =>
               this.setState({
@@ -389,31 +407,6 @@ const Banner = styled.div`
   }
 `;
 
-const Highlight = styled.div`
-  color: #8590ff;
-  cursor: pointer;
-  margin-left: 5px;
-  margin-right: 10px;
-`;
-
-const StyledStatusPlaceholder = styled.div`
-  width: 100%;
-  height: calc(100vh - 365px);
-  margin-top: 20px;
-  display: flex;
-  color: #aaaabb;
-  border-radius: 5px;
-  padding-bottom: 20px;
-  text-align: center;
-  font-size: 13px;
-  background: #ffffff09;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-family: "Work Sans", sans-serif;
-  user-select: text;
-`;
-
 const LoadingWrapper = styled.div`
   padding-top: 300px;
 `;
@@ -432,73 +425,6 @@ const Polymer = styled.div`
   }
 `;
 
-const TemplateDescription = styled.div`
-  margin-bottom: 26px;
-  color: #ffffff66;
-  text-align: center;
-  font-weight: default;
-  padding: 0px 25px;
-  height: 2.4em;
-  font-size: 12px;
-  display: -webkit-box;
-  overflow: hidden;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-`;
-
-const TemplateTitle = styled.div`
-  margin-bottom: 12px;
-  width: 80%;
-  text-align: center;
-  font-size: 14px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TemplateBlock = styled.div`
-  border: 1px solid #ffffff00;
-  align-items: center;
-  user-select: none;
-  border-radius: 8px;
-  display: flex;
-  font-size: 13px;
-  font-weight: 500;
-  padding: 3px 0px 5px;
-  flex-direction: column;
-  align-item: center;
-  justify-content: space-between;
-  height: 200px;
-  cursor: pointer;
-  color: #ffffff;
-  position: relative;
-  background: #26282f;
-  box-shadow: 0 4px 15px 0px #00000044;
-  :hover {
-    background: #ffffff11;
-  }
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const TemplateList = styled.div`
-  overflow: visible;
-  margin-top: 35px;
-  padding-bottom: 150px;
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
-`;
-
 const TemplatesWrapper = styled.div`
   width: calc(85%);
   overflow: visible;

+ 236 - 0
dashboard/src/main/home/launch/TemplateList.tsx

@@ -0,0 +1,236 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import { hardcodedNames } from "shared/hardcodedNameDict";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+
+type Props = {
+  helm_repo_id?: number;
+  templates?: PorterTemplate[];
+  setCurrentTemplate: (template: PorterTemplate) => void;
+};
+
+const TemplateList: React.FC<Props> = ({
+  helm_repo_id,
+  templates,
+  setCurrentTemplate,
+}) => {
+  const [isLoading, setIsLoading] = useState(!!helm_repo_id);
+  const [hasError, setHasError] = useState(false);
+  const [templateList, setTemplateList] = useState<PorterTemplate[]>(null);
+  const { currentProject, setCurrentError } = useContext(Context);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!currentProject || !helm_repo_id) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    api
+      .getChartsFromHelmRepo(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          helm_repo_id: helm_repo_id,
+        }
+      )
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+        if (!Array.isArray(data)) {
+          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
+        );
+
+        setTemplateList(sortedVersionData);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+
+        setHasError(true);
+        setCurrentError(err.response?.data?.error);
+        setIsLoading(false);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject, helm_repo_id]);
+
+  if (isLoading || (!templates && !templateList)) {
+    return (
+      <LoadingWrapper>
+        <Loading />
+      </LoadingWrapper>
+    );
+  } else if (hasError) {
+    return (
+      <Placeholder>
+        <i className="material-icons">error</i> Error retrieving templates.
+      </Placeholder>
+    );
+  } else if (templateList && templateList.length === 0) {
+    return (
+      <Placeholder>
+        <i className="material-icons">category</i> No templates found.
+      </Placeholder>
+    );
+  }
+
+  const renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />;
+    }
+
+    return (
+      <Polymer>
+        <i className="material-icons">layers</i>
+      </Polymer>
+    );
+  };
+
+  return (
+    <TemplateListWrapper>
+      {(templates || templateList)?.map((template: PorterTemplate) => {
+        let { name, icon, description } = template;
+        if (hardcodedNames[name]) {
+          name = hardcodedNames[name];
+        }
+        return (
+          <TemplateBlock
+            key={name}
+            onClick={() => setCurrentTemplate(template)}
+          >
+            {renderIcon(icon)}
+            <TemplateTitle>{name}</TemplateTitle>
+            <TemplateDescription>{description}</TemplateDescription>
+          </TemplateBlock>
+        );
+      })}
+    </TemplateListWrapper>
+  );
+};
+
+export default TemplateList;
+
+const Placeholder = styled.div`
+  padding-top: 200px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 300px;
+`;
+
+const Icon = styled.img`
+  height: 42px;
+  margin-top: 35px;
+  margin-bottom: 13px;
+`;
+
+const Polymer = styled.div`
+  > i {
+    font-size: 34px;
+    margin-top: 38px;
+    margin-bottom: 20px;
+  }
+`;
+
+const TemplateDescription = styled.div`
+  margin-bottom: 26px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const TemplateTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  border: 1px solid #ffffff00;
+  align-items: center;
+  user-select: none;
+  border-radius: 8px;
+  display: flex;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 200px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 4px 15px 0px #00000044;
+  :hover {
+    background: #ffffff11;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TemplateListWrapper = styled.div`
+  overflow: visible;
+  margin-top: 35px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;

+ 56 - 23
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -6,6 +6,7 @@ import api from "shared/api";
 
 import TemplateInfo from "./TemplateInfo";
 import Loading from "components/Loading";
+import { Context } from "shared/Context";
 
 type PropsType = {
   currentTemplate: PorterTemplate;
@@ -14,6 +15,8 @@ type PropsType = {
   skipDescription?: boolean;
   showLaunchFlow: () => void;
   setForm: (x: any) => void;
+  helm_repo_id?: number;
+  repo_url?: string;
 };
 
 type StateType = {
@@ -43,29 +46,57 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
 
   fetchTemplateInfo = () => {
     this.setState({ loading: true });
-    let params =
-      this.props.currentTab == "porter"
-        ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
-        : { repo_url: process.env.ADDON_CHART_REPO_URL };
-
-    api
-      .getTemplateInfo("<token>", params, {
-        name: this.props.currentTemplate.name.toLowerCase().trim(),
-        version: this.props.currentTemplate.currentVersion,
-      })
-      .then((res) => {
-        let { form, values, markdown, metadata } = res.data;
-        let keywords = metadata.keywords;
-        this.props.setForm(form);
-        this.setState({
-          values,
-          markdown,
-          keywords,
-          loading: false,
-          error: false,
-        });
-      })
-      .catch((err) => this.setState({ loading: false, error: true }));
+
+    if (this.props.helm_repo_id) {
+      api
+        .getChartInfoFromHelmRepo(
+          "<token>",
+          {},
+          {
+            project_id: this.context.currentProject.id,
+            helm_repo_id: this.props.helm_repo_id,
+            name: this.props.currentTemplate.name.toLowerCase().trim(),
+            version: this.props.currentTemplate.currentVersion,
+          }
+        )
+        .then((res) => {
+          let { form, values, markdown, metadata } = res.data;
+          let keywords = metadata.keywords;
+          this.props.setForm(form);
+          this.setState({
+            values,
+            markdown,
+            keywords,
+            loading: false,
+            error: false,
+          });
+        })
+        .catch((err) => this.setState({ loading: false, error: true }));
+    } else {
+      let params =
+        this.props.currentTab == "porter"
+          ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
+          : { repo_url: process.env.ADDON_CHART_REPO_URL };
+
+      api
+        .getTemplateInfo("<token>", params, {
+          name: this.props.currentTemplate.name.toLowerCase().trim(),
+          version: this.props.currentTemplate.currentVersion,
+        })
+        .then((res) => {
+          let { form, values, markdown, metadata } = res.data;
+          let keywords = metadata.keywords;
+          this.props.setForm(form);
+          this.setState({
+            values,
+            markdown,
+            keywords,
+            loading: false,
+            error: false,
+          });
+        })
+        .catch((err) => this.setState({ loading: false, error: true }));
+    }
   };
 
   componentDidUpdate = (prevProps: PropsType) => {
@@ -116,6 +147,8 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   }
 }
 
+ExpandedTemplate.contextType = Context;
+
 const FadeWrapper = styled.div`
   animation: fadeIn 0.2s;
   @keyframes fadeIn {

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

@@ -6,11 +6,10 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { getQueryParam, getQueryParams, pushFiltered } from "shared/routing";
+import { pushFiltered } from "shared/routing";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
-import WorkflowPage from "./WorkflowPage";
 import SettingsPage from "./SettingsPage";
 import TitleSection from "components/TitleSection";
 
@@ -19,7 +18,6 @@ import {
   ChartTypeWithExtendedConfig,
   FullActionConfigType,
   PorterTemplate,
-  StorageType,
 } from "shared/types";
 
 type PropsType = RouteComponentProps & {
@@ -115,7 +113,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           id: currentProject.id,
           cluster_id: currentCluster.id,
           namespace: selectedNamespace,
-          repo_url: process.env.ADDON_CHART_REPO_URL,
+          repo_url:
+            props.currentTemplate?.repo_url || process.env.ADDON_CHART_REPO_URL,
         }
       )
       .then((_) => {

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

@@ -11,7 +11,7 @@ const ConnectToDatabaseInstructionsModal = () => {
       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}{" "}
-      database.
+      env group.
       <p>
         This will set the following environment variables in your application:
       </p>

+ 44 - 11
dashboard/src/main/home/modals/Modal.tsx

@@ -1,5 +1,6 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import ReactDOM from "react-dom";
 
 type PropsType = {
   onRequestClose?: () => void;
@@ -10,6 +11,8 @@ type PropsType = {
 
 type StateType = {};
 
+const modalRoot = document.getElementById("modal-root");
+
 export default class Modal extends Component<PropsType, StateType> {
   wrapperRef: any = React.createRef();
 
@@ -38,21 +41,51 @@ export default class Modal extends Component<PropsType, StateType> {
   render() {
     let { width, height } = this.props;
     return (
-      <Overlay>
-        <StyledModal ref={this.wrapperRef} width={width} height={height}>
-          {this.props.onRequestClose && (
-            <CloseButton onClick={this.props.onRequestClose}>
-              <i className="material-icons">close</i>
-            </CloseButton>
-          )}
-          {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
-          {this.props.children}
-        </StyledModal>
-      </Overlay>
+      <PortalModal>
+        <Overlay>
+          <StyledModal ref={this.wrapperRef} width={width} height={height}>
+            {this.props.onRequestClose && (
+              <CloseButton onClick={this.props.onRequestClose}>
+                <i className="material-icons">close</i>
+              </CloseButton>
+            )}
+            {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
+            {this.props.children}
+          </StyledModal>
+        </Overlay>
+      </PortalModal>
     );
   }
 }
 
+export class PortalModal extends Component {
+  el: Element;
+  constructor(props: any) {
+    super(props);
+    this.el = document.createElement("div");
+  }
+
+  componentDidMount() {
+    // The portal element is inserted in the DOM tree after
+    // the Modal's children are mounted, meaning that children
+    // will be mounted on a detached DOM node. If a child
+    // component requires to be attached to the DOM tree
+    // immediately when mounted, for example to measure a
+    // DOM node, or uses 'autoFocus' in a descendant, add
+    // state to Modal and only render the children when Modal
+    // is inserted in the DOM tree.
+    modalRoot.appendChild(this.el);
+  }
+
+  componentWillUnmount() {
+    modalRoot.removeChild(this.el);
+  }
+
+  render() {
+    return ReactDOM.createPortal(this.props.children, this.el);
+  }
+}
+
 const ModalTitle = styled.div`
   font-size: 18px;
   font-weight: 500;

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

@@ -1048,6 +1048,32 @@ const getTemplates = baseApi<
   {}
 >("GET", "/api/templates");
 
+const getHelmRepos = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/helmrepos`;
+});
+
+const getChartsFromHelmRepo = baseApi<
+  {},
+  {
+    project_id: number;
+    helm_repo_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/helmrepos/${pathParams.helm_repo_id}/charts`;
+});
+
+const getChartInfoFromHelmRepo = baseApi<
+  {},
+  { project_id: number; helm_repo_id: number; name: string; version: string }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/helmrepos/${pathParams.helm_repo_id}/charts/${pathParams.name}/${pathParams.version}`;
+});
+
 const getMetadata = baseApi<{}, {}>("GET", () => {
   return `/api/metadata`;
 });
@@ -1667,6 +1693,9 @@ export default {
   getTemplateInfo,
   getTemplateUpgradeNotes,
   getTemplates,
+  getHelmRepos,
+  getChartsFromHelmRepo,
+  getChartInfoFromHelmRepo,
   linkGithubProject,
   getGithubAccounts,
   listConfigMaps,

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

@@ -152,6 +152,7 @@ export interface PorterTemplate {
   currentVersion: string;
   description: string;
   icon: string;
+  repo_url?: string;
 }
 
 // FormYAML represents a chart's values.yaml form abstraction

+ 2 - 1
go.mod

@@ -11,7 +11,7 @@ require (
 	github.com/buildpacks/pack v0.19.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
-	github.com/digitalocean/godo v1.56.0
+	github.com/digitalocean/godo v1.75.0
 	github.com/docker/cli v20.10.11+incompatible
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v20.10.12+incompatible
@@ -94,6 +94,7 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bits-and-blooms/bitset v1.2.0 // indirect
+	github.com/briandowns/spinner v1.18.1 // indirect
 	github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 // indirect
 	github.com/buildpacks/lifecycle v0.11.3 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect

+ 5 - 0
go.sum

@@ -217,6 +217,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR
 github.com/bradleyfalzon/ghinstallation/v2 v2.0.3 h1:ywF/8q+GVpvlsEuvRb1SGSDQDUxntW1d4kFu/9q/YAE=
 github.com/bradleyfalzon/ghinstallation/v2 v2.0.3/go.mod h1:tlgi+JWCXnKFx/Y4WtnDbZEINo31N5bcvnCoqieefmk=
 github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
+github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
+github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
@@ -431,6 +433,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
 github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
+github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y=
+github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684 h1:DBZ2sN7CK6dgvHVpQsQj4sRMCbWTmd17l+5SUCjnQSY=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684/go.mod h1:UfCu3YXJJCI+IdnqGgYP82dk2+Joxmv+mUTVBES6wac=
 github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
@@ -1645,6 +1649,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
 golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=

+ 11 - 9
internal/helm/config.go

@@ -20,11 +20,12 @@ import (
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 type Form struct {
-	Cluster           *models.Cluster `form:"required"`
-	Repo              repository.Repository
-	DigitalOceanOAuth *oauth2.Config
-	Storage           string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
-	Namespace         string `json:"namespace"`
+	Cluster                   *models.Cluster `form:"required"`
+	Repo                      repository.Repository
+	DigitalOceanOAuth         *oauth2.Config
+	Storage                   string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
+	Namespace                 string `json:"namespace"`
+	AllowInClusterConnections bool
 }
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -32,10 +33,11 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
-		Cluster:           form.Cluster,
-		DefaultNamespace:  form.Namespace,
-		Repo:              form.Repo,
-		DigitalOceanOAuth: form.DigitalOceanOAuth,
+		Cluster:                   form.Cluster,
+		DefaultNamespace:          form.Namespace,
+		Repo:                      form.Repo,
+		DigitalOceanOAuth:         form.DigitalOceanOAuth,
+		AllowInClusterConnections: form.AllowInClusterConnections,
 	}
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)

+ 2 - 1
internal/helm/loader/loader.go

@@ -17,7 +17,7 @@ import (
 )
 
 // RepoIndexToPorterChartList converts an index file to a list of porter charts
-func RepoIndexToPorterChartList(index *repo.IndexFile) types.ListTemplatesResponse {
+func RepoIndexToPorterChartList(index *repo.IndexFile, repoURL string) types.ListTemplatesResponse {
 	// sort the entries before parsing
 	index.SortEntries()
 
@@ -36,6 +36,7 @@ func RepoIndexToPorterChartList(index *repo.IndexFile) types.ListTemplatesRespon
 			Description: indexChart.Description,
 			Icon:        indexChart.Icon,
 			Versions:    versions,
+			RepoURL:     repoURL,
 		}
 
 		porterCharts = append(porterCharts, porterChart)

+ 10 - 4
internal/helm/postrenderer.go

@@ -237,10 +237,14 @@ func (d *DockerSecretsPostRenderer) Run(
 	defer encoder.Close()
 
 	for _, resource := range d.resources {
-		err = encoder.Encode(resource)
+		// if the resource is empty, we skip encoding it to prevent errors. Helm/k8s expects empty resources to take the form "{}",
+		// while this library writes an empty string, causing problems during installation.
+		if len(resource) != 0 {
+			err = encoder.Encode(resource)
 
-		if err != nil {
-			return nil, err
+			if err != nil {
+				return nil, err
+			}
 		}
 	}
 
@@ -314,7 +318,9 @@ func decodeRenderedManifests(
 			return resArr, err
 		}
 
-		resArr = append(resArr, res)
+		if len(res) != 0 {
+			resArr = append(resArr, res)
+		}
 	}
 
 	return resArr, nil

+ 1 - 1
internal/helm/repo/repo.go

@@ -59,7 +59,7 @@ func (hr *HelmRepo) listChartsBasic(
 		return nil, err
 	}
 
-	return loader.RepoIndexToPorterChartList(repoIndex), nil
+	return loader.RepoIndexToPorterChartList(repoIndex, hr.RepoURL), nil
 }
 
 func (hr *HelmRepo) getChartBasic(

+ 109 - 0
internal/kubernetes/agent.go

@@ -688,6 +688,115 @@ func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Jo
 	return resp.Items, nil
 }
 
+// StreamJobs streams a list of jobs to the websocket writer, closing the connection once all jobs have been sent
+func (a *Agent) StreamJobs(namespace string, selectors string, rw *websocket.WebsocketSafeReadWriter) error {
+	run := func() error {
+		errorchan := make(chan error)
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		var wg sync.WaitGroup
+		var once sync.Once
+		var err error
+
+		wg.Add(2)
+
+		go func() {
+			wg.Wait()
+			close(errorchan)
+		}()
+
+		go func() {
+			defer func() {
+				if r := recover(); r != nil {
+					// TODO: add method to alert on panic
+					return
+				}
+			}()
+
+			// listens for websocket closing handshake
+			defer wg.Done()
+
+			for {
+				if _, _, err := rw.ReadMessage(); err != nil {
+					errorchan <- nil
+					return
+				}
+			}
+		}()
+
+		go func() {
+			defer func() {
+				if r := recover(); r != nil {
+					// TODO: add method to alert on panic
+					return
+				}
+			}()
+
+			// listens for websocket closing handshake
+			defer wg.Done()
+
+			continueVal := ""
+
+			for {
+				if ctx.Err() != nil {
+					errorchan <- nil
+					return
+				}
+
+				jobs, err := a.Clientset.BatchV1().Jobs(namespace).List(
+					ctx,
+					metav1.ListOptions{
+						Limit:         100,
+						Continue:      continueVal,
+						LabelSelector: "meta.helm.sh/release-name",
+					},
+				)
+
+				if err != nil {
+					errorchan <- err
+					return
+				}
+
+				for _, job := range jobs.Items {
+					err := rw.WriteJSON(job)
+
+					if err != nil {
+						errorchan <- err
+						return
+					}
+				}
+
+				if jobs.Continue == "" {
+					// we have reached the end of the list of jobs
+					break
+				} else {
+					// start pagination
+					continueVal = jobs.Continue
+				}
+			}
+
+			// at this point, we can return the status finished
+			err := rw.WriteJSON(map[string]interface{}{
+				"streamStatus": "finished",
+			})
+
+			errorchan <- err
+		}()
+
+		for err = range errorchan {
+			once.Do(func() {
+				rw.Close()
+				cancel()
+			})
+		}
+
+		return err
+	}
+
+	return a.RunWebsocketTask(run)
+}
+
 // DeleteJob deletes the job in the given name and namespace.
 func (a *Agent) DeleteJob(name, namespace string) error {
 	return a.Clientset.BatchV1().Jobs(namespace).Delete(

+ 16 - 4
internal/kubernetes/config.go

@@ -34,7 +34,14 @@ import (
 
 // GetDynamicClientOutOfClusterConfig creates a new dynamic client using the OutOfClusterConfig
 func GetDynamicClientOutOfClusterConfig(conf *OutOfClusterConfig) (dynamic.Interface, error) {
-	restConf, err := conf.ToRESTConfig()
+	var restConf *rest.Config
+	var err error
+
+	if conf.AllowInClusterConnections && conf.Cluster.AuthMechanism == models.InCluster {
+		restConf, err = rest.InClusterConfig()
+	} else {
+		restConf, err = conf.ToRESTConfig()
+	}
 
 	if err != nil {
 		return nil, err
@@ -51,6 +58,10 @@ func GetDynamicClientOutOfClusterConfig(conf *OutOfClusterConfig) (dynamic.Inter
 
 // GetAgentOutOfClusterConfig creates a new Agent using the OutOfClusterConfig
 func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
+	if conf.AllowInClusterConnections && conf.Cluster.AuthMechanism == models.InCluster {
+		return GetAgentInClusterConfig()
+	}
+
 	restConf, err := conf.ToRESTConfig()
 
 	if err != nil {
@@ -99,9 +110,10 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // This implements RESTClientGetter
 type OutOfClusterConfig struct {
-	Cluster          *models.Cluster
-	Repo             repository.Repository
-	DefaultNamespace string // optional
+	Cluster                   *models.Cluster
+	Repo                      repository.Repository
+	DefaultNamespace          string // optional
+	AllowInClusterConnections bool
 
 	// Only required if using DigitalOcean OAuth as an auth mechanism
 	DigitalOceanOAuth *oauth2.Config

+ 9 - 8
internal/models/cluster.go

@@ -13,14 +13,15 @@ type ClusterAuth string
 
 // The support cluster candidate auth mechanisms
 const (
-	X509   ClusterAuth = "x509"
-	Basic  ClusterAuth = "basic"
-	Bearer ClusterAuth = "bearerToken"
-	OIDC   ClusterAuth = "oidc"
-	GCP    ClusterAuth = "gcp-sa"
-	AWS    ClusterAuth = "aws-sa"
-	DO     ClusterAuth = "do-oauth"
-	Local  ClusterAuth = "local"
+	X509      ClusterAuth = "x509"
+	Basic     ClusterAuth = "basic"
+	Bearer    ClusterAuth = "bearerToken"
+	OIDC      ClusterAuth = "oidc"
+	GCP       ClusterAuth = "gcp-sa"
+	AWS       ClusterAuth = "aws-sa"
+	DO        ClusterAuth = "do-oauth"
+	Local     ClusterAuth = "local"
+	InCluster ClusterAuth = "in-cluster"
 )
 
 // Cluster is an integration that can connect to a Kubernetes cluster via

+ 4 - 3
internal/usage/usage.go

@@ -136,9 +136,10 @@ func getResourceUsage(opts *GetUsageOpts, clusters []*models.Cluster) (uint, uin
 
 	for _, cluster := range clusters {
 		ooc := &kubernetes.OutOfClusterConfig{
-			Cluster:           cluster,
-			Repo:              opts.Repo,
-			DigitalOceanOAuth: opts.DOConf,
+			Cluster:                   cluster,
+			Repo:                      opts.Repo,
+			DigitalOceanOAuth:         opts.DOConf,
+			AllowInClusterConnections: false,
 		}
 
 		agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)