瀏覽代碼

Merge branch '0.4.0-cli-client-get-release' into 0.2.0-github-actions-tab

merge cli client update
Alexander Belanger 5 年之前
父節點
當前提交
ad60120196
共有 100 個文件被更改,包括 6135 次插入1604 次删除
  1. 4 3
      .github/workflows/dev.yaml
  2. 3 3
      .github/workflows/production.yaml
  3. 1 1
      .github/workflows/release.yaml
  4. 3 3
      .github/workflows/staging.yaml
  5. 113 0
      CONTRIBUTING.md
  6. 10 5
      README.md
  7. 40 0
      cli/cmd/api/k8s.go
  8. 1 0
      cli/cmd/auth.go
  9. 0 1
      cli/cmd/github/release.go
  10. 3 1
      cli/cmd/helm_repo.go
  11. 1 0
      cli/cmd/run.go
  12. 8 0
      cli/cmd/server.go
  13. 1 1
      cli/cmd/version.go
  14. 6 1
      cmd/app/main.go
  15. 1 0
      dashboard/decs.d.ts
  16. 3 2
      dashboard/docker/dev.Dockerfile
  17. 76 13
      dashboard/package-lock.json
  18. 6 2
      dashboard/package.json
  19. 123 0
      dashboard/src/assets/GoogleIcon.tsx
  20. 二進制
      dashboard/src/assets/Light Gradient 08.png
  21. 二進制
      dashboard/src/assets/close-rounded.png
  22. 二進制
      dashboard/src/assets/gradient.png
  23. 5 0
      dashboard/src/assets/upload.svg
  24. 2 2
      dashboard/src/components/ConfirmOverlay.tsx
  25. 6 0
      dashboard/src/components/SaveButton.tsx
  26. 11 1
      dashboard/src/components/TabSelector.tsx
  27. 6 2
      dashboard/src/components/YamlEditor.tsx
  28. 10 0
      dashboard/src/components/image-selector/TagList.tsx
  29. 1 1
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  30. 22 18
      dashboard/src/components/repo-selector/ActionDetails.tsx
  31. 13 6
      dashboard/src/components/repo-selector/ContentsList.tsx
  32. 13 9
      dashboard/src/components/values-form/CheckboxRow.tsx
  33. 319 0
      dashboard/src/components/values-form/FormDebugger.tsx
  34. 478 0
      dashboard/src/components/values-form/FormWrapper.tsx
  35. 9 8
      dashboard/src/components/values-form/InputRow.tsx
  36. 168 30
      dashboard/src/components/values-form/KeyValueArray.tsx
  37. 2 2
      dashboard/src/components/values-form/RangeSlider.tsx
  38. 131 0
      dashboard/src/components/values-form/UploadArea.tsx
  39. 104 79
      dashboard/src/components/values-form/ValuesForm.tsx
  40. 0 177
      dashboard/src/components/values-form/ValuesWrapper.tsx
  41. 4 0
      dashboard/src/index.html
  42. 97 14
      dashboard/src/main/CurrentError.tsx
  43. 14 1
      dashboard/src/main/Main.tsx
  44. 142 57
      dashboard/src/main/auth/Login.tsx
  45. 128 59
      dashboard/src/main/auth/Register.tsx
  46. 29 1
      dashboard/src/main/home/Home.tsx
  47. 0 2
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  48. 7 5
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  49. 43 5
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  50. 418 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  51. 86 11
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  52. 155 95
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  53. 253 76
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  54. 73 8
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  55. 15 19
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  56. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  57. 8 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  58. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  59. 174 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  60. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  61. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  62. 194 20
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  63. 0 16
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  64. 96 39
      dashboard/src/main/home/dashboard/Dashboard.tsx
  65. 27 36
      dashboard/src/main/home/integrations/create-integration/GCRForm.tsx
  66. 39 0
      dashboard/src/main/home/integrations/create-integration/GKEForm.tsx
  67. 173 144
      dashboard/src/main/home/launch/Launch.tsx
  68. 21 6
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  69. 55 142
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  70. 47 12
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  71. 5 0
      dashboard/src/main/home/launch/hardcodedNameDict.tsx
  72. 555 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  73. 407 0
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  74. 454 0
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  75. 169 0
      dashboard/src/main/home/modals/EnvEditorModal.tsx
  76. 0 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  77. 7 1
      dashboard/src/main/home/navbar/Navbar.tsx
  78. 8 208
      dashboard/src/main/home/new-project/NewProject.tsx
  79. 3 3
      dashboard/src/main/home/project-settings/InviteList.tsx
  80. 1 1
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  81. 1 1
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  82. 9 8
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  83. 10 2
      dashboard/src/main/home/provisioner/Provisioner.tsx
  84. 14 14
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  85. 78 31
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  86. 9 4
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  87. 3 1
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  88. 5 1
      dashboard/src/shared/Context.tsx
  89. 115 0
      dashboard/src/shared/ace-porter-theme.js
  90. 30 5
      dashboard/src/shared/api.tsx
  91. 11 1
      dashboard/src/shared/types.tsx
  92. 4 2
      docker-compose.dev.yaml
  93. 0 32
      docs/DEVELOPING.md
  94. 0 26
      docs/GCR.md
  95. 0 103
      docs/GETTING_STARTED.md
  96. 55 0
      docs/deploy/addons/mongo.md
  97. 7 0
      docs/deploy/addons/overview.md
  98. 42 0
      docs/deploy/addons/postgres.md
  99. 25 0
      docs/deploy/addons/redis.md
  100. 92 0
      docs/deploy/applications/deploying-django-application-non-docker.md

+ 4 - 3
.github/workflows/dev.yaml

@@ -14,8 +14,7 @@ jobs:
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
       - name: Install kubectl
-        run: |
-          sudo apt-get install kubectl
+        uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -31,7 +30,9 @@ jobs:
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
           POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
-          APPLICATION_CHART_REPO_URL=${{secrets.APPLICATION_CHART_REPO_URL}}
+          SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
+          APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           EOL
       - name: Build
         run: |

+ 3 - 3
.github/workflows/production.yaml

@@ -14,8 +14,7 @@ jobs:
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
       - name: Install kubectl
-        run: |
-          sudo apt-get install kubectl
+        uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -32,7 +31,8 @@ jobs:
           POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
           POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
-          APPLICATION_CHART_REPO_URL=${{secrets.APPLICATION_CHART_REPO_URL}}
+          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           EOL
       - name: Build
         run: |

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

@@ -84,7 +84,7 @@ jobs:
         run: |
           go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
-          go build -ldflags="-w -s" -a -o ./portersvr ./cmd/app/ &
+          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./portersvr ./cmd/app/ &
           wait
         env:
           GOOS: linux

+ 3 - 3
.github/workflows/staging.yaml

@@ -14,8 +14,7 @@ jobs:
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
       - name: Install kubectl
-        run: |
-          sudo apt-get install kubectl
+        uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -32,7 +31,8 @@ jobs:
           POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
           POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
-          APPLICATION_CHART_REPO_URL=${{secrets.APPLICATION_CHART_REPO_URL}}
+          APPLICATION_CHART_REPO_URL=https://charts.staging.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev
           EOL
       - name: Build
         run: |

+ 113 - 0
CONTRIBUTING.md

@@ -0,0 +1,113 @@
+# Contributing to Porter
+
+First off, thanks for considering contributing to Porter. There are many types of contributions you can make, including bug reports and fixes, improving documentation, writing tutorials, and larger feature requests or changes. You can contribute to this repo or the [porter-charts](https://github.com/porter-dev/porter-charts) repo if you're interested in developing charts. 
+
+Before you contribute, make sure to read these guidelines thoroughly, so that you can get your pull request reviewed and finalized as quickly as possible. 
+
+- [Reporting Issues](#reporting-issues)
+- [Development Process Overview](#development-process-overview)
+  * [Good first issues and bug fixes](#good-first-issues-and-bug-fixes)
+  * [Improving Documentation and Writing Tutorials](#improving-documentation-and-writing-tutorials)
+  * [Features](#features)
+- [Writing Code](#writing-code)
+  * [Navigating the Codebase](#navigating-the-codebase)
+  * [Getting started](#getting-started)
+  * [Testing](#testing)
+- [Making the PR](#making-the-pr)
+
+> **Note:** we're still working on our contributing process, as we're a young project. If you'd like to suggest or discuss changes to this document or the process in general, we're very open to suggestions and would appreciate if you reach out on Discord or at [contact@getporter.dev](mailto:contact@getporter.dev)! To suggest additions to this document, feel free to raise an issue or make a PR with the changes. 
+
+## Reporting Issues
+
+> **IMPORTANT:** If you've found a security issue, please email us directly at [contact@getporter.dev](mailto:contact@getporter.dev) instead of raising a public issue.
+
+Bug reports help make Porter better for everyone. To create a bug report, select the "Bug Report" template when you create a new issue. This template will provide you with a structure so we can best recreate the issue. Please search within our issues before raising a new one to make sure you're not raising a duplicate.
+
+## Development Process Overview 
+
+We officially build new releases every other Friday, but we merge new features and fixes to our hosted version as soon as those features are ready. If the PR can get reviewed and merged before the next release, we will add it to our public roadmap for that upcoming release (https://github.com/porter-dev/porter/projects), which gets announced to the community every other Friday.
+
+### Good first issues and bug fixes
+
+> **Note:** if you're a first-time contributor, we recommend that you [follow this tutorial](http://makeapullrequest.com/) to learn how to start contributing. 
+
+If you want to start getting familiar with Porter's codebase, we do our best to tag issues with [`good-first-issue`](https://github.com/porter-dev/porter/labels/good%20first%20issue) if the issue is very limited in scope or only requires changes to a few localized files. If you'd like to be assigned an issue, feel free to reach out on Discord or over email, or you can simply comment on an issue you'd like to work on. 
+
+### Improving Documentation and Writing Tutorials
+
+Documentation is hosted at [docs.getporter.dev](https://docs.getporter.dev). To update existing documentation, you can suggest changes directly from the docs website. To create new documentation or write a tutorial, you can add a document in the `/docs` folder and make a PR directly. 
+
+### Features
+
+If you'd like to suggest a feature, we have a **#suggestions** channel on Discord that we frequently check to see which new features to work on. If you'd like to suggest and also work on the feature, we ask that you first message one of us on Discord or send an email describing the feature -- some features may not be entirely feasible. We require that all features have a detailed spec written in a PR **before** work on that feature begins. We will then review that spec in the PR discussion until the spec is clear and accomplishes the end goal of the feature request. 
+
+## Writing Code 
+
+Our backend is written in Golang, and our frontend is written in Typescript (using React). The root of the project is a Go repository containing `go.mod` and `go.sum`, while the `/dashboard` folder contains the React app with `package.json`. Our templates/add-ons are hosted in other repositories and are written using Helm (more info on contributing to these repositories will be added soon). 
+
+### Navigating the Codebase
+
+Here's an annotated directory structure to assist you in navigating the codebase. This only lists the most important folders and packages: 
+
+```bash
+.
+├── cli              # CLI commands and runtime
+├── cmd              # Entrypoint packages (main.go files)
+│   └── app            # The primary entrypoint to running the server
+├── dashboard        # contains the frontend React app
+├── internal         # Internal Go packages
+│   ├── forms          # contains the web form specifications for POST requests
+│   ├── helm           # contains the logic for performing helm actions
+│   ├── kubernetes     # contains the logic for interacting with the kubernetes api
+│   ├── models         # contains the DB (and some other shared) models
+│   └── repository     # implements a repository pattern for DB CRUD operations using gorm
+├── scripts          # contains build scripts for releases (rarely modified)
+├── server           # contains routes, API handlers, and server middleware
+│   ├── api            # api handlers
+│   └── router         # routes and routing middleware
+└── services         # contains auxiliary stand-alone services that are run on Porter
+```
+
+### Getting started
+
+If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should save two `.env` files in the repo. 
+
+First, in `/dashboard/.env`:
+
+```
+NODE_ENV=development
+API_SERVER=localhost:8080
+```
+
+Next, in `/docker/.env`:
+
+```
+SERVER_URL=http://localhost:8080
+SERVER_PORT=8080
+DB_HOST=postgres
+DB_PORT=5432
+DB_USER=porter
+DB_PASS=porter
+DB_NAME=porter
+SQL_LITE=false
+```
+
+Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. 
+
+At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
+
+For a more detailed development guide, [go here](/docs/developing/setup.md). 
+
+Happy developing!
+
+### Testing 
+
+All backend changes made after [release 0.2.0](https://github.com/porter-dev/porter/projects/2) will require tests. Backend testing is done using Golang's [built in testing package](https://golang.org/pkg/testing/). Before pushing changes, run `go test ./...` in the root directory and make sure that your changes did not break any tests. While we don't require 100% code coverage for tests, we expect tests to cover all functionality and common edge cases/exceptions. If you're fixing a backend bug, add a test to ensure that bug doesn't come up again. 
+
+We do not currently have a process for frontend testing -- if building out a frontend testing process is something you'd like to work on, don't hesitate to reach out as this is something we'll introduce soon. 
+
+## Making the PR
+
+To ensure that your PR is merged before an upcoming release, it is easiest if you prefix the branch with the release version you'd like to be finished by (the upcoming two releases will always exist at https://github.com/porter-dev/porter/projects). If your pull request is related to an issue, please mention that issue in the branch name. So for example, if I'd like to close issue `200` and I'd like to merge the PR by release `0.3.0`, I would run `git checkout -b 0.3.0-200-pod-deletion`. 
+
+For now, request [**@abelanger5**](https://github.com/abelanger5) to review your PR. 

+ 10 - 5
README.md

@@ -1,6 +1,6 @@
 # Porter
 
-[![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/34n7NN7FJ7)
+[![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/mmGAw5nNjr)
 [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow)](https://twitter.com/getporterdev)
 
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to your own AWS/GCP account, while upgrading your infrastructure to Kubernetes. Get started on Porter without the overhead of DevOps and customize your infrastructure later when you need to.
@@ -9,7 +9,7 @@
 
 ## Community and Updates
 
-For help, questions, or if you just want a place to hang out, [join our Discord community.](https://discord.gg/34n7NN7FJ7)
+For help, questions, or if you just want a place to hang out, [join our Discord community.](https://discord.gg/mmGAw5nNjr)
 
 To keep updated on our progress, please watch the repo for new releases (**Watch > Custom > Releases**) and [follow us on Twitter](https://twitter.com/getporterdev)!
 
@@ -36,6 +36,7 @@ Porter brings the simplicity of a traditional PaaS to your own cloud provider wh
 - Heroku-like GUI to monitor application status, logs, and history
 - Application rollback to previously deployed versions
 - Zero-downtime deploy and health checks
+- Monitor CPU, RAM, and Network usage per deployment
 - Marketplace for one click add-ons (e.g. MongoDB, Redis, PostgreSQL)
 
 ### DevOps Mode
@@ -44,7 +45,7 @@ For those who are familiar with Kubernetes and Helm:
 
 - Connect to existing Kubernetes clusters that are not provisioned by Porter
 - Visualize, deploy, and configure Helm charts via the GUI
-- User-generated [form overlays](https://docs.getporter.dev/docs/porter-templates) for managing `values.yaml`
+- User-generated [form overlays](https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md) for managing `values.yaml`
 - In-depth view of releases, including revision histories and component graphs
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 
@@ -60,10 +61,14 @@ Below are instructions for a quickstart. For full documentation, please visit ou
 
 2. Create a Project and [put in your cloud provider credentials](https://docs.getporter.dev/docs/getting-started-with-porter-on-aws). Porter will automatically provision a Kubernetes cluster in your own cloud. It is also possible to [link up an existing Kubernetes cluster.](https://docs.getporter.dev/docs/cli-documentation#connecting-to-an-existing-cluster)
 
-3. Deploy your applications from a [git repository](https://docs.getporter.dev/docs/applications) or [Docker image registry](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure).
+3. 🚀 Deploy your applications from a [git repository](https://docs.getporter.dev/docs/applications) or [Docker image registry](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure).
+
+## Running Porter Locally
+
+While it requires a few additional steps, it is possible to run Porter locally. Follow [this guide](https://docs.getporter.dev/docs/running-porter-locally) to run the local version of Porter.
 
 ## Want to Help?
 
-We welcome all contributions. Submit an issue or a pull request to help us improve Porter! If you're interested in contributing, please [join our Discord community](https://discord.gg/34n7NN7FJ7) for more info.
+We welcome all contributions. If you're interested in contributing, please read our [contributing guide](https://github.com/porter-dev/porter/blob/master/CONTRIBUTING.md) and [join our Discord community](https://discord.gg/GJynMR3KXK).
 
 ![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)

+ 40 - 0
cli/cmd/api/k8s.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"net/url"
 
+	"github.com/porter-dev/porter/server/api"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -87,6 +88,45 @@ func (c *Client) GetKubeconfig(
 	return bodyResp, nil
 }
 
+// GetReleaseLatestRevision gets the latest revision of a Helm release
+type GetReleaseResponse api.PorterRelease
+
+// GetK8sAllPods gets all pods for a given release
+func (c *Client) GetRelease(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, name string,
+) (GetReleaseResponse, error) {
+	cl := fmt.Sprintf("%d", clusterID)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/releases/%s/0?"+url.Values{
+			"cluster_id": []string{cl},
+			"namespace":  []string{namespace},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projectID, name),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetReleaseResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}
+
 // GetReleaseAllPodsResponse is the list of all pods for a given Helm release
 type GetReleaseAllPodsResponse []v1.Pod
 

+ 1 - 0
cli/cmd/auth.go

@@ -80,6 +80,7 @@ func login() error {
 	user, _ := client.AuthCheck(context.Background())
 
 	if user != nil {
+		color.Yellow(getToken())
 		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
 		return nil
 	}

+ 0 - 1
cli/cmd/github/release.go

@@ -156,7 +156,6 @@ func (z *ZIPReleaseGetter) getDownloadRegexp() (*regexp.Regexp, error) {
 // // it, and adds the binary to the porter directory
 // func DownloadLatestServerRelease(porterDir string) error {
 // 	releaseURL, staticReleaseURL, err := getLatestReleaseDownloadURL()
-// 	fmt.Println(releaseURL)
 
 // 	if err != nil {
 // 		return err

+ 3 - 1
cli/cmd/helm_repo.go

@@ -113,7 +113,9 @@ func listHelmRepoCharts(user *api.AuthCheckResponse, client *api.Client, args []
 	fmt.Fprintf(w, "%s\t%s\n", "NAME", "VERSION")
 
 	for _, chart := range charts {
-		fmt.Fprintf(w, "%s\t%s\n", strings.ToLower(chart.Name), chart.Version)
+		for _, version := range chart.Versions {
+			fmt.Fprintf(w, "%s\t%s\n", strings.ToLower(chart.Name), version)
+		}
 	}
 
 	w.Flush()

+ 1 - 0
cli/cmd/run.go

@@ -156,6 +156,7 @@ func executeRun(config *rest.Config, namespace, name string, args []string) erro
 	t := term.TTY{
 		In:  os.Stdin,
 		Out: os.Stdout,
+		Raw: true,
 	}
 
 	fn := func() error {

+ 8 - 0
cli/cmd/server.go

@@ -205,6 +205,14 @@ func startLocal(
 		"REDIS_ENABLED=false",
 	}...)
 
+	if _, found := os.LookupEnv("GITHUB_ENABLED"); !found {
+		cmdPorter.Env = append(cmdPorter.Env, "GITHUB_ENABLED=false")
+	}
+
+	if _, found := os.LookupEnv("PROVISIONER_ENABLED"); !found {
+		cmdPorter.Env = append(cmdPorter.Env, "PROVISIONER_ENABLED=false")
+	}
+
 	cmdPorter.Stdout = os.Stdout
 	cmdPorter.Stderr = os.Stderr
 

+ 1 - 1
cli/cmd/version.go

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

+ 6 - 1
cmd/app/main.go

@@ -102,14 +102,19 @@ func main() {
 		go prov.GlobalStreamListener(redis, *repo, errorChan)
 	}
 
-	a, _ := api.New(&api.AppConfig{
+	a, err := api.New(&api.AppConfig{
 		Logger:     logger,
 		Repository: repo,
 		ServerConf: appConf.Server,
 		RedisConf:  &appConf.Redis,
+		CapConf:    appConf.Capabilities,
 		DBConf:     appConf.Db,
 	})
 
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	appRouter := router.New(a)
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)

+ 1 - 0
dashboard/decs.d.ts

@@ -0,0 +1 @@
+declare module "js-yaml";

+ 3 - 2
dashboard/docker/dev.Dockerfile

@@ -5,10 +5,11 @@ WORKDIR /webpack
 
 COPY package*.json ./
 
-RUN npm install
-
 ENV NODE_ENV=development
 
+RUN npm install
+RUN npm i -g http-parser-js
+
 COPY . ./
 
 CMD npm start

+ 76 - 13
dashboard/package-lock.json

@@ -556,11 +556,6 @@
       "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
       "dev": true
     },
-    "@types/js-yaml": {
-      "version": "3.12.5",
-      "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz",
-      "integrity": "sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww=="
-    },
     "@types/json-schema": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
@@ -690,6 +685,12 @@
         "@types/react": "*"
       }
     },
+    "@types/semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-iotVxtCCsPLRAvxMFFgxL8HD2l4mAZ2Oin7/VJ2ooWO0VOK4EGOGmZWZn1uCq7RofR3I/1IOSjCHlFT71eVK0Q==",
+      "dev": true
+    },
     "@types/source-list-map": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@@ -1359,6 +1360,11 @@
       "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
       "dev": true
     },
+    "anser": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/anser/-/anser-2.0.1.tgz",
+      "integrity": "sha512-4g5Np4CVD3c5c/36Mj0jllEA5bQcuXF0dqakZcuHGeubBzw93EAhwRuQCzgFm4/ZwvyBMzFdtn9BcihOjnxIdQ=="
+    },
     "ansi-colors": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz",
@@ -1746,6 +1752,11 @@
       "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=",
       "dev": true
     },
+    "brace": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz",
+      "integrity": "sha1-SJb8ydVE7vRfS7dmDbMg07N5/lg="
+    },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -2418,6 +2429,14 @@
         "semver": "^5.5.0",
         "shebang-command": "^1.2.0",
         "which": "^1.2.9"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+          "dev": true
+        }
       }
     },
     "crypto-browserify": {
@@ -4044,6 +4063,11 @@
       "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
       "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
     },
+    "highlight.run": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/highlight.run/-/highlight.run-1.4.5.tgz",
+      "integrity": "sha512-rxStmRGVUtnT0CZ6kbjAv/DomrzjZ/xfj7cPmFy3Ua4W23RSgp5V/m0BdS7FmTumhAQ1jVmdnbLv3DiY/m0xzQ=="
+    },
     "history": {
       "version": "4.10.1",
       "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
@@ -4759,12 +4783,18 @@
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
     "js-yaml": {
-      "version": "3.14.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
-      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
       "requires": {
-        "argparse": "^1.0.7",
-        "esprima": "^4.0.0"
+        "argparse": "^2.0.1"
+      },
+      "dependencies": {
+        "argparse": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+          "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+        }
       }
     },
     "jsesc": {
@@ -4979,6 +5009,14 @@
       "requires": {
         "pify": "^4.0.1",
         "semver": "^5.6.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+          "dev": true
+        }
       }
     },
     "map-cache": {
@@ -5371,6 +5409,13 @@
       "requires": {
         "object.getownpropertydescriptors": "^2.0.3",
         "semver": "^5.7.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
+        }
       }
     },
     "node-forge": {
@@ -6566,9 +6611,27 @@
       }
     },
     "semver": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "requires": {
+        "lru-cache": "^6.0.0"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+        }
+      }
     },
     "send": {
       "version": "0.17.1",

+ 6 - 2
dashboard/package.json

@@ -6,7 +6,6 @@
     "@material-ui/core": "^4.11.3",
     "@types/d3-array": "^2.9.0",
     "@types/d3-time-format": "^3.0.0",
-    "@types/js-yaml": "^3.12.5",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/material-ui": "^0.21.8",
@@ -23,13 +22,16 @@
     "@visx/shape": "^1.4.0",
     "@visx/tooltip": "^1.3.0",
     "ace-builds": "^1.4.12",
+    "anser": "^2.0.1",
     "axios": "^0.20.0",
+    "brace": "^0.11.1",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
+    "highlight.run": "^1.4.5",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
-    "js-yaml": "^3.14.0",
+    "js-yaml": "^4.1.0",
     "lodash": "^4.17.20",
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
@@ -39,6 +41,7 @@
     "react-dom": "^16.13.1",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
+    "semver": "^7.3.5",
     "styled-components": "^5.2.0"
   },
   "scripts": {
@@ -60,6 +63,7 @@
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
     "@types/react-router-dom": "^5.1.5",
+    "@types/semver": "^7.3.5",
     "@types/styled-components": "^5.1.3",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",

+ 123 - 0
dashboard/src/assets/GoogleIcon.tsx

@@ -0,0 +1,123 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {};
+
+type StateType = {};
+
+export default class GHIcon extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <Svg width="46px" height="46px" viewBox="0 0 46 46">
+        <title>btn_google_light_normal_ios</title>
+        <desc>Created with Sketch.</desc>
+        <defs>
+          <filter
+            x="-50%"
+            y="-50%"
+            width="200%"
+            height="200%"
+            filterUnits="objectBoundingBox"
+            id="filter-1"
+          >
+            <feOffset
+              dx="0"
+              dy="1"
+              in="SourceAlpha"
+              result="shadowOffsetOuter1"
+            ></feOffset>
+            <feGaussianBlur
+              stdDeviation="0.5"
+              in="shadowOffsetOuter1"
+              result="shadowBlurOuter1"
+            ></feGaussianBlur>
+            <feColorMatrix
+              values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.168 0"
+              in="shadowBlurOuter1"
+              type="matrix"
+              result="shadowMatrixOuter1"
+            ></feColorMatrix>
+            <feOffset
+              dx="0"
+              dy="0"
+              in="SourceAlpha"
+              result="shadowOffsetOuter2"
+            ></feOffset>
+            <feGaussianBlur
+              stdDeviation="0.5"
+              in="shadowOffsetOuter2"
+              result="shadowBlurOuter2"
+            ></feGaussianBlur>
+            <feColorMatrix
+              values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.084 0"
+              in="shadowBlurOuter2"
+              type="matrix"
+              result="shadowMatrixOuter2"
+            ></feColorMatrix>
+            <feMerge>
+              <feMergeNode in="shadowMatrixOuter1"></feMergeNode>
+              <feMergeNode in="shadowMatrixOuter2"></feMergeNode>
+              <feMergeNode in="SourceGraphic"></feMergeNode>
+            </feMerge>
+          </filter>
+          <rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
+        </defs>
+        <g
+          id="Google-Button"
+          stroke="none"
+          stroke-width="1"
+          fill="none"
+          fill-rule="evenodd"
+        >
+          <g id="9-PATCH" transform="translate(-608.000000, -160.000000)"></g>
+          <g
+            id="btn_google_light_normal"
+            transform="translate(-1.000000, -1.000000)"
+          >
+            <g
+              id="button"
+              transform="translate(4.000000, 4.000000)"
+              filter="url(#filter-1)"
+            >
+              <g id="button-bg">
+                <use fill="#FFFFFF" fill-rule="evenodd"></use>
+                <use fill="none"></use>
+                <use fill="none"></use>
+                <use fill="none"></use>
+              </g>
+            </g>
+            <g
+              id="logo_googleg_48dp"
+              transform="translate(15.000000, 15.000000)"
+            >
+              <path
+                d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z"
+                id="Shape"
+                fill="#4285F4"
+              ></path>
+              <path
+                d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z"
+                id="Shape"
+                fill="#34A853"
+              ></path>
+              <path
+                d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z"
+                id="Shape"
+                fill="#FBBC05"
+              ></path>
+              <path
+                d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z"
+                id="Shape"
+                fill="#EA4335"
+              ></path>
+              <path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape"></path>
+            </g>
+            <g id="handles_square"></g>
+          </g>
+        </g>
+      </Svg>
+    );
+  }
+}
+
+const Svg = styled.svg``;

二進制
dashboard/src/assets/Light Gradient 08.png


二進制
dashboard/src/assets/close-rounded.png


二進制
dashboard/src/assets/gradient.png


+ 5 - 0
dashboard/src/assets/upload.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M18.8088 9.021C18.3573 9.021 17.7592 9.011 17.0146 9.011C15.1987 9.011 13.7055 7.508 13.7055 5.675V2.459C13.7055 2.206 13.5026 2 13.253 2H7.96363C5.49517 2 3.5 4.026 3.5 6.509V17.284C3.5 19.889 5.59022 22 8.16958 22H16.0453C18.5058 22 20.5 19.987 20.5 17.502V9.471C20.5 9.217 20.298 9.012 20.0465 9.013C19.6247 9.016 19.1168 9.021 18.8088 9.021Z" fill="white"/>
+<path opacity="0.4" d="M16.0842 2.56729C15.7852 2.25629 15.2632 2.47029 15.2632 2.90129V5.53829C15.2632 6.64429 16.1742 7.55429 17.2792 7.55429C17.9772 7.56229 18.9452 7.56429 19.7672 7.56229C20.1882 7.56129 20.4022 7.05829 20.1102 6.75429C19.0552 5.65729 17.1662 3.69129 16.0842 2.56729Z" fill="white"/>
+<path d="M15.1052 12.8837C14.8142 13.1727 14.3432 13.1747 14.0512 12.8817L12.4622 11.2847V16.1117C12.4622 16.5227 12.1282 16.8567 11.7172 16.8567C11.3062 16.8567 10.9732 16.5227 10.9732 16.1117V11.2847L9.3822 12.8817C9.0922 13.1747 8.6202 13.1727 8.3292 12.8837C8.0382 12.5947 8.0372 12.1227 8.3272 11.8307L11.1892 8.95569C11.1902 8.95469 11.1902 8.95469 11.1902 8.95469C11.2582 8.88669 11.3402 8.83169 11.4302 8.79469C11.5202 8.75669 11.6182 8.73669 11.7172 8.73669C11.8172 8.73669 11.9152 8.75669 12.0052 8.79469C12.0942 8.83169 12.1752 8.88669 12.2432 8.95369C12.2442 8.95469 12.2452 8.95469 12.2452 8.95569L15.1072 11.8307C15.3972 12.1227 15.3972 12.5947 15.1052 12.8837Z" fill="white"/>
+</svg>

+ 2 - 2
dashboard/src/components/ConfirmOverlay.tsx

@@ -4,8 +4,8 @@ import styled from "styled-components";
 type PropsType = {
   message: string;
   show: boolean;
-  onYes: () => void;
-  onNo: () => void;
+  onYes: React.MouseEventHandler;
+  onNo: React.MouseEventHandler;
 };
 
 type StateType = {};

+ 6 - 0
dashboard/src/components/SaveButton.tsx

@@ -81,6 +81,12 @@ const StatusWrapper = styled.div`
   font-size: 13px;
   color: #ffffff55;
   margin-right: 25px;
+  padding: 0 10px;
+
+  max-width: 500px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 
   > i {
     font-size: 18px;

+ 11 - 1
dashboard/src/components/TabSelector.tsx

@@ -44,6 +44,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
     return (
       <StyledTabSelector>
         <TabWrapper>
+          <Line />
           {this.renderTabList()}
           <Tab lastItem={true} highlight={null}>
             {this.props.noBuffer ? null : <Buffer />}
@@ -55,6 +56,16 @@ export default class TabSelector extends Component<PropsType, StateType> {
   }
 }
 
+const Line = styled.div`
+  height: 1px;
+  position: absolute;
+  top: 29px;
+  z-index: 0;
+  left: 0;
+  background: #aaaabb55;
+  width: 100%;
+`;
+
 const Buffer = styled.div`
   width: 138px;
   height: 10px;
@@ -98,7 +109,6 @@ const StyledTabSelector = styled.div`
   display: flex;
   width: calc(100% - 2px);
   align-items: center;
-  border-bottom: 1px solid #aaaabb55;
   padding-bottom: 1px;
   margin-left: 1px;
   position: relative;

+ 6 - 2
dashboard/src/components/YamlEditor.tsx

@@ -2,8 +2,8 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import AceEditor from "react-ace";
 
+import "shared/ace-porter-theme";
 import "ace-builds/src-noconflict/mode-yaml";
-import "ace-builds/src-noconflict/theme-terminal";
 
 type PropsType = {
   value: string;
@@ -45,7 +45,7 @@ class YamlEditor extends Component<PropsType, StateType> {
           <AceEditor
             mode="yaml"
             value={this.props.value}
-            theme="terminal"
+            theme="porter"
             onChange={this.props.onChange}
             name="codeEditor"
             readOnly={this.props.readOnly}
@@ -53,6 +53,10 @@ class YamlEditor extends Component<PropsType, StateType> {
             height={this.props.height}
             width="100%"
             style={{ borderRadius: "5px" }}
+            showPrintMargin={false}
+            showGutter={true}
+            highlightActiveLine={true}
+            fontSize={14}
           />
         </Editor>
       </Holder>

+ 10 - 0
dashboard/src/components/image-selector/TagList.tsx

@@ -8,6 +8,8 @@ import { Context } from "shared/Context";
 
 import Loading from "../Loading";
 
+var ecrRepoRegex = /(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?/gim;
+
 type PropsType = {
   setSelectedTag: (x: string) => void;
   selectedTag: string;
@@ -32,8 +34,16 @@ export default class TagList extends Component<PropsType, StateType> {
 
   componentDidMount() {
     const { currentProject } = this.context;
+
     let splits = this.props.selectedImageUrl.split("/");
     let repoName = splits[splits.length - 1];
+
+    let matches = this.props.selectedImageUrl.match(ecrRepoRegex);
+
+    if (matches) {
+      repoName = this.props.selectedImageUrl.split(/\/(.+)/)[1];
+    }
+
     api
       .getImageTags(
         "<token>",

+ 1 - 1
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -215,7 +215,7 @@ const BackButton = styled.div`
   margin-bottom: -7px;
   padding-right: 15px;
   border: 1px solid #ffffff55;
-  border-radius: 3px;
+  border-radius: 100px;
   width: ${(props: { width: string }) => props.width};
   color: white;
   background: #ffffff11;

+ 22 - 18
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -3,8 +3,8 @@ import React, { Component } from "react";
 import styled from "styled-components";
 
 import { integrationList } from "shared/common";
-import { Context } from "../../shared/Context";
-import api from "../../shared/api";
+import { Context } from "shared/Context";
+import api from "shared/api";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../values-form/InputRow";
@@ -164,26 +164,30 @@ export default class ActionDetails extends Component<PropsType, StateType> {
               this.props.setFolderPath(null);
               this.props.setProcfilePath(null);
               this.props.setProcfileProcess(null);
+              this.props.setSelectedRegistry(null);
             }}
           >
             <i className="material-icons">keyboard_backspace</i>
             Select Folder
           </BackButton>
-          {!this.props.procfilePath && !this.props.dockerfilePath ? (
-            <StatusWrapper>
-              <i className="material-icons">error_outline</i>
-              Procfile not detected.
-            </StatusWrapper>
-          ) : this.props.selectedRegistry ? (
-            <StatusWrapper successful={true}>
-              <i className="material-icons">done</i> Source selected
-            </StatusWrapper>
-          ) : (
-            <StatusWrapper>
-              <i className="material-icons">error_outline</i>A connected
-              container registry is required
-            </StatusWrapper>
-          )}
+          {
+            // !this.props.procfilePath && !this.props.dockerfilePath ? (
+            //   <StatusWrapper>
+            //     <i className="material-icons">error_outline</i>
+            //     Procfile not detected.
+            //   </StatusWrapper>
+            // ) :
+            this.props.selectedRegistry ? (
+              <StatusWrapper successful={true}>
+                <i className="material-icons">done</i> Source selected
+              </StatusWrapper>
+            ) : (
+              <StatusWrapper>
+                <i className="material-icons">error_outline</i>A connected
+                container registry is required
+              </StatusWrapper>
+            )
+          }
         </Flex>
       </>
     );
@@ -327,7 +331,7 @@ const BackButton = styled.div`
   margin-bottom: -7px;
   padding-right: 15px;
   border: 1px solid #ffffff55;
-  border-radius: 3px;
+  border-radius: 100px;
   width: ${(props: { width: string }) => props.width};
   color: white;
   background: #ffffff11;

+ 13 - 6
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -197,8 +197,8 @@ export default class ContentsList extends Component<PropsType, StateType> {
       if (fileName.includes("Dockerfile")) {
         dockerfiles.push(fileName);
       }
-      if (fileName.includes("Procfile")) {
-        this.props.setProcfilePath(item.Path);
+      if (this.state.currentDir === "" && fileName == "Procfile") {
+        this.props.setProcfilePath("./Procfile");
       }
     });
     if (dockerfiles.length > 0) {
@@ -214,7 +214,9 @@ export default class ContentsList extends Component<PropsType, StateType> {
 
   renderOverlay = () => {
     if (this.props.procfilePath) {
-      let processes = Object.keys(this.state.processes);
+      let processes = this.state.processes
+        ? Object.keys(this.state.processes)
+        : [];
       return (
         <Overlay>
           <BgOverlay
@@ -289,7 +291,12 @@ export default class ContentsList extends Component<PropsType, StateType> {
           <ConfirmButton
             onClick={() => {
               this.props.setFolderPath(this.state.currentDir || "./");
-              this.props.setProcfilePath("./Procfile");
+              if (
+                this.state.processes &&
+                Object.keys(this.state.processes).length > 0
+              ) {
+                this.props.setProcfilePath("./Procfile");
+              }
             }}
           >
             No, I don't want to use a Dockerfile
@@ -325,7 +332,7 @@ ContentsList.contextType = Context;
 const FlexWrapper = styled.div`
   position: absolute;
   bottom: 28px;
-  left: 185px;
+  left: 195px;
   display: flex;
   align-items: center;
 `;
@@ -475,7 +482,7 @@ const UseButton = styled.div`
   background: #616feecc;
   font-weight: 500;
   padding: 10px 15px;
-  border-radius: 3px;
+  border-radius: 100px;
   box-shadow: 0 2px 5px 0 #00000030;
   cursor: pointer;
   :hover {

+ 13 - 9
dashboard/src/components/values-form/CheckboxRow.tsx

@@ -5,7 +5,8 @@ type PropsType = {
   label: string;
   checked: boolean;
   toggle: () => void;
-  required?: boolean;
+  isRequired?: boolean;
+  disabled?: boolean;
 };
 
 type StateType = {};
@@ -14,12 +15,15 @@ export default class CheckboxRow extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledCheckboxRow>
-        <CheckboxWrapper onClick={this.props.toggle}>
+        <CheckboxWrapper
+          disabled={this.props.disabled}
+          onClick={!this.props.disabled ? this.props.toggle : undefined}
+        >
           <Checkbox checked={this.props.checked}>
             <i className="material-icons">done</i>
           </Checkbox>
           {this.props.label}
-          {this.props.required && <Required>*</Required>}
+          {this.props.isRequired && <Required>*</Required>}
         </CheckboxWrapper>
       </StyledCheckboxRow>
     );
@@ -31,10 +35,11 @@ const Required = styled.section`
   color: #fc4976;
 `;
 
-const CheckboxWrapper = styled.div`
+const CheckboxWrapper = styled.div<{ disabled?: boolean }>`
   display: flex;
   align-items: center;
-  cursor: pointer;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  font-size: 13px;
   :hover {
     > div {
       background: #ffffff22;
@@ -42,14 +47,13 @@ const CheckboxWrapper = styled.div`
   }
 `;
 
-const Checkbox = styled.div`
+const Checkbox = styled.div<{ checked: boolean }>`
   width: 16px;
   height: 16px;
   border: 1px solid #ffffff55;
   margin: 1px 10px 0px 1px;
   border-radius: 3px;
-  background: ${(props: { checked: boolean }) =>
-    props.checked ? "#ffffff22" : "#ffffff11"};
+  background: ${(props) => (props.checked ? "#ffffff22" : "#ffffff11")};
   display: flex;
   align-items: center;
   justify-content: center;
@@ -57,7 +61,7 @@ const Checkbox = styled.div`
   > i {
     font-size: 12px;
     padding-left: 0px;
-    display: ${(props: { checked: boolean }) => (props.checked ? "" : "none")};
+    display: ${(props) => (props.checked ? "" : "none")};
   }
 `;
 

+ 319 - 0
dashboard/src/components/values-form/FormDebugger.tsx

@@ -0,0 +1,319 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import AceEditor from "react-ace";
+import FormWrapper from "components/values-form/FormWrapper";
+import CheckboxRow from "components/values-form/CheckboxRow";
+import InputRow from "components/values-form/InputRow";
+import yaml from "js-yaml";
+
+import "shared/ace-porter-theme";
+import "ace-builds/src-noconflict/mode-text";
+
+import Heading from "./Heading";
+import Helper from "./Helper";
+
+type PropsType = {
+  goBack: () => void;
+};
+
+type StateType = {
+  rawYaml: string;
+  showBonusTabs: boolean;
+  showStateDebugger: boolean;
+  valuesToOverride: any;
+  checkbox_a: boolean;
+  input_a: string;
+  isReadOnly: boolean;
+};
+
+const tabOptions = [
+  { value: "a", label: "Bonus Tab A" },
+  { value: "b", label: "Bonus Tab B" },
+];
+
+export default class FormDebugger extends Component<PropsType, StateType> {
+  state = {
+    rawYaml: initYaml,
+    showBonusTabs: false,
+    showStateDebugger: true,
+    valuesToOverride: {
+      checkbox_a: {
+        value: true,
+      },
+    } as any,
+    checkbox_a: true,
+    input_a: "",
+    isReadOnly: false,
+  };
+
+  renderTabContents = (currentTab: string) => {
+    return (
+      <TabWrapper>
+        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
+      </TabWrapper>
+    );
+  };
+
+  aceEditorRef = React.createRef<AceEditor>();
+  render() {
+    let formData = {};
+    try {
+      formData = yaml.load(this.state.rawYaml);
+    } catch (err: any) {
+      console.log("YAML parsing error.");
+    }
+    return (
+      <StyledFormDebugger>
+        <Button onClick={this.props.goBack}>
+          <i className="material-icons">keyboard_backspace</i>
+          Back
+        </Button>
+        <Heading>✨ Form.yaml Editor</Heading>
+        <Helper>Write and test form.yaml free of consequence.</Helper>
+
+        <EditorWrapper>
+          <AceEditor
+            ref={this.aceEditorRef}
+            mode="yaml"
+            value={this.state.rawYaml}
+            theme="porter"
+            onChange={(e: string) => this.setState({ rawYaml: e })}
+            name="codeEditor"
+            editorProps={{ $blockScrolling: true }}
+            height="450px"
+            width="100%"
+            style={{
+              borderRadius: "5px",
+              border: "1px solid #ffffff22",
+              marginTop: "27px",
+              marginBottom: "27px",
+            }}
+            showPrintMargin={false}
+            showGutter={true}
+            highlightActiveLine={true}
+          />
+        </EditorWrapper>
+
+        <CheckboxRow
+          label="Show form state debugger"
+          checked={this.state.showStateDebugger}
+          toggle={() =>
+            this.setState({ showStateDebugger: !this.state.showStateDebugger })
+          }
+        />
+        <CheckboxRow
+          label="Read-only"
+          checked={this.state.isReadOnly}
+          toggle={() =>
+            this.setState({
+              isReadOnly: !this.state.isReadOnly,
+            })
+          }
+        />
+        <CheckboxRow
+          label="Include non-form dummy tabs"
+          checked={this.state.showBonusTabs}
+          toggle={() =>
+            this.setState({ showBonusTabs: !this.state.showBonusTabs })
+          }
+        />
+        <CheckboxRow
+          label="checkbox_a"
+          checked={this.state.checkbox_a}
+          toggle={() =>
+            this.setState({
+              checkbox_a: !this.state.checkbox_a,
+
+              // Override the form value for checkbox_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                checkbox_a: {
+                  value: !this.state.checkbox_a,
+                },
+              },
+            })
+          }
+        />
+        <InputRow
+          type="string"
+          value={this.state.input_a}
+          setValue={(x: string) =>
+            this.setState({
+              input_a: x,
+
+              // Override the form value for input_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                input_a: {
+                  value: x,
+                },
+              },
+            })
+          }
+          label={"input_a"}
+          placeholder="ex: override text"
+        />
+
+        <Heading>🎨 Rendered Form</Heading>
+        <Br />
+        <FormWrapper
+          valuesToOverride={this.state.valuesToOverride}
+          clearValuesToOverride={() =>
+            this.setState({ valuesToOverride: null })
+          }
+          showStateDebugger={this.state.showStateDebugger}
+          formData={formData}
+          isReadOnly={this.state.isReadOnly}
+          tabOptions={this.state.showBonusTabs ? tabOptions : []}
+          renderTabContents={
+            this.state.showBonusTabs ? this.renderTabContents : null
+          }
+          onSubmit={(values: any) => {
+            alert("Check console output.");
+            console.log("Raw submission values:");
+            console.log(values);
+          }}
+        />
+      </StyledFormDebugger>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 12px;
+`;
+
+const TabWrapper = styled.div`
+  background: #ffffff11;
+  height: 200px;
+  width: 100%;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  overflow: auto;
+  padding: 50px;
+`;
+
+const EditorWrapper = styled.div`
+  .ace_editor,
+  .ace_editor * {
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
+      monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
+`;
+
+const StyledFormDebugger = styled.div`
+  position: relative;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  width: 85px;
+  float: right;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const initYaml = `name: Porter Example
+hasSource: true
+tabs:
+- name: main
+  label: Main
+  sections:
+  - name: header
+    contents: 
+    - type: heading
+      label: 🍺 Porter Demo Form
+    - type: subtitle
+      name: command_description
+      label: Basic form demonstrating some of the features of form.yaml
+    - type: string-input
+      placeholder: "ex: pilsner"
+      label: Required Field A
+      required: true
+      variable: field_a
+    - type: string-input
+      placeholder: "ex: sapporo"
+      required: true
+      label: Required Field B
+      variable: field_b
+    - type: subtitle
+      label: "Note: Hidden required fields aren't supported yet (global only)"
+  - name: controlled-by-external
+    show_if: checkbox_a
+    contents:
+    - type: heading
+      label: Conditional Display (A)
+    - type: subtitle
+      label: This section can be externally controlled by the value of checkbox_a
+    - type: string-input
+      variable: input_a
+      placeholder: "Override w/ input_a"
+  - name: domain_name
+    show_if: ingress.custom_domain
+    contents:
+    - type: array-input
+      variable: ingress.hosts
+      label: Domain Name
+- name: env
+  label: Environment
+  sections:
+  - name: env_vars
+    contents:
+    - type: heading
+      label: Environment Variables
+    - type: subtitle
+      label: Set environment variables for your secrets and environment-specific configuration.
+    - type: env-key-value-array
+      label: 
+      variable: container.env.normal
+- name: advanced
+  label: Advanced
+  sections:
+  - name: advanced
+    contents:
+    - type: heading
+      label: Some Header
+    - type: subtitle
+      label: Some helper text
+`;

+ 478 - 0
dashboard/src/components/values-form/FormWrapper.tsx

@@ -0,0 +1,478 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import { Section, FormElement } from "shared/types";
+import { Context } from "shared/Context";
+import TabRegion from "components/TabRegion";
+import ValuesForm from "components/values-form/ValuesForm";
+import SaveButton from "../SaveButton";
+
+type PropsType = {
+  formData: any;
+  onSubmit?: (formValues: any) => void;
+  saveValuesStatus?: string | null;
+
+  // Handle additional non-form tabs
+  // TODO: find cleaner way to share submitValues w/ rerun jobs button
+  renderTabContents?: (currentTab: string, submitValues?: any) => any;
+  tabOptions?: any[];
+  tabOptionsOnly?: boolean;
+
+  // Allow external control of state
+  valuesToOverride?: any;
+  clearValuesToOverride?: () => void;
+
+  // External values made available to all child components
+  externalValues?: any;
+
+  // Display and debugger settings
+  isInModal?: boolean;
+  isReadOnly?: boolean;
+  showStateDebugger?: boolean;
+
+  // TabRegion props to pass through
+  color?: string;
+  addendum?: any;
+};
+
+type StateType = {
+  metaState: any;
+  requiredFields: string[];
+  currentTab: string;
+  tabOptions: { value: string; label: string }[];
+};
+
+/**
+ * Renders from raw JSON form data and manages form state.
+ *
+ * To control values using external state prop in "valuesToOverride" (refer to
+ * FormDebugger or LaunchTemplate for example usage).
+ *
+ * TODO: Handle passing in valuesToOverride at same time as formData
+ */
+export default class FormWrapper extends Component<PropsType, StateType> {
+  state = {
+    metaState: {} as any,
+    requiredFields: [] as string[],
+    currentTab: "",
+    tabOptions: [] as { value: string; label: string }[],
+  };
+
+  updateTabs = (resetState?: boolean, callback?: any) => {
+    if (resetState) {
+      let tabOptions = [] as { value: string; label: string }[];
+      let tabs = this.props.formData?.tabs;
+      let requiredFields = [] as string[];
+      let metaState: any = {};
+      if (tabs) {
+        tabs.forEach((tab: any, i: number) => {
+          if (tab?.name && tab.label) {
+            // If a tab is valid, extract state
+            tab.sections?.forEach((section: Section, i: number) => {
+              section?.contents?.forEach((item: FormElement, i: number) => {
+                if (item === null || item === undefined) {
+                  return;
+                }
+
+                if (
+                  item.type === "variable" &&
+                  item.variable &&
+                  item.settings?.default
+                ) {
+                  metaState[item.variable] = { value: item.settings.default };
+                  return;
+                }
+
+                // If no name is assigned use values.yaml variable as identifier
+                let key = item.name || item.variable;
+
+                let def =
+                  item.settings &&
+                  item.settings.unit &&
+                  !item.settings.omitUnitFromValue
+                    ? `${item.settings.default}${item.settings.unit}`
+                    : item.settings?.default;
+                def = (item.value && item.value[0]) || def;
+
+                if (item.type === "checkbox") {
+                  def = item.value && item.value[0];
+                }
+
+                // Handle add to list of required fields
+                if (item.required && key) {
+                  requiredFields.push(key);
+                }
+
+                let value: any = def;
+                switch (item.type) {
+                  case "checkbox":
+                    value = def || false;
+                    break;
+                  case "string-input":
+                    value = def || "";
+                    break;
+                  case "string-input-password":
+                    value = def || item.settings.default;
+                  case "array-input":
+                    value = def || [];
+                    break;
+                  case "env-key-value-array":
+                    value = def || {};
+                    break;
+                  case "key-value-array":
+                    value = def || {};
+                    break;
+                  case "number-input":
+                    value = def?.toString() ? def : "";
+                    break;
+                  case "select":
+                    value = def || item.settings.options[0].value;
+                    break;
+                  case "provider-select":
+                    let providerMap: any = {
+                      gke: "gcp",
+                      eks: "aws",
+                      doks: "do",
+                    };
+                    def = providerMap[this.context.currentCluster.service];
+                    value = def || "aws";
+                    break;
+                  case "base-64":
+                    value = def || "";
+                  case "base-64-password":
+                    value = def || "";
+                  default:
+                }
+                if (value !== null && value !== undefined) {
+                  metaState[key] = { value };
+                }
+              });
+            });
+            if (!this.props.tabOptionsOnly) {
+              tabOptions.push({ value: tab.name, label: tab.label });
+            }
+          }
+        });
+      }
+      if (this.props.tabOptions?.length > 0) {
+        tabOptions = tabOptions.concat(this.props.tabOptions);
+      }
+      if (tabOptions.length > 0) {
+        this.setState(
+          {
+            tabOptions: tabOptions,
+            currentTab:
+              this.state.currentTab === ""
+                ? tabOptions[0].value
+                : this.state.currentTab,
+            metaState,
+            requiredFields: requiredFields,
+          },
+          callback
+        );
+      } else {
+        this.setState({ tabOptions }, callback);
+      }
+    } else {
+      // TODO: refactor by consolidating w/ above
+      // Handle change only to external tabs (e.g. DevOps mode toggle)
+      let tabOptions = [] as { value: string; label: string }[];
+      let tabs = this.props.formData?.tabs;
+      if (tabs) {
+        tabs.forEach((tab: any, i: number) => {
+          if (tab?.name && tab.label) {
+            tabOptions.push({ value: tab.name, label: tab.label });
+          }
+        });
+      }
+      if (this.props.tabOptions?.length > 0) {
+        tabOptions = tabOptions.concat(this.props.tabOptions);
+      }
+      this.setState({ tabOptions }, callback);
+    }
+  };
+
+  componentDidMount() {
+    this.updateTabs(true, () => {
+      this.setState(
+        {
+          metaState: {
+            ...this.state.metaState,
+            ...this.props.valuesToOverride,
+          },
+        },
+        () => {
+          this.props.clearValuesToOverride &&
+            this.props.clearValuesToOverride();
+        }
+      );
+    });
+  }
+
+  componentDidUpdate(prevProps: any) {
+    // Override metaState values set from outside FormWrapper
+    if (
+      this.props.valuesToOverride &&
+      !_.isEmpty(this.props.valuesToOverride) &&
+      !_.isEqual(prevProps.valuesToOverride, this.props.valuesToOverride)
+    ) {
+      this.setState(
+        {
+          metaState: {
+            ...this.state.metaState,
+            ...this.props.valuesToOverride,
+          },
+        },
+        () => {
+          // Seems redundant with below but need to ensure no leaked state updates
+          if (
+            !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
+            !_.isEqual(prevProps.formData, this.props.formData)
+          ) {
+            let formHasChanged = !_.isEqual(
+              prevProps.formData,
+              this.props.formData
+            );
+            this.updateTabs(formHasChanged);
+          }
+          this.props.clearValuesToOverride &&
+            this.props.clearValuesToOverride();
+        }
+      );
+    } else if (
+      !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
+      !_.isEqual(prevProps.formData, this.props.formData)
+    ) {
+      let formHasChanged = !_.isEqual(prevProps.formData, this.props.formData);
+      this.updateTabs(formHasChanged);
+    }
+  }
+
+  isSet = (value: any) => {
+    if (
+      value === null ||
+      value === undefined ||
+      value === "" ||
+      value === false
+    ) {
+      return false;
+    }
+    return true;
+  };
+
+  isDisabled = () => {
+    if (this.props.saveValuesStatus == "loading") {
+      return true;
+    }
+
+    let requiredMissing = false;
+    this.state.requiredFields?.forEach((requiredKey: string, i: number) => {
+      if (!this.isSet(this.state.metaState[requiredKey]?.value)) {
+        requiredMissing = true;
+      }
+    });
+    return requiredMissing;
+  };
+
+  renderTabContents = () => {
+    let tabs = this.props.formData?.tabs;
+    if (tabs) {
+      let matchedTab = null as any;
+      tabs.forEach((tab: any, i: number) => {
+        if (tab?.name === this.state.currentTab) {
+          matchedTab = tab;
+        }
+      });
+      if (matchedTab) {
+        return (
+          <ValuesForm
+            externalValues={this.props.externalValues}
+            disabled={this.props.isReadOnly}
+            metaState={this.state.metaState}
+            setMetaState={(key: string, value: any) => {
+              let metaState: any = this.state.metaState;
+              metaState[key] = { value };
+              this.setState({ metaState });
+            }}
+            sections={matchedTab.sections}
+          />
+        );
+      }
+    }
+
+    // If no form tabs match, check against external tabs
+    if (this.props.renderTabContents) {
+      // TODO: find a cleaner way to share submissionValues w/ rerun button
+      let submissionValues: any = {};
+      Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
+        submissionValues[key] = this.state.metaState[key]?.value;
+      });
+
+      return this.props.renderTabContents(
+        this.state.currentTab,
+        submissionValues
+      );
+    }
+    return <div>No matched tabs found.</div>;
+  };
+
+  renderStateDebugger = () => {
+    if (this.props.showStateDebugger) {
+      return (
+        <>
+          <StateDisplay>
+            <Header>FormWrapper State</Header>
+            <ScrollWrapper>
+              {JSON.stringify(this.state.metaState, undefined, 2)}
+            </ScrollWrapper>
+          </StateDisplay>
+        </>
+      );
+    }
+  };
+
+  handleSubmit = () => {
+    // Extract metaState values
+    let submissionValues: any = {};
+    Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
+      submissionValues[key] = this.state.metaState[key]?.value;
+    });
+
+    this.props.onSubmit && this.props.onSubmit(submissionValues);
+  };
+
+  showSaveButton = (): boolean => {
+    if (this.props.isReadOnly || this.state.tabOptions?.length === 0) {
+      return false;
+    }
+
+    let tabs = this.props.formData?.tabs;
+    if (tabs) {
+      let matchedTab = null as any;
+      tabs.forEach((tab: any, i: number) => {
+        if (tab?.name === this.state.currentTab) {
+          matchedTab = tab;
+        }
+      });
+      if (matchedTab) {
+        return true;
+      }
+    }
+
+    // Check if current tab is among non-form tab options
+    let nonFormTabValues = this.props.tabOptions?.map((tab: any, i: number) => {
+      return tab.value;
+    });
+    if (nonFormTabValues && nonFormTabValues.includes(this.state.currentTab)) {
+      return false;
+    }
+    return true;
+  };
+
+  renderContents = (showSave: boolean) => {
+    return (
+      <>
+        <TabRegion
+          options={this.state.tabOptions}
+          currentTab={this.state.currentTab}
+          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          addendum={this.props.addendum}
+          color={this.props.color}
+        >
+          {this.renderTabContents()}
+        </TabRegion>
+        {showSave && (
+          <SaveButton
+            disabled={this.isDisabled()}
+            text="Deploy"
+            onClick={this.handleSubmit}
+            status={
+              this.isDisabled() && this.props.saveValuesStatus != "loading"
+                ? "Missing required fields"
+                : this.props.saveValuesStatus
+            }
+            makeFlush={!this.props.isInModal}
+          />
+        )}
+        {this.renderStateDebugger()}
+      </>
+    );
+  };
+
+  render() {
+    let showSave = this.showSaveButton();
+    return (
+      <>
+        {this.props.isInModal ? (
+          <StyledValuesWrapper showSave={showSave}>
+            {this.renderContents(showSave)}
+          </StyledValuesWrapper>
+        ) : (
+          <PaddedWrapper>
+            <StyledValuesWrapper showSave={showSave}>
+              {this.renderContents(showSave)}
+            </StyledValuesWrapper>
+          </PaddedWrapper>
+        )}
+      </>
+    );
+  }
+}
+
+FormWrapper.contextType = Context;
+
+const Spacer = styled.div`
+  width: 100%;
+  height: 200px;
+  background: red;
+  position: relative;
+`;
+
+const TabWrapper = styled.div`
+  min-height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ScrollWrapper = styled.div`
+  padding: 20px;
+  overflow-y: auto;
+  max-height: 300px;
+  padding-top: 15px;
+`;
+
+const Header = styled.div`
+  width: 100%;
+  height: 40px;
+  color: #ffffff;
+  font-weight: 500;
+  padding-left: 17px;
+  background: #00000022;
+  display: flex;
+  align-items: center;
+`;
+
+const StateDisplay = styled.pre`
+  width: 100%;
+  font-size: 13px;
+  display:
+  overflow: hidden;
+  border-radius: 5px;
+  position: relative;
+  line-height: 1.5em;
+  color: #aaaabb;
+  background: #ffffff11;
+`;
+
+const StyledValuesWrapper = styled.div<{ showSave: boolean }>`
+  width: 100%;
+  padding: 0;
+  height: ${(props) => (props.showSave ? "calc(100% - 55px)" : "100%")};
+`;
+
+const PaddedWrapper = styled.div`
+  padding-bottom: 65px;
+  position: relative;
+`;

+ 9 - 8
dashboard/src/components/values-form/InputRow.tsx

@@ -34,9 +34,11 @@ export default class InputRow extends Component<PropsType, StateType> {
     let { label, value, type, unit, placeholder, width } = this.props;
     return (
       <StyledInputRow>
-        <Label>
-          {label} <Required>{this.props.isRequired ? " *" : null}</Required>
-        </Label>
+        {label && (
+          <Label>
+            {label} <Required>{this.props.isRequired ? " *" : null}</Required>
+          </Label>
+        )}
         <InputWrapper>
           <Input
             readOnly={this.state.readOnly}
@@ -70,17 +72,16 @@ const InputWrapper = styled.div`
   align-items: center;
 `;
 
-const Input = styled.input`
+const Input = styled.input<{ disabled: boolean; width: string }>`
   outline: none;
   border: none;
   font-size: 13px;
   background: #ffffff11;
   border: 1px solid #ffffff55;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
   border-radius: 3px;
-  width: ${(props: { disabled: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
+  width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
   padding: 5px 10px;
   height: 35px;
 `;

+ 168 - 30
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -2,29 +2,35 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import Modal from "../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../main/home/modals/LoadEnvGroupModal";
+import EnvEditorModal from "../../main/home/modals/EnvEditorModal";
 
 import sliders from "assets/sliders.svg";
+import upload from "assets/upload.svg";
+import { keysIn } from "lodash";
 
 type PropsType = {
   label?: string;
   values: any;
-  setValues: (x: any) => void;
+  setValues?: (x: any) => void;
   width?: string;
   disabled?: boolean;
-  namespace?: string;
-  clusterId?: number;
+  externalValues?: any;
   envLoader?: boolean;
+  fileUpload?: boolean;
+  secretOption?: boolean;
 };
 
 type StateType = {
   values: any[];
   showEnvModal: boolean;
+  showEditorModal: boolean;
 };
 
 export default class KeyValueArray extends Component<PropsType, StateType> {
   state = {
     values: [] as any[],
     showEnvModal: false,
+    showEditorModal: false,
   };
 
   componentDidMount() {
@@ -74,10 +80,28 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     }
   };
 
+  renderHiddenOption = (hidden: boolean, i: number) => {
+    if (this.props.secretOption && hidden) {
+      return (
+        <HideButton>
+          <i className="material-icons">lock</i>
+        </HideButton>
+      );
+    }
+  };
+
   renderInputList = () => {
     return (
       <>
         {this.state.values.map((entry: any, i: number) => {
+          // Preprocess non-string env values set via raw Helm values
+          let { value } = entry;
+          if (typeof value === "object") {
+            value = JSON.stringify(value);
+          } else if (typeof value === "number" || typeof value === "boolean") {
+            value = value.toString();
+          }
+
           return (
             <InputWrapper key={i}>
               <Input
@@ -91,13 +115,14 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled}
+                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                spellCheck={false}
               />
               <Spacer />
               <Input
                 placeholder="ex: value"
                 width="270px"
-                value={entry.value}
+                value={value}
                 onChange={(e: any) => {
                   this.state.values[i].value = e.target.value;
                   this.setState({ values: this.state.values });
@@ -105,9 +130,12 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled}
+                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                spellCheck={false}
               />
               {this.renderDeleteButton(i)}
+              {this.renderHiddenOption(value.includes("PORTERSECRET"), i)}
             </InputWrapper>
           );
         })}
@@ -124,8 +152,8 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
           height="342px"
         >
           <LoadEnvGroupModal
-            namespace={this.props.namespace}
-            clusterId={this.props.clusterId}
+            namespace={this.props.externalValues?.namespace}
+            clusterId={this.props.externalValues?.clusterId}
             closeModal={() => this.setState({ showEnvModal: false })}
             setValues={(values: any) => {
               this.props.setValues(values);
@@ -137,6 +165,96 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     }
   };
 
+  renderEditorModal = () => {
+    if (this.state.showEditorModal) {
+      return (
+        <Modal
+          onRequestClose={() => this.setState({ showEditorModal: false })}
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() => this.setState({ showEditorModal: false })}
+            setEnvVariables={(envFile: string) => this.readFile(envFile)}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  // Parses src into an Object
+  parseEnv = (src: any, options: any) => {
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+    // convert Buffers before splitting into lines and processing
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
+          }
+
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
+
+    return obj;
+  };
+
+  readFile = (env: string) => {
+    let envObj = this.parseEnv(env, null);
+    let push = true;
+
+    for (let key in envObj) {
+      for (var i = 0; i < this.state.values.length; i++) {
+        let existingKey = this.state.values[i]["key"];
+        if (key === existingKey) {
+          this.state.values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        this.state.values.push({ key, value: envObj[key] });
+      }
+    }
+
+    this.setState({ values: this.state.values }, () => {
+      let obj = this.valuesToObject();
+      this.props.setValues(obj);
+    });
+  };
+
   render() {
     return (
       <>
@@ -156,7 +274,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                 <i className="material-icons">add</i> Add Row
               </AddRowButton>
               <Spacer />
-              {this.props.namespace && this.props.envLoader && (
+              {this.props.externalValues?.namespace && this.props.envLoader && (
                 <LoadButton
                   onClick={() =>
                     this.setState({ showEnvModal: !this.state.showEnvModal })
@@ -165,36 +283,25 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   <img src={sliders} /> Load from Env Group
                 </LoadButton>
               )}
+              {this.props.fileUpload && (
+                <UploadButton
+                  onClick={() => {
+                    this.setState({ showEditorModal: true });
+                  }}
+                >
+                  <img src={upload} /> Copy from File
+                </UploadButton>
+              )}
             </InputWrapper>
           )}
         </StyledInputArray>
         {this.renderEnvModal()}
+        {this.renderEditorModal()}
       </>
     );
   }
 }
 
-const CloseOverlay = styled.div`
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100vw;
-  height: 100vh;
-  z-index: 999;
-  background: #202227;
-  animation: fadeIn 0.2s 0s;
-  opacity: 0;
-  animation-fill-mode: forwards;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
 const Spacer = styled.div`
   width: 10px;
   height: 20px;
@@ -244,6 +351,26 @@ const LoadButton = styled(AddRowButton)`
   }
 `;
 
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
 const DeleteButton = styled.div`
   width: 15px;
   height: 15px;
@@ -266,6 +393,17 @@ const DeleteButton = styled.div`
   }
 `;
 
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: default;
+    :hover {
+      color: #ffffff44;
+    }
+  }
+`;
+
 const InputWrapper = styled.div`
   display: flex;
   align-items: center;

+ 2 - 2
dashboard/src/components/values-form/RangeSlider.tsx

@@ -12,10 +12,10 @@ export default class RangeSelector extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledInputRow>
-        <Label>asdfasdf</Label>
+        <Label>XYZ</Label>
         <Slider
           value={12}
-          onChange={() => console.log("huh")}
+          onChange={() => console.log("xyz")}
           valueLabelDisplay="auto"
           aria-labelledby="range-slider"
         />

+ 131 - 0
dashboard/src/components/values-form/UploadArea.tsx

@@ -0,0 +1,131 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import upload from "assets/upload.svg";
+
+type PropsType = {
+  label?: string;
+  setValue: (x: any) => void;
+  width?: string;
+  height?: string;
+  placeholder?: string;
+  isRequired?: boolean;
+};
+
+type StateType = {
+  fileName: string;
+};
+
+export default class UploadArea extends Component<PropsType, StateType> {
+  state = {
+    fileName: null as string,
+  };
+  handleChange = (e: any) => {
+    this.props.setValue(e.target.value);
+  };
+
+  readFile = (file: any) => {
+    const reader = new FileReader();
+    reader.onload = async (e) => {
+      let text = e.target.result;
+      this.props.setValue(text);
+    };
+    reader.readAsText(file, "UTF-8");
+    this.setState({ fileName: file.name });
+  };
+
+  render() {
+    let { label, placeholder } = this.props;
+    console.log(this.state.fileName);
+    if (this.state.fileName) {
+      placeholder = `Uploaded ${this.state.fileName}`;
+    }
+
+    return (
+      <StyledUploadArea>
+        <Label>
+          {label}
+          <Required>{this.props.isRequired ? " *" : null}</Required>
+        </Label>
+        <DNDArea
+          onDragOver={(e: any) => {
+            e.preventDefault();
+          }}
+          onDragEnter={(e: any) => {
+            e.preventDefault();
+          }}
+          onDragLeave={(e: any) => {
+            e.preventDefault();
+          }}
+          onDrop={(e: any) => {
+            e.preventDefault();
+            const files = e.dataTransfer.files;
+            this.readFile(files[0]);
+          }}
+          onClick={() => {
+            document.getElementById("file").click();
+          }}
+        >
+          <input
+            id="file"
+            hidden
+            type="file"
+            accept=".json"
+            onChange={(event) => {
+              event.preventDefault();
+              this.readFile(event.target.files[0]);
+              event.currentTarget.value = null;
+            }}
+          />
+          <Message>
+            <img src={upload} style={{ marginRight: "6px", height: "16px" }} />{" "}
+            {placeholder}
+          </Message>
+        </DNDArea>
+      </StyledUploadArea>
+    );
+  }
+}
+
+const Message = styled.div`
+  display: flex;
+  align-items: center;
+  vertical-align: middle;
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const DNDArea = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  outline: none;
+  border: none;
+  resize: none;
+  font-size: 14px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  color: grey;
+  padding: 5px 10px;
+  margin-right: 8px;
+  width: 100%;
+  height: 150px;
+  cursor: pointer;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const StyledUploadArea = styled.div`
+  margin-top: 20px;
+`;

+ 104 - 79
dashboard/src/components/values-form/ValuesForm.tsx

@@ -18,12 +18,10 @@ import KeyValueArray from "./KeyValueArray";
 type PropsType = {
   sections?: Section[];
   metaState?: any;
-  setMetaState?: any;
+  setMetaState?: (key: string, value: any) => void;
   handleEnvChange?: (x: any) => void;
   disabled?: boolean;
-  namespace?: string;
-  clusterId?: number;
-  procfileProcess?: string;
+  externalValues?: any;
 };
 
 type StateType = any;
@@ -31,29 +29,36 @@ type StateType = any;
 // Requires an internal representation unlike other values components because metaState value underdetermines input order
 export default class ValuesForm extends Component<PropsType, StateType> {
   getInputValue = (item: FormElement) => {
-    let key = item.name || item.variable;
-    let value = this.props.metaState[key];
+    if (item) {
+      let key = item.name || item.variable;
+      let value = this.props.metaState[key]?.value;
 
-    if (item.settings && item.settings.unit && value && value.includes) {
-      value = value.split(item.settings.unit)[0];
+      if (
+        item.settings &&
+        item.settings.unit &&
+        value &&
+        value.includes &&
+        !item.settings.omitUnitFromValue
+      ) {
+        value = value.split(item.settings.unit)[0];
+      }
+      return value;
     }
-    return value;
   };
 
   renderSection = (section: Section) => {
-    return section.contents.map((item: FormElement, i: number) => {
-      // If no name is assigned use values.yaml variable as identifier
-      let key = item.name || item.variable;
-
-      // ugly exception to hide start command option when procfile process is set.
-      if (
-        (item.variable === "container.command" ||
-          (item.type == "subtitle" && item.name == "command_description")) &&
-        this.props.procfileProcess
-      ) {
+    return section.contents?.map((item: FormElement, i: number) => {
+      if (!item) {
         return;
       }
 
+      // If no name is assigned use values.yaml variable as identifier
+      let key = item.name || item.variable;
+      let isDisabled =
+        item.settings?.disableAfterLaunch &&
+        !this.props.externalValues?.isLaunch;
+      isDisabled = isDisabled || this.props.disabled;
+
       switch (item.type) {
         case "heading":
           return <Heading key={i}>{item.label}</Heading>;
@@ -62,8 +67,8 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "resource-list":
           if (Array.isArray(item.value)) {
             return (
-              <ResourceList key={i}>
-                {item.value.map((resource: any, i: number) => {
+              <ResourceList key={key}>
+                {item.value?.map((resource: any, i: number) => {
                   return (
                     <ExpandableResource
                       key={i}
@@ -79,10 +84,12 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "checkbox":
           return (
             <CheckboxRow
-              key={i}
-              checked={this.props.metaState[key]}
+              key={key}
+              disabled={isDisabled}
+              isRequired={item.required}
+              checked={this.props.metaState[key]?.value}
               toggle={() =>
-                this.props.setMetaState({ [key]: !this.props.metaState[key] })
+                this.props.setMetaState(key, !this.props.metaState[key]?.value)
               }
               label={item.label}
             />
@@ -90,101 +97,102 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "env-key-value-array":
           return (
             <KeyValueArray
-              key={i}
+              key={key}
               envLoader={true}
-              namespace={this.props.namespace}
-              clusterId={this.props.clusterId}
-              values={this.props.metaState[key]}
+              externalValues={this.props.externalValues}
+              values={this.props.metaState[key]?.value}
               setValues={(x: any) => {
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
 
                 // Need to pull env vars out of form.yaml for createGHA build env vars
                 if (
                   this.props.handleEnvChange &&
                   key === "container.env.normal"
                 ) {
-                  this.props.handleEnvChange(x);
+                  // this.props.handleEnvChange(x);
                 }
               }}
               label={item.label}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
+              secretOption={true}
             />
           );
         case "key-value-array":
           return (
             <KeyValueArray
-              key={i}
-              namespace={this.props.namespace}
-              clusterId={this.props.clusterId}
-              values={this.props.metaState[key]}
-              setValues={(x: any) => {
-                this.props.setMetaState({ [key]: x });
-
-                // Need to pull env vars out of form.yaml for createGHA build env vars
-                if (
-                  this.props.handleEnvChange &&
-                  key === "container.env.normal"
-                ) {
-                  this.props.handleEnvChange(x);
-                }
-              }}
+              key={key}
+              externalValues={this.props.externalValues}
+              values={this.props.metaState[key]?.value}
+              setValues={(x: any) => this.props.setMetaState(key, x)}
               label={item.label}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         case "array-input":
           return (
             <InputArray
-              key={i}
-              values={this.props.metaState[key]}
+              key={key}
+              values={this.props.metaState[key]?.value}
               setValues={(x: string[]) => {
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
               }}
               label={item.label}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         case "string-input":
           return (
             <InputRow
-              key={i}
+              key={key}
+              placeholder={item.placeholder}
               isRequired={item.required}
               type="text"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
-                if (item.settings && item.settings.unit && x !== "") {
+                if (
+                  item.settings &&
+                  item.settings.unit &&
+                  x !== "" &&
+                  !item.settings.omitUnitFromValue
+                ) {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         case "string-input-password":
           return (
             <InputRow
-              key={i}
+              key={key}
               isRequired={item.required}
               type="password"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
-                if (item.settings && item.settings.unit && x !== "") {
+                if (
+                  item.settings &&
+                  item.settings.unit &&
+                  x !== "" &&
+                  !item.settings.omitUnitFromValue
+                ) {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         case "number-input":
           return (
             <InputRow
-              key={i}
+              key={key}
               isRequired={item.required}
+              placeholder={item.placeholder}
               type="number"
               value={this.getInputValue(item)}
               setValue={(x: number) => {
@@ -194,24 +202,28 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 }
 
                 // Convert to string if unit is set
-                if (item.settings && item.settings.unit) {
+                if (
+                  item.settings &&
+                  item.settings.unit &&
+                  !item.settings.omitUnitFromValue
+                ) {
                   val = x.toString();
                   val = val + item.settings.unit;
                 }
 
-                this.props.setMetaState({ [key]: val });
+                this.props.setMetaState(key, val);
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         case "select":
           return (
             <SelectRow
-              key={i}
-              value={this.props.metaState[key]}
-              setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
+              key={key}
+              value={this.props.metaState[key]?.value}
+              setActiveValue={(val) => this.props.setMetaState(key, val)}
               options={item.settings.options}
               dropdownLabel=""
               label={item.label}
@@ -220,9 +232,9 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "provider-select":
           return (
             <SelectRow
-              key={i}
-              value={this.props.metaState[key]}
-              setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
+              key={key}
+              value={this.props.metaState[key]?.value}
+              setActiveValue={(val) => this.props.setMetaState(key, val)}
               options={[
                 { value: "aws", label: "Amazon Web Services (AWS)" },
                 { value: "gcp", label: "Google Cloud Platform (GCP)" },
@@ -237,37 +249,47 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "base-64":
           return (
             <Base64InputRow
-              key={i}
+              key={key}
               isRequired={item.required}
               type="text"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
-                if (item.settings && item.settings.unit && x !== "") {
+                if (
+                  item.settings &&
+                  item.settings.unit &&
+                  x !== "" &&
+                  !item.settings.omitUnitFromValue
+                ) {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: btoa(x) });
+                this.props.setMetaState(key, btoa(x));
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         case "base-64-password":
           return (
             <Base64InputRow
-              key={i}
+              key={key}
               isRequired={item.required}
               type="password"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
-                if (item.settings && item.settings.unit && x !== "") {
+                if (
+                  item.settings &&
+                  item.settings.unit &&
+                  x !== "" &&
+                  !item.settings.omitUnitFromValue
+                ) {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: btoa(x) });
+                this.props.setMetaState(key, btoa(x));
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
-              disabled={this.props.disabled}
+              disabled={isDisabled}
             />
           );
         default:
@@ -277,10 +299,13 @@ export default class ValuesForm extends Component<PropsType, StateType> {
 
   renderFormContents = () => {
     if (this.props.metaState) {
-      return this.props.sections.map((section: Section, i: number) => {
+      return this.props.sections?.map((section: Section, i: number) => {
         // Hide collapsible section if deciding field is false
         if (section.show_if) {
-          if (!this.props.metaState[section.show_if]) {
+          if (
+            !this.props.metaState[section.show_if] ||
+            this.props.metaState[section.show_if].value === false
+          ) {
             return null;
           }
         }

+ 0 - 177
dashboard/src/components/values-form/ValuesWrapper.tsx

@@ -1,177 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import { Section, FormElement } from "../../shared/types";
-import { Context } from "../../shared/Context";
-
-import SaveButton from "../SaveButton";
-
-type PropsType = {
-  formTabs: any;
-  onSubmit: (formValues: any) => void;
-  disabled?: boolean;
-  saveValuesStatus?: string | null;
-  isInModal?: boolean;
-  currentTab?: string; // For resetting state when flipping b/w tabs in ExpandedChart
-  renderSaveButton?: boolean;
-};
-
-type StateType = any;
-
-const providerMap: any = {
-  gke: "gcp",
-  eks: "aws",
-  doks: "do",
-};
-
-// Manages the consolidated state of all form tabs ("metastate")
-export default class ValuesWrapper extends Component<PropsType, StateType> {
-  // No need to render, so OK to set as class variable outside of state
-  requiredFields: string[] = [];
-
-  updateFormState() {
-    let metaState: any = {};
-    this.props.formTabs.forEach((tab: any, i: number) => {
-      // TODO: reconcile tab.name and tab.value
-      if (tab.name || (tab.value && tab.value.includes("@"))) {
-        tab.sections.forEach((section: Section, i: number) => {
-          section.contents.forEach((item: FormElement, i: number) => {
-            // If no name is assigned use values.yaml variable as identifier
-            let key = item.name || item.variable;
-
-            let def =
-              item.settings && item.settings.unit
-                ? `${item.settings.default}${item.settings.unit}`
-                : item.settings.default;
-            def = (item.value && item.value[0]) || def;
-
-            if (item.type === "checkbox") {
-              def = item.value[0];
-            }
-
-            // Handle add to list of required fields
-            if (item.required) {
-              key && this.requiredFields.push(key);
-            }
-
-            switch (item.type) {
-              case "checkbox":
-                metaState[key] = def ? def : false;
-                break;
-              case "string-input":
-                metaState[key] = def ? def : "";
-                break;
-              case "string-input-password":
-                metaState[key] = def ? def : item.settings.default;
-              case "array-input":
-                metaState[key] = def ? def : [];
-                break;
-              case "env-key-value-array":
-                metaState[key] = def ? def : {};
-                break;
-              case "key-value-array":
-                metaState[key] = def ? def : {};
-                break;
-              case "number-input":
-                metaState[key] = def.toString() ? def : "";
-                break;
-              case "select":
-                metaState[key] = def ? def : item.settings.options[0].value;
-                break;
-              case "provider-select":
-                def = providerMap[this.context.currentCluster.service];
-                metaState[key] = def ? def : "aws";
-                break;
-              case "base-64":
-                metaState[key] = def ? def : "";
-              case "base-64-password":
-                metaState[key] = def ? def : "";
-              default:
-            }
-          });
-        });
-      }
-    });
-    this.setState(metaState);
-  }
-
-  // Initialize corresponding state fields for form blocks
-  componentDidMount() {
-    this.updateFormState();
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (
-      this.props.formTabs !== prevProps.formTabs ||
-      this.props.currentTab !== prevProps.currentTab
-    ) {
-      this.updateFormState();
-    }
-  }
-
-  // Checks if all required fields are set
-  isDisabled = (): boolean => {
-    let valueIndicators: any[] = [];
-    this.requiredFields.forEach((field: string, i: number) => {
-      valueIndicators.push(this.state[field] && true);
-    });
-    return valueIndicators.includes(false) || valueIndicators.includes("");
-  };
-
-  renderButton = () => {
-    if (this.props.renderSaveButton) {
-      let { formTabs, currentTab } = this.props;
-      let tab = formTabs.find(
-        (t: any) => t.name === currentTab || t.value === currentTab
-      );
-      if (tab && tab.context && tab.context.type === "helm/values") {
-        return (
-          <SaveButton
-            disabled={this.isDisabled() || this.props.disabled}
-            text="Deploy"
-            onClick={() => this.props.onSubmit(this.state)}
-            status={
-              this.isDisabled()
-                ? "Missing required fields"
-                : this.props.saveValuesStatus
-            }
-            makeFlush={true}
-          />
-        );
-      }
-    }
-  };
-
-  render() {
-    let renderFunc: any = this.props.children;
-    if (this.props.isInModal) {
-      return (
-        <StyledValuesWrapper>
-          {renderFunc(this.state, (x: any) => this.setState(x))}
-          {this.renderButton()}
-        </StyledValuesWrapper>
-      );
-    }
-    return (
-      <PaddedWrapper>
-        <StyledValuesWrapper>
-          {renderFunc(this.state, (x: any) => this.setState(x))}
-          {this.renderButton()}
-        </StyledValuesWrapper>
-      </PaddedWrapper>
-    );
-  }
-}
-
-ValuesWrapper.contextType = Context;
-
-const StyledValuesWrapper = styled.div`
-  width: 100%;
-  padding: 0;
-  height: calc(100% - 65px);
-`;
-
-const PaddedWrapper = styled.div`
-  padding-bottom: 65px;
-  position: relative;
-`;

+ 4 - 0
dashboard/src/index.html

@@ -104,6 +104,10 @@
       href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined"
       rel="stylesheet"
     />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Roboto+Mono"
+      rel="stylesheet"
+    />
   </head>
   <body>
     <div id="output"></div>

+ 97 - 14
dashboard/src/main/CurrentError.tsx

@@ -29,12 +29,18 @@ export default class CurrentError extends Component<PropsType, StateType> {
     if (this.props.currentError) {
       if (!this.state.expanded) {
         return (
-          <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
+          <StyledCurrentError>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
+            <ExpandButton onClick={() => this.setState({ expanded: true })}>
+              <i className="material-icons">launch</i>
+            </ExpandButton>
             <CloseButton
               onClick={(e) => {
-                this.context.setCurrentError(null);
                 e.stopPropagation();
+
+                this.setState({ expanded: false }, () => {
+                  this.context.setCurrentError(null);
+                });
               }}
             >
               <CloseButtonImg src={close} />
@@ -44,12 +50,26 @@ export default class CurrentError extends Component<PropsType, StateType> {
       }
 
       return (
-        <ExpandedError onClick={() => this.setState({ expanded: false })}>
-          Error: {this.props.currentError}
-          <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
-            <CloseButtonImg src={close} />
-          </CloseButtonAlt>
-        </ExpandedError>
+        <Overlay>
+          <ExpandedError>
+            Porter encountered an error. Full error log:
+            <CodeBlock>{this.props.currentError}</CodeBlock>
+            <ExpandButtonAlt onClick={() => this.setState({ expanded: false })}>
+              <i className="material-icons">remove</i>
+            </ExpandButtonAlt>
+            <CloseButtonAlt
+              onClick={(e) => {
+                e.stopPropagation();
+
+                this.setState({ expanded: false }, () => {
+                  this.context.setCurrentError(null);
+                });
+              }}
+            >
+              <CloseButtonImg src={close} />
+            </CloseButtonAlt>
+          </ExpandedError>
+        </Overlay>
       );
     }
 
@@ -66,7 +86,6 @@ const CloseButton = styled.div`
   width: 30px;
   height: 30px;
   border-radius: 50%;
-  margin-left: 10px;
   cursor: pointer;
   :hover {
     background-color: #ffffff11;
@@ -87,13 +106,13 @@ const ErrorText = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  width: calc(100% - 50px);
+  width: calc(100% - 80px);
 `;
 
 const StyledCurrentError = styled.div`
   position: fixed;
   bottom: 22px;
-  width: 300px;
+  width: 310px;
   left: 20px;
   padding: 15px;
   padding-right: 0px;
@@ -127,10 +146,74 @@ const StyledCurrentError = styled.div`
   }
 `;
 
-const ExpandedError = styled(StyledCurrentError)`
-  width: 500px;
+const ExpandButton = styled(CloseButton)`
+  display: flex;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  cursor: pointer;
+
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 16px;
+  }
+`;
+
+const ExpandButtonAlt = styled(ExpandButton)`
+  position: absolute;
+  top: 5px;
+  right: 34px;
+`;
+
+const Overlay = styled.div`
+  position: fixed;
+  margin: 0;
+  padding: 0;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.6);
+  z-index: 3;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ExpandedError = styled.div`
+  position: fixed;
+  display: block;
+  width: 700px;
+  left: calc(50% - 350px);
   height: auto;
-  max-height: 300px;
+  max-height: 500px;
+  top: 50%;
+  transform: translateY(-50%);
   padding: 20px;
   overflow-y: auto;
+  background: #272731;
+  border: 1px solid #ffffff55;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  border-radius: 12px;
+`;
+
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
 `;

+ 14 - 1
dashboard/src/main/Main.tsx

@@ -22,6 +22,7 @@ type StateType = {
   isLoggedIn: boolean;
   isEmailVerified: boolean;
   initialized: boolean;
+  local: boolean;
 };
 
 export default class Main extends Component<PropsType, StateType> {
@@ -30,6 +31,7 @@ export default class Main extends Component<PropsType, StateType> {
     isLoggedIn: false,
     isEmailVerified: false,
     initialized: localStorage.getItem("init") === "true",
+    local: false,
   };
 
   componentDidMount() {
@@ -53,6 +55,13 @@ export default class Main extends Component<PropsType, StateType> {
         }
       })
       .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
+
+    api
+      .getCapabilities("", {}, {})
+      .then((res) => {
+        this.setState({ local: !res.data?.provisioner });
+      })
+      .catch((err) => console.log(err));
   }
 
   initialize = () => {
@@ -100,7 +109,11 @@ export default class Main extends Component<PropsType, StateType> {
     }
 
     // if logged in but not verified, block until email verification
-    if (this.state.isLoggedIn && !this.state.isEmailVerified) {
+    if (
+      !this.state.local &&
+      this.state.isLoggedIn &&
+      !this.state.isEmailVerified
+    ) {
       return (
         <Switch>
           <Route

+ 142 - 57
dashboard/src/main/auth/Login.tsx

@@ -2,6 +2,7 @@ import React, { ChangeEvent, Component } from "react";
 import styled from "styled-components";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
@@ -16,6 +17,10 @@ type StateType = {
   password: string;
   emailError: boolean;
   credentialError: boolean;
+  hasBasic: boolean;
+  hasGithub: boolean;
+  hasGoogle: boolean;
+  hasResetPassword: boolean;
 };
 
 export default class Login extends Component<PropsType, StateType> {
@@ -24,6 +29,10 @@ export default class Login extends Component<PropsType, StateType> {
     password: "",
     emailError: false,
     credentialError: false,
+    hasBasic: true,
+    hasGithub: true,
+    hasGoogle: false,
+    hasResetPassword: true,
   };
 
   handleKeyDown = (e: any) => {
@@ -36,6 +45,19 @@ export default class Login extends Component<PropsType, StateType> {
     emailFromCLI
       ? this.setState({ email: emailFromCLI })
       : document.addEventListener("keydown", this.handleKeyDown);
+
+    // get capabilities to case on github
+    api
+      .getCapabilities("", {}, {})
+      .then((res) => {
+        this.setState({
+          hasBasic: res.data?.basic_login,
+          hasGithub: res.data?.github_login,
+          hasGoogle: res.data?.google_login,
+          hasResetPassword: res.data?.email,
+        });
+      })
+      .catch((err) => console.log(err));
   }
 
   componentWillUnmount() {
@@ -105,69 +127,124 @@ export default class Login extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
   };
 
-  render() {
-    let { email, password, credentialError, emailError } = this.state;
+  googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
+  };
+
+  renderGithubSection = () => {
+    if (this.state.hasGithub) {
+      return (
+        <OAuthButton onClick={this.githubRedirect}>
+          <IconWrapper>
+            <Icon src={github} />
+            Log in with GitHub
+          </IconWrapper>
+        </OAuthButton>
+      );
+    }
+  };
 
+  renderGoogleSection = () => {
+    if (this.state.hasGoogle) {
+      return (
+        <OAuthButton onClick={this.googleRedirect}>
+          <IconWrapper>
+            <StyledGoogleIcon />
+            Log in with Google
+          </IconWrapper>
+        </OAuthButton>
+      );
+    }
+  };
+
+  renderBasicSection = () => {
+    if (this.state.hasBasic) {
+      let { email, password, credentialError, emailError } = this.state;
+
+      return (
+        <div>
+          <InputWrapper>
+            <Input
+              type="email"
+              placeholder="Email"
+              value={email}
+              onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                this.setState({
+                  email: e.target.value,
+                  emailError: false,
+                  credentialError: false,
+                })
+              }
+              valid={!credentialError && !emailError}
+            />
+            {this.renderEmailError()}
+          </InputWrapper>
+          <InputWrapper>
+            <Input
+              type="password"
+              placeholder="Password"
+              value={password}
+              onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                this.setState({
+                  password: e.target.value,
+                  credentialError: false,
+                })
+              }
+              valid={!credentialError}
+            />
+            {this.renderCredentialError()}
+          </InputWrapper>
+          <Button onClick={this.handleLogin}>Continue</Button>
+        </div>
+      );
+    }
+  };
+
+  renderHelper() {
+    if (this.state.hasResetPassword) {
+      return (
+        <Helper>
+          <Link href="/register">Sign up</Link> |
+          <Link href="/password/reset">Forgot password?</Link>
+        </Helper>
+      );
+    }
+
+    return (
+      <Helper>
+        <Link href="/register">Sign up</Link>
+      </Helper>
+    );
+  }
+
+  render() {
     return (
       <StyledLogin>
-        <LoginPanel>
+        <LoginPanel
+          hasBasic={this.state.hasBasic}
+          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+        >
           <OverflowWrapper>
             <GradientBg />
           </OverflowWrapper>
           <FormWrapper>
             <Logo src={logo} />
             <Prompt>Log in to Porter</Prompt>
-            <OAuthButton onClick={this.githubRedirect}>
-              <IconWrapper>
-                <Icon src={github} />
-                Log in with GitHub
-              </IconWrapper>
-            </OAuthButton>
-            <OrWrapper>
-              <Line />
-              <Or>or</Or>
-            </OrWrapper>
+            {this.renderGithubSection()}
+            {this.renderGoogleSection()}
+            {(this.state.hasGithub || this.state.hasGoogle) &&
+            this.state.hasBasic ? (
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper>
+            ) : null}
             <DarkMatter />
-            <InputWrapper>
-              <Input
-                type="email"
-                placeholder="Email"
-                value={email}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({
-                    email: e.target.value,
-                    emailError: false,
-                    credentialError: false,
-                  })
-                }
-                valid={!credentialError && !emailError}
-              />
-              {this.renderEmailError()}
-            </InputWrapper>
-            <InputWrapper>
-              <Input
-                type="password"
-                placeholder="Password"
-                value={password}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({
-                    password: e.target.value,
-                    credentialError: false,
-                  })
-                }
-                valid={!credentialError}
-              />
-              {this.renderCredentialError()}
-            </InputWrapper>
-            <Button onClick={this.handleLogin}>Continue</Button>
-
-            <Helper>
-              <Link href="/register">Sign up</Link> |
-              <Link href="/password/reset">Forgot password?</Link>
-            </Helper>
+            {this.renderBasicSection()}
+            {this.renderHelper()}
           </FormWrapper>
         </LoginPanel>
-
         <Footer>
           © 2021 Porter Technologies Inc. •
           <Link
@@ -229,7 +306,12 @@ const IconWrapper = styled.div`
 
 const Icon = styled.img`
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 
 const OAuthButton = styled.div`
@@ -244,6 +326,8 @@ const OAuthButton = styled.div`
   user-select: none;
   font-weight: 500;
   font-size: 13px;
+  margin: 10px 0;
+  overflow: hidden;
   :hover {
     background: #ffffffdd;
   }
@@ -372,11 +456,11 @@ const FormWrapper = styled.div`
 
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   @keyframes flip {
     from {
@@ -390,7 +474,8 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 470px;
+  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
+    280 + +props.hasBasic * 150 + props.numOAuth * 50}px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 128 - 59
dashboard/src/main/auth/Register.tsx

@@ -2,6 +2,7 @@ import React, { ChangeEvent, Component, useContext } from "react";
 import styled from "styled-components";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
@@ -17,6 +18,9 @@ type StateType = {
   confirmPassword: string;
   emailError: boolean;
   confirmPasswordError: boolean;
+  hasGithub: boolean;
+  hasGoogle: boolean;
+  hasBasic: boolean;
 };
 
 export default class Register extends Component<PropsType, StateType> {
@@ -26,6 +30,9 @@ export default class Register extends Component<PropsType, StateType> {
     confirmPassword: "",
     emailError: false,
     confirmPasswordError: false,
+    hasBasic: true,
+    hasGithub: true,
+    hasGoogle: false,
   };
 
   handleKeyDown = (e: any) => {
@@ -34,6 +41,18 @@ export default class Register extends Component<PropsType, StateType> {
 
   componentDidMount() {
     document.addEventListener("keydown", this.handleKeyDown);
+
+    // get capabilities to case on github
+    api
+      .getCapabilities("", {}, {})
+      .then((res) => {
+        this.setState({
+          hasGithub: res.data?.github_login,
+          hasGoogle: res.data?.google_login,
+          hasBasic: res.data?.basic_login,
+        });
+      })
+      .catch((err) => console.log(err));
   }
 
   componentWillUnmount() {
@@ -45,6 +64,11 @@ export default class Register extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
   };
 
+  googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
+  };
+
   handleRegister = (): void => {
     let { email, password, confirmPassword } = this.state;
     let { authenticate } = this.props;
@@ -106,7 +130,33 @@ export default class Register extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
+  renderGithubSection = () => {
+    if (this.state.hasGithub) {
+      return (
+        <OAuthButton onClick={this.githubRedirect}>
+          <IconWrapper>
+            <Icon src={github} />
+            Sign up with GitHub
+          </IconWrapper>
+        </OAuthButton>
+      );
+    }
+  };
+
+  renderGoogleSection = () => {
+    if (this.state.hasGoogle) {
+      return (
+        <OAuthButton onClick={this.googleRedirect}>
+          <IconWrapper>
+            <StyledGoogleIcon />
+            Sign up with Google
+          </IconWrapper>
+        </OAuthButton>
+      );
+    }
+  };
+
+  renderBasicSection = () => {
     let {
       email,
       password,
@@ -115,67 +165,78 @@ export default class Register extends Component<PropsType, StateType> {
       confirmPasswordError,
     } = this.state;
 
-    return (
-      <StyledRegister>
-        <LoginPanel>
-          <OverflowWrapper>
-            <GradientBg />
-          </OverflowWrapper>
-          <FormWrapper>
-            <Logo src={logo} />
-            <Prompt>Sign up for Porter</Prompt>
-            <OAuthButton onClick={this.githubRedirect}>
-              <IconWrapper>
-                <Icon src={github} />
-                Sign up with GitHub
-              </IconWrapper>
-            </OAuthButton>
-            <OrWrapper>
-              <Line />
-              <Or>or</Or>
-            </OrWrapper>
-            <DarkMatter />
-            <InputWrapper>
-              <Input
-                type="email"
-                placeholder="Email"
-                value={email}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({ email: e.target.value, emailError: false })
-                }
-                valid={!emailError}
-              />
-              {this.renderEmailError()}
-            </InputWrapper>
+    if (this.state.hasBasic) {
+      return (
+        <div>
+          <InputWrapper>
+            <Input
+              type="email"
+              placeholder="Email"
+              value={email}
+              onChange={(e: ChangeEvent<HTMLInputElement>) =>
+                this.setState({ email: e.target.value, emailError: false })
+              }
+              valid={!emailError}
+            />
+            {this.renderEmailError()}
+          </InputWrapper>
+          <Input
+            type="password"
+            placeholder="Password"
+            value={password}
+            onChange={(e: ChangeEvent<HTMLInputElement>) =>
+              this.setState({
+                password: e.target.value,
+                confirmPasswordError: false,
+              })
+            }
+            valid={true}
+          />
+          <InputWrapper>
             <Input
               type="password"
-              placeholder="Password"
-              value={password}
+              placeholder="Confirm Password"
+              value={confirmPassword}
               onChange={(e: ChangeEvent<HTMLInputElement>) =>
                 this.setState({
-                  password: e.target.value,
+                  confirmPassword: e.target.value,
                   confirmPasswordError: false,
                 })
               }
-              valid={true}
+              valid={!confirmPasswordError}
             />
-            <InputWrapper>
-              <Input
-                type="password"
-                placeholder="Confirm Password"
-                value={confirmPassword}
-                onChange={(e: ChangeEvent<HTMLInputElement>) =>
-                  this.setState({
-                    confirmPassword: e.target.value,
-                    confirmPasswordError: false,
-                  })
-                }
-                valid={!confirmPasswordError}
-              />
-              {this.renderConfirmPasswordError()}
-            </InputWrapper>
-            <Button onClick={this.handleRegister}>Continue</Button>
+            {this.renderConfirmPasswordError()}
+          </InputWrapper>
+          <Button onClick={this.handleRegister}>Continue</Button>
+        </div>
+      );
+    }
+  };
 
+  render() {
+    return (
+      <StyledRegister>
+        <LoginPanel
+          hasBasic={this.state.hasBasic}
+          numOAuth={+this.state.hasGithub + +this.state.hasGoogle}
+        >
+          <OverflowWrapper>
+            <GradientBg />
+          </OverflowWrapper>
+          <FormWrapper>
+            <Logo src={logo} />
+            <Prompt>Sign up for Porter</Prompt>
+            {this.renderGithubSection()}
+            {this.renderGoogleSection()}
+            {(this.state.hasGithub || this.state.hasGoogle) &&
+            this.state.hasBasic ? (
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper>
+            ) : null}
+            <DarkMatter />
+            {this.renderBasicSection()}
             <Helper>
               Have an account?
               <Link href="/login">Sign in</Link>
@@ -243,7 +304,12 @@ const IconWrapper = styled.div`
 
 const Icon = styled.img`
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
+`;
+
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
 `;
 
 const OAuthButton = styled.div`
@@ -258,6 +324,8 @@ const OAuthButton = styled.div`
   user-select: none;
   font-weight: 500;
   font-size: 13px;
+  margin: 10px 0;
+  overflow: hidden;
   :hover {
     background: #ffffffdd;
   }
@@ -385,11 +453,11 @@ const FormWrapper = styled.div`
 
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   @keyframes flip {
     from {
@@ -403,7 +471,8 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 500px;
+  height: ${(props: { numOAuth: number; hasBasic: boolean }) =>
+    270 + +props.hasBasic * 180 + props.numOAuth * 50}px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 29 - 1
dashboard/src/main/home/Home.tsx

@@ -3,6 +3,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 import styled from "styled-components";
 
 import api from "shared/api";
+import { H } from "highlight.run";
 import { Context } from "shared/Context";
 import { PorterUrl } from "shared/routing";
 import { ClusterType, ProjectType } from "shared/types";
@@ -82,6 +83,20 @@ class Home extends Component<PropsType, StateType> {
       });
   };
 
+  getCapabilities = () => {
+    let { currentProject } = this.props;
+    if (!currentProject) return;
+
+    api
+      .getCapabilities("<token>", {}, {})
+      .then((res) => {
+        this.context.setCapabilities(res.data);
+      })
+      .catch((err) => {
+        console.log(err);
+      });
+  };
+
   getProjects = (id?: number) => {
     let { user, setProjects } = this.context;
     let { currentProject } = this.props;
@@ -202,6 +217,17 @@ class Home extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
+    let { user } = this.context;
+
+    // Initialize Highlight
+    if (
+      window.location.href.includes("dashboard.getporter.dev") &&
+      !user.email.includes("@getporter.dev")
+    ) {
+      H.init("y2d13lgr");
+      H.identify(user.email, { id: user.id });
+    }
+
     // Handle redirect from DO
     let queryString = window.location.search;
     let urlParams = new URLSearchParams(queryString);
@@ -222,6 +248,7 @@ class Home extends Component<PropsType, StateType> {
     this.setState({ ghRedirect: urlParams.get("gh_oauth") !== null });
     urlParams.delete("gh_oauth");
     this.getProjects(defaultProjectId);
+    this.getCapabilities();
   }
 
   // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
@@ -237,6 +264,7 @@ class Home extends Component<PropsType, StateType> {
         this.checkDO();
       } else {
         this.initializeView();
+        this.getCapabilities();
       }
     }
   }
@@ -248,7 +276,7 @@ class Home extends Component<PropsType, StateType> {
       return (
         <DashboardWrapper>
           <Placeholder>
-            <Bold>Porter - Getting Started</Bold>
+            <Bold>Porter - Getting</Bold>
             <br />
             <br />
             1. Navigate to{" "}

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

@@ -1,9 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
-import gradient from "assets/gradient.jpg";
 import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
-import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";

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

@@ -48,15 +48,16 @@ export default class ChartList extends Component<PropsType, StateType> {
           namespace: this.props.namespace,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
-          limit: 20,
+          limit: 50,
           skip: 0,
           byDate: false,
           statusFilter: [
             "deployed",
             "uninstalled",
             "pending",
-            "pending_upgrade",
-            "pending_rollback",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
             "superseded",
             "failed",
           ],
@@ -107,9 +108,10 @@ export default class ChartList extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");

+ 43 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -8,7 +8,7 @@ import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
 import InputRow from "components/values-form/InputRow";
-import KeyValueArray from "components/values-form/KeyValueArray";
+import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Selector from "components/Selector";
 import Helper from "components/values-form/Helper";
 import SaveButton from "components/SaveButton";
@@ -25,7 +25,7 @@ type StateType = {
   envGroupName: string;
   selectedNamespace: string;
   namespaceOptions: any[];
-  envVariables: any;
+  envVariables: KeyValueType[];
   submitStatus: string;
 };
 
@@ -36,7 +36,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
     envGroupName: "",
     selectedNamespace: "default",
     namespaceOptions: [] as any[],
-    envVariables: {} as any,
+    envVariables: [] as KeyValueType[],
     submitStatus: "",
   };
 
@@ -52,13 +52,49 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
 
   onSubmit = () => {
     this.setState({ submitStatus: "loading" });
+
+    let apiEnvVariables: Record<string, string> = {};
+    let secretEnvVariables: Record<string, string> = {};
+
+    let envVariables = this.state.envVariables;
+
+    envVariables
+      .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are marked as deleted and are duplicates
+        let numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) =>
+                _envVar.key === envVar.key && !_envVar.deleted
+            )
+          );
+        }
+      })
+      .forEach((envVar: KeyValueType) => {
+        if (!envVar.deleted) {
+          if (envVar.hidden) {
+            secretEnvVariables[envVar.key] = envVar.value;
+          } else {
+            apiEnvVariables[envVar.key] = envVar.value;
+          }
+        }
+      });
+
     api
       .createConfigMap(
         "<token>",
         {
           name: this.state.envGroupName,
           namespace: this.state.selectedNamespace,
-          variables: this.state.envVariables,
+          variables: apiEnvVariables,
+          secret_variables: secretEnvVariables,
         },
         {
           id: this.context.currentProject.id,
@@ -159,10 +195,12 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
             Set environment variables for your secrets and environment-specific
             configuration.
           </Helper>
-          <KeyValueArray
+          <EnvGroupArray
             namespace={this.state.selectedNamespace}
             values={this.state.envVariables}
             setValues={(x: any) => this.setState({ envVariables: x })}
+            fileUpload={true}
+            secretOption={true}
           />
           <SaveButton
             disabled={this.isDisabled()}

+ 418 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -0,0 +1,418 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import Modal from "main/home/modals/Modal";
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+
+import sliders from "assets/sliders.svg";
+import upload from "assets/upload.svg";
+import { keysIn } from "lodash";
+
+export type KeyValueType = {
+  key: string;
+  value: string;
+  hidden: boolean;
+  locked: boolean;
+  deleted: boolean;
+};
+
+type PropsType = {
+  label?: string;
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+  width?: string;
+  disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
+  envLoader?: boolean;
+  fileUpload?: boolean;
+  secretOption?: boolean;
+};
+
+type StateType = {
+  showEnvModal: boolean;
+  showEditorModal: boolean;
+};
+
+export default class EnvGroupArray extends Component<PropsType, StateType> {
+  state = {
+    showEnvModal: false,
+    showEditorModal: false,
+  };
+
+  componentDidMount() {
+    if (!this.props.values) {
+      let _values = [] as KeyValueType[];
+      this.props.setValues(_values);
+    }
+  }
+
+  renderDeleteButton = (i: number) => {
+    if (!this.props.disabled) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            let _values = this.props.values;
+            _values[i].deleted = true;
+            this.props.setValues(_values);
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  renderHiddenOption = (hidden: boolean, locked: boolean, i: number) => {
+    if (this.props.secretOption) {
+      let icon = <i className="material-icons">lock_open</i>;
+
+      if (hidden) {
+        icon = <i className="material-icons">lock</i>;
+      }
+
+      return (
+        <HideButton
+          onClick={() => {
+            if (!locked) {
+              let _values = this.props.values;
+              _values[i].hidden = !_values[i].hidden;
+              this.props.setValues(_values);
+            }
+          }}
+          disabled={locked}
+        >
+          {icon}
+        </HideButton>
+      );
+    }
+  };
+
+  renderInputList = () => {
+    return (
+      <>
+        {this.props.values.map((entry: KeyValueType, i: number) => {
+          if (!entry.deleted) {
+            return (
+              <InputWrapper key={i}>
+                <Input
+                  placeholder="ex: key"
+                  width="270px"
+                  value={entry.key}
+                  onChange={(e: any) => {
+                    let _values = this.props.values;
+                    _values[i].key = e.target.value;
+                    this.props.setValues(_values);
+                  }}
+                  disabled={this.props.disabled || entry.locked}
+                  spellCheck={false}
+                />
+                <Spacer />
+                <Input
+                  placeholder="ex: value"
+                  width="270px"
+                  value={entry.value}
+                  onChange={(e: any) => {
+                    let _values = this.props.values;
+                    _values[i].value = e.target.value;
+                    this.props.setValues(_values);
+                  }}
+                  disabled={this.props.disabled || entry.locked}
+                  type={entry.hidden ? "password" : "text"}
+                  spellCheck={false}
+                />
+                {this.renderHiddenOption(entry.hidden, entry.locked, i)}
+                {this.renderDeleteButton(i)}
+              </InputWrapper>
+            );
+          }
+        })}
+      </>
+    );
+  };
+
+  renderEditorModal = () => {
+    if (this.state.showEditorModal) {
+      return (
+        <Modal
+          onRequestClose={() => this.setState({ showEditorModal: false })}
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() => this.setState({ showEditorModal: false })}
+            setEnvVariables={(envFile: string) => this.readFile(envFile)}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  // Parses src into an Object
+  parseEnv = (src: any, options: any) => {
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+    // convert Buffers before splitting into lines and processing
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
+          }
+
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
+
+    return obj;
+  };
+
+  readFile = (env: string) => {
+    let envObj = this.parseEnv(env, null);
+    let push = true;
+    let _values = this.props.values;
+
+    for (let key in envObj) {
+      for (var i = 0; i < this.props.values.length; i++) {
+        let existingKey = this.props.values[i]["key"];
+        if (key === existingKey) {
+          _values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        _values.push({
+          key,
+          value: envObj[key],
+          hidden: false,
+          locked: false,
+          deleted: false,
+        });
+      }
+    }
+
+    this.props.setValues(_values);
+  };
+
+  render() {
+    if (this.props.values) {
+      return (
+        <>
+          <StyledInputArray>
+            <Label>{this.props.label}</Label>
+            {this.props.values.length === 0 ? <></> : this.renderInputList()}
+            {this.props.disabled ? (
+              <></>
+            ) : (
+              <InputWrapper>
+                <AddRowButton
+                  onClick={() => {
+                    let _values = this.props.values;
+                    _values.push({
+                      key: "",
+                      value: "",
+                      hidden: false,
+                      locked: false,
+                      deleted: false,
+                    });
+                    this.props.setValues(_values);
+                  }}
+                >
+                  <i className="material-icons">add</i> Add Row
+                </AddRowButton>
+                <Spacer />
+                {this.props.namespace && this.props.envLoader && (
+                  <LoadButton
+                    onClick={() =>
+                      this.setState({ showEnvModal: !this.state.showEnvModal })
+                    }
+                  >
+                    <img src={sliders} /> Load from Env Group
+                  </LoadButton>
+                )}
+                {this.props.fileUpload && (
+                  <UploadButton
+                    onClick={() => {
+                      this.setState({ showEditorModal: true });
+                    }}
+                  >
+                    <img src={upload} /> Copy from File
+                  </UploadButton>
+                )}
+              </InputWrapper>
+            )}
+          </StyledInputArray>
+          {this.renderEditorModal()}
+        </>
+      );
+    }
+
+    return null;
+  }
+}
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: ${(props: { disabled: boolean }) =>
+      props.disabled ? "default" : "pointer"};
+    :hover {
+      color: ${(props: { disabled: boolean }) =>
+        props.disabled ? "#ffffff44" : "#ffffff88"};
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

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

@@ -1,6 +1,5 @@
 import React, { Component } from "react";
 import styled from "styled-components";
-import yaml from "js-yaml";
 import close from "assets/close.png";
 import key from "assets/key.svg";
 import _ from "lodash";
@@ -13,7 +12,7 @@ import SaveButton from "components/SaveButton";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import TabRegion from "components/TabRegion";
-import KeyValueArray from "components/values-form/KeyValueArray";
+import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
 
@@ -30,7 +29,7 @@ type StateType = {
   showDeleteOverlay: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
-  values: any;
+  envVariables: KeyValueType[];
 };
 
 const tabOptions = [
@@ -45,14 +44,88 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     showDeleteOverlay: false,
     deleting: false,
     saveValuesStatus: null as string | null,
-    values: this.props.envGroup.data as any,
+    envVariables: [] as KeyValueType[],
   };
 
-  handleUpdateValues = (config?: any) => {
+  componentDidMount() {
+    // parse env group props into values type
+    let envVariables = [] as KeyValueType[];
+    let envGroupData = this.props.envGroup.data;
+
+    for (const key in envGroupData) {
+      envVariables.push({
+        key: key,
+        value: envGroupData[key],
+        hidden: envGroupData[key].includes("PORTERSECRET"),
+        locked: envGroupData[key].includes("PORTERSECRET"),
+        deleted: false,
+      });
+    }
+
+    this.setState({ envVariables });
+  }
+
+  handleUpdateValues = () => {
     let { envGroup } = this.props;
     let name = envGroup.metadata.name;
     let namespace = envGroup.metadata.namespace;
 
+    let apiEnvVariables: Record<string, string> = {};
+    let secretEnvVariables: Record<string, string> = {};
+
+    let envVariables = this.state.envVariables;
+
+    envVariables
+      .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are marked as deleted and are duplicates, unless they are
+        // all delete collisions
+        let numDeleteCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key && envVar.deleted ? 1 : 0);
+        }, 0);
+
+        let numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == numDeleteCollisions) {
+          // if all collisions are delete collisions, just remove duplicates
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) => _envVar.key === envVar.key
+            )
+          );
+        } else if (numCollisions == 1) {
+          // if there's just one collision (self), keep the object
+          return true;
+        } else {
+          // if there are more collisions than delete collisions, remove all duplicates that
+          // are deletions
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) =>
+                _envVar.key === envVar.key && !_envVar.deleted
+            )
+          );
+        }
+      })
+      .forEach((envVar: KeyValueType) => {
+        if (envVar.hidden) {
+          if (envVar.deleted) {
+            secretEnvVariables[envVar.key] = null;
+          } else if (!envVar.value.includes("PORTERSECRET")) {
+            secretEnvVariables[envVar.key] = envVar.value;
+          }
+        } else {
+          if (envVar.deleted) {
+            apiEnvVariables[envVar.key] = null;
+          } else {
+            apiEnvVariables[envVar.key] = envVar.value;
+          }
+        }
+      });
+
     this.setState({ saveValuesStatus: "loading" });
     api
       .updateConfigMap(
@@ -60,7 +133,8 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
         {
           name,
           namespace,
-          variables: this.state.values,
+          variables: apiEnvVariables,
+          secret_variables: secretEnvVariables,
         },
         {
           id: this.context.currentProject.id,
@@ -90,10 +164,12 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 Set environment variables for your secrets and
                 environment-specific configuration.
               </Helper>
-              <KeyValueArray
+              <EnvGroupArray
                 namespace={namespace}
-                values={this.state.values || {}}
-                setValues={(x: any) => this.setState({ values: x })}
+                values={this.state.envVariables}
+                setValues={(x: any) => this.setState({ envVariables: x })}
+                fileUpload={true}
+                secretOption={true}
               />
             </InnerWrapper>
             <SaveButton
@@ -154,11 +230,9 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
       .then((res) => {
         this.props.closeExpanded();
         this.setState({ deleting: false });
-        // console.log("CONFIGMAP", res);
       })
       .catch((err) => {
         this.setState({ deleting: false, showDeleteOverlay: false });
-        // console.log("CONFIGMAP", err);
       });
   };
 
@@ -440,6 +514,7 @@ const StyledExpandedChart = styled.div`
   position: absolute;
   top: 25px;
   left: 25px;
+  overflow: hidden;
   border-radius: 10px;
   background: #26272f;
   box-shadow: 0 5px 12px 4px #00000033;

+ 155 - 95
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -16,9 +16,7 @@ import api from "shared/api";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import StatusIndicator from "components/StatusIndicator";
-import TabRegion from "components/TabRegion";
-import ValuesWrapper from "components/values-form/ValuesWrapper";
-import ValuesForm from "components/values-form/ValuesForm";
+import FormWrapper from "components/values-form/FormWrapper";
 import RevisionSection from "./RevisionSection";
 import ValuesYaml from "./ValuesYaml";
 import GraphSection from "./GraphSection";
@@ -26,6 +24,7 @@ import MetricsSection from "./metrics/MetricsSection";
 import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
+import ChartList from "../chart/ChartList";
 
 type PropsType = {
   namespace: string;
@@ -43,10 +42,9 @@ type StateType = {
   components: ResourceType[];
   podSelectors: string[];
   isPreview: boolean;
+  isUpdatingChart: boolean;
   devOpsMode: boolean;
   tabOptions: any[];
-  tabContents: any;
-  currentTab: string | null;
   saveValuesStatus: string | null;
   forceRefreshRevisions: boolean; // Update revisions after upgrading values
   controllers: Record<string, Record<string, any>>;
@@ -54,6 +52,7 @@ type StateType = {
   url: string | null;
   showDeleteOverlay: boolean;
   deleting: boolean;
+  formData: any;
 };
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
@@ -64,10 +63,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     components: [] as ResourceType[],
     podSelectors: [] as string[],
     isPreview: false,
+    isUpdatingChart: false,
     devOpsMode: localStorage.getItem("devOpsMode") === "true",
     tabOptions: [] as any[],
-    tabContents: [] as any,
-    currentTab: null as string | null,
     saveValuesStatus: null as string | null,
     forceRefreshRevisions: false,
     controllers: {} as Record<string, Record<string, any>>,
@@ -75,6 +73,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     url: null as string | null,
     showDeleteOverlay: false,
     deleting: false,
+    formData: {} as any,
   };
 
   // Retrieve full chart data (includes form and values)
@@ -98,9 +97,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        this.setState({ currentChart: res.data, loading: false }, () => {
-          this.updateTabs();
-        });
+        this.updateComponents(
+          { currentChart: res.data, loading: false },
+          res.data
+        );
       })
       .catch(console.log);
   };
@@ -152,9 +152,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string, chart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -197,9 +197,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     this.setState({ websockets });
   };
 
-  updateResources = () => {
+  updateComponents = (state: any, currentChart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
-    let { currentChart } = this.state;
 
     api
       .getChartComponents(
@@ -216,10 +215,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        this.setState({
-          components: res.data.Objects,
-          podSelectors: res.data.PodSelectors,
-        });
+        let newState = state || {};
+
+        newState.components = res.data.Objects;
+        newState.podSelectors = res.data.PodSelectors;
+
+        this.setState(newState);
+        this.updateTabs();
       })
       .catch(console.log);
   };
@@ -232,18 +234,22 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Convert dotted keys to nested objects
     let values = {};
 
+    // Weave in preexisting values and convert to yaml
+    if (this.props.currentChart.config) {
+      values = this.props.currentChart.config;
+    }
+
     for (let key in rawValues) {
       _.set(values, key, rawValues[key]);
     }
 
-    // Weave in preexisting values and convert to yaml
     let valuesYaml = yaml.dump({
-      ...(this.state.currentChart.config as Object),
       ...values,
     });
 
     this.setState({ saveValuesStatus: "loading" });
     this.refreshChart();
+
     api
       .upgradeChartValues(
         "<token>",
@@ -270,7 +276,19 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         });
       })
       .catch((err) => {
-        this.setState({ saveValuesStatus: "error" });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: err,
+        });
+
+        setCurrentError(parsedErr);
+
         window.analytics.track("Failed to Upgrade Chart", {
           chart: this.state.currentChart.name,
           values: valuesYaml,
@@ -279,15 +297,72 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       });
   };
 
-  renderTabContents = () => {
-    let {
-      currentTab,
-      podSelectors,
-      components,
-      showRevisions,
-      saveValuesStatus,
-      tabOptions,
-    } = this.state;
+  handleUpgradeVersion = (version: string, cb: () => void) => {
+    let { currentProject, currentCluster, setCurrentError } = this.context;
+
+    // convert current values to yaml
+    let values = this.props.currentChart.config;
+
+    let valuesYaml = yaml.dump({
+      ...values,
+    });
+
+    this.setState({ saveValuesStatus: "loading" });
+    this.refreshChart();
+
+    api
+      .upgradeChartValues(
+        "<token>",
+        {
+          namespace: this.state.currentChart.namespace,
+          storage: StorageType.Secret,
+          values: valuesYaml,
+          version: version,
+        },
+        {
+          id: currentProject.id,
+          name: this.state.currentChart.name,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({
+          saveValuesStatus: "successful",
+          forceRefreshRevisions: true,
+        });
+
+        window.analytics.track("Chart Upgraded", {
+          chart: this.state.currentChart.name,
+          values: valuesYaml,
+        });
+
+        cb && cb();
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: err,
+          loading: false,
+        });
+
+        setCurrentError(parsedErr);
+
+        window.analytics.track("Failed to Upgrade Chart", {
+          chart: this.state.currentChart.name,
+          values: valuesYaml,
+          error: err,
+        });
+      });
+  };
+
+  renderTabContents = (currentTab: string) => {
+    let { components, showRevisions } = this.state;
     let { setSidebar } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
@@ -331,56 +406,17 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           <ValuesYaml currentChart={chart} refreshChart={this.refreshChart} />
         );
       default:
-        if (tabOptions && currentTab && currentTab.includes("@")) {
-          return (
-            <ValuesWrapper
-              formTabs={tabOptions}
-              onSubmit={this.onSubmit}
-              saveValuesStatus={this.state.saveValuesStatus}
-              isInModal={true}
-              currentTab={currentTab}
-              renderSaveButton={true}
-            >
-              {(metaState: any, setMetaState: any) => {
-                return tabOptions.map((tab: any, i: number) => {
-                  // If tab is current, render
-                  if (tab.value === currentTab) {
-                    return (
-                      <ValuesForm
-                        key={i}
-                        metaState={metaState}
-                        setMetaState={setMetaState}
-                        sections={tab.sections}
-                        // For env group loader
-                        namespace={this.props.namespace}
-                      />
-                    );
-                  }
-                });
-              }}
-            </ValuesWrapper>
-          );
-        }
     }
   };
 
   updateTabs() {
     let formData = this.state.currentChart.form;
-    let tabOptions = [] as any[];
-
-    // Generate form tabs if form.yaml exists
     if (formData) {
-      formData.tabs.map((tab: any, i: number) => {
-        tabOptions.push({
-          value: "@" + tab.name,
-          label: tab.label,
-          sections: tab.sections,
-          context: tab.context,
-        });
-      });
+      this.setState({ formData });
     }
 
-    // Append universal tabs
+    // Collate non-form tabs
+    let tabOptions = [] as any[];
     tabOptions.push({ label: "Status", value: "status" });
 
     if (this.props.isMetricsInstalled) {
@@ -399,9 +435,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Settings tab is always last
     tabOptions.push({ label: "Settings", value: "settings" });
 
-    // Filter tabs if previewing an old revision
-    if (this.state.isPreview) {
-      let liveTabs = ["status", "settings", "deploy"];
+    // Filter tabs if previewing an old revision or updating the chart version
+    if (this.state.isPreview || this.state.isUpdatingChart) {
+      let liveTabs = ["status", "settings", "deploy", "metrics"];
       tabOptions = tabOptions.filter(
         (tab: any) => !liveTabs.includes(tab.value)
       );
@@ -593,6 +629,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       });
 
+      if (!serviceName || !serviceNamespace) {
+        return;
+      }
+
       return (
         <Url>
           <Bolded>Internal URI:</Bolded>
@@ -685,9 +725,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
             <RevisionSection
               showRevisions={this.state.showRevisions}
-              toggleShowRevisions={() =>
-                this.setState({ showRevisions: !this.state.showRevisions })
-              }
+              toggleShowRevisions={() => {
+                this.setState({ showRevisions: !this.state.showRevisions });
+              }}
               chart={chart}
               refreshChart={this.refreshChart}
               setRevision={this.setRevision}
@@ -696,25 +736,37 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
                 this.setState({ forceRefreshRevisions: false })
               }
               status={status}
+              shouldUpdate={
+                chart.latest_version &&
+                chart.latest_version !== chart.chart.metadata.version
+              }
+              latestVersion={chart.latest_version}
+              upgradeVersion={this.handleUpgradeVersion}
             />
           </HeaderWrapper>
-
-          <TabRegion
-            currentTab={this.state.currentTab}
-            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-            options={this.state.tabOptions}
-            color={this.state.isPreview ? "#f5cb42" : null}
-            addendum={
-              <TabButton
-                onClick={this.toggleDevOpsMode}
-                devOpsMode={this.state.devOpsMode}
-              >
-                <i className="material-icons">offline_bolt</i> DevOps Mode
-              </TabButton>
-            }
-          >
-            {this.renderTabContents()}
-          </TabRegion>
+          <BodyWrapper>
+            <FormWrapper
+              formData={this.state.formData}
+              tabOptions={this.state.tabOptions}
+              isInModal={true}
+              renderTabContents={this.renderTabContents}
+              onSubmit={this.onSubmit}
+              saveValuesStatus={this.state.saveValuesStatus}
+              externalValues={{
+                namespace: this.props.namespace,
+                clusterId: this.context.currentCluster.id,
+              }}
+              color={this.state.isPreview ? "#f5cb42" : null}
+              addendum={
+                <TabButton
+                  onClick={this.toggleDevOpsMode}
+                  devOpsMode={this.state.devOpsMode}
+                >
+                  <i className="material-icons">offline_bolt</i> DevOps Mode
+                </TabButton>
+              }
+            />
+          </BodyWrapper>
         </StyledExpandedChart>
       </>
     );
@@ -723,6 +775,12 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 ExpandedChart.contextType = Context;
 
+const BodyWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
 const DeleteOverlay = styled.div`
   position: absolute;
   top: 0px;
@@ -906,6 +964,7 @@ const Title = styled.div`
   font-weight: 500;
   display: flex;
   align-items: center;
+  user-select: text;
 `;
 
 const TitleSection = styled.div`
@@ -949,6 +1008,7 @@ const StyledExpandedChart = styled.div`
   animation-fill-mode: forwards;
   padding: 25px;
   display: flex;
+  overflow: hidden;
   flex-direction: column;
 
   @keyframes floatIn {

+ 253 - 76
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import yaml from "js-yaml";
 import close from "assets/close.png";
 import _ from "lodash";
+import loading from "assets/loading.gif";
 
 import { ChartType, StorageType, ClusterType } from "shared/types";
 import { Context } from "shared/Context";
@@ -14,8 +15,8 @@ import Loading from "components/Loading";
 import TabRegion from "components/TabRegion";
 import JobList from "./jobs/JobList";
 import SettingsSection from "./SettingsSection";
-import ValuesWrapper from "components/values-form/ValuesWrapper";
-import ValuesForm from "components/values-form/ValuesForm";
+import FormWrapper from "components/values-form/FormWrapper";
+import { PlaceHolder } from "brace";
 
 type PropsType = {
   namespace: string;
@@ -27,6 +28,8 @@ type PropsType = {
 
 type StateType = {
   currentChart: ChartType;
+  imageIsPlaceholder: boolean;
+  newestImage: string;
   loading: boolean;
   jobs: any[];
   tabOptions: any[];
@@ -36,11 +39,15 @@ type StateType = {
   showDeleteOverlay: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
+  formData: any;
+  valuesToOverride: any;
 };
 
 export default class ExpandedJobChart extends Component<PropsType, StateType> {
   state = {
     currentChart: this.props.currentChart,
+    imageIsPlaceholder: false,
+    newestImage: null as string,
     loading: true,
     jobs: [] as any[],
     tabOptions: [] as any[],
@@ -50,10 +57,12 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     showDeleteOverlay: false,
     deleting: false,
     saveValuesStatus: null as string | null,
+    formData: {} as any,
+    valuesToOverride: {} as any,
   };
 
   // Retrieve full chart data (includes form and values)
-  getChartData = (chart: ChartType) => {
+  getChartData = (chart: ChartType, revision: number) => {
     let { currentProject } = this.context;
     let { currentCluster, currentChart } = this.props;
 
@@ -68,19 +77,49 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         },
         {
           name: chart.name,
-          revision: chart.version,
+          revision: revision,
           id: currentProject.id,
         }
       )
       .then((res) => {
-        this.setState({ currentChart: res.data, loading: false }, () => {
-          this.updateTabs();
-        });
+        let image = res.data?.config?.image?.repository;
+        let tag = res.data?.config?.image?.tag.toString();
+        let newestImage = tag ? image + ":" + tag : image;
+
+        if (
+          (image === "porterdev/hello-porter-job" ||
+            image === "public.ecr.aws/o1j4x7p4/hello-porter-job") &&
+          !this.state.newestImage
+        ) {
+          this.setState(
+            {
+              currentChart: res.data,
+              loading: false,
+              imageIsPlaceholder: true,
+              newestImage: newestImage,
+            },
+            () => {
+              this.updateTabs();
+            }
+          );
+        } else {
+          this.setState(
+            {
+              currentChart: res.data,
+              loading: false,
+              newestImage: newestImage,
+            },
+            () => {
+              this.updateTabs();
+            }
+          );
+        }
       })
       .catch(console.log);
   };
 
-  refreshChart = () => this.getChartData(this.state.currentChart);
+  refreshChart = (revision: number) =>
+    this.getChartData(this.state.currentChart, revision);
 
   mergeNewJob = (newJob: any) => {
     let jobs = this.state.jobs;
@@ -106,9 +145,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -149,6 +188,69 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     return ws;
   };
 
+  setupCronJobWebsocket = (chart: ChartType) => {
+    let releaseName = chart.name;
+    let releaseNamespace = chart.namespace;
+
+    let { currentCluster, currentProject } = this.context;
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    let ws = new WebSocket(
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
+    );
+    ws.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      // if imageIsPlaceholder is true, update the newestImage and imageIsPlaceholder fields
+      if (
+        (event.event_type == "ADD" || event.event_type == "UPDATE") &&
+        this.state.imageIsPlaceholder
+      ) {
+        // filter job belonging to chart
+        let relNameAnn =
+          event.Object?.metadata?.annotations["meta.helm.sh/release-name"];
+        let relNamespaceAnn =
+          event.Object?.metadata?.annotations["meta.helm.sh/release-namespace"];
+
+        if (
+          relNameAnn &&
+          relNamespaceAnn &&
+          releaseName == relNameAnn &&
+          releaseNamespace == relNamespaceAnn
+        ) {
+          let newestImage =
+            event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
+              ?.image;
+          if (
+            newestImage &&
+            newestImage !== "porterdev/hello-porter-job" &&
+            newestImage !== "porterdev/hello-porter-job:latest" &&
+            newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
+            newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
+          ) {
+            this.setState({ newestImage, imageIsPlaceholder: false });
+          }
+        }
+      }
+    };
+
+    ws.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
+
+    return ws;
+  };
+
   handleSaveValues = (config?: any) => {
     let { currentCluster, setCurrentError, currentProject } = this.context;
     this.setState({ saveValuesStatus: "loading" });
@@ -156,8 +258,26 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let conf: string;
 
     if (!config) {
+      let values = {};
+      let imageUrl = this.state.newestImage;
+      let tag = null;
+
+      if (imageUrl) {
+        if (imageUrl.includes(":")) {
+          let splits = imageUrl.split(":");
+          imageUrl = splits[0];
+          tag = splits[1].toString();
+        } else if (!tag) {
+          tag = "latest";
+        }
+
+        _.set(values, "image.repository", imageUrl);
+        _.set(values, "image.tag", tag);
+      }
+
       conf = yaml.dump({
         ...this.state.currentChart.config,
+        ...values,
       });
     } else {
       // Convert dotted keys to nested objects
@@ -167,11 +287,29 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         _.set(values, key, config[key]);
       }
 
+      let imageUrl = this.state.newestImage;
+      let tag = null as string;
+
+      if (imageUrl) {
+        if (imageUrl.includes(":")) {
+          let splits = imageUrl.split(":");
+          imageUrl = splits[0];
+          tag = splits[1].toString();
+        } else if (!tag) {
+          tag = "latest";
+        }
+
+        _.set(values, "image.repository", imageUrl);
+        _.set(values, "image.tag", `${tag}`);
+      }
+
       // Weave in preexisting values and convert to yaml
-      conf = yaml.dump({
-        ...(this.state.currentChart.config as Object),
-        ...values,
-      });
+      conf = yaml.dump(
+        {
+          ...(this.state.currentChart.config as Object),
+          ...values,
+        }, { forceQuotes: true }
+      );
     }
 
     api
@@ -190,12 +328,21 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       )
       .then((res) => {
         this.setState({ saveValuesStatus: "successful" });
-        this.refreshChart();
+        this.refreshChart(0);
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
-        setCurrentError(JSON.stringify(err));
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 
@@ -229,23 +376,42 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
       return date2.getTime() - date1.getTime();
     });
-
-    console.log("JOBS ARE", jobs);
-
-    this.setState({ jobs });
+    let newestImage = jobs[0]?.spec?.template?.spec?.containers[0]?.image;
+    if (
+      newestImage &&
+      newestImage !== "porterdev/hello-porter-job" &&
+      newestImage !== "porterdev/hello-porter-job:latest" &&
+      newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
+      newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
+    ) {
+      this.setState({ jobs, newestImage, imageIsPlaceholder: false });
+    } else {
+      this.setState({ jobs });
+    }
   };
 
-  renderTabContents = () => {
-    let currentTab = this.state.currentTab;
-
+  renderTabContents = (currentTab: string, submitValues?: any) => {
     switch (currentTab) {
       case "jobs":
+        if (this.state.imageIsPlaceholder) {
+          return (
+            <Placeholder>
+              <TextWrap>
+                <Header>
+                  <Spinner src={loading} /> This job is currently being deployed
+                </Header>
+                Navigate to the "Actions" tab of your GitHub repo to view live
+                build logs.
+              </TextWrap>
+            </Placeholder>
+          );
+        }
         return (
           <TabWrapper>
             <JobList jobs={this.state.jobs} />
             <SaveButton
               text="Rerun Job"
-              onClick={() => this.handleSaveValues()}
+              onClick={() => this.handleSaveValues(submitValues)}
               status={this.state.saveValuesStatus}
               makeFlush={true}
             />
@@ -255,55 +421,23 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         return (
           <SettingsSection
             currentChart={this.state.currentChart}
-            refreshChart={this.refreshChart}
+            refreshChart={() => this.refreshChart(0)}
             setShowDeleteOverlay={(x: boolean) =>
               this.setState({ showDeleteOverlay: x })
             }
           />
         );
       default:
-        if (this.state.tabOptions && currentTab && currentTab.includes("@")) {
-          return (
-            <TabWrapper>
-              <ValuesWrapper
-                formTabs={this.state.tabOptions}
-                onSubmit={this.handleSaveValues}
-                saveValuesStatus={this.state.saveValuesStatus}
-                isInModal={true}
-                currentTab={currentTab}
-                renderSaveButton={false}
-              >
-                {(metaState: any, setMetaState: any) => {
-                  return this.state.tabOptions.map((tab: any, i: number) => {
-                    // If tab is current, render
-                    if (tab.value === currentTab) {
-                      return (
-                        <ValuesForm
-                          key={i}
-                          metaState={metaState}
-                          setMetaState={setMetaState}
-                          sections={tab.sections}
-                          disabled={true}
-                        />
-                      );
-                    }
-                  });
-                }}
-              </ValuesWrapper>
-              <SaveButton
-                text="Rerun Job"
-                onClick={() => this.handleSaveValues()}
-                status={this.state.saveValuesStatus}
-                makeFlush={true}
-              />
-            </TabWrapper>
-          );
-        }
     }
   };
 
   updateTabs() {
     let formData = this.state.currentChart.form;
+    if (formData) {
+      this.setState({
+        formData,
+      });
+    }
     let tabOptions = [] as any[];
 
     // Append universal tabs
@@ -312,7 +446,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     if (formData) {
       formData.tabs.map((tab: any, i: number) => {
         tabOptions.push({
-          value: "@" + tab.name,
+          value: tab.name,
           label: tab.label,
           sections: tab.sections,
           context: tab.context,
@@ -350,16 +484,16 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
     let { currentChart } = this.state;
 
     window.analytics.track("Opened Chart", {
       chart: currentChart.name,
     });
 
-    this.getChartData(currentChart);
+    this.getChartData(currentChart, currentChart.version);
     this.getJobs(currentChart);
     this.setupJobWebsocket(currentChart);
+    this.setupCronJobWebsocket(currentChart);
   }
 
   handleUninstallChart = () => {
@@ -420,7 +554,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               </Title>
               <InfoWrapper>
                 <LastDeployed>
-                  Run {this.state.jobs.length} times <Dot>•</Dot>Last run
+                  Run {this.state.jobs.length} times <Dot>•</Dot>Last template update at
                   {" " + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
               </InfoWrapper>
@@ -435,14 +569,22 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
             </CloseButton>
           </HeaderWrapper>
 
-          <TabRegion
-            currentTab={this.state.currentTab}
-            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-            options={this.state.tabOptions}
-            color={null}
-          >
-            {this.renderTabContents()}
-          </TabRegion>
+          <BodyWrapper>
+            <FormWrapper
+              isReadOnly={this.state.imageIsPlaceholder}
+              valuesToOverride={this.state.valuesToOverride}
+              clearValuesToOverride={() =>
+                this.setState({ valuesToOverride: {} })
+              }
+              formData={this.state.formData}
+              tabOptions={this.state.tabOptions}
+              isInModal={true}
+              renderTabContents={this.renderTabContents}
+              tabOptionsOnly={true}
+              onSubmit={this.handleSaveValues}
+              saveValuesStatus={this.state.saveValuesStatus}
+            />
+          </BodyWrapper>
         </StyledExpandedChart>
       </>
     );
@@ -451,6 +593,40 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
 ExpandedJobChart.contextType = Context;
 
+const TextWrap = styled.div``;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Placeholder = styled.div`
+  height: 100%;
+  padding: 30px;
+  padding-bottom: 70px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
+const BodyWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
 const TabWrapper = styled.div`
   height: 100%;
   width: 100%;
@@ -632,6 +808,7 @@ const StyledExpandedChart = styled.div`
   animation-fill-mode: forwards;
   padding: 25px;
   display: flex;
+  overflow: hidden;
   flex-direction: column;
 
   @keyframes floatIn {

+ 73 - 8
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -17,11 +17,15 @@ type PropsType = {
   forceRefreshRevisions: boolean;
   refreshRevisionsOff: () => void;
   status: string;
+  shouldUpdate: boolean;
+  upgradeVersion: (version: string, cb: () => void) => void;
+  latestVersion: string;
 };
 
 type StateType = {
   revisions: ChartType[];
   rollbackRevision: number | null;
+  upgradeVersion: string;
   loading: boolean;
   maxVersion: number;
 };
@@ -31,6 +35,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
   state = {
     revisions: [] as ChartType[],
     rollbackRevision: null as number | null,
+    upgradeVersion: "",
     loading: false,
     maxVersion: 0, // Track most recent version even when previewing old revisions
   };
@@ -159,6 +164,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
           <Td>{this.renderStatus(revision)}</Td>
+          <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
               disabled={isCurrent}
@@ -184,6 +190,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
                 <Th>Revision No.</Th>
                 <Th>Timestamp</Th>
                 <Th>Status</Th>
+                <Th>Template Version</Th>
                 <Th>Rollback</Th>
               </Tr>
               {this.renderRevisionList()}
@@ -215,13 +222,43 @@ export default class RevisionSection extends Component<PropsType, StateType> {
           isCurrent={isCurrent}
           onClick={this.props.toggleShowRevisions}
         >
-          {isCurrent
-            ? `Current Revision`
-            : `Previewing Revision (Not Deployed)`}{" "}
-          - <Revision>No. {this.props.chart.version}</Revision>
-          <i className="material-icons">arrow_drop_down</i>
+          <RevisionPreview>
+            {isCurrent
+              ? `Current Revision`
+              : `Previewing Revision (Not Deployed)`}{" "}
+            - <Revision>No. {this.props.chart.version}</Revision>
+            <i className="material-icons">arrow_drop_down</i>
+          </RevisionPreview>
+          {this.props.shouldUpdate && isCurrent && (
+            <div>
+              <RevisionUpdateMessage
+                onClick={(e) => {
+                  e.stopPropagation();
+                  this.setState({ upgradeVersion: this.props.latestVersion });
+                }}
+              >
+                <i className="material-icons">notification_important</i>
+                Template Update Available
+              </RevisionUpdateMessage>
+              <ConfirmOverlay
+                show={!!this.state.upgradeVersion}
+                message={`Are you sure you want to redeploy and upgrade to version ${this.state.upgradeVersion}?`}
+                onYes={(e) => {
+                  e.stopPropagation();
+
+                  this.props.upgradeVersion(this.state.upgradeVersion, () => {
+                    this.setState({ loading: false });
+                  });
+                  this.setState({ upgradeVersion: "", loading: true });
+                }}
+                onNo={(e) => {
+                  e.stopPropagation();
+                  this.setState({ upgradeVersion: "" });
+                }}
+              />
+            </div>
+          )}
         </RevisionHeader>
-
         <RevisionList>{this.renderExpanded()}</RevisionList>
       </div>
     );
@@ -342,6 +379,7 @@ const RevisionHeader = styled.div`
   color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
     props.isCurrent ? "#ffffff66" : "#f5cb42"};
   display: flex;
+  justify-content: space-between;
   align-items: center;
   height: 40px;
   font-size: 13px;
@@ -352,12 +390,12 @@ const RevisionHeader = styled.div`
     props.showRevisions ? "#ffffff11" : ""};
   :hover {
     background: #ffffff18;
-    > i {
+    > div > i {
       background: #ffffff22;
     }
   }
 
-  > i {
+  > div > i {
     margin-left: 12px;
     font-size: 20px;
     cursor: pointer;
@@ -389,3 +427,30 @@ const StyledRevisionSection = styled.div`
     }
   }
 `;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const RevisionUpdateMessage = styled.div`
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 5px;
+  margin-right: 10px;
+
+  :hover {
+    border: 1px solid white;
+    padding: 3px 9px;
+  }
+
+  > i {
+    margin-right: 6px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: none;
+  }
+`;

+ 15 - 19
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -82,7 +82,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 
   redeployWithNewImage = (img: string, tag: string) => {
     this.setState({ saveValuesStatus: "loading" });
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
 
     // If tag is explicitly declared, parse tag
     let imgSplits = img.split(":");
@@ -130,8 +130,18 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         this.props.refreshChart();
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 
@@ -141,7 +151,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     }
 
     if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH&repository=IMAGE_REPOSITORY_URL'`;
+      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
       return (
         <>
           <Heading>Redeploy Webhook</Heading>
@@ -179,20 +189,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
             Delete {this.props.currentChart.name}
           </Button>
         </StyledSettingsSection>
-        <SaveButton
-          text="Save Settings"
-          onClick={() =>
-            this.redeployWithNewImage(
-              this.state.selectedImageUrl,
-              this.state.selectedTag
-            )
-          }
-          status={this.state.saveValuesStatus}
-          makeFlush={true}
-          disabled={
-            this.state.selectedImageUrl && this.state.selectedTag ? false : true
-          }
-        />
       </Wrapper>
     );
   }
@@ -286,7 +282,7 @@ const Wrapper = styled.div`
 
 const StyledSettingsSection = styled.div`
   width: 100%;
-  height: calc(100% - 65px);
+  height: calc(100%);
   background: #ffffff11;
   padding: 0 35px;
   padding-bottom: 50px;

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

@@ -67,8 +67,18 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
         this.props.refreshChart();
       })
       .catch((err) => {
-        console.log(err);
-        this.setState({ saveValuesStatus: "error" });
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+
+        setCurrentError(parsedErr);
       });
   };
 

+ 8 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -8,6 +8,7 @@ import Edge from "./Edge";
 import InfoPanel from "./InfoPanel";
 import ZoomPanel from "./ZoomPanel";
 import SelectRegion from "./SelectRegion";
+import _ from "lodash";
 
 const zoomConstant = 0.01;
 const panConstant = 0.8;
@@ -100,8 +101,10 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     let graph = localStorage.getItem(
       `charts.${currentChart.name}-${currentChart.version}`
     );
+
     let nodes = [] as NodeType[];
     let edges = [] as EdgeType[];
+
     if (!graph) {
       nodes = this.createNodes(components);
       edges = this.createEdges(components);
@@ -143,7 +146,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
   // Live update on rollback/upgrade
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.components !== this.props.components) {
+    if (!_.isEqual(prevProps.currentChart, this.props.currentChart)) {
       this.storeChartGraph(prevProps);
       this.getChartGraph();
     }
@@ -244,6 +247,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
   storeChartGraph = (props?: PropsType) => {
     let useProps = props || this.props;
+
     let { currentChart } = useProps;
     let graph = JSON.parse(JSON.stringify(this.state));
 
@@ -559,9 +563,9 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           showKindLabels={this.state.showKindLabels}
           isOpen={node === this.state.openedNode}
           // Parameterized to allow setting to null
-          setCurrentNode={(node: NodeType) =>
-            this.setState({ currentNode: node })
-          }
+          setCurrentNode={(node: NodeType) => {
+            this.setState({ currentNode: node });
+          }}
         />
       );
     });

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

@@ -24,7 +24,7 @@ export default class JobList extends Component<PropsType, StateType> {
       return (
         <>
           {this.props.jobs.map((job: any, i: number) => {
-            return <JobResource key={i} job={job} />;
+            return <JobResource key={job?.metadata?.name} job={job} />;
           })}
         </>
       );

+ 174 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -1,9 +1,13 @@
 import React, { MouseEvent, Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
+import _ from "lodash";
 
 import api from "shared/api";
 import Logs from "../status/Logs";
+import plus from "assets/plus.svg";
+import closeRounded from "assets/close-rounded.png";
+import KeyValueArray from "components/values-form/KeyValueArray";
 
 type PropsType = {
   job: any;
@@ -11,21 +15,49 @@ type PropsType = {
 
 type StateType = {
   expanded: boolean;
+  configIsExpanded: boolean;
   pods: any[];
 };
 
 export default class JobResource extends Component<PropsType, StateType> {
   state = {
     expanded: false,
+    configIsExpanded: false,
     pods: [] as any[],
   };
 
-  expandJob = () => {
+  expandJob = (event: MouseEvent) => {
+    if (event) {
+      event.stopPropagation();
+    }
+
     this.getPods(() => {
       this.setState({ expanded: !this.state.expanded });
     });
   };
 
+  stopJob = (event: MouseEvent) => {
+    if (event) {
+      event.stopPropagation();
+    }
+
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api
+      .stopJob(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: this.props.job.metadata?.name,
+          namespace: this.props.job.metadata?.namespace,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {})
+      .catch((err) => setCurrentError(JSON.stringify(err)));
+  };
+
   getPods = (callback: () => void) => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
 
@@ -89,16 +121,79 @@ export default class JobResource extends Component<PropsType, StateType> {
       : "Failed";
   };
 
+  renderConfigSection = () => {
+    let { job } = this.props;
+    let commandString = job?.spec?.template?.spec?.containers[0]?.command?.join(
+      " "
+    );
+    let envArray = job?.spec?.template?.spec?.containers[0]?.env;
+    let envObject = {} as any;
+    envArray &&
+      envArray.forEach((env: any, i: number) => {
+        envObject[env.name] = env.value;
+      });
+
+    // Handle no config to show
+    if (!commandString && _.isEmpty(envObject)) {
+      return;
+    }
+
+    if (!this.state.configIsExpanded) {
+      return (
+        <ExpandConfigBar
+          onClick={() => this.setState({ configIsExpanded: true })}
+        >
+          <img src={plus} />
+          Show Job Config
+        </ExpandConfigBar>
+      );
+    } else {
+      return (
+        <>
+          <ExpandConfigBar
+            onClick={() => this.setState({ configIsExpanded: false })}
+          >
+            <img src={closeRounded} />
+            Hide Job Config
+          </ExpandConfigBar>
+          <ConfigSection>
+            {commandString ? (
+              <>
+                Command: <Command>{commandString}</Command>
+              </>
+            ) : (
+              <DarkMatter size="-18px" />
+            )}
+            {!_.isEmpty(envObject) && (
+              <>
+                <KeyValueArray
+                  envLoader={true}
+                  values={envObject}
+                  label="Environment Variables:"
+                  disabled={true}
+                />
+                <DarkMatter />
+              </>
+            )}
+          </ConfigSection>
+        </>
+      );
+    }
+  };
+
   renderLogsSection = () => {
     if (this.state.expanded) {
       return (
-        <JobLogsWrapper>
-          <Logs
-            selectedPod={this.state.pods[0]}
-            podError={!this.state.pods[0] ? "Pod no longer exists." : ""}
-            rawText={true}
-          />
-        </JobLogsWrapper>
+        <>
+          {this.renderConfigSection()}
+          <JobLogsWrapper>
+            <Logs
+              selectedPod={this.state.pods[0]}
+              podError={!this.state.pods[0] ? "Pod no longer exists." : ""}
+              rawText={true}
+            />
+          </JobLogsWrapper>
+        </>
       );
     }
 
@@ -129,9 +224,25 @@ export default class JobResource extends Component<PropsType, StateType> {
     return <Status color="#ffffff11">Running</Status>;
   };
 
+  renderStopButton = () => {
+    if (!this.props.job.status?.succeeded && !this.props.job.status?.failed) {
+      // look for a sidecar container
+      if (this.props.job?.spec?.template?.spec?.containers.length == 2) {
+        return (
+          <i className="material-icons" onClick={this.stopJob}>
+            stop
+          </i>
+        );
+      }
+    }
+  };
+
   render() {
     let icon =
       "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png";
+    let commandString = this.props.job?.spec?.template?.spec?.containers[0]?.command?.join(
+      " "
+    );
 
     return (
       <StyledJob>
@@ -146,10 +257,10 @@ export default class JobResource extends Component<PropsType, StateType> {
             </Description>
           </Flex>
           <EndWrapper>
+            <CommandString>{commandString}</CommandString>
             {this.renderStatus()}
             <MaterialIconTray disabled={false}>
-              {/* <i className="material-icons"
-              onClick={this.editButtonOnClick}>mode_edit</i> */}
+              {this.renderStopButton()}
               <i className="material-icons" onClick={this.expandJob}>
                 {this.state.expanded ? "expand_less" : "expand_more"}
               </i>
@@ -164,6 +275,55 @@ export default class JobResource extends Component<PropsType, StateType> {
 
 JobResource.contextType = Context;
 
+const DarkMatter = styled.div<{ size?: string }>`
+  width: 100%;
+  margin-bottom: ${(props) => props.size || "-13px"};
+`;
+
+const Command = styled.span`
+  font-family: monospace;
+  color: #aaaabb;
+  margin-left: 7px;
+`;
+
+const ConfigSection = styled.div`
+  padding: 20px 30px;
+  font-size: 13px;
+  font-weight: 500;
+`;
+
+const ExpandConfigBar = styled.div`
+  display: flex;
+  align-items: center;
+  padding-left: 28px;
+  font-size: 13px;
+  height: 40px;
+  width: 100%;
+  background: #3f465288;
+  color: #ffffff;
+  user-select: none;
+  cursor: pointer;
+
+  > img {
+    width: 18px;
+    margin-right: 10px;
+  }
+
+  :hover {
+    background: #3f4652cc;
+  }
+`;
+
+const CommandString = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 300px;
+  color: #ffffff55;
+  margin-right: 27px;
+  font-family: monospace;
+`;
+
 const EndWrapper = styled.div`
   display: flex;
   align-items: center;
@@ -171,7 +331,7 @@ const EndWrapper = styled.div`
 
 const Status = styled.div<{ color: string }>`
   padding: 5px 10px;
-  margin-right: 20px;
+  margin-right: 12px;
   background: ${(props) => props.color};
   font-size: 13px;
   border-radius: 3px;
@@ -208,7 +368,6 @@ const StyledJob = styled.div`
   display: flex;
   flex-direction: column;
   background: #2b2e36;
-  cursor: pointer;
   margin-bottom: 20px;
   border-radius: 5px;
   overflow: hidden;
@@ -223,14 +382,16 @@ const MainRow = styled.div`
   height: 70px;
   width: 100%;
   display: flex;
+  cursor: pointer;
   align-items: center;
   justify-content: space-between;
   padding: 25px;
+  padding-right: 18px;
   border-radius: 5px;
 `;
 
 const MaterialIconTray = styled.div`
-  max-width: 60px;
+  user-select: none;
   display: flex;
   align-items: center;
   justify-content: space-between;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -369,6 +369,7 @@ export default class MetricsSection extends Component<PropsType, StateType> {
         "<token>",
         {
           cluster_id: currentCluster.id,
+          namespace: selectedController?.metadata?.namespace,
           selectors,
         },
         {

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

@@ -59,6 +59,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
         "<token>",
         {
           cluster_id: currentCluster.id,
+          namespace: controller?.metadata?.namespace,
           selectors,
         },
         {

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

@@ -1,6 +1,8 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
+import * as Anser from "anser";
+import api from "shared/api";
 
 type PropsType = {
   selectedPod: any;
@@ -9,16 +11,18 @@ type PropsType = {
 };
 
 type StateType = {
-  logs: string[];
+  logs: Anser.AnserJsonEntry[][];
   ws: any;
   scroll: boolean;
+  currentTab: string;
 };
 
 export default class Logs extends Component<PropsType, StateType> {
   state = {
-    logs: [] as string[],
+    logs: [] as Anser.AnserJsonEntry[][],
     ws: null as any,
     scroll: true,
+    currentTab: "Application",
   };
 
   ws = null as any;
@@ -28,10 +32,14 @@ export default class Logs extends Component<PropsType, StateType> {
     if (smooth) {
       this.parentRef.current.lastElementChild.scrollIntoView({
         behavior: "smooth",
+        block: "nearest",
+        inline: "start",
       });
     } else {
       this.parentRef.current.lastElementChild.scrollIntoView({
         behavior: "auto",
+        block: "nearest",
+        inline: "start",
       });
     }
   };
@@ -56,10 +64,33 @@ export default class Logs extends Component<PropsType, StateType> {
     }
 
     if (this.state.logs.length == 0) {
-      return <Message>No logs to display from this pod.</Message>;
+      return (
+        <Message>
+          No logs to display from this pod.
+          <Highlight onClick={this.refreshLogs}>
+            <i className="material-icons">autorenew</i>
+            Refresh
+          </Highlight>
+        </Message>
+      );
     }
+
     return this.state.logs.map((log, i) => {
-      return <Log key={i}>{log}</Log>;
+      return (
+        <Log key={i}>
+          {this.state.logs[i].map((ansi, j) => {
+            if (ansi.clearLine) {
+              return null;
+            }
+
+            return (
+              <LogSpan key={i + "." + j} ansi={ansi}>
+                {ansi.content.replace(/ /g, "\u00a0")}
+              </LogSpan>
+            );
+          })}
+        </Log>
+      );
     });
   };
 
@@ -67,48 +98,104 @@ export default class Logs extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod?.metadata?.name) return;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
     );
 
-    this.ws.onopen = () => {
-      console.log("connected to websocket");
-    };
+    this.ws.onopen = () => {};
 
     this.ws.onmessage = (evt: MessageEvent) => {
-      this.setState({ logs: [...this.state.logs, evt.data] }, () => {
+      let ansiLog = Anser.ansiToJson(evt.data);
+
+      let logs = this.state.logs;
+      logs.push(ansiLog);
+
+      this.setState({ logs: logs }, () => {
         if (this.state.scroll) {
           this.scrollToBottom(false);
         }
       });
     };
 
-    this.ws.onerror = (err: ErrorEvent) => {
-      console.log("websocket error:", err);
-    };
+    this.ws.onerror = (err: ErrorEvent) => {};
 
-    this.ws.onclose = () => {
-      console.log("closing pod logs");
-    };
+    this.ws.onclose = () => {};
   };
 
   refreshLogs = () => {
-    if (this.ws) {
+    let { selectedPod } = this.props;
+    if (this.ws && this.state.currentTab == "Application") {
       this.ws.close();
       this.ws = null;
       this.setState({ logs: [] });
       this.setupWebsocket();
     }
+    this.retrieveEvents(selectedPod);
+  };
+
+  componentDidUpdate = (prevProps: any, prevState: any) => {
+    if (prevState.currentTab !== this.state.currentTab) {
+      let { selectedPod } = this.props;
+
+      this.setState({ logs: [] });
+
+      if (this.state.currentTab == "Application") {
+        this.setupWebsocket();
+        this.scrollToBottom(false);
+        return;
+      }
+
+      this.retrieveEvents(selectedPod);
+    }
+  };
+
+  retrieveEvents = (selectedPod: any) => {
+    api
+      .getPodEvents(
+        "<token>",
+        {
+          cluster_id: this.context.currentCluster.id,
+        },
+        {
+          name: selectedPod?.metadata?.name,
+          namespace: selectedPod?.metadata?.namespace,
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        let logs = [] as Anser.AnserJsonEntry[][];
+        // TODO: column view
+        // logs.push(Anser.ansiToJson("\u001b[33;5;196mEvent Type\u001b[0m \t || \t \u001b[43m\u001b[34m\tReason\t\u001b[0m \t ||\tMessage"))
+
+        res.data.items.forEach((evt: any) => {
+          let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
+          let ansiLog = Anser.ansiToJson(
+            `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
+          );
+          logs.push(ansiLog);
+        });
+        this.setState({ logs: logs });
+        console.log(res);
+      })
+      .catch((err) => {
+        console.log(err);
+      });
   };
 
   componentDidMount() {
-    this.setupWebsocket();
-    this.scrollToBottom(false);
+    let { selectedPod } = this.props;
+
+    if (this.state.currentTab == "Application") {
+      this.setupWebsocket();
+      this.scrollToBottom(false);
+      return;
+    }
+
+    this.retrieveEvents(selectedPod);
   }
 
   componentWillUnmount() {
-    console.log("log unmount");
     if (this.ws) {
       this.ws.close();
     }
@@ -119,6 +206,24 @@ export default class Logs extends Component<PropsType, StateType> {
       return (
         <LogStreamAlt>
           <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+          <LogTabs>
+            <Tab
+              onClick={() => {
+                this.setState({ currentTab: "Application" });
+              }}
+              clicked={this.state.currentTab == "Application"}
+            >
+              Application
+            </Tab>
+            <Tab
+              onClick={() => {
+                this.setState({ currentTab: "System" });
+              }}
+              clicked={this.state.currentTab == "System"}
+            >
+              System
+            </Tab>
+          </LogTabs>
         </LogStreamAlt>
       );
     }
@@ -126,6 +231,24 @@ export default class Logs extends Component<PropsType, StateType> {
     return (
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+        <LogTabs>
+          <Tab
+            onClick={() => {
+              this.setState({ currentTab: "Application" });
+            }}
+            clicked={this.state.currentTab == "Application"}
+          >
+            Application
+          </Tab>
+          <Tab
+            onClick={() => {
+              this.setState({ currentTab: "System" });
+            }}
+            clicked={this.state.currentTab == "System"}
+          >
+            System
+          </Tab>
+        </LogTabs>
         <Options>
           <Scroll
             onClick={() => {
@@ -159,6 +282,20 @@ export default class Logs extends Component<PropsType, StateType> {
 
 Logs.contextType = Context;
 
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: #8590ff;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
+`;
+
 const Scroll = styled.div`
   align-items: center;
   display: flex;
@@ -178,6 +315,22 @@ const Scroll = styled.div`
   }
 `;
 
+const Tab = styled.div`
+  background: ${(props: { clicked: boolean }) =>
+    props.clicked ? "#503559" : "#7c548a"};
+  padding: 0px 10px;
+  margin: 0px 7px 0px 0px;
+  align-items: center;
+  display: flex;
+  cursor: pointer;
+  height: 100%;
+  border-radius: 8px 8px 0px 0px;
+
+  :hover {
+    background: #503559;
+  }
+`;
+
 const Refresh = styled.div`
   display: flex;
   align-items: center;
@@ -197,6 +350,16 @@ const Refresh = styled.div`
   }
 `;
 
+const LogTabs = styled.div`
+  width: 100%;
+  height: 25px;
+  background: #202227;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+`;
+
 const Options = styled.div`
   width: 100%;
   height: 25px;
@@ -247,3 +410,14 @@ const Message = styled.div`
 const Log = styled.div`
   font-family: monospace;
 `;
+
+const LogSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;

+ 0 - 16
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -77,22 +77,6 @@ const Highlight = styled.div`
   margin-right: 10px;
 `;
 
-const Banner = styled.div`
-  height: 40px;
-  width: 100%;
-  margin: 15px 0;
-  font-size: 13px;
-  display: flex;
-  border-radius: 5px;
-  padding-left: 15px;
-  align-items: center;
-  background: #ffffff11;
-  > i {
-    margin-right: 10px;
-    font-size: 18px;
-  }
-`;
-
 const StyledStatusPlaceholder = styled.div`
   width: 100%;
   height: calc(100vh - 470px);

+ 96 - 39
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import gradient from "assets/gradient.jpg";
+import gradient from "assets/gradient.png";
 import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 import api from "shared/api";
@@ -11,6 +11,7 @@ import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
+import FormDebugger from "components/values-form/FormDebugger";
 
 import { setSearchParam } from "shared/routing";
 
@@ -30,11 +31,17 @@ const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
 
 type StateType = {
   infras: InfraType[];
+  pressingCtrl: boolean;
+  pressingK: boolean;
+  showFormDebugger: boolean;
 };
 
 class Dashboard extends Component<PropsType, StateType> {
   state = {
     infras: [] as InfraType[],
+    pressingCtrl: false,
+    pressingK: false,
+    showFormDebugger: false,
   };
 
   refreshInfras = () => {
@@ -54,8 +61,35 @@ class Dashboard extends Component<PropsType, StateType> {
 
   componentDidMount() {
     this.refreshInfras();
+    document.addEventListener("keydown", this.handleKeyDown);
+    document.addEventListener("keyup", this.handleKeyUp);
   }
 
+  componentWillUnmount() {
+    document.removeEventListener("keydown", this.handleKeyDown);
+    document.removeEventListener("keyup", this.handleKeyUp);
+  }
+
+  handleKeyDown = (e: KeyboardEvent): void => {
+    let { pressingK, pressingCtrl } = this.state;
+    if (e.key === "Meta" || e.key === "Control") {
+      this.setState({ pressingCtrl: true });
+    }
+    if (e.key === "k") {
+      this.setState({ pressingK: true });
+    }
+    if (e.key === "z" && pressingK && pressingCtrl) {
+      this.setState({ pressingK: false, pressingCtrl: false });
+      this.setState({ showFormDebugger: !this.state.showFormDebugger });
+    }
+  };
+
+  handleKeyUp = (e: KeyboardEvent): void => {
+    if (e.key === "Meta" || e.key === "Control" || e.key === "k") {
+      this.setState({ pressingCtrl: false, pressingK: false });
+    }
+  };
+
   componentDidUpdate(prevProps: PropsType) {
     if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
       this.refreshInfras();
@@ -82,7 +116,7 @@ class Dashboard extends Component<PropsType, StateType> {
             <i className="material-icons">info</i>
             Create a cluster to link to this project.
           </Banner>
-          <ProvisionerSettings infras={this.state.infras} />
+          <ProvisionerSettings infras={this.state.infras} provisioner={true} />
         </>
       );
     } else {
@@ -95,47 +129,69 @@ class Dashboard extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { currentProject } = this.context;
+    let { currentProject, capabilities } = this.context;
     let { onShowProjectSettings } = this;
+
+    let tabOptions = [
+      { label: "Project Overview", value: "overview" },
+      { label: "Create a Cluster", value: "create-cluster" },
+      { label: "Provisioner Status", value: "provisioner" },
+    ];
+
+    if (!capabilities?.provisioner) {
+      tabOptions = [{ label: "Project Overview", value: "overview" }];
+    }
+
     return (
       <>
         {currentProject && (
           <DashboardWrapper>
-            <TitleSection>
-              <DashboardIcon>
-                <DashboardImage src={gradient} />
-                <Overlay>
-                  {currentProject && currentProject.name[0].toUpperCase()}
-                </Overlay>
-              </DashboardIcon>
-              <Title>{currentProject && currentProject.name}</Title>
-              {this.context.currentProject.roles.filter((obj: any) => {
-                return obj.user_id === this.context.user.userId;
-              })[0].kind === "admin" && (
-                <i className="material-icons" onClick={onShowProjectSettings}>
-                  more_vert
-                </i>
-              )}
-            </TitleSection>
-
-            <InfoSection>
-              <TopRow>
-                <InfoLabel>
-                  <i className="material-icons">info</i> Info
-                </InfoLabel>
-              </TopRow>
-              <Description>
-                Project overview for {currentProject && currentProject.name}.
-              </Description>
-            </InfoSection>
-
-            <TabRegion
-              currentTab={this.currentTab()}
-              setCurrentTab={this.setCurrentTab}
-              options={tabOptions}
-            >
-              {this.renderTabContents()}
-            </TabRegion>
+            {this.state.showFormDebugger ? (
+              <FormDebugger
+                goBack={() => this.setState({ showFormDebugger: false })}
+              />
+            ) : (
+              <>
+                <TitleSection>
+                  <DashboardIcon>
+                    <DashboardImage src={gradient} />
+                    <Overlay>
+                      {currentProject && currentProject.name[0].toUpperCase()}
+                    </Overlay>
+                  </DashboardIcon>
+                  <Title>{currentProject && currentProject.name}</Title>
+                  {this.context.currentProject.roles.filter((obj: any) => {
+                    return obj.user_id === this.context.user.userId;
+                  })[0].kind === "admin" && (
+                    <i
+                      className="material-icons"
+                      onClick={onShowProjectSettings}
+                    >
+                      more_vert
+                    </i>
+                  )}
+                </TitleSection>
+
+                <InfoSection>
+                  <TopRow>
+                    <InfoLabel>
+                      <i className="material-icons">info</i> Info
+                    </InfoLabel>
+                  </TopRow>
+                  <Description>
+                    Project overview for {currentProject && currentProject.name}
+                    .
+                  </Description>
+                </InfoSection>
+                <TabRegion
+                  currentTab={this.currentTab()}
+                  setCurrentTab={this.setCurrentTab}
+                  options={tabOptions}
+                >
+                  {this.renderTabContents()}
+                </TabRegion>
+              </>
+            )}
           </DashboardWrapper>
         )}
       </>
@@ -204,7 +260,7 @@ const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;
   background: #ffffff20;
-  margin: 10px 0px 35px;
+  margin: 10px 0px 20px;
 `;
 
 const Overlay = styled.div`
@@ -228,6 +284,7 @@ const DashboardImage = styled.img`
   height: 45px;
   width: 45px;
   border-radius: 5px;
+  box-shadow: 0 2px 5px 4px #00000011;
 `;
 
 const DashboardIcon = styled.div`

+ 27 - 36
dashboard/src/main/home/integrations/create-integration/GCRForm.tsx

@@ -5,7 +5,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 
 import InputRow from "components/values-form/InputRow";
-import TextArea from "components/values-form/TextArea";
+import UploadArea from "components/values-form/UploadArea";
 import SaveButton from "components/SaveButton";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
@@ -32,18 +32,8 @@ export default class GCRForm extends Component<PropsType, StateType> {
   };
 
   isDisabled = (): boolean => {
-    let {
-      credentialsName,
-      gcpRegion,
-      gcpProjectID,
-      serviceAccountKey,
-    } = this.state;
-    if (
-      credentialsName === "" ||
-      gcpRegion === "" ||
-      serviceAccountKey === "" ||
-      gcpProjectID === ""
-    ) {
+    let { serviceAccountKey, credentialsName } = this.state;
+    if (serviceAccountKey === "" || credentialsName === "") {
       return true;
     }
     return false;
@@ -99,37 +89,26 @@ export default class GCRForm extends Component<PropsType, StateType> {
             setValue={(credentialsName: string) =>
               this.setState({ credentialsName })
             }
+            isRequired={true}
             label="🏷️ Registry Name"
             placeholder="ex: paper-straw"
             width="100%"
           />
           <Heading>GCP Settings</Heading>
           <Helper>Service account credentials for GCP permissions.</Helper>
-          <InputRow
-            type="text"
-            value={this.state.gcpRegion}
-            setValue={(gcpRegion: string) => this.setState({ gcpRegion })}
-            label="📍 GCP Region"
-            placeholder="ex: uranus-north3"
-            width="100%"
-          />
-          <TextArea
-            value={this.state.serviceAccountKey}
-            setValue={(serviceAccountKey: string) =>
-              this.setState({ serviceAccountKey })
-            }
-            label="🔑 Service Account Key (JSON)"
-            placeholder="(Paste your JSON service account key here)"
-            width="100%"
-          />
-          <InputRow
-            type="text"
-            value={this.state.gcpProjectID}
-            setValue={(gcpProjectID: string) => this.setState({ gcpProjectID })}
-            label="📝 GCP Project ID"
-            placeholder="ex: skynet-dev-172969"
+          <UploadArea
+            setValue={(x: any) => this.setState({ serviceAccountKey: x })}
+            label="🔒 GCP Key Data (JSON)"
+            placeholder="Choose a file or drag it here."
             width="100%"
+            height="100%"
+            isRequired={true}
           />
+          <Helper>
+            GCR URI, in the form{" "}
+            <CodeBlock>[gcr_domain]/[gcp_project_id]</CodeBlock>. For example,{" "}
+            <CodeBlock>gcr.io/skynet-dev-172969</CodeBlock>.
+          </Helper>
           <InputRow
             type="text"
             value={this.state.url}
@@ -137,6 +116,7 @@ export default class GCRForm extends Component<PropsType, StateType> {
             label="🔗 GCR URL"
             placeholder="ex: gcr.io/skynet-dev-172969"
             width="100%"
+            isRequired={true}
           />
         </CredentialWrapper>
         <SaveButton
@@ -162,3 +142,14 @@ const StyledForm = styled.div`
   position: relative;
   padding-bottom: 75px;
 `;
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  padding: 2px 3px;
+  margin-top: -2px;
+  user-select: text;
+`;

+ 39 - 0
dashboard/src/main/home/integrations/create-integration/GKEForm.tsx

@@ -48,6 +48,32 @@ export default class GKEForm extends Component<PropsType, StateType> {
     // TODO: implement once api is restructured
   };
 
+  // readFile = (env: string) => {
+  //   console.log(env)
+  //   event.preventDefault()
+  //   const reader = new FileReader()
+  //   reader.onload = async (e) => {
+  //     let text = (e.target.result)
+  //     let env = this.parseEnv(text, null)
+
+  //     for (let key in env) {
+  //       // filter duplicate keys
+  //       let dup = this.state.values.filter((el) => {
+  //         console.log(el, key)
+  //         if (el["key"] == key) {
+  //           return false
+  //         }
+  //       })
+
+  //       console.log(dup)
+
+  //       this.state.values.push({ key, value: env[key] });
+  //     }
+  //     this.setState({ values: this.state.values });
+  //   }
+  //   reader.readAsText(event.target.files[0], 'UTF-8')
+  // }
+
   render() {
     return (
       <StyledForm>
@@ -94,6 +120,19 @@ export default class GKEForm extends Component<PropsType, StateType> {
           disabled={this.isDisabled()}
           onClick={this.isDisabled() ? null : this.handleSubmit}
         />
+
+        {/* <UploadButton
+      onClick={()=>{
+        // document.getElementById("file").click();
+        this.setState({ showEditorModal: true });
+      }}
+      >
+      <img src={upload} /> Copy from File
+      {<input id='file' hidden type="file" onChange={(event) => {
+        this.readFile(event)
+        event.currentTarget.value = null
+      }}/>}
+    </UploadButton> */}
       </StyledForm>
     );
   }

+ 173 - 144
dashboard/src/main/home/launch/Launch.tsx

@@ -8,12 +8,13 @@ import { PorterTemplate } from "shared/types";
 import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
+import LaunchFlow from "./launch-flow/LaunchFlow";
 
 import hardcodedNames from "./hardcodedNameDict";
-import { Link } from "react-router-dom";
+import semver from "semver";
 
 const tabOptions = [
-  { label: "New Application", value: "docker" },
+  { label: "New Application", value: "porter" },
   { label: "Community Add-ons", value: "community" },
 ];
 
@@ -21,38 +22,66 @@ type PropsType = {};
 
 type StateType = {
   currentTemplate: PorterTemplate | null;
+  form: any;
   currentTab: string;
   addonTemplates: PorterTemplate[];
   applicationTemplates: PorterTemplate[];
   loading: boolean;
   error: boolean;
+  isOnLaunchFlow: boolean;
 };
 
 export default class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
-    currentTab: "docker",
+    form: null as any,
+    currentTab: "porter",
     addonTemplates: [] as PorterTemplate[],
     applicationTemplates: [] as PorterTemplate[],
     loading: true,
     error: false,
+    isOnLaunchFlow: false,
   };
 
   componentDidMount() {
     api
-      .getAddonTemplates("<token>", {}, {})
+      .getTemplates(
+        "<token>",
+        {
+          repo_url: process.env.ADDON_CHART_REPO_URL,
+        },
+        {}
+      )
       .then((res) => {
-        this.setState({ addonTemplates: res.data, error: false }, () => {
-          this.state.addonTemplates.sort((a, b) => (a.name > b.name ? 1 : -1));
-          this.setState({
-            loading: false,
-          });
+        let sortedVersionData = res.data.map((template: any) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
         });
+
+        this.setState(
+          { addonTemplates: sortedVersionData, error: false },
+          () => {
+            this.state.addonTemplates.sort((a, b) =>
+              a.name > b.name ? 1 : -1
+            );
+
+            this.setState({
+              loading: false,
+            });
+          }
+        );
       })
       .catch(() => this.setState({ loading: false, error: true }));
 
     api
-      .getApplicationTemplates(
+      .getTemplates(
         "<token>",
         {
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
@@ -60,17 +89,32 @@ export default class Templates extends Component<PropsType, StateType> {
         {}
       )
       .then((res) => {
-        this.setState({ applicationTemplates: res.data, error: false }, () => {
-          let preferredOrder = ["web", "worker", "job"];
-          this.state.applicationTemplates.sort((a, b) => {
-            return (
-              preferredOrder.indexOf(a.name) - preferredOrder.indexOf(b.name)
-            );
-          });
-          this.setState({
-            loading: false,
-          });
+        let sortedVersionData = res.data.map((template: any) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
         });
+
+        this.setState(
+          { applicationTemplates: sortedVersionData, error: false },
+          () => {
+            let preferredOrder = ["web", "worker", "job"];
+            this.state.applicationTemplates.sort((a, b) => {
+              return (
+                preferredOrder.indexOf(a.name) - preferredOrder.indexOf(b.name)
+              );
+            });
+            this.setState({
+              loading: false,
+            });
+          }
+        );
       })
       .catch(() => this.setState({ loading: false, error: true }));
   }
@@ -87,8 +131,8 @@ export default class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderApplicationList = () => {
-    let { loading, error, applicationTemplates } = this.state;
+  renderTemplateList = (templates: any) => {
+    let { loading, error } = this.state;
 
     if (loading) {
       return (
@@ -102,7 +146,7 @@ export default class Templates extends Component<PropsType, StateType> {
           <i className="material-icons">error</i> Error retrieving templates.
         </Placeholder>
       );
-    } else if (applicationTemplates.length === 0) {
+    } else if (templates.length === 0) {
       return (
         <Placeholder>
           <i className="material-icons">category</i> No templates found.
@@ -110,146 +154,106 @@ export default class Templates extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.applicationTemplates.map(
-      (template: PorterTemplate, i: number) => {
-        let { name, icon, description } = template;
-        if (hardcodedNames[name]) {
-          name = hardcodedNames[name];
-        }
-        return (
-          <TemplateBlock
-            key={i}
-            onClick={() => this.setState({ currentTemplate: template })}
-          >
-            {this.renderIcon(icon)}
-            <TemplateTitle>{name}</TemplateTitle>
-            <TemplateDescription>{description}</TemplateDescription>
-          </TemplateBlock>
-        );
-      }
-    );
-  };
-
-  renderAddonList = () => {
-    let { loading, error, addonTemplates } = 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 (addonTemplates.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No templates found.
-        </Placeholder>
-      );
-    }
-
-    return this.state.addonTemplates.map(
-      (template: PorterTemplate, i: number) => {
-        let { name, icon, description } = template;
-        if (hardcodedNames[name]) {
-          name = hardcodedNames[name];
-        }
-        return (
-          <TemplateBlock
-            key={i}
-            onClick={() => this.setState({ currentTemplate: template })}
-          >
-            {this.renderIcon(icon)}
-            <TemplateTitle>{name}</TemplateTitle>
-            <TemplateDescription>{description}</TemplateDescription>
-          </TemplateBlock>
-        );
-      }
+    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>
     );
   };
 
-  renderApplicationTemplates = () => {
-    if (!this.context.currentCluster) {
-      return (
-        <>
-          <Banner>
-            <i className="material-icons">error_outline</i>
-            <Link to="dashboard">Provision</Link> &nbsp;or&nbsp;
-            <Link
-              to="#"
-              onClick={() =>
-                this.context.setCurrentModal("ClusterInstructionsModal")
-              }
-            >
-              connect
-            </Link>
-            &nbsp;to a cluster
-          </Banner>
-        </>
-      );
-    }
+  renderTabContents = () => {
     if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
+          setForm={(x: any) => this.setState({ form: x })}
+          showLaunchFlow={() => this.setState({ isOnLaunchFlow: true })}
           currentTab={this.state.currentTab}
           currentTemplate={this.state.currentTemplate}
-          setCurrentTemplate={(currentTemplate: PorterTemplate) =>
-            this.setState({ currentTemplate })
-          }
-          skipDescription={false}
+          setCurrentTemplate={(currentTemplate: PorterTemplate) => {
+            this.setState({ currentTemplate });
+          }}
         />
       );
     }
-    return <TemplateList>{this.renderApplicationList()}</TemplateList>;
+    if (this.state.currentTab === "porter") {
+      return this.renderTemplateList(this.state.applicationTemplates);
+    } else {
+      return this.renderTemplateList(this.state.addonTemplates);
+    }
   };
 
-  renderAddonTemplates = () => {
-    if (this.state.currentTemplate) {
+  render() {
+    if (!this.state.isOnLaunchFlow || !this.state.currentTemplate) {
       return (
-        <ExpandedTemplate
+        <TemplatesWrapper>
+          <TitleSection>
+            <Title>Launch</Title>
+            <a href="https://docs.getporter.dev/docs/add-ons" target="_blank">
+              <i className="material-icons">help_outline</i>
+            </a>
+          </TitleSection>
+          {this.context.currentCluster ? (
+            <>
+              <TabSelector
+                options={tabOptions}
+                currentTab={this.state.currentTab}
+                setCurrentTab={(value: string) =>
+                  this.setState({
+                    currentTab: value,
+                    currentTemplate: null,
+                  })
+                }
+              />
+              {this.renderTabContents()}
+            </>
+          ) : (
+            <>
+              <Banner>
+                <i className="material-icons">error_outline</i>
+                No cluster connected to this project.
+              </Banner>
+              <StyledStatusPlaceholder>
+                You need to connect a cluster to use Porter.
+                <Highlight
+                  onClick={() => {
+                    this.context.setCurrentModal(
+                      "ClusterInstructionsModal",
+                      {}
+                    );
+                  }}
+                >
+                  + Connect an existing cluster
+                </Highlight>
+              </StyledStatusPlaceholder>
+            </>
+          )}
+        </TemplatesWrapper>
+      );
+    } else {
+      return (
+        <LaunchFlow
+          form={this.state.form}
           currentTab={this.state.currentTab}
           currentTemplate={this.state.currentTemplate}
-          setCurrentTemplate={(currentTemplate: PorterTemplate) =>
-            this.setState({ currentTemplate })
-          }
+          hideLaunchFlow={() => this.setState({ isOnLaunchFlow: false })}
         />
       );
     }
-    return <TemplateList>{this.renderAddonList()}</TemplateList>;
-  };
-
-  render() {
-    return (
-      <TemplatesWrapper>
-        <TitleSection>
-          <Title>Launch</Title>
-          <a
-            href="https://docs.getporter.dev/docs/porter-templates"
-            target="_blank"
-          >
-            <i className="material-icons">help_outline</i>
-          </a>
-        </TitleSection>
-        <TabSelector
-          options={tabOptions}
-          currentTab={this.state.currentTab}
-          setCurrentTab={(value: string) =>
-            this.setState({
-              currentTab: value,
-              currentTemplate: null,
-            })
-          }
-        />
-        {this.state.currentTab === "docker"
-          ? this.renderApplicationTemplates()
-          : this.renderAddonTemplates()}
-      </TemplatesWrapper>
-    );
   }
 }
 
@@ -286,6 +290,31 @@ 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;
 `;

+ 21 - 6
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -7,12 +7,15 @@ import api from "shared/api";
 import TemplateInfo from "./TemplateInfo";
 import LaunchTemplate from "./LaunchTemplate";
 import Loading from "components/Loading";
+import { template } from "lodash";
 
 type PropsType = {
   currentTemplate: PorterTemplate;
   currentTab: string;
   setCurrentTemplate: (x: PorterTemplate) => void;
   skipDescription?: boolean;
+  showLaunchFlow: () => void;
+  setForm: (x: any) => void;
 };
 
 type StateType = {
@@ -43,20 +46,20 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   fetchTemplateInfo = () => {
     this.setState({ loading: true });
     let params =
-      this.props.currentTab == "docker"
+      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: "latest",
+        version: this.props.currentTemplate.currentVersion,
       })
       .then((res) => {
         let { form, values, markdown, metadata } = res.data;
         let keywords = metadata.keywords;
+        this.props.setForm(form);
         this.setState({
-          form,
           values,
           markdown,
           keywords,
@@ -68,7 +71,11 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   };
 
   componentDidUpdate = (prevProps: PropsType) => {
-    if (prevProps.currentTemplate !== this.props.currentTemplate) {
+    if (
+      prevProps.currentTemplate.name !== this.props.currentTemplate.name ||
+      prevProps.currentTemplate.currentVersion !==
+        this.props.currentTemplate.currentVersion
+    ) {
       this.fetchTemplateInfo();
     }
   };
@@ -100,7 +107,15 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
           currentTab={this.props.currentTab}
           currentTemplate={this.props.currentTemplate}
           setCurrentTemplate={this.props.setCurrentTemplate}
-          launchTemplate={() => this.setState({ showLaunchTemplate: true })}
+          setCurrentVersion={(version) => {
+            let template = {
+              ...this.props.currentTemplate,
+              currentVersion: version,
+            };
+
+            this.props.setCurrentTemplate(template);
+          }}
+          launchTemplate={this.props.showLaunchFlow}
           markdown={this.state.markdown}
           keywords={this.state.keywords}
         />

+ 55 - 142
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -19,8 +19,7 @@ import TabRegion from "components/TabRegion";
 import InputRow from "components/values-form/InputRow";
 import SaveButton from "components/SaveButton";
 import ActionConfEditor from "components/repo-selector/ActionConfEditor";
-import ValuesWrapper from "components/values-form/ValuesWrapper";
-import ValuesForm from "components/values-form/ValuesForm";
+import FormWrapper from "components/values-form/FormWrapper";
 import RadioSelector from "components/RadioSelector";
 import { isAlphanumeric } from "shared/common";
 
@@ -58,6 +57,7 @@ type StateType = {
   folderPath: string | null;
   selectedRegistry: any | null;
   env: any;
+  valuesToOverride: any | null;
 };
 
 const defaultActionConfig: ActionConfigType = {
@@ -93,6 +93,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     folderPath: null as string | null,
     selectedRegistry: null as any | null,
     env: {},
+    valuesToOverride: null as any | null,
   };
 
   createGHAction = (chartName: string, chartNamespace: string) => {
@@ -154,7 +155,8 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           id: currentProject.id,
           cluster_id: currentCluster.id,
           name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: "latest",
+          version: this.props.currentTemplate?.currentVersion || "latest",
+          repo_url: process.env.ADDON_CHART_REPO_URL,
         }
       )
       .then((_) => {
@@ -210,10 +212,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     if (this.state.sourceType === "repo") {
       if (this.props.currentTemplate?.name == "job") {
-        imageUrl = "porterdev/hello-porter-job";
+        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter-job";
         tag = "latest";
       } else {
-        imageUrl = "porterdev/hello-porter";
+        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter";
         tag = "latest";
       }
     }
@@ -241,10 +243,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     _.set(values, "ingress.provider", provider);
     var url: string;
-
     // check if template is docker and create external domain if necessary
     if (this.props.currentTemplate.name == "web") {
-      if (values?.ingress?.enabled && values?.ingress?.hosts?.length == 0) {
+      if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
         url = await new Promise((resolve, reject) => {
           api
             .createSubdomain(
@@ -265,13 +266,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             });
         });
 
-        values.ingress.hosts = [url];
-        values.ingress.custom_domain = true;
+        values.ingress.porter_hosts = [url];
       }
     }
 
-    console.log("VALUES ARE", values);
-
     api
       .deployTemplate(
         "<token>",
@@ -287,7 +285,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           id: currentProject.id,
           cluster_id: currentCluster.id,
           name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: "latest",
+          version: this.props.currentTemplate?.currentVersion || "latest",
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
         }
       )
@@ -383,15 +381,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
       procfilePath,
     } = this.state;
 
-    if (
-      sourceType === "repo" &&
-      !dockerfilePath &&
-      folderPath &&
-      !procfilePath
-    ) {
-      return "Procfile not detected.";
-    }
-
     if (!this.submitIsDisabled()) {
       return this.state.saveValuesStatus;
     }
@@ -415,53 +404,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     return "No application source specified";
   };
 
-  renderTabContents = () => {
-    return (
-      <ValuesWrapper
-        formTabs={this.props.form?.tabs}
-        onSubmit={
-          this.props.currentTab === "docker"
-            ? this.onSubmit
-            : this.onSubmitAddon
-        }
-        saveValuesStatus={this.getStatus()}
-        disabled={this.submitIsDisabled()}
-        renderSaveButton={true}
-      >
-        {(metaState: any, setMetaState: any) => {
-          if (!metaState) {
-            return;
-          }
-
-          // handle when procfileProcess is already specified
-          metaState["container.command"] = this.state.procfileProcess
-            ? this.state.procfileProcess
-            : "";
-
-          return this.props.form?.tabs.map((tab: any, i: number) => {
-            // If tab is current, render
-            if (tab.name === this.state.currentTab) {
-              return (
-                <ValuesForm
-                  metaState={metaState}
-                  handleEnvChange={(x: any) => this.setState({ env: x })}
-                  setMetaState={setMetaState}
-                  key={tab.name}
-                  sections={tab.sections}
-                  // For env group loader
-                  namespace={this.state.selectedNamespace}
-                  clusterId={this.state.selectedClusterId}
-                  // For procfile process
-                  procfileProcess={this.state.procfileProcess}
-                />
-              );
-            }
-          });
-        }}
-      </ValuesWrapper>
-    );
-  };
-
   componentDidMount() {
     if (this.props.currentTemplate.name !== "docker") {
       this.setState({ saveValuesStatus: "" });
@@ -547,13 +489,24 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           <Subtitle>
             Configure additional settings for this template. (Optional)
           </Subtitle>
-          <TabRegion
-            options={this.state.tabOptions}
-            currentTab={this.state.currentTab}
-            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          >
-            {this.renderTabContents()}
-          </TabRegion>
+          <FormWrapper
+            formData={this.props.form}
+            saveValuesStatus={this.state.saveValuesStatus}
+            valuesToOverride={this.state.valuesToOverride}
+            clearValuesToOverride={() =>
+              this.setState({ valuesToOverride: null })
+            }
+            externalValues={{
+              namespace: this.state.selectedNamespace,
+              clusterId: this.context.currentCluster.id,
+              isLaunch: true,
+            }}
+            onSubmit={
+              this.props.currentTab === "docker"
+                ? this.onSubmit
+                : this.onSubmitAddon
+            }
+          />
         </>
       );
     } else {
@@ -563,7 +516,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             To configure this chart through Porter,
             <Link
               target="_blank"
-              href="https://docs.getporter.dev/docs/porter-templates"
+              href="https://docs.getporter.dev/docs/add-ons"
             >
               refer to our docs
             </Link>
@@ -582,20 +535,24 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelectorContent = () => {
+    let { capabilities } = this.context;
+
     if (this.state.sourceType === "") {
       return (
         <BlockList>
-          <Block
-            onClick={() => {
-              this.setState({ sourceType: "repo" });
-            }}
-          >
-            <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
-            <BlockTitle>Git Repository</BlockTitle>
-            <BlockDescription>
-              Deploy using source from a Git repo.
-            </BlockDescription>
-          </Block>
+          {capabilities.github && (
+            <Block
+              onClick={() => {
+                this.setState({ sourceType: "repo" });
+              }}
+            >
+              <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
+              <BlockTitle>Git Repository</BlockTitle>
+              <BlockDescription>
+                Deploy using source from a Git repo.
+              </BlockDescription>
+            </Block>
+          )}
           <Block
             onClick={() => {
               this.setState({ sourceType: "registry" });
@@ -644,32 +601,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           <br />
         </StyledSourceBox>
       );
-    } else if (this.state.repoType === "" && false) {
-      return (
-        <StyledSourceBox>
-          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Subtitle>
-            Are you using an existing Dockerfile from your repo?
-            <Required>*</Required>
-          </Subtitle>
-          <RadioSelector
-            options={[
-              {
-                value: "dockerfile",
-                label: "Yes, I am using an existing Dockerfile",
-              },
-              {
-                value: "buildpack",
-                label: "No, I am not using an existing Dockerfile",
-              },
-            ]}
-            selected={this.state.repoType}
-            setSelected={(x: string) => this.setState({ repoType: x })}
-          />
-        </StyledSourceBox>
-      );
     } else {
       return (
         <StyledSourceBox>
@@ -698,7 +629,17 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             }
             procfileProcess={this.state.procfileProcess}
             setProcfileProcess={(procfileProcess: string) =>
-              this.setState({ procfileProcess })
+              this.setState({
+                procfileProcess,
+                valuesToOverride: {
+                  "container.command": {
+                    value: procfileProcess || "",
+                  },
+                  showStartCommand: {
+                    value: !procfileProcess,
+                  },
+                },
+              })
             }
             setBranch={(branch: string) => this.setState({ branch })}
             setDockerfilePath={(x: string) =>
@@ -829,12 +770,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 LaunchTemplate.contextType = Context;
 export default withRouter(LaunchTemplate);
 
-const Bold = styled.div`
-  font-weight: bold;
-  color: white;
-  margin-right: 5px;
-`;
-
 const CloseButton = styled.div`
   position: absolute;
   display: block;
@@ -1056,12 +991,6 @@ const Polymer = styled.div`
   }
 `;
 
-const Template = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 13px;
-`;
-
 const ClusterSection = styled.div`
   display: flex;
   align-items: center;
@@ -1079,22 +1008,6 @@ const ClusterSection = styled.div`
   }
 `;
 
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-
-  > i {
-    cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
-    padding: 3px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-  }
-`;
-
 const StyledLaunchTemplate = styled.div`
   width: 100%;
   padding-bottom: 150px;

+ 47 - 12
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -7,6 +7,7 @@ import { Context } from "shared/Context";
 
 import { PorterTemplate } from "shared/types";
 import Helper from "components/values-form/Helper";
+import Selector from "components/Selector";
 
 import hardcodedNames from "../hardcodedNameDict";
 
@@ -14,14 +15,21 @@ type PropsType = {
   currentTemplate: any;
   currentTab: string;
   setCurrentTemplate: (x: PorterTemplate) => void;
+  setCurrentVersion: (x: string) => void;
   launchTemplate: () => void;
   markdown: string | null;
   keywords: string[];
 };
 
-type StateType = {};
+type StateType = {
+  currentVersion: string;
+};
 
 export default class TemplateInfo extends Component<PropsType, StateType> {
+  state = {
+    currentVersion: this.props.currentTemplate.currentVersion,
+  };
+
   renderIcon = (icon: string) => {
     if (icon) {
       return <Icon src={icon} />;
@@ -74,7 +82,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
           </Banner>
         </>
       );
-    } else if (this.props.currentTab == "docker") {
+    } else if (this.props.currentTab == "porter") {
       return (
         <>
           <Br />
@@ -105,8 +113,8 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
               href="https://docs.getporter.dev/docs/https-and-custom-domains"
             >
               Porter's HTTPS setup guide
-            </Link>{" "}
-            (5 minutes).
+            </Link>
+            &nbsp;(5 minutes).
           </Banner>
         </>
       );
@@ -122,6 +130,15 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
       name = hardcodedNames[name];
     }
 
+    let versionOptions = this.props.currentTemplate.versions.map(
+      (version: string) => {
+        return {
+          value: version,
+          label: "v" + version,
+        };
+      }
+    );
+
     return (
       <StyledExpandedTemplate>
         <TitleSection>
@@ -137,13 +154,26 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
               : this.renderIcon(currentTemplate.icon)}
             <Title>{name}</Title>
           </Flex>
-          <Button
-            isDisabled={!currentCluster}
-            onClick={!currentCluster ? null : this.props.launchTemplate}
-          >
-            <img src={rocket} />
-            Launch Template
-          </Button>
+          <StyledVersionSelector>
+            <Selector
+              activeValue={this.props.currentTemplate.currentVersion}
+              setActiveValue={(version) =>
+                this.props.setCurrentVersion(version)
+              }
+              options={versionOptions}
+              dropdownLabel="Version"
+              width="150px"
+              dropdownWidth="230px"
+              closeOverlay={true}
+            />
+            <Button
+              isDisabled={!currentCluster}
+              onClick={!currentCluster ? null : this.props.launchTemplate}
+            >
+              <img src={rocket} />
+              Launch Template
+            </Button>
+          </StyledVersionSelector>
         </TitleSection>
         <Helper>{description}</Helper>
         {this.renderTagSection()}
@@ -159,7 +189,6 @@ TemplateInfo.contextType = Context;
 
 const Link = styled.a`
   color: #8590ff;
-  margin-right: 5px;
   cursor: pointer;
   margin-left: 5px;
 `;
@@ -258,6 +287,7 @@ const Button = styled.div`
   display: flex;
   flex-direction: row;
   align-items: center;
+  margin-left: 10px;
 
   > img {
     width: 16px;
@@ -309,3 +339,8 @@ const TitleSection = styled.div`
 const StyledExpandedTemplate = styled.div`
   width: 100%;
 `;
+
+const StyledVersionSelector = styled.div`
+  display: flex;
+  font-size: 13px;
+`;

+ 5 - 0
dashboard/src/main/home/launch/hardcodedNameDict.tsx

@@ -10,6 +10,11 @@ const hardcodedNames: { [key: string]: string } = {
   web: "Web Service",
   worker: "Worker",
   job: "Job",
+  "cert-manager": "Cert Manager",
+  elasticsearch: "Elasticsearch",
+  prometheus: "Prometheus",
+  rabbitmq: "RabbitMQ",
+  logdna: "LogDNA",
 };
 
 export default hardcodedNames;

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

@@ -0,0 +1,555 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+import randomWords from "random-words";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import hardcodedNames from "../hardcodedNameDict";
+import SourcePage from "./SourcePage";
+import SettingsPage from "./SettingsPage";
+
+import {
+  PorterTemplate,
+  ActionConfigType,
+  ChoiceType,
+  ClusterType,
+  StorageType,
+} from "shared/types";
+
+type PropsType = RouteComponentProps & {
+  currentTab?: string;
+  currentTemplate: PorterTemplate;
+  hideLaunchFlow: () => void;
+  form: any;
+};
+
+type StateType = {
+  currentPage: string;
+  templateName: string;
+  sourceType: string;
+  valuesToOverride: any;
+
+  imageUrl: string;
+  imageTag: string;
+
+  actionConfig: ActionConfigType;
+  procfileProcess: string;
+  branch: string;
+  repoType: string;
+  dockerfilePath: string | null;
+  procfilePath: string | null;
+  folderPath: string | null;
+  selectedRegistry: any;
+
+  selectedNamespace: string;
+  saveValuesStatus: string;
+};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  branch: "",
+  git_repo_id: 0,
+};
+
+class LaunchFlow extends Component<PropsType, StateType> {
+  state = {
+    currentPage: "source",
+    templateName: "",
+    saveValuesStatus: "",
+    sourceType: "",
+    selectedNamespace: "default",
+    valuesToOverride: {} as any,
+
+    imageUrl: "",
+    imageTag: "",
+
+    actionConfig: { ...defaultActionConfig },
+    procfileProcess: "",
+    branch: "",
+    repoType: "",
+    dockerfilePath: null as string | null,
+    procfilePath: null as string | null,
+    folderPath: null as string | null,
+    selectedRegistry: null as any,
+  };
+
+  createGHAction = (chartName: string, chartNamespace: string, env?: any) => {
+    let { currentProject, currentCluster, setCurrentError } = this.context;
+    let {
+      actionConfig,
+      branch,
+      selectedRegistry,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+    let imageRepoUri = `${selectedRegistry.url}/${chartName}-${chartNamespace}`;
+
+    // DockerHub registry integration is per repo
+    if (selectedRegistry.service === "dockerhub") {
+      imageRepoUri = selectedRegistry.url;
+    }
+
+    api
+      .createGHAction(
+        "<token>",
+        {
+          git_repo: actionConfig.git_repo,
+          git_branch: branch,
+          registry_id: selectedRegistry.id,
+          dockerfile_path: dockerfilePath,
+          folder_path: folderPath,
+          image_repo_uri: imageRepoUri,
+          git_repo_id: actionConfig.git_repo_id,
+          env: env,
+        },
+        {
+          project_id: currentProject.id,
+          CLUSTER_ID: currentCluster.id,
+          RELEASE_NAME: chartName,
+          RELEASE_NAMESPACE: chartNamespace,
+        }
+      )
+      .then((res) => console.log(""))
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        this.setState({
+          saveValuesStatus: `Could not create GitHub Action: ${err}`,
+        });
+
+        setCurrentError(err);
+      });
+  };
+
+  onSubmitAddon = (wildcard?: any) => {
+    let { selectedNamespace } = this.state;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let name =
+      this.state.templateName || randomWords({ exactly: 3, join: "-" });
+    this.setState({ saveValuesStatus: "loading" });
+
+    let values = {};
+    for (let key in wildcard) {
+      _.set(values, key, wildcard[key]);
+    }
+
+    api
+      .deployTemplate(
+        "<token>",
+        {
+          templateName: this.props.currentTemplate.name,
+          storage: StorageType.Secret,
+          formValues: values,
+          namespace: selectedNamespace,
+          name,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          name: this.props.currentTemplate.name.toLowerCase().trim(),
+          version: this.props.currentTemplate?.currentVersion || "latest",
+          repo_url: process.env.ADDON_CHART_REPO_URL,
+        }
+      )
+      .then((_) => {
+        // this.props.setCurrentView('cluster-dashboard');
+        this.setState({ saveValuesStatus: "successful" }, () => {
+          // redirect to dashboard
+          let dst =
+            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
+          setTimeout(() => {
+            this.props.history.push(dst);
+          }, 500);
+          window.analytics.track("Deployed Add-on", {
+            name: this.props.currentTemplate.name,
+            namespace: selectedNamespace,
+            values: values,
+          });
+        });
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        this.setState({
+          saveValuesStatus: parsedErr,
+        });
+        setCurrentError(err.response.data.errors[0]);
+        window.analytics.track("Failed to Deploy Add-on", {
+          name: this.props.currentTemplate.name,
+          namespace: selectedNamespace,
+          values: values,
+          error: err,
+        });
+      });
+  };
+
+  onSubmit = async (rawValues: any) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let {
+      selectedNamespace,
+      templateName,
+      imageUrl,
+      imageTag,
+      sourceType,
+    } = this.state;
+    let name = templateName || randomWords({ exactly: 3, join: "-" });
+    this.setState({ saveValuesStatus: "loading" });
+
+    // Convert dotted keys to nested objects
+    let values: any = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    let tag = imageTag;
+    if (imageUrl.includes(":")) {
+      let splits = imageUrl.split(":");
+      imageUrl = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (sourceType === "repo") {
+      if (this.props.currentTemplate?.name == "job") {
+        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      default:
+        provider = "";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (imageUrl && tag) {
+      _.set(values, "image.repository", imageUrl);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+    var url: string;
+    // check if template is docker and create external domain if necessary
+    if (this.props.currentTemplate.name == "web") {
+      if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
+        url = await new Promise((resolve, reject) => {
+          api
+            .createSubdomain(
+              "<token>",
+              {
+                release_name: name,
+              },
+              {
+                id: currentProject.id,
+                cluster_id: currentCluster.id,
+              }
+            )
+            .then((res) => {
+              resolve(res.data?.external_url);
+            })
+            .catch((err) => {
+              let parsedErr =
+                err?.response?.data?.errors && err.response.data.errors[0];
+              if (parsedErr) {
+                err = parsedErr;
+              }
+              this.setState({
+                saveValuesStatus: `Could not create subdomain: ${err}`,
+              });
+
+              setCurrentError(err);
+            });
+        });
+
+        values.ingress.porter_hosts = [url];
+      }
+    }
+
+    api
+      .deployTemplate(
+        "<token>",
+        {
+          templateName: this.props.currentTemplate.name,
+          imageURL: imageUrl,
+          storage: StorageType.Secret,
+          formValues: values,
+          namespace: selectedNamespace,
+          name,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          name: this.props.currentTemplate.name.toLowerCase().trim(),
+          version: this.props.currentTemplate?.currentVersion || "latest",
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        }
+      )
+      .then((res: any) => {
+        if (sourceType === "repo") {
+          let env = rawValues["container.env.normal"];
+          console.log(env);
+          this.createGHAction(name, selectedNamespace, env);
+        }
+        // this.props.setCurrentView('cluster-dashboard');
+        this.setState({ saveValuesStatus: "successful" }, () => {
+          // redirect to dashboard with namespace
+          setTimeout(() => {
+            let dst =
+              this.props.currentTemplate.name === "job"
+                ? "jobs"
+                : "applications";
+            this.props.history.push(dst);
+          }, 1000);
+        });
+      })
+      .catch((err: any) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        console.log(parsedErr);
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        this.setState({
+          saveValuesStatus: `Could not deploy template: ${err}`,
+        });
+        setCurrentError(err);
+      });
+  };
+
+  renderCurrentPage = () => {
+    let { form, currentTab } = this.props;
+    let {
+      currentPage,
+      valuesToOverride,
+      templateName,
+      imageUrl,
+      imageTag,
+      actionConfig,
+      branch,
+      repoType,
+      dockerfilePath,
+      procfileProcess,
+      procfilePath,
+      folderPath,
+      selectedNamespace,
+      selectedRegistry,
+      saveValuesStatus,
+      sourceType,
+    } = this.state;
+
+    if (currentPage === "source" && currentTab === "porter") {
+      return (
+        <SourcePage
+          sourceType={sourceType}
+          setSourceType={(x: string) => this.setState({ sourceType: x })}
+          templateName={templateName}
+          setPage={(x: string) => {
+            this.setState({ currentPage: x });
+          }}
+          setTemplateName={(x: string) => this.setState({ templateName: x })}
+          setValuesToOverride={(x: any) =>
+            this.setState({ valuesToOverride: x })
+          }
+          imageUrl={imageUrl}
+          setImageUrl={(x: string) => this.setState({ imageUrl: x })}
+          imageTag={imageTag}
+          setImageTag={(x: string) => this.setState({ imageTag: x })}
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) =>
+            this.setState({ actionConfig: x })
+          }
+          branch={branch}
+          setBranch={(x: string) => this.setState({ branch: x })}
+          procfileProcess={procfileProcess}
+          setProcfileProcess={(x: string) =>
+            this.setState({ procfileProcess: x })
+          }
+          repoType={repoType}
+          setRepoType={(x: string) => this.setState({ repoType: x })}
+          dockerfilePath={dockerfilePath}
+          setDockerfilePath={(x: string) =>
+            this.setState({ dockerfilePath: x })
+          }
+          folderPath={folderPath}
+          setFolderPath={(x: string) => this.setState({ folderPath: x })}
+          procfilePath={procfilePath}
+          setProcfilePath={(x: string) => this.setState({ procfilePath: x })}
+          selectedRegistry={selectedRegistry}
+          setSelectedRegistry={(x: string) =>
+            this.setState({ selectedRegistry: x })
+          }
+        />
+      );
+    }
+
+    // Display main (non-source) settings page
+    return (
+      <SettingsPage
+        onSubmit={currentTab === "porter" ? this.onSubmit : this.onSubmitAddon}
+        saveValuesStatus={saveValuesStatus}
+        selectedNamespace={selectedNamespace}
+        setSelectedNamespace={(x: string) =>
+          this.setState({ selectedNamespace: x })
+        }
+        templateName={templateName}
+        setTemplateName={(x: string) => this.setState({ templateName: x })}
+        hasSource={currentTab === "porter"}
+        setPage={(x: string) => this.setState({ currentPage: x })}
+        form={form}
+        valuesToOverride={valuesToOverride}
+        clearValuesToOverride={() => this.setState({ valuesToOverride: null })}
+      />
+    );
+  };
+
+  renderIcon = () => {
+    let icon = this.props.currentTemplate?.icon;
+    if (icon) {
+      return <Icon src={icon} />;
+    }
+
+    return (
+      <Polymer>
+        <i className="material-icons">layers</i>
+      </Polymer>
+    );
+  };
+
+  render() {
+    let { currentTab } = this.props;
+    let { name } = this.props.currentTemplate;
+    if (hardcodedNames[name]) {
+      name = hardcodedNames[name];
+    }
+
+    return (
+      <StyledLaunchFlow>
+        <TitleSection>
+          <i className="material-icons" onClick={this.props.hideLaunchFlow}>
+            keyboard_backspace
+          </i>
+          {this.renderIcon()}
+          <Title>
+            New {name} {currentTab === "porter" ? null : "Instance"}
+          </Title>
+        </TitleSection>
+        {this.renderCurrentPage()}
+        <Br />
+      </StyledLaunchFlow>
+    );
+  }
+}
+
+LaunchFlow.contextType = Context;
+export default withRouter(LaunchFlow);
+
+const Br = styled.div`
+  width: 100%;
+  height: 120px;
+`;
+
+const Icon = styled.img`
+  width: 40px;
+  margin-right: 14px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -3px;
+
+  > i {
+    color: ${(props) => props.theme.containerIcon};
+    font-size: 24px;
+    margin-left: 12px;
+    margin-right: 3px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 18px;
+      color: #858faaaa;
+      cursor: pointer;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(90% - 130px);
+  min-width: 300px;
+  padding-top: 20px;
+  margin-top: calc(50vh - 340px);
+`;

+ 407 - 0
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -0,0 +1,407 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+
+import {
+  ActionConfigType,
+  ChoiceType,
+  ClusterType,
+  StorageType,
+} from "shared/types";
+
+import { isAlphanumeric } from "shared/common";
+
+import InputRow from "components/values-form/InputRow";
+import SaveButton from "components/SaveButton";
+import Helper from "components/values-form/Helper";
+import FormWrapper from "components/values-form/FormWrapper";
+import Selector from "components/Selector";
+import Loading from "components/Loading";
+
+type PropsType = {
+  onSubmit: (x?: any) => void;
+  hasSource: boolean;
+  setPage: (x: string) => void;
+  form: any;
+  valuesToOverride: any;
+  clearValuesToOverride: () => void;
+
+  templateName: string;
+  setTemplateName: (x: string) => void;
+  selectedNamespace: string;
+  setSelectedNamespace: (x: string) => void;
+  saveValuesStatus: string;
+};
+
+type StateType = {
+  tabOptions: ChoiceType[];
+  currentTab: string;
+  clusterOptions: { label: string; value: string }[];
+  selectedCluster: string;
+  clusterMap: { [clusterId: string]: ClusterType };
+  namespaceOptions: { label: string; value: string }[];
+};
+
+export default class SettingsPage extends Component<PropsType, StateType> {
+  state = {
+    tabOptions: [] as ChoiceType[],
+    currentTab: "",
+    clusterOptions: [] as { label: string; value: string }[],
+    selectedCluster: this.context.currentCluster.name,
+    clusterMap: {} as { [clusterId: string]: ClusterType },
+    namespaceOptions: [] as { label: string; value: string }[],
+  };
+
+  componentDidMount() {
+    window.scrollBy(0, -window.innerHeight);
+
+    // Retrieve tab options
+    let tabOptions = [] as ChoiceType[];
+    this.props.form?.tabs.map((tab: any, i: number) => {
+      if (tab.context.type === "helm/values") {
+        tabOptions.push({ value: tab.name, label: tab.label });
+      }
+    });
+
+    this.setState({
+      tabOptions,
+      currentTab: tabOptions[0] && tabOptions[0]["value"],
+    });
+
+    // TODO: query with selected filter once implemented
+    let { currentProject, currentCluster } = this.context;
+    api.getClusters("<token>", {}, { id: currentProject.id }).then((res) => {
+      if (res.data) {
+        let clusterOptions: { label: string; value: string }[] = [];
+        let clusterMap: { [clusterId: string]: ClusterType } = {};
+        res.data.forEach((cluster: ClusterType, i: number) => {
+          clusterOptions.push({ label: cluster.name, value: cluster.name });
+          clusterMap[cluster.name] = cluster;
+        });
+        if (res.data.length > 0) {
+          this.setState({ clusterOptions, clusterMap });
+        }
+      }
+    });
+
+    this.updateNamespaces(currentCluster.id);
+  }
+
+  updateNamespaces = (id: number) => {
+    let { currentProject } = this.context;
+    api
+      .getNamespaces(
+        "<token>",
+        {
+          cluster_id: id,
+        },
+        { id: currentProject.id }
+      )
+      .then((res) => {
+        if (res.data) {
+          let namespaceOptions = res.data.items.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (res.data.items.length > 0) {
+            this.setState({ namespaceOptions });
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
+  renderSettingsRegion = () => {
+    let { saveValuesStatus, selectedNamespace, onSubmit } = this.props;
+
+    if (this.state.currentTab === "") {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    }
+    if (this.state.tabOptions.length > 0) {
+      let {
+        form,
+        valuesToOverride,
+        clearValuesToOverride,
+        onSubmit,
+      } = this.props;
+      return (
+        <>
+          <Heading>Additional Settings</Heading>
+          <Helper>
+            Configure additional settings for this template. (Optional)
+          </Helper>
+          <FormWrapper
+            formData={form}
+            saveValuesStatus={saveValuesStatus}
+            valuesToOverride={valuesToOverride}
+            clearValuesToOverride={clearValuesToOverride}
+            externalValues={{
+              namespace: selectedNamespace,
+              clusterId: this.context.currentCluster.id,
+              isLaunch: true,
+            }}
+            onSubmit={onSubmit}
+          />
+        </>
+      );
+    } else {
+      return (
+        <Wrapper>
+          <Placeholder>
+            To configure this chart through Porter,
+            <Link
+              target="_blank"
+              href="https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md"
+            >
+              refer to our docs
+            </Link>
+            .
+          </Placeholder>
+          <SaveButton
+            text="Deploy"
+            onClick={onSubmit}
+            status={saveValuesStatus}
+            makeFlush={true}
+          />
+        </Wrapper>
+      );
+    }
+  };
+
+  renderHeaderSection = () => {
+    let { hasSource, templateName, setTemplateName } = this.props;
+
+    if (hasSource) {
+      return (
+        <BackButton
+          width="155px"
+          onClick={() => {
+            this.props.setPage("source");
+          }}
+        >
+          <i className="material-icons">first_page</i>
+          Source Settings
+        </BackButton>
+      );
+    }
+
+    return (
+      <>
+        <Heading>Name</Heading>
+        <Helper>
+          Randomly generated if left blank
+          <Warning
+            highlight={!isAlphanumeric(templateName) && templateName !== ""}
+          >
+            (lowercase letters, numbers, and "-" only)
+          </Warning>
+        </Helper>
+        <InputWrapper>
+          <InputRow
+            type="string"
+            value={templateName}
+            setValue={setTemplateName}
+            placeholder="ex: perspective-vortex"
+            width="470px"
+          />
+        </InputWrapper>
+      </>
+    );
+  };
+
+  render() {
+    let { selectedCluster } = this.state;
+
+    let { selectedNamespace, setSelectedNamespace } = this.props;
+
+    return (
+      <PaddingWrapper>
+        <StyledSettingsPage>
+          {this.renderHeaderSection()}
+          <Heading>Destination</Heading>
+          <Helper>
+            Specify the cluster and namespace you would like to deploy your
+            application to.
+          </Helper>
+          <ClusterSection>
+            <ClusterLabel>
+              <i className="material-icons">device_hub</i>Cluster
+            </ClusterLabel>
+            <Selector
+              activeValue={selectedCluster}
+              setActiveValue={(cluster: string) => {
+                this.context.setCurrentCluster(this.state.clusterMap[cluster]);
+                this.updateNamespaces(this.state.clusterMap[cluster].id);
+                this.setState({
+                  selectedCluster: cluster,
+                });
+              }}
+              options={this.state.clusterOptions}
+              width="250px"
+              dropdownWidth="335px"
+              closeOverlay={true}
+            />
+            <NamespaceLabel>
+              <i className="material-icons">view_list</i>Namespace
+            </NamespaceLabel>
+            <Selector
+              key={"namespace"}
+              activeValue={selectedNamespace}
+              setActiveValue={setSelectedNamespace}
+              options={this.state.namespaceOptions}
+              width="250px"
+              dropdownWidth="335px"
+              closeOverlay={true}
+            />
+          </ClusterSection>
+          {this.renderSettingsRegion()}
+        </StyledSettingsPage>
+      </PaddingWrapper>
+    );
+  }
+}
+
+SettingsPage.contextType = Context;
+
+const LoadingWrapper = styled.div`
+  margin-top: 80px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+  margin-bottom: -6px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  margin-top: 25px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const ClusterLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const NamespaceLabel = styled.div`
+  margin-left: 15px;
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const Link = styled.a`
+  margin-left: 5px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  position: relative;
+  padding-top: 20px;
+  padding-bottom: 70px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ClusterSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-top: 2px;
+  font-weight: 500;
+  margin-bottom: 32px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const PaddingWrapper = styled.div`
+  padding-bottom: 40px;
+`;
+
+const StyledSettingsPage = styled.div`
+  position: relative;
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;

+ 454 - 0
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -0,0 +1,454 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import { RouteComponentProps, withRouter } from "react-router";
+import close from "assets/close.png";
+import { isAlphanumeric } from "shared/common";
+
+import InputRow from "components/values-form/InputRow";
+import Helper from "components/values-form/Helper";
+import ImageSelector from "components/image-selector/ImageSelector";
+import ActionConfEditor from "components/repo-selector/ActionConfEditor";
+import SaveButton from "components/SaveButton";
+import { ActionConfigType } from "shared/types";
+
+type PropsType = RouteComponentProps & {
+  templateName: string;
+  setTemplateName: (x: string) => void;
+  setValuesToOverride: (x: any) => void;
+  setPage: (x: string) => void;
+  sourceType: string;
+  setSourceType: (x: string) => void;
+
+  imageUrl: string;
+  setImageUrl: (x: string) => void;
+  imageTag: string;
+  setImageTag: (x: string) => void;
+
+  actionConfig: ActionConfigType;
+  setActionConfig: (x: ActionConfigType) => void;
+  procfileProcess: string;
+  setProcfileProcess: (x: string) => void;
+  branch: string;
+  setBranch: (x: string) => void;
+  repoType: string;
+  setRepoType: (x: string) => void;
+  dockerfilePath: string | null;
+  setDockerfilePath: (x: string) => void;
+  procfilePath: string | null;
+  setProcfilePath: (x: string) => void;
+  folderPath: string | null;
+  setFolderPath: (x: string) => void;
+  selectedRegistry: any;
+  setSelectedRegistry: (x: string) => void;
+};
+
+type StateType = {};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  branch: "",
+  git_repo_id: 0,
+};
+
+class SourcePage extends Component<PropsType, StateType> {
+  renderSourceSelector = () => {
+    let { capabilities } = this.context;
+    let { sourceType, setSourceType } = this.props;
+
+    if (sourceType === "") {
+      return (
+        <BlockList>
+          {capabilities.github && (
+            <Block onClick={() => setSourceType("repo")}>
+              <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
+              <BlockTitle>Git Repository</BlockTitle>
+              <BlockDescription>
+                Deploy using source from a Git repo.
+              </BlockDescription>
+            </Block>
+          )}
+          <Block onClick={() => setSourceType("registry")}>
+            <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
+            <BlockTitle>Docker Registry</BlockTitle>
+            <BlockDescription>
+              Deploy a container from an image registry.
+            </BlockDescription>
+          </Block>
+        </BlockList>
+      );
+    }
+
+    // Display image selector
+    if (sourceType === "registry") {
+      let { imageUrl, setImageUrl, imageTag, setImageTag } = this.props;
+      return (
+        <StyledSourceBox>
+          <CloseButton
+            onClick={() => {
+              setSourceType("");
+              setImageUrl("");
+              setImageTag("");
+            }}
+          >
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Subtitle>
+            Specify the container image you would like to connect to this
+            template.
+            <Highlight
+              onClick={() => this.props.history.push("integrations/registry")}
+            >
+              Manage Docker registries
+            </Highlight>
+            <Required>*</Required>
+          </Subtitle>
+          <DarkMatter antiHeight="-4px" />
+          <ImageSelector
+            selectedTag={imageTag}
+            selectedImageUrl={imageUrl}
+            setSelectedImageUrl={setImageUrl}
+            setSelectedTag={setImageTag}
+            forceExpanded={true}
+          />
+          <br />
+        </StyledSourceBox>
+      );
+    }
+
+    // Display repo selector
+    let {
+      history,
+      setValuesToOverride,
+      setImageUrl,
+      actionConfig,
+      setActionConfig,
+      branch,
+      setBranch,
+      procfileProcess,
+      setProcfileProcess,
+      dockerfilePath,
+      setDockerfilePath,
+      procfilePath,
+      setProcfilePath,
+      folderPath,
+      setFolderPath,
+      selectedRegistry,
+      setSelectedRegistry,
+    } = this.props;
+    return (
+      <StyledSourceBox>
+        <CloseButton onClick={() => setSourceType("")}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Subtitle>
+          Provide a repo folder to use as source.
+          <Highlight onClick={() => history.push("integrations/repo")}>
+            Manage Git repos
+          </Highlight>
+          <Required>*</Required>
+        </Subtitle>
+        <DarkMatter antiHeight="-4px" />
+        <ActionConfEditor
+          actionConfig={actionConfig}
+          branch={branch}
+          setActionConfig={(actionConfig: ActionConfigType) => {
+            setActionConfig(actionConfig);
+            setImageUrl(actionConfig.image_repo_uri);
+            /*
+            setParentState({ actionConfig }, () =>
+              setParentState({ imageUrl: actionConfig.image_repo_uri })
+            )
+            */
+          }}
+          procfileProcess={procfileProcess}
+          setProcfileProcess={(procfileProcess: string) => {
+            setProcfileProcess(procfileProcess);
+            setValuesToOverride({
+              "container.command": {
+                value: procfileProcess || "",
+              },
+              showStartCommand: {
+                value: !procfileProcess,
+              },
+            });
+          }}
+          setBranch={setBranch}
+          setDockerfilePath={setDockerfilePath}
+          setProcfilePath={setProcfilePath}
+          procfilePath={procfilePath}
+          dockerfilePath={dockerfilePath}
+          folderPath={folderPath}
+          setFolderPath={setFolderPath}
+          reset={() => {
+            setActionConfig({ ...defaultActionConfig });
+            setBranch("");
+            setDockerfilePath(null);
+            setFolderPath(null);
+          }}
+          setSelectedRegistry={setSelectedRegistry}
+          selectedRegistry={selectedRegistry}
+        />
+        <br />
+      </StyledSourceBox>
+    );
+  };
+
+  checkSourceSelected = () => {
+    let { imageUrl, selectedRegistry } = this.props;
+    return imageUrl || selectedRegistry;
+  };
+
+  // TODO: consolidate status w/ helper at button-level
+  getButtonStatus = () => {
+    let { imageUrl, selectedRegistry, imageTag, templateName } = this.props;
+    if (!isAlphanumeric(templateName) && templateName !== "") {
+      return "Name contains illegal characters";
+    }
+    if (imageUrl || selectedRegistry) {
+      return "";
+    }
+    return "No source selected";
+  };
+
+  getButtonHelper = () => {
+    let { imageUrl, imageTag } = this.props;
+    if (imageUrl && !imageTag) {
+      return 'Tag "latest" will be used by default';
+    }
+  };
+
+  render() {
+    let { templateName, setTemplateName, setPage } = this.props;
+
+    return (
+      <StyledSourcePage>
+        <Heading>Name</Heading>
+        <Helper>
+          Randomly generated if left blank
+          <Warning
+            highlight={!isAlphanumeric(templateName) && templateName !== ""}
+          >
+            (lowercase letters, numbers, and "-" only)
+          </Warning>
+        </Helper>
+        <InputWrapper>
+          <InputRow
+            type="string"
+            value={templateName}
+            setValue={setTemplateName}
+            placeholder="ex: perspective-vortex"
+            width="470px"
+          />
+        </InputWrapper>
+        <Heading>Deployment Method</Heading>
+        <Helper>
+          Deploy from a Git repository or a Docker registry:
+          <Required>*</Required>
+        </Helper>
+        <Br />
+        {this.renderSourceSelector()}
+        <Helper>
+          Learn more about
+          <Highlight
+            href="https://docs.getporter.dev/docs/add-ons"
+            target="_blank"
+          >
+            deploying services to Porter
+          </Highlight>
+        </Helper>
+        <Buffer />
+        <SaveButton
+          text="Continue"
+          disabled={!this.checkSourceSelected()}
+          onClick={() => setPage("settings")}
+          status={this.getButtonStatus()}
+          makeFlush={true}
+          helper={this.getButtonHelper()}
+        />
+      </StyledSourcePage>
+    );
+  }
+}
+
+SourcePage.contextType = Context;
+export default withRouter(SourcePage);
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourcePage = styled.div`
+  position: relative;
+  margin-top: -5px;
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 5px;
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const BlockIcon = styled.img<{ bw?: boolean }>`
+  height: 38px;
+  padding: 2px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${(props) => (props.disabled ? "" : "#ffffff11")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+  margin-bottom: -6px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const Highlight = styled.a`
+  color: #8590ff;
+  text-decoration: none;
+  margin-left: 5px;
+  cursor: pointer;
+  display: inline;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 14px 35px 20px;
+  position: relative;
+  border-radius: 5px;
+  font-size: 13px;
+  margin-top: 6px;
+  overflow: auto;
+  margin-bottom: 25px;
+`;

+ 169 - 0
dashboard/src/main/home/modals/EnvEditorModal.tsx

@@ -0,0 +1,169 @@
+import React, { Component, createRef } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import AceEditor from "react-ace";
+
+import "shared/ace-porter-theme";
+import "ace-builds/src-noconflict/mode-text";
+
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  closeModal: () => void;
+  setEnvVariables: (values: any) => void;
+};
+
+type StateType = {
+  error: boolean;
+  buttonStatus: string;
+  envFile: string;
+};
+
+export default class EnvEditorModal extends Component<PropsType, StateType> {
+  state = {
+    error: false,
+    buttonStatus: "",
+    envFile: "",
+  };
+
+  aceEditorRef = React.createRef<AceEditor>();
+
+  onSubmit = () => {
+    this.props.setEnvVariables(this.state.envFile);
+    this.props.closeModal();
+  };
+
+  onChange = (e: string) => {
+    this.setState({ envFile: e });
+  };
+
+  componentDidMount() {}
+
+  render() {
+    return (
+      <StyledLoadEnvGroupModal>
+        <CloseButton onClick={this.props.closeModal}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Load from Environment Group</ModalTitle>
+        <Subtitle>Copy paste your environment file in .env format:</Subtitle>
+
+        <Editor
+          onSubmit={(e: any) => {
+            e.preventDefault();
+          }}
+          border={true}
+        >
+          <AceEditor
+            ref={this.aceEditorRef}
+            mode="text"
+            value={this.state.envFile}
+            theme="porter"
+            onChange={(e: string) => this.onChange(e)}
+            name="codeEditor"
+            editorProps={{ $blockScrolling: true }}
+            height="100%"
+            width="100%"
+            style={{ borderRadius: "5px" }}
+            showPrintMargin={false}
+            showGutter={true}
+            highlightActiveLine={true}
+            fontSize={14}
+          />
+        </Editor>
+
+        <SaveButton
+          disabled={this.state.envFile == ""}
+          text="Submit"
+          status={
+            this.state.envFile == ""
+              ? "No env file detected"
+              : "Existing env variables will be overidden"
+          }
+          onClick={this.onSubmit}
+        />
+      </StyledLoadEnvGroupModal>
+    );
+  }
+}
+
+EnvEditorModal.contextType = Context;
+
+const Editor = styled.form`
+  margin-top: 20px;
+  border-radius: ${(props: { border: boolean }) => (props.border ? "5px" : "")};
+  border: ${(props: { border: boolean }) =>
+    props.border ? "1px solid #ffffff22" : ""};
+  height: 80%;
+  font-family: monospace !important;
+  .ace_scrollbar {
+    display: none;
+  }
+  .ace_editor,
+  .ace_editor * {
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
+      monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
+`;
+
+const Subtitle = styled.div`
+  margin-top: 15px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledLoadEnvGroupModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 0 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -1,7 +1,6 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import close from "assets/close.png";
-import gradient from "assets/gradient.jpg";
 
 import api from "shared/api";
 import { Context } from "shared/Context";

+ 7 - 1
dashboard/src/main/home/navbar/Navbar.tsx

@@ -40,10 +40,16 @@ export default class Navbar extends Component<PropsType, StateType> {
     }
   };
 
+  renderFeedbackButton = () => {
+    if (this.context?.capabilities?.provisioner) {
+      return <Feedback currentView={this.props.currentView} />;
+    }
+  };
+
   render() {
     return (
       <StyledNavbar>
-        <Feedback currentView={this.props.currentView} />
+        {this.renderFeedbackButton()}
         <NavButton
           selected={this.state.showDropdown}
           onClick={() =>

+ 8 - 208
dashboard/src/main/home/new-project/NewProject.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import gradient from "assets/gradient.jpg";
+import gradient from "assets/gradient.png";
 import { Context } from "shared/Context";
 import { isAlphanumeric } from "shared/common";
 
@@ -23,6 +23,7 @@ export default class NewProject extends Component<PropsType, StateType> {
   };
 
   render() {
+    let { capabilities } = this.context;
     let { projectName } = this.state;
     return (
       <StyledNewProject>
@@ -58,7 +59,11 @@ export default class NewProject extends Component<PropsType, StateType> {
             width="470px"
           />
         </InputWrapper>
-        <ProvisionerSettings isInNewProject={true} projectName={projectName} />
+        <ProvisionerSettings
+          isInNewProject={true}
+          projectName={projectName}
+          provisioner={capabilities?.provisioner}
+        />
         <Br />
       </StyledNewProject>
     );
@@ -72,125 +77,12 @@ const Br = styled.div`
   height: 100px;
 `;
 
-const Link = styled.a`
-  cursor: pointer;
-  margin-left: 5px;
-`;
-
-const GuideButton = styled.a`
-  display: flex;
-  align-items: center;
-  margin-left: 20px;
-  color: #aaaabb;
-  font-size: 13px;
-  margin-bottom: -1px;
-  border: 1px solid #aaaabb;
-  padding: 5px 10px;
-  padding-left: 6px;
-  border-radius: 5px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff11;
-    color: #ffffff;
-    border: 1px solid #ffffff;
-
-    > i {
-      color: #ffffff;
-    }
-  }
-
-  > i {
-    color: #aaaabb;
-    font-size: 16px;
-    margin-right: 6px;
-  }
-`;
-
-const Flex = styled.div`
-  display: flex;
-  height: 170px;
-  width: 100%;
-  margin-top: -10px;
-  color: #ffffff;
-  align-items: center;
-  justify-content: center;
-`;
-
-const BlockOverlay = styled.div`
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  background: #00000055;
-  top: 0;
-  left: 0;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const DarkMatter = styled.div`
-  margin-top: -30px;
-`;
-
-const FormSection = styled.div`
-  background: #ffffff11;
-  margin-top: 25px;
-  margin-bottom: 27px;
-  background: #26282f;
-  border-radius: 5px;
-  min-height: 170px;
-  padding: 25px;
-  padding-bottom: 15px;
-  font-size: 13px;
-  animation: fadeIn 0.3s 0s;
-  position: relative;
-`;
-
-const Placeholder = styled.div`
-  background: #ffffff11;
-  margin-top: 25px;
-  margin-bottom: 27px;
-  background: #26282f;
-  border-radius: 5px;
-  height: 170px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
   display: inline-block;
 `;
 
-const Highlight = styled.div`
-  margin-left: 5px;
-  color: #8590ff;
-  cursor: pointer;
-`;
-
 const Letter = styled.div`
   height: 100%;
   width: 100%;
@@ -221,7 +113,7 @@ const ProjectIcon = styled.div`
   position: relative;
   margin-right: 15px;
   font-weight: 400;
-  margin-top: 17px;
+  margin-top: 9px;
 `;
 
 const InputWrapper = styled.div`
@@ -237,98 +129,6 @@ const Warning = styled.span`
     props.makeFlush ? "" : "5px"};
 `;
 
-const Icon = styled.img`
-  height: 42px;
-  margin-top: 30px;
-  margin-bottom: 15px;
-  filter: ${(props: { bw?: boolean }) => (props.bw ? "grayscale(1)" : "")};
-`;
-
-const BlockDescription = styled.div`
-  margin-bottom: 12px;
-  color: #ffffff66;
-  text-align: center;
-  font-weight: default;
-  font-size: 13px;
-  padding: 0px 25px;
-  height: 2.4em;
-  font-size: 12px;
-  display: -webkit-box;
-  overflow: hidden;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-`;
-
-const BlockTitle = styled.div`
-  margin-bottom: 12px;
-  width: 80%;
-  text-align: center;
-  font-size: 14px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const Block = styled.div`
-  align-items: center;
-  user-select: none;
-  border-radius: 5px;
-  display: flex;
-  font-size: 13px;
-  overflow: hidden;
-  font-weight: 500;
-  padding: 3px 0px 5px;
-  flex-direction: column;
-  align-item: center;
-  justify-content: space-between;
-  height: 170px;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "" : "pointer"};
-  color: #ffffff;
-  position: relative;
-  background: #26282f;
-  box-shadow: 0 3px 5px 0px #00000022;
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#ffffff11"};
-  }
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const ShinyBlock = styled(Block)`
-  background: linear-gradient(
-    36deg,
-    rgba(240, 106, 40, 0.9) 0%,
-    rgba(229, 83, 229, 0.9) 100%
-  );
-  :hover {
-    background: linear-gradient(
-      36deg,
-      rgba(240, 106, 40, 1) 0%,
-      rgba(229, 83, 229, 1) 100%
-    );
-  }
-`;
-
-const BlockList = styled.div`
-  overflow: visible;
-  margin-top: 25px;
-  margin-bottom: 27px;
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
-`;
-
 const Title = styled.div`
   font-size: 24px;
   font-weight: 600;

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

@@ -28,7 +28,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     invites: [] as InviteType[],
     email: "",
     invalidEmail: false,
-    isHTTPS: process.env.API_SERVER === "dashboard.getporter.dev",
+    isHTTPS: window.location.protocol === "https:",
   };
 
   componentDidMount() {
@@ -118,7 +118,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     navigator.clipboard
       .writeText(
         `${this.state.isHTTPS ? "https://" : ""}${
-          process.env.API_SERVER
+          window.location.host
         }/api/projects/${currentProject.id}/invites/${
           this.state.invites[index].token
         }`
@@ -182,7 +182,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                     disabled={true}
                     type="string"
                     value={`${this.state.isHTTPS ? "https://" : ""}${
-                      process.env.API_SERVER
+                      window.location.host
                     }/api/projects/${currentProject.id}/invites/${
                       this.state.invites[i].token
                     }`}

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

@@ -429,7 +429,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
             .
           </Helper>
           <CheckboxRow
-            required={true}
+            isRequired={true}
             checked={this.state.provisionConfirmed}
             toggle={() =>
               this.setState({

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

@@ -280,7 +280,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
             .
           </Helper>
           <CheckboxRow
-            required={true}
+            isRequired={true}
             checked={this.state.provisionConfirmed}
             toggle={() =>
               this.setState({

+ 9 - 8
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -7,6 +7,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
+import UploadArea from "components/values-form/UploadArea";
 import SelectRow from "components/values-form/SelectRow";
 import CheckboxRow from "components/values-form/CheckboxRow";
 import InputRow from "components/values-form/InputRow";
@@ -26,7 +27,7 @@ type PropsType = RouteComponentProps & {
 type StateType = {
   gcpRegion: string;
   gcpProjectId: string;
-  gcpKeyData: string;
+  gcpKeyData: any;
   clusterName: string;
   clusterNameSet: boolean;
   selectedInfras: { value: string; label: string }[];
@@ -318,7 +319,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
   render() {
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
-
+    console.log("gcpkeydata", gcpKeyData);
     return (
       <StyledGCPFormSection>
         <FormSection>
@@ -352,15 +353,15 @@ class GCPFormSection extends Component<PropsType, StateType> {
             width="100%"
             isRequired={true}
           />
-          <InputRow
-            type="password"
-            value={gcpKeyData}
-            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+          <UploadArea
+            setValue={(x: any) => this.setState({ gcpKeyData: x })}
             label="🔒 GCP Key Data (JSON)"
-            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            placeholder="Choose a file or drag it here."
             width="100%"
+            height="100%"
             isRequired={true}
           />
+
           <Br />
           <Heading>GCP Resources</Heading>
           <Helper>
@@ -387,7 +388,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
             .
           </Helper>
           <CheckboxRow
-            required={true}
+            isRequired={true}
             checked={this.state.provisionConfirmed}
             toggle={() =>
               this.setState({

+ 10 - 2
dashboard/src/main/home/provisioner/Provisioner.tsx

@@ -146,6 +146,7 @@ class Provisioner extends Component<PropsType, StateType> {
             this.refresh();
           }}
         >
+          <i className="material-icons">autorenew</i>
           Refresh
         </RefreshText>
       </StyledProvisioner>
@@ -178,8 +179,15 @@ const TabWrapper = styled.div`
 `;
 
 const RefreshText = styled.div`
-  display: inline;
-  margin-left: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
   color: #8590ff;
   cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
 `;

+ 14 - 14
dashboard/src/main/home/provisioner/ProvisionerLogs.tsx

@@ -46,18 +46,18 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
       return <Message>Please select a resource.</Message>;
     }
 
-    if (selectedInfra.status == "destroyed") {
-      return (
-        <Message>
-          This resource has been auto-destroyed due to an error during
-          provisioning.
-          <div>
-            Please check with your cloud provider to make sure all resources
-            have been properly destroyed.
-          </div>
-        </Message>
-      );
-    }
+    // if (selectedInfra.status == "destroyed") {
+    //   return (
+    //     <Message>
+    //       This resource has been auto-destroyed due to an error during
+    //       provisioning.
+    //       <div>
+    //         Please check with your cloud provider to make sure all resources
+    //         have been properly destroyed.
+    //       </div>
+    //     </Message>
+    //   );
+    // }
 
     if (logs.length == 0) {
       switch (selectedInfra.status) {
@@ -192,9 +192,9 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
 
     if (!selectedInfra) return;
 
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
     );
 
     this.setupWebsocket();

+ 78 - 31
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -17,6 +17,7 @@ type PropsType = RouteComponentProps & {
   isInNewProject?: boolean;
   projectName?: string;
   infras?: InfraType[];
+  provisioner?: boolean;
 };
 
 type StateType = {
@@ -42,11 +43,19 @@ class NewProject extends Component<PropsType, StateType> {
     this.props.history.push("dashboard?tab=overview");
   };
 
-  renderSelectedProvider = () => {
+  renderSelectedProvider = (override?: string) => {
     let { selectedProvider } = this.state;
     let { projectName, infras } = this.props;
 
+    if (override) {
+      selectedProvider = override;
+    }
+
     let renderSkipHelper = () => {
+      if (!this.props.provisioner) {
+        return;
+      }
+
       return (
         <>
           {selectedProvider === "skipped" ? (
@@ -125,20 +134,77 @@ class NewProject extends Component<PropsType, StateType> {
     }
   };
 
-  render() {
+  renderFooter = () => {
     let { selectedProvider } = this.state;
     let { isInNewProject } = this.props;
+    let { provisioner } = this.props;
+    let helper = provisioner
+      ? "Note: Provisioning can take up to 15 minutes"
+      : "";
+
+    if (isInNewProject && !selectedProvider) {
+      return (
+        <>
+          <Helper>
+            Already have a Kubernetes cluster?
+            <Highlight
+              onClick={() => this.setState({ selectedProvider: "skipped" })}
+            >
+              Skip
+            </Highlight>
+          </Helper>
+          <Br />
+          <SaveButton
+            text="Submit"
+            disabled={true}
+            onClick={() => {}}
+            makeFlush={true}
+            helper={helper}
+          />
+        </>
+      );
+    }
+  };
+
+  componentDidMount() {
+    let { provisioner } = this.props;
+
+    if (!provisioner) {
+      this.setState({ selectedProvider: "skipped" });
+    }
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.provisioner !== this.props.provisioner) {
+      if (!this.props.provisioner) {
+        this.setState({ selectedProvider: "skipped" });
+      }
+    }
+  }
+
+  renderHelperText = () => {
+    let { isInNewProject, provisioner } = this.props;
+    if (!provisioner) {
+      return;
+    }
+
+    if (isInNewProject) {
+      return (
+        <>
+          Select your hosting backend:<Required>*</Required>
+        </>
+      );
+    } else {
+      return "Need a cluster? Provision through Porter:";
+    }
+  };
+
+  render() {
+    let { selectedProvider } = this.state;
+
     return (
       <StyledProvisionerSettings>
-        <Helper>
-          {isInNewProject ? (
-            <>
-              Select your hosting backend:<Required>*</Required>
-            </>
-          ) : (
-            "Need a cluster? Provision through Porter:"
-          )}
-        </Helper>
+        <Helper>{this.renderHelperText()}</Helper>
         {!selectedProvider ? (
           <BlockList>
             {providers.map((provider: string, i: number) => {
@@ -160,26 +226,7 @@ class NewProject extends Component<PropsType, StateType> {
         ) : (
           <>{this.renderSelectedProvider()}</>
         )}
-        {isInNewProject && !selectedProvider && (
-          <>
-            <Helper>
-              Already have a Kubernetes cluster?
-              <Highlight
-                onClick={() => this.setState({ selectedProvider: "skipped" })}
-              >
-                Skip
-              </Highlight>
-            </Helper>
-            <Br />
-            <SaveButton
-              text="Submit"
-              disabled={true}
-              onClick={() => {}}
-              makeFlush={true}
-              helper="Note: Provisioning can take up to 15 minutes"
-            />
-          </>
-        )}
+        {this.renderFooter()}
       </StyledProvisionerSettings>
     );
   }

+ 9 - 4
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -59,18 +59,23 @@ class ClusterSection extends Component<PropsType, StateType> {
             let saved = JSON.parse(
               localStorage.getItem(currentProject.id + "-cluster")
             );
-            if (saved !== "null") {
-              setCurrentCluster(clusters[0]);
+            if (saved && saved !== "null") {
+              // Ensures currentCluster isn't prematurely set (causes issues downstream)
+              let loaded = false;
               for (let i = 0; i < clusters.length; i++) {
                 if (
                   clusters[i].id === saved.id &&
                   clusters[i].project_id === saved.project_id &&
                   clusters[i].name === saved.name
                 ) {
+                  loaded = true;
                   setCurrentCluster(clusters[i]);
                   break;
                 }
               }
+              if (!loaded) {
+                setCurrentCluster(clusters[0]);
+              }
             } else {
               setCurrentCluster(clusters[0]);
             }
@@ -173,10 +178,10 @@ class ClusterSection extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <div>
+      <>
         {this.renderDrawer()}
         {this.renderContents()}
-      </div>
+      </>
     );
   }
 }

+ 3 - 1
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from "react";
 import styled from "styled-components";
-import gradient from "assets/gradient.jpg";
+import gradient from "assets/gradient.png";
 
 import { Context } from "shared/Context";
 import { ProjectType } from "shared/types";
@@ -210,6 +210,8 @@ const Letter = styled.div`
   height: 100%;
   width: 100%;
   position: absolute;
+  padding-bottom: 2px;
+  font-weight: 500;
   background: #00000028;
   top: 0;
   left: 0;

+ 5 - 1
dashboard/src/shared/Context.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from "react";
 
-import { ProjectType, ClusterType } from "shared/types";
+import { ProjectType, ClusterType, CapabilityType } from "shared/types";
 
 const Context = React.createContext({});
 
@@ -63,6 +63,10 @@ class ContextProvider extends Component {
     setDevOpsMode: (devOpsMode: boolean) => {
       this.setState({ devOpsMode });
     },
+    capabilities: null as CapabilityType,
+    setCapabilities: (capabilities: CapabilityType) => {
+      this.setState({ capabilities });
+    },
     clearContext: () => {
       this.setState({
         currentModal: null,

+ 115 - 0
dashboard/src/shared/ace-porter-theme.js

@@ -0,0 +1,115 @@
+import ace from "brace";
+
+ace["define"](
+  "ace/theme/porter",
+  ["require", "exports", "module", "ace/lib/dom"],
+  (acequire, exports) => {
+    exports.isDark = true;
+    exports.cssClass = "ace-porter";
+    exports.cssText = `.ace-porter, div.ace_content, div.ace_line, div.ace_gutter-cell {\
+    font-family: monospace;
+    font-size: 14px;
+    }
+    .ace-porter {
+    background-color: #1b1d26;
+    }
+    .ace-porter .ace_gutter {
+    background: #1b1d26;
+    color: #929292
+    }
+    .ace-porter .ace_print-margin {
+    width: 1px;
+    background: #1b1d26
+    }
+    .ace-porter .ace_cursor {
+    color: #bfc7d5
+    }
+    .ace-porter .ace_marker-layer .ace_selection {
+    background: #32374D
+    }
+    .ace-porter.ace_multiselect .ace_selection.ace_start {
+    box-shadow: 0 0 3px 0px #191919;
+    }
+    .ace-porter .ace_marker-layer .ace_step {
+    background: rgb(102, 82, 0)
+    }
+    .ace-porter .ace_marker-layer .ace_bracket {
+    margin: -1px 0 0 -1px;
+    border: 1px solid #BFBFBF
+    }
+    .ace-porter .ace_marker-layer .ace_active-line {
+    background: rgba(215, 215, 215, 0.031)
+    }
+    .ace-porter .ace_gutter-active-line {
+    background-color: rgba(215, 215, 215, 0.031)
+    }
+    .ace-porter .ace_marker-layer .ace_selected-word {
+    border: 1px solid #424242
+    }
+    .ace-porter .ace_invisible {
+    color: #343434
+    }
+    .ace-porter .ace_keyword,
+    .ace-porter .ace_meta,
+    .ace-porter .ace_storage,
+    .ace-porter .ace_storage.ace_type,
+    .ace-porter .ace_support.ace_type {
+    color: #ff5572
+    }
+    .ace-porter .ace_keyword.ace_operator {
+    color: #ff5572
+    }
+    .ace-porter .ace_constant.ace_character,
+    .ace-porter .ace_constant.ace_language,
+    .ace-porter .ace_constant.ace_numeric,
+    .ace-porter .ace_keyword.ace_other.ace_unit,
+    .ace-porter .ace_support.ace_constant,
+    .ace-porter .ace_variable.ace_parameter {
+    color: #F78C6C
+    }
+    .ace-porter .ace_constant.ace_other {
+    color: gold
+    }
+    .ace-porter .ace_invalid {
+    color: yellow;
+    background-color: red
+    }
+    .ace-porter .ace_invalid.ace_deprecated {
+    color: #CED2CF;
+    background-color: #B798BF
+    }
+    .ace-porter .ace_fold {
+    background-color: #7AA6DA;
+    border-color: #DEDEDE
+    }
+    .ace-porter .ace_entity.ace_name.ace_function,
+    .ace-porter .ace_support.ace_function,
+    .ace-porter .ace_variable {
+    color: #7AA6DA
+    }
+    .ace-porter .ace_support.ace_class,
+    .ace-porter .ace_support.ace_type {
+    color: #E7C547
+    }
+    .ace-porter .ace_heading,
+    .ace-porter .ace_string {
+    color: #80CBC4
+    }
+    .ace-porter .ace_entity.ace_name.ace_tag,
+    .ace-porter .ace_entity.ace_other.ace_attribute-name,
+    .ace-porter .ace_meta.ace_tag,
+    .ace-porter .ace_string.ace_regexp,
+    .ace-porter .ace_variable {
+    color: #ff5572
+    }
+    .ace-porter .ace_comment {
+    color: #949eff
+    }
+    .ace-porter .ace_indent-guide {
+    background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYLBWV/8PAAK4AYnhiq+xAAAAAElFTkSuQmCC) right repeat-y;
+    }`;
+
+    var dom = acequire("../lib/dom");
+    dom.importCssString(exports.cssText, exports.cssClass);
+  }
+);

+ 30 - 5
dashboard/src/shared/api.tsx

@@ -230,6 +230,15 @@ const deletePod = baseApi<
   return `/api/projects/${pathParams.id}/k8s/pods/${pathParams.namespace}/${pathParams.name}`;
 });
 
+const getPodEvents = baseApi<
+  {
+    cluster_id: number;
+  },
+  { name: string; namespace: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/pods/${pathParams.namespace}/${pathParams.name}/events/list`;
+});
+
 const deleteProject = baseApi<{}, { id: number }>("DELETE", (pathParams) => {
   return `/api/projects/${pathParams.id}`;
 });
@@ -463,6 +472,7 @@ const getJobPods = baseApi<
 const getMatchingPods = baseApi<
   {
     cluster_id: number;
+    namespace: string;
     selectors: string[];
   },
   { id: number }
@@ -616,9 +626,7 @@ const getTemplateInfo = baseApi<
   return `/api/templates/${pathParams.name}/${pathParams.version}`;
 });
 
-const getAddonTemplates = baseApi("GET", "/api/templates");
-
-const getApplicationTemplates = baseApi<
+const getTemplates = baseApi<
   {
     repo_url?: string;
   },
@@ -629,6 +637,10 @@ const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/users/${pathParams.id}`;
 });
 
+const getCapabilities = baseApi<{}, {}>("GET", () => {
+  return `/api/capabilities`;
+});
+
 const linkGithubProject = baseApi<
   {},
   {
@@ -716,6 +728,7 @@ const upgradeChartValues = baseApi<
     namespace: string;
     storage: StorageType;
     values: string;
+    version?: string;
   },
   {
     id: number;
@@ -753,6 +766,7 @@ const createConfigMap = baseApi<
     name: string;
     namespace: string;
     variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
   },
   { id: number; cluster_id: number }
 >("POST", (pathParams) => {
@@ -765,6 +779,7 @@ const updateConfigMap = baseApi<
     name: string;
     namespace: string;
     variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
   },
   { id: number; cluster_id: number }
 >("POST", (pathParams) => {
@@ -783,6 +798,14 @@ const deleteConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
 });
 
+const stopJob = baseApi<
+  {},
+  { name: string; namespace: string; id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, name, namespace, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -816,6 +839,7 @@ export default {
   destroyDOKS,
   getBranchContents,
   getBranches,
+  getCapabilities,
   getChart,
   getCharts,
   getChartComponents,
@@ -837,6 +861,7 @@ export default {
   getNamespaces,
   getNGINXIngresses,
   getOAuthIds,
+  getPodEvents,
   getProcfileContents,
   getProjectClusters,
   getProjectRegistries,
@@ -849,8 +874,7 @@ export default {
   getRepos,
   getRevisions,
   getTemplateInfo,
-  getAddonTemplates,
-  getApplicationTemplates,
+  getTemplates,
   getUser,
   linkGithubProject,
   listConfigMaps,
@@ -864,4 +888,5 @@ export default {
   updateUser,
   updateConfigMap,
   upgradeChartValues,
+  stopJob,
 };

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

@@ -34,6 +34,7 @@ export interface ChartType {
   config: any;
   version: number;
   namespace: string;
+  latest_version: string;
 }
 
 export interface ResourceType {
@@ -72,7 +73,8 @@ export enum StorageType {
 // PorterTemplate represents a bundled Porter template
 export interface PorterTemplate {
   name: string;
-  version: string;
+  versions: string[];
+  currentVersion: string;
   description: string;
   icon: string;
 }
@@ -104,10 +106,13 @@ export interface FormElement {
   required?: boolean;
   name?: string;
   variable?: string;
+  placeholder?: string;
   value?: any;
   settings?: {
     default?: number | string | boolean;
     options?: any[];
+    omitUnitFromValue?: boolean;
+    disableAfterLaunch?: boolean;
     unit?: string;
   };
 }
@@ -167,3 +172,8 @@ export interface ActionConfigType {
   image_repo_uri: string;
   git_repo_id: number;
 }
+
+export interface CapabilityType {
+  github: boolean;
+  provisioner: boolean;
+}

+ 4 - 2
docker-compose.dev.yaml

@@ -4,10 +4,12 @@ services:
     build:
       context: ./dashboard
       dockerfile: ./docker/dev.Dockerfile
+    env_file:
+      - ./dashboard/.env
     restart: on-failure
     volumes:
-      - ./dashboard:/webpack:rw,cached
-      - /webpack/node_modules
+      - ./dashboard/src:/webpack/src:rw,cached
+      - ./dashboard/package.json:/webpack/package.json
   porter:
     build:
       context: .

+ 0 - 32
docs/DEVELOPING.md

@@ -1,32 +0,0 @@
-### Development
-
-```sh
-docker-compose -f docker-compose.dev.yaml up --build
-```
-
-And then visit `localhost:8080` in the browser.
-
-### Testing
-
-From the root directory, run `go test ./...` to run all tests and ensure the builds/tests pass.
-
-### Building
-
-From the root directory, run `DOCKER_BUILDKIT=1 docker build . --file ./docker/Dockerfile -t porter`. Then you can run `docker run -p 8080:8080 porter`.
-
-To build the test container, run `DOCKER_BUILDKIT=1 docker build . --file ./docker/Dockerfile -t porter-test --target porter-test`.
-
-### CLI Release
-
-```sh
-docker run --rm --privileged \
--v $PWD:/go/src/github.com/porter-dev/porter \
--v /var/run/docker.sock:/var/run/docker.sock \
--w /go/src/github.com/porter-dev/porter \
--e GORELEASER_GITHUB_TOKEN='THEGITHUBTOKEN' \
-mailchain/goreleaser-xcgo ""
-```
-
-### Dashboard
-
-We use Prettier for all ts/tsx formatting. This will eventually be enforced rigorously.

+ 0 - 26
docs/GCR.md

@@ -1,26 +0,0 @@
-# Google Container Registry (GCR) Connection
-
-To authenticate a private GCR registry, you will first need a Google Cloud service account with registry viewing permissions. To create a new service account, go to your Google Cloud console and navigate to the **IAM & Admin** tab in the navigation menu and select **Service Accounts**:
-
-<img src="https://files.readme.io/a0c0c75-Screen_Shot_2020-06-24_at_2.51.46_PM.png" width="80%">
-
-Select **Create Service Account** and provide a name and brief description for the new service account. Next, choose the role **Viewer** when you are prompted to grant permissions to your service account:
-
-<img src="https://files.readme.io/aa8cda5-Screen_Shot_2020-06-24_at_4.03.33_PM.png" width="80%">
-
-After the service account has been created, you need to create a JSON key for your service account by going to **Actions** -> **Create key** and then selecting JSON as your key type. Once your JSON key file has downloaded, use the `porter connect gcr` command to add the registry to your project.
-
-For example, for a key named `gcp-key-file.json` on Mac:
-
-```diff
-$ cd ~/Downloads
-$ porter connect gcr
-Please provide the full path to a service account key file.
-Key file location: ./gcp-key-file.json
-+ created gcp integration with id 3
-Give this registry a name: gcr-registry
-+ created registry with id 16 and name gcr-test
-+ Set the current registry id as 16
-```
-
-Having issues authenticating your private registry? You can reach us at [contact@getporter.dev](mailto:contact@getporter.dev).

+ 0 - 103
docs/GETTING_STARTED.md

@@ -1,103 +0,0 @@
-## Getting Started
-
-- [Prerequisites](#prerequisites)
-- [Installing](#installing)
-  - [Mac Installation](#mac-installation)
-  - [Linux Installation](#linux-installation)
-  - [Windows Installation](#windows-installation)
-- [Local Setup](#local-setup)
-  - [Connecting to a Cluster](#connecting-to-a-cluster)
-
-## Prerequisites
-
-You must have access to a Kubernetes cluster with Helm charts installed and the Docker engine must be running on your machine. To quickly get a local Kubernetes cluster set up, following the instructions for [installing minikube](https://minikube.sigs.k8s.io/docs/start/), and make sure that minikube is set as the current context by ensuring the output of `kubectl config current-context` is `minikube`.
-
-## Installing
-
-### Mac Installation
-
-Run the following command to grab the latest binary:
-
-```sh
-{
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
-name=$(basename $name)
-curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
-unzip -a $name
-rm $name
-}
-```
-
-Then move the file into your bin:
-
-```sh
-chmod +x ./porter
-sudo mv ./porter /usr/local/bin/porter
-```
-
-### Linux Installation
-
-Run the following command to grab the latest binary:
-
-```sh
-{
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
-name=$(basename $name)
-curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
-unzip -a $name
-rm $name
-}
-```
-
-Then move the file into your bin:
-
-```sh
-chmod +x ./porter
-sudo mv ./porter /usr/local/bin/porter
-```
-
-### Windows Installation
-
-Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0.1.0-beta.1_Windows_x86_64.zip) to download the Windows executable and add the binary to your `PATH`.
-
-## Local Setup
-
-> **Note:** the local setup process is tracked in [issue #60](https://github.com/porter-dev/porter/issues/60), while the overall onboarding flow is tracked in [issue #50](https://github.com/porter-dev/porter/issues/50).
-
-To view Porter locally, you must have access to a Kubernetes cluster with Helm charts installed. The simplest way to run Porter is via `porter server start`. After doing this, you can go to `http://localhost:8080` to register an account and create a project manually. Alternatively, you can run the following commands:
-
-```sh
-porter auth register
-porter project create porter-test
-```
-
-### Connecting to a Cluster
-
-In the case of local setup, you will have to connect to a cluster using the CLI command `porter connect kubeconfig`. By default, this command will read the `current-context` that's set in your default `kubeconfig` (either by reading the `$KUBECONFIG` env variable or reading from `$HOME/.kube/config`). You can also pass a path to a kubeconfig file explicitly (see below).
-
-The Porter CLI will attempt to generate a working kubeconfig for many types of cluster configurations and auth mechanisms, even though the necessary commands and/or certificates will not be present in the Porter container. The CLI will attempt the following resolutions:
-
-1. If a kubeconfig requires cluster CA data via the `certificate-authority` field, the CA data will be automatically populated.
-2. If a kubeconfig requires client cert data via the `client-certificate` field, the certificate data will be automatically populated.
-3. If a kubeconfig requires client key data via the `client-key` field, the key data will be automatically populated.
-4. If a kubeconfig requires a custom `oidc` auth mechanism, and this mechanism requires OIDC issuer CA data via the `idp-certificate-authority` field, the CA data will be automatically populated.
-5. If a kubeconfig requires a bearer token to be read from a `token-file` field, the token data will be automatically populated.
-6. If a kubeconfig requires a custom `gcp` auth mechanism (for connecting with GKE clusters), the CLI will require a GCP `service-account` that has permissions to read from the GKE cluster. The CLI will ask the user if it can set this up automatically: if so, it will automatically detect the correct GCP project ID and will create a service account and download a key file. If the user does not wish the CLI to set this up automatically, the user will need to provide a file path to a service account key file that was downloaded from GCloud.
-
-> **Note:** AWS EKS support coming soon.
-
-#### Passing `kubeconfig` explicitly
-
-You can pass a path to a `kubeconfig` file explicitly via:
-
-```sh
-porter connect kubeconfig --kubeconfig path/to/kubeconfig
-```
-
-#### Passing a context list
-
-You can initialize Porter with a set of contexts by passing a context list to start. The contexts that Porter will be able to access are the same as `kubectl config get-contexts`. For example, if there are two contexts named `minikube` and `staging`, you could connect both of them via:
-
-```sh
-porter connect kubeconfig --context minikube --context staging
-```

+ 55 - 0
docs/deploy/addons/mongo.md

@@ -0,0 +1,55 @@
+# Deployment
+To deploy a MongoDB instance on Porter, head to the **Community Add-ons** tab. You must specify username and password for the instance. You can optionally modify the size of the persistent volume attached to the database.
+
+![Mongo deploy](https://files.readme.io/b401d7a-Screen_Shot_2021-03-19_at_12.49.14_PM.png "Screen Shot 2021-03-19 at 12.49.14 PM.png")
+
+# Connecting to the Database
+
+MongoDB on Porter is by default only exposed to internal traffic - only applications and add-on's that are deployed in the same Kubernetes cluster can connect to the MongoDB instance. The DNS name for the instance can be found on the deployment view as shown below. Note that MongoDB listens on port 27017 by default.
+
+![Mongo URI](https://files.readme.io/7fa74b5-Screen_Shot_2021-03-19_at_1.14.43_PM.png "Screen Shot 2021-03-19 at 1.14.43 PM.png")
+
+MongoDB provisioned through Porter has replica sets enabled by default. The connection URI for the MongoDB instance follows this format: 
+
+```
+mongodb://root:${PASSWORD}@${REPLICASET_1}:27017,${REPLICASET_2}:27017/?authSource=admin&replicaSet=rs0
+```
+
+The `REPLICASET_1` and `REPLICASET_2` arguments are formed by the following:
+
+```sh
+REPLICASET_1=${pod_1}.${internal_uri}
+REPLICASET_2=${pod_2}.${internal_uri}
+```
+
+So in this case, this would be:
+
+```sh
+REPLICASET_1=medicine-lucky-place-mongodb-0.medicine-lucky-place-mongodb.default.svc.cluster.local
+REPLICASET_2=medicine-lucky-place-mongodb-1.medicine-lucky-place-mongodb.default.svc.cluster.local
+```
+
+And the full connection string would be:
+
+```
+mongodb://root:mongopassword@medicine-lucky-place-mongodb-0.medicine-lucky-place-mongodb.default.svc.cluster.local:27017,medicine-lucky-place-mongodb-1.medicine-lucky-place-mongodb.default.svc.cluster.local:27017/?authSource=admin&replicaSet=rs0
+```
+
+# Deletion
+To delete this add-on, navigate to the **Settings** tab of the deployment. Note that deleting from the Porter dashboard will not delete the persistent volumes that have been attached to your MongoDB instance. To delete these dangling volumes, see the next section.
+
+# Persistent Volumes
+
+## AWS
+By default, Porter creates [EBS volumes](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-volumes.html) of type **gp2** (general purpose SSD) volumes that are attached to the database. To view the volumes attached to your cluster, navigate to **EC2 > Volumes** tab in your AWS console.
+
+> ❗️
+> 
+> The unnamed 100GB volumes are attached to your EKS cluster itself. Make sure to not delete them - this will make your cluster not functional.
+
+![Persistent Volume](https://files.readme.io/db82b92-Screen_Shot_2021-03-18_at_3.11.11_PM.png "Screen Shot 2021-03-18 at 3.11.11 PM.png")
+
+
+Click on the volume and navigate to the **Tags** tab to see which deployment the volume belongs to. You can modify, delete, and make a snapshot of this volume from the AWS console.
+
+![Volume](https://files.readme.io/ba15d35-Screen_Shot_2021-03-18_at_3.17.19_PM.png "Screen Shot 2021-03-18 at 3.17.19 PM.png")

+ 7 - 0
docs/deploy/addons/overview.md

@@ -0,0 +1,7 @@
+For deployments that do not fall into the three application types (i.e. web service, worker, and cron job), you can deploy them as add-ons on Porter. Below is the list of add-ons that are currently supported. 
+
+If you have requests for add-ons you'd like us to support, please let us know in the #suggestions channel of our [community](https://discord.gg/mmGAw5nNjr).
+
+- [PostgresDB](doc:postgresdb)
+- [Redis](doc:redis)
+- [MongoDB](doc:mongodb)

+ 42 - 0
docs/deploy/addons/postgres.md

@@ -0,0 +1,42 @@
+# Deployment
+To deploy a PostgresDB instance on Porter, head to the **Community Add-ons** tab. Specify a username and password you'd like for the instance. You can optionally configure the amount of resources (i.e. CPU and RAM) assigned to the database instance.
+
+PostgresDB instances deployed on Porter have persistent volumes attached to them to prevent data loss in the case of accidents. See [Persistent Volumes](#persistent-volumes) for a guide on how to manage these volumes in your cloud provider.
+
+![Postgres](https://files.readme.io/2ddb8a2-Screen_Shot_2021-03-18_at_2.48.50_PM.png "Screen Shot 2021-03-18 at 2.48.50 PM.png")
+
+# Connecting to the Database
+
+PostgresDB on Porter is by default only exposed to internal traffic - only applications and add-on's that are deployed in the same Kubernetes cluster can connect to the database. The DNS name for the instance can be found on the deployment view as shown below. Note that Postgres listens on port 5432 by default.
+
+![Internal URI](https://files.readme.io/857e0ed-Screen_Shot_2021-03-18_at_2.58.57_PM.png "Screen Shot 2021-03-18 at 2.58.57 PM.png")
+
+Note that the connection URI for the PostgresDB instance follows this format: 
+
+```
+postgres://${USERNAME}:${PASSWORD}@${DNS_NAME}:5432/${DATABASE_NAME}
+```
+
+For the example above, the connection string would be:
+
+```
+postgres://postgres@force-double-snake-postgresql.default.svc.cluster.local:5432/postgres
+```
+
+# Deletion
+To delete this add-on, navigate to the **Settings** tab of the deployment. Note that deleting from the Porter dashboard will not delete the persistent volumes that have been attached to your PostgresDB instance. To delete these dangling volumes, see the next section.
+
+# Persistent Volumes
+
+## AWS
+By default, Porter creates [EBS volumes](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-volumes.html) of type **gp2** (general purpose SSD) volumes that are attached to the database. To view the volumes attached to your cluster, navigate to **EC2 > Volumes** tab in your AWS console.
+
+> ❗️
+> 
+> The unnamed 100GB volumes are attached to your EKS cluster itself. Make sure to not delete them - this will make your cluster not functional.
+
+![AWS Volumes](https://files.readme.io/c9b77c7-Screen_Shot_2021-03-18_at_3.11.11_PM.png "Screen Shot 2021-03-18 at 3.11.11 PM.png")
+
+Click on the volume and navigate to the **Tags** tab to see which deployment the volume belongs to. You can modify, delete, and make a snapshot of this volume from the AWS console.
+
+![AWS DB Volume](https://files.readme.io/d2b93d2-Screen_Shot_2021-03-18_at_3.17.19_PM.png "Screen Shot 2021-03-18 at 3.17.19 PM.png")

+ 25 - 0
docs/deploy/addons/redis.md

@@ -0,0 +1,25 @@
+# Deployment
+To deploy a Redis instance on Porter, head to the **Community Add-ons** tab. You can optionally specify a password for the instance or configure the amount of resources (i.e. CPU and RAM) assigned to the instance.
+
+![Redis settings](https://files.readme.io/3274ddb-Screen_Shot_2021-03-19_at_12.26.26_PM.png "Screen Shot 2021-03-19 at 12.26.26 PM.png")
+
+# Connecting to the Database
+
+Redis on Porter is by default only exposed to internal traffic - only applications and add-on's that are deployed in the same Kubernetes cluster can connect to the Redis instance. The DNS name for the instance can be found on the deployment view as shown below. Note that Redis listens on port 6379 by default.
+
+![Redis URI](https://files.readme.io/d0d7317-Screen_Shot_2021-03-19_at_12.27.42_PM.png "Screen Shot 2021-03-19 at 12.27.42 PM.png")
+
+The connection URI for the Redis instance follows this format: 
+```
+redis://${DNS_NAME}:6379
+```
+If you've enabled password, the connection string would look like:
+```
+redis://${ARBITRARY_USERNAME}:${PASSWORD}@${DNA_NAME}:6379
+```
+You can pass in any string as your username (even an empty string). Redis does not support users but implements this behavior to comply with [URI RFC standard](https://tools.ietf.org/html/rfc3986).
+
+For the example above that does not have password enabled, the connection string would be:
+```
+redis://peaches-redis-master.default.svc.cluster.local:6379
+```

+ 92 - 0
docs/deploy/applications/deploying-django-application-non-docker.md

@@ -0,0 +1,92 @@
+# Deploy Django Application (Non-Dockerize)
+
+To deploy your Django Application in Porter. You need to tweak something on your Django Application.
+
+> 📘 Prerequisites
+> - Django Application
+> - Docker Registry integration on your account ([See docs](https://docs.getporter.dev/docs/linking-an-existing-docker-container-registry))
+
+
+## Prepare Django Application
+
+1. Install `django-allow-cidr` (Django middleware to enable the use of CIDR IP ranges in `ALLOWED_HOSTS`)
+  ```sh
+  pip install django-allow-cidr
+  ```
+2.  Go to Django Settings and add os.environ.get in allowed host.
+  ```python
+  ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default='127.0.0.1').split(" ")
+  ```
+3. Add this below allowed host. Put CIDR according to the K8s kubelet CIDR
+  ```python
+  ALLOWED_CIDR_NETS = os.environ.get("ALLOWED_CIDR_NETS", default='10.0.0.0/16').split(" ")
+  ```
+4. Add `django-allow-cidr` middleware on the top of Django middleware:
+  ```python
+  MIDDLEWARE = [
+    'allow_cidr.middleware.AllowCIDRMiddleware',
+    #'django.middleware.security.SecurityMiddleware',
+  ]
+  ```
+5. Add Gunicorn
+  ```sh
+  pip install gunicorn
+  ```
+6. Add static folder and add your HTML and CSS files. Locate static URL settings and add static file dirs below:
+  ```python
+  STATICFILES_DIRS = (
+    os.path.join(BASE_DIR, 'static'),
+  )
+  STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
+  ```
+7. Add Procfile and add this:
+  ```
+web: gunicorn <project-name>.wsgi -b 0.0.0.0:8989 --timeout 120 
+  
+For example: 
+  
+web: gunicorn djangosample.wsgi -b 0.0.0.0:8989 --timeout 120
+  ```
+8. Then pip freeze requirements
+  ```sh
+  pip freeze > requirements.txt
+  ```
+  Reference:
+    https://github.com/jimcru21/porter-sample-django-non-docker
+
+## Deploy Django in Porter
+
+1. Click Web Service then Launch Template
+2. Name your application. ex. django-sample
+
+  ![image](https://user-images.githubusercontent.com/52728901/118363487-16c41280-b5c7-11eb-8abb-c3065b9bde76.png)
+  
+3. In Deployment Method. Connect git repo. After that, select repo. ( ex. porter-sample-django-non-docker )
+
+  ![image](https://user-images.githubusercontent.com/52728901/118363563-918d2d80-b5c7-11eb-8d72-200c68132e4e.png)
+
+4. Click Main then continue
+
+  ![image](https://user-images.githubusercontent.com/52728901/118363600-bda8ae80-b5c7-11eb-9ee7-9d6c821b4b34.png)
+
+  ![image](https://user-images.githubusercontent.com/52728901/118363620-cd27f780-b5c7-11eb-8d8e-3b7a5be6ad22.png)
+
+5. Click web then choose an image destination ( ex. mine is aws (see image) )
+
+  ![image](https://user-images.githubusercontent.com/52728901/118363671-0f513900-b5c8-11eb-8592-ce9ba44ea1f3.png)
+  
+  ![image](https://user-images.githubusercontent.com/52728901/118363692-2db73480-b5c8-11eb-8420-cf05a8cabf44.png)
+
+6. Then in Destination, just leave it default.
+7. In Additional settings, specify the container port that you use in gunicorn in Procfile ( ex. 8989). 
+   You can configure your domain, click Configure Custom Domain then put your desire domain name (im using the default porter domain)
+   
+   ![tempsnip](https://user-images.githubusercontent.com/52728901/118364073-8a671f00-b5c9-11eb-9b15-cfe53b1db7bf.png)
+
+8. In Environment. Put DJANGO_ALLOWED_HOSTS that we specify on django settings. Then input your domain that you put in Configure Custom Domain.
+
+    ![image](https://user-images.githubusercontent.com/52728901/118364222-28f38000-b5ca-11eb-9ce3-94b24f3f43b7.png)
+
+9. Click Deploy then wait for buildpack to finish and push to porter. (You can see it on your repository under the Action tab )
+
+  ![image](https://user-images.githubusercontent.com/52728901/118364697-209c4480-b5cc-11eb-8b06-d9a4a1a89143.png)

部分文件因文件數量過多而無法顯示