Prechádzať zdrojové kódy

merge with master and fix merge conflicts

Alexander Belanger 5 rokov pred
rodič
commit
f7ffba4175
72 zmenil súbory, kde vykonal 2553 pridanie a 921 odobranie
  1. 1 1
      .github/workflows/release.yaml
  2. 109 0
      CONTRIBUTING.md
  3. 9 4
      README.md
  4. 1 0
      cli/cmd/run.go
  5. 8 0
      cli/cmd/server.go
  6. 1 1
      cli/cmd/version.go
  7. 1 0
      cmd/app/main.go
  8. 1 0
      dashboard/package.json
  9. BIN
      dashboard/src/assets/Light Gradient 08.png
  10. BIN
      dashboard/src/assets/close-rounded.png
  11. BIN
      dashboard/src/assets/gradient.png
  12. 11 1
      dashboard/src/components/TabSelector.tsx
  13. 10 0
      dashboard/src/components/image-selector/TagList.tsx
  14. 0 2
      dashboard/src/components/repo-selector/ContentsList.tsx
  15. 13 9
      dashboard/src/components/values-form/CheckboxRow.tsx
  16. 319 0
      dashboard/src/components/values-form/FormDebugger.tsx
  17. 478 0
      dashboard/src/components/values-form/FormWrapper.tsx
  18. 9 8
      dashboard/src/components/values-form/InputRow.tsx
  19. 5 6
      dashboard/src/components/values-form/KeyValueArray.tsx
  20. 2 2
      dashboard/src/components/values-form/RangeSlider.tsx
  21. 103 79
      dashboard/src/components/values-form/ValuesForm.tsx
  22. 0 177
      dashboard/src/components/values-form/ValuesWrapper.tsx
  23. 14 1
      dashboard/src/main/Main.tsx
  24. 30 10
      dashboard/src/main/auth/Login.tsx
  25. 30 10
      dashboard/src/main/auth/Register.tsx
  26. 29 1
      dashboard/src/main/home/Home.tsx
  27. 0 2
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  28. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  29. 39 83
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  30. 193 60
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  31. 1 15
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  32. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  33. 174 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  34. 27 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  35. 96 39
      dashboard/src/main/home/dashboard/Dashboard.tsx
  36. 50 134
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  37. 0 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  38. 7 1
      dashboard/src/main/home/navbar/Navbar.tsx
  39. 7 2
      dashboard/src/main/home/new-project/NewProject.tsx
  40. 1 1
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  41. 1 1
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  42. 1 1
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  43. 10 2
      dashboard/src/main/home/provisioner/Provisioner.tsx
  44. 12 12
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  45. 78 31
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  46. 1 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  47. 3 1
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  48. 5 1
      dashboard/src/shared/Context.tsx
  49. 14 0
      dashboard/src/shared/api.tsx
  50. 8 0
      dashboard/src/shared/types.tsx
  51. 0 32
      docs/DEVELOPING.md
  52. 0 26
      docs/GCR.md
  53. 1 27
      go.mod
  54. 249 70
      go.sum
  55. 6 0
      internal/config/config.go
  56. 2 2
      internal/helm/grapher/object.go
  57. 1 1
      internal/helm/grapher/object_test.go
  58. 3 3
      internal/helm/grapher/relation_test.go
  59. 67 0
      internal/helm/postrenderer.go
  60. 10 2
      internal/integrations/ci/actions/actions.go
  61. 72 1
      internal/kubernetes/agent.go
  62. 15 12
      internal/models/templates.go
  63. 0 15
      scripts/release.sh
  64. 5 0
      server/api/api.go
  65. 28 0
      server/api/capability_handler.go
  66. 49 0
      server/api/k8s_handler.go
  67. 4 2
      server/api/release_handler.go
  68. 21 0
      server/router/router.go
  69. 10 0
      services/job_sidecar_container/Dockerfile
  70. 90 0
      services/job_sidecar_container/job_killer.sh
  71. 6 0
      services/job_sidecar_container/signal.sh
  72. 0 11
      staging.sh

+ 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

+ 109 - 0
CONTRIBUTING.md

@@ -0,0 +1,109 @@
+# 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.
+
+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. 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. 

+ 9 - 4
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
@@ -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)

+ 1 - 0
cli/cmd/run.go

@@ -163,6 +163,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

@@ -210,6 +210,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",

+ 1 - 0
cmd/app/main.go

@@ -107,6 +107,7 @@ func main() {
 		Repository: repo,
 		ServerConf: appConf.Server,
 		RedisConf:  &appConf.Redis,
+		CapConf: 	appConf.Capabilities,
 		DBConf:     appConf.Db,
 	})
 

+ 1 - 0
dashboard/package.json

@@ -29,6 +29,7 @@
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
+    "highlight.run": "^1.4.3",
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
     "js-yaml": "^3.14.0",

BIN
dashboard/src/assets/Light Gradient 08.png


BIN
dashboard/src/assets/close-rounded.png


BIN
dashboard/src/assets/gradient.png


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

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

+ 0 - 2
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -213,7 +213,6 @@ export default class ContentsList extends Component<PropsType, StateType> {
   };
 
   renderOverlay = () => {
-    console.log(this.props.procfilePath);
     if (this.props.procfilePath) {
       let processes = this.state.processes
         ? Object.keys(this.state.processes)
@@ -296,7 +295,6 @@ export default class ContentsList extends Component<PropsType, StateType> {
                 this.state.processes &&
                 Object.keys(this.state.processes).length > 0
               ) {
-                console.log("setting procfile");
                 this.props.setProcfilePath("./Procfile");
               }
             }}

+ 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;
 `;

+ 5 - 6
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -11,11 +11,10 @@ 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;
@@ -153,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);
@@ -275,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 })

+ 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"
         />

+ 103 - 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,102 +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) => {
@@ -195,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}
@@ -221,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)" },
@@ -238,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:
@@ -278,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;
-`;

+ 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

+ 30 - 10
dashboard/src/main/auth/Login.tsx

@@ -16,6 +16,7 @@ type StateType = {
   password: string;
   emailError: boolean;
   credentialError: boolean;
+  hasGithub: boolean;
 };
 
 export default class Login extends Component<PropsType, StateType> {
@@ -24,6 +25,7 @@ export default class Login extends Component<PropsType, StateType> {
     password: "",
     emailError: false,
     credentialError: false,
+    hasGithub: true,
   };
 
   handleKeyDown = (e: any) => {
@@ -36,6 +38,14 @@ 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({ hasGithub: res.data?.github });
+      })
+      .catch((err) => console.log(err));
   }
 
   componentWillUnmount() {
@@ -105,6 +115,25 @@ export default class Login extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
   };
 
+  renderGithubSection = () => {
+    if (this.state.hasGithub) {
+      return (
+        <>
+          <OAuthButton onClick={this.githubRedirect}>
+            <IconWrapper>
+              <Icon src={github} />
+              Log in with GitHub
+            </IconWrapper>
+          </OAuthButton>
+          <OrWrapper>
+            <Line />
+            <Or>or</Or>
+          </OrWrapper>
+        </>
+      );
+    }
+  };
+
   render() {
     let { email, password, credentialError, emailError } = this.state;
 
@@ -117,16 +146,7 @@ export default class Login extends Component<PropsType, StateType> {
           <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()}
             <DarkMatter />
             <InputWrapper>
               <Input

+ 30 - 10
dashboard/src/main/auth/Register.tsx

@@ -17,6 +17,7 @@ type StateType = {
   confirmPassword: string;
   emailError: boolean;
   confirmPasswordError: boolean;
+  hasGithub: boolean;
 };
 
 export default class Register extends Component<PropsType, StateType> {
@@ -26,6 +27,7 @@ export default class Register extends Component<PropsType, StateType> {
     confirmPassword: "",
     emailError: false,
     confirmPasswordError: false,
+    hasGithub: true,
   };
 
   handleKeyDown = (e: any) => {
@@ -34,6 +36,14 @@ 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 });
+      })
+      .catch((err) => console.log(err));
   }
 
   componentWillUnmount() {
@@ -106,6 +116,25 @@ export default class Register extends Component<PropsType, StateType> {
     }
   };
 
+  renderGithubSection = () => {
+    if (this.state.hasGithub) {
+      return (
+        <>
+          <OAuthButton onClick={this.githubRedirect}>
+            <IconWrapper>
+              <Icon src={github} />
+              Sign up with GitHub
+            </IconWrapper>
+          </OAuthButton>
+          <OrWrapper>
+            <Line />
+            <Or>or</Or>
+          </OrWrapper>
+        </>
+      );
+    }
+  };
+
   render() {
     let {
       email,
@@ -124,16 +153,7 @@ export default class Register extends Component<PropsType, StateType> {
           <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>
+            {this.renderGithubSection()}
             <DarkMatter />
             <InputWrapper>
               <Input

+ 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";

+ 1 - 1
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";
@@ -515,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;

+ 39 - 83
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";
@@ -47,8 +45,6 @@ type StateType = {
   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>>;
@@ -56,6 +52,7 @@ type StateType = {
   url: string | null;
   showDeleteOverlay: boolean;
   deleting: boolean;
+  formData: any;
 };
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
@@ -69,8 +66,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     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>>,
@@ -78,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)
@@ -105,14 +101,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           { currentChart: res.data, loading: false },
           res.data
         );
-        // // if the current tab is manifests or chart overview, update components as well
-        // if (this.state.currentTab == "graph" || this.state.currentTab == "list") {
-        //   this.updateComponents({ currentChart: res.data, loading: false }, currentChart);
-        // } else {
-        //   this.setState({ currentChart: res.data, loading: false }, () => {
-        //     this.updateTabs()
-        //   })
-        // }
       })
       .catch(console.log);
   };
@@ -348,15 +336,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       });
   };
 
-  renderTabContents = () => {
-    let {
-      currentTab,
-      podSelectors,
-      components,
-      showRevisions,
-      saveValuesStatus,
-      tabOptions,
-    } = this.state;
+  renderTabContents = (currentTab: string) => {
+    let { components, showRevisions } = this.state;
     let { setSidebar } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
@@ -400,56 +381,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) {
@@ -770,23 +712,29 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               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>
       </>
     );
@@ -795,6 +743,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;
@@ -978,6 +932,7 @@ const Title = styled.div`
   font-weight: 500;
   display: flex;
   align-items: center;
+  user-select: text;
 `;
 
 const TitleSection = styled.div`
@@ -1021,6 +976,7 @@ const StyledExpandedChart = styled.div`
   animation-fill-mode: forwards;
   padding: 25px;
   display: flex;
+  overflow: hidden;
   flex-direction: column;
 
   @keyframes floatIn {

+ 193 - 60
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,6 +57,8 @@ 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)
@@ -73,9 +82,27 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        this.setState({ currentChart: res.data, loading: false }, () => {
-          this.updateTabs();
-        });
+        let image = res.data?.config?.image?.repository;
+        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,
+            },
+            () => {
+              this.updateTabs();
+            }
+          );
+        } else {
+          this.setState({ currentChart: res.data, loading: false }, () => {
+            this.updateTabs();
+          });
+        }
       })
       .catch(console.log);
   };
@@ -149,6 +176,48 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     return ws;
   };
 
+  setupCronJobWebsocket = (chart: ChartType) => {
+    // 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 ws = new WebSocket(
+      `${protocol}://${process.env.API_SERVER}/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
+      ) {
+        let newestImage =
+          event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
+            ?.image;
+
+        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 +225,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.includes(":")) {
+        let splits = imageUrl.split(":");
+        imageUrl = splits[0];
+        tag = splits[1];
+      } else if (!tag) {
+        tag = "latest";
+      }
+
+      if (imageUrl) {
+        _.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,6 +254,22 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         _.set(values, key, config[key]);
       }
 
+      let imageUrl = this.state.newestImage;
+      let tag = null;
+
+      if (imageUrl.includes(":")) {
+        let splits = imageUrl.split(":");
+        imageUrl = splits[0];
+        tag = splits[1];
+      } else if (!tag) {
+        tag = "latest";
+      }
+
+      if (imageUrl) {
+        _.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),
@@ -229,23 +332,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}
             />
@@ -262,48 +384,16 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
           />
         );
       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 +402,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,7 +440,6 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
     let { currentChart } = this.state;
 
     window.analytics.track("Opened Chart", {
@@ -360,6 +449,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     this.getChartData(currentChart);
     this.getJobs(currentChart);
     this.setupJobWebsocket(currentChart);
+    this.setupCronJobWebsocket(currentChart);
   }
 
   handleUninstallChart = () => {
@@ -435,14 +525,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 +549,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 +764,7 @@ const StyledExpandedChart = styled.div`
   animation-fill-mode: forwards;
   padding: 25px;
   display: flex;
+  overflow: hidden;
   flex-direction: column;
 
   @keyframes floatIn {

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

@@ -179,20 +179,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 +272,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;

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

+ 27 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -29,10 +29,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",
       });
     }
   };
@@ -57,7 +61,15 @@ 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) => {
@@ -123,7 +135,6 @@ export default class Logs extends Component<PropsType, StateType> {
   }
 
   componentWillUnmount() {
-    console.log("log unmount");
     if (this.ws) {
       this.ws.close();
     }
@@ -174,6 +185,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;

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

+ 50 - 134
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) => {
@@ -211,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";
       }
     }
@@ -242,7 +243,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     _.set(values, "ingress.provider", provider);
     var url: string;
-
+    console.log("ok here", values);
     // 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) {
@@ -381,15 +382,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;
     }
@@ -413,53 +405,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
-          if (this.state.procfileProcess) {
-            metaState["container.command"] = 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: "" });
@@ -545,13 +490,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 {
@@ -580,20 +536,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" });
@@ -642,32 +602,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>
@@ -696,7 +630,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) =>
@@ -827,12 +771,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;
@@ -1054,12 +992,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;
@@ -1077,22 +1009,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;

+ 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={() =>

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

+ 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({

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

@@ -388,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;
+  }
 `;

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

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

+ 1 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -59,7 +59,7 @@ class ClusterSection extends Component<PropsType, StateType> {
             let saved = JSON.parse(
               localStorage.getItem(currentProject.id + "-cluster")
             );
-            if (saved !== "null") {
+            if (saved && saved !== "null") {
               // Ensures currentCluster isn't prematurely set (causes issues downstream)
               let loaded = false;
               for (let i = 0; i < clusters.length; i++) {

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

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

@@ -627,6 +627,10 @@ const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/users/${pathParams.id}`;
 });
 
+const getCapabilities = baseApi<{}, {}>("GET", () => {
+  return `/api/capabilities`;
+});
+
 const linkGithubProject = baseApi<
   {},
   {
@@ -784,6 +788,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,
@@ -817,6 +829,7 @@ export default {
   destroyDOKS,
   getBranchContents,
   getBranches,
+  getCapabilities,
   getChart,
   getCharts,
   getChartComponents,
@@ -864,4 +877,5 @@ export default {
   updateUser,
   updateConfigMap,
   upgradeChartValues,
+  stopJob,
 };

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

@@ -106,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;
   };
 }
@@ -169,3 +172,8 @@ export interface ActionConfigType {
   image_repo_uri: string;
   git_repo_id: number;
 }
+
+export interface CapabilityType {
+  github: boolean;
+  provisioner: boolean;
+}

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

+ 1 - 27
go.mod

@@ -94,30 +94,4 @@ require (
 	rsc.io/letsencrypt v0.0.3 // indirect
 	sigs.k8s.io/aws-iam-authenticator v0.5.2
 	sigs.k8s.io/yaml v1.2.0
-)
-
-// Used to pin the k8s library versions regardless of what other dependencies enforce
-// replace (
-// 	k8s.io/api => k8s.io/api v0.18.8
-// 	k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.18.8
-// 	k8s.io/apimachinery => k8s.io/apimachinery v0.18.8
-// 	k8s.io/apiserver => k8s.io/apiserver v0.18.8
-// 	k8s.io/cli-runtime => k8s.io/cli-runtime v0.18.8
-// 	k8s.io/client-go => k8s.io/client-go v0.18.8
-// 	k8s.io/cloud-provider => k8s.io/cloud-provider v0.18.8
-// 	k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.18.8
-// 	k8s.io/code-generator => k8s.io/code-generator v0.18.8
-// 	k8s.io/component-base => k8s.io/component-base v0.18.8
-// 	k8s.io/cri-api => k8s.io/cri-api v0.18.8
-// 	k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.18.8
-// 	k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.18.8
-// 	k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.18.8
-// 	k8s.io/kube-proxy => k8s.io/kube-proxy v0.18.8
-// 	k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.18.8
-// 	k8s.io/kubectl => k8s.io/kubectl v0.18.8
-// 	k8s.io/kubelet => k8s.io/kubelet v0.18.8
-// 	k8s.io/kubernetes => k8s.io/kubernetes v1.16.8
-// 	k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.18.8
-// 	k8s.io/metrics => k8s.io/metrics v0.18.8
-// 	k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.18.8
-// )
+)

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 249 - 70
go.sum


+ 6 - 0
internal/config/config.go

@@ -14,6 +14,7 @@ type Conf struct {
 	Db     DBConf
 	K8s    K8sConf
 	Redis  RedisConf
+	Capabilities CapConf
 }
 
 // ServerConf is the server configuration
@@ -71,6 +72,11 @@ type K8sConf struct {
 	IsTesting bool `env:"K8S_IS_TESTING,default=false"`
 }
 
+type CapConf struct {
+	Provisioner bool `env:"PROVISIONER_ENABLED,default=true"`
+	Github bool `env:"GITHUB_ENABLED,default=true"`
+}
+
 // FromEnv generates a configuration from environment variables
 func FromEnv() *Conf {
 	var c Conf

+ 2 - 2
internal/helm/grapher/object.go

@@ -12,7 +12,7 @@ type Object struct {
 
 // ParseObjs parses a k8s object from a single-document yaml
 // and returns an array of objects that includes its children.
-func ParseObjs(objs []map[string]interface{}) []Object {
+func ParseObjs(objs []map[string]interface{}, releaseNamespace string) []Object {
 	objArr := []Object{}
 
 	for i, obj := range objs {
@@ -27,7 +27,7 @@ func ParseObjs(objs []map[string]interface{}) []Object {
 		namespace := getField(obj, "metadata", "namespace")
 
 		if namespace == nil {
-			namespace = "default"
+			namespace = releaseNamespace
 		}
 
 		if name == nil {

+ 1 - 1
internal/helm/grapher/object_test.go

@@ -136,7 +136,7 @@ func TestParseObj(t *testing.T) {
 		}
 
 		yamlArr := grapher.ImportMultiDocYAML(file)
-		objects := grapher.ParseObjs(yamlArr)
+		objects := grapher.ParseObjs(yamlArr, "default")
 
 		for i, o := range objects {
 			if k8sObj.Expected[i].Kind != o.Kind {

+ 3 - 3
internal/helm/grapher/relation_test.go

@@ -139,7 +139,7 @@ func TestControlRels(t *testing.T) {
 		}
 
 		yamlArr := grapher.ImportMultiDocYAML(file)
-		objects := grapher.ParseObjs(yamlArr)
+		objects := grapher.ParseObjs(yamlArr, "default")
 		parsed := grapher.ParsedObjs{
 			Objects: objects,
 		}
@@ -192,7 +192,7 @@ func TestLabelRels(t *testing.T) {
 		}
 
 		yamlArr := grapher.ImportMultiDocYAML(file)
-		objects := grapher.ParseObjs(yamlArr)
+		objects := grapher.ParseObjs(yamlArr, "default")
 		parsed := grapher.ParsedObjs{
 			Objects: objects,
 		}
@@ -241,7 +241,7 @@ func TestSpecRels(t *testing.T) {
 		}
 
 		yamlArr := grapher.ImportMultiDocYAML(file)
-		objects := grapher.ParseObjs(yamlArr)
+		objects := grapher.ParseObjs(yamlArr, "default")
 		parsed := grapher.ParsedObjs{
 			Objects: objects,
 		}

+ 67 - 0
internal/helm/postrenderer.go

@@ -101,6 +101,72 @@ func (d *DockerSecretsPostRenderer) Run(
 		return renderedManifests, nil
 	}
 
+	// Check to see if the resources loaded into the postrenderer contain a configmap
+	// with a manifest that needs secrets generation as well. If this is the case, create and
+	// run another postrenderer for this specific manifest.
+	for i, res := range d.resources {
+		kindVal, hasKind := res["kind"]
+		if !hasKind {
+			continue
+		}
+
+		kind, ok := kindVal.(string)
+
+		if !ok {
+			continue
+		}
+
+		if kind == "ConfigMap" {
+			labelVal := getNestedResource(res, "metadata", "labels")
+
+			if labelVal == nil {
+				continue
+			}
+
+			porterLabelVal, exists := labelVal["getporter.dev/manifest"]
+
+			if !exists {
+				continue
+			}
+
+			if labelValStr, ok := porterLabelVal.(string); ok && labelValStr == "true" {
+				data := getNestedResource(res, "data")
+				manifestData, exists := data["manifest"]
+
+				if !exists {
+					continue
+				}
+
+				manifestDataStr, ok := manifestData.(string)
+
+				if !ok {
+					continue
+				}
+
+				dCopy := &DockerSecretsPostRenderer{
+					Cluster:    d.Cluster,
+					Repo:       d.Repo,
+					Agent:      d.Agent,
+					Namespace:  d.Namespace,
+					DOAuth:     d.DOAuth,
+					registries: d.registries,
+					podSpecs:   make([]resource, 0),
+					resources:  make([]resource, 0),
+				}
+
+				newData, err := dCopy.Run(bytes.NewBufferString(manifestDataStr))
+
+				if err != nil {
+					continue
+				}
+
+				data["manifest"] = string(newData.Bytes())
+
+				d.resources[i] = res
+			}
+		}
+	}
+
 	// create the necessary secrets
 	secrets, err := d.Agent.CreateImagePullSecrets(
 		d.Repo,
@@ -425,6 +491,7 @@ func getPodSpecFromResource(kind string, res resource) resource {
 
 func getNestedResource(res resource, keys ...string) resource {
 	curr := res
+
 	var ok bool
 
 	for _, key := range keys {

+ 10 - 2
internal/integrations/ci/actions/actions.go

@@ -324,13 +324,21 @@ func (g *GithubActions) commitGithubFile(
 	filepath := ".github/workflows/" + filename
 	sha := ""
 
+	branch := g.GitBranch
+
+	if branch == "" {
+		branch = g.defaultBranch
+	}
+
 	// get contents of a file if it exists
 	fileData, _, _, _ := client.Repositories.GetContents(
 		context.TODO(),
 		g.GitRepoOwner,
 		g.GitRepoName,
 		filepath,
-		&github.RepositoryContentGetOptions{},
+		&github.RepositoryContentGetOptions{
+			Ref: branch,
+		},
 	)
 
 	if fileData != nil {
@@ -340,7 +348,7 @@ func (g *GithubActions) commitGithubFile(
 	opts := &github.RepositoryContentFileOptions{
 		Message: github.String(fmt.Sprintf("Create %s file", filename)),
 		Content: contents,
-		Branch:  github.String(g.defaultBranch),
+		Branch:  github.String(branch),
 		SHA:     &sha,
 		Committer: &github.CommitAuthor{
 			Name:  github.String("Porter Bot"),

+ 72 - 1
internal/kubernetes/agent.go

@@ -34,11 +34,15 @@ import (
 	v1beta1 "k8s.io/api/extensions/v1beta1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
 	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/remotecommand"
 
 	"github.com/porter-dev/porter/internal/config"
 )
@@ -347,19 +351,34 @@ func (a *Agent) DeletePod(namespace string, name string) error {
 
 // GetPodLogs streams real-time logs from a given pod.
 func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+	// get the pod to read in the list of contains
+	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil {
+		return fmt.Errorf("Cannot get pod %s: %s", name, err.Error())
+	}
+
+	container := pod.Spec.Containers[0].Name
+
 	tails := int64(400)
 
 	// follow logs
 	podLogOpts := v1.PodLogOptions{
 		Follow:    true,
 		TailLines: &tails,
+		Container: container,
 	}
+
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
 
 	podLogs, err := req.Stream(context.TODO())
 
 	if err != nil {
-		return fmt.Errorf("Cannot open log stream for pod %s", name)
+		return fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
 	}
 	defer podLogs.Close()
 
@@ -410,6 +429,55 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 	}
 }
 
+// StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
+func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
+	jobPods, err := a.GetJobPods(namespace, name)
+
+	if err != nil {
+		return err
+	}
+
+	podName := jobPods[0].ObjectMeta.Name
+
+	restConf, err := a.RESTClientGetter.ToRESTConfig()
+
+	restConf.GroupVersion = &schema.GroupVersion{
+		Group:   "api",
+		Version: "v1",
+	}
+
+	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
+
+	restClient, err := rest.RESTClientFor(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	req := restClient.Post().
+		Resource("pods").
+		Name(podName).
+		Namespace(namespace).
+		SubResource("exec")
+
+	req.Param("command", "./signal.sh")
+	req.Param("container", "sidecar")
+	req.Param("stdin", "true")
+	req.Param("stdout", "false")
+	req.Param("tty", "false")
+
+	exec, err := remotecommand.NewSPDYExecutor(restConf, "POST", req.URL())
+
+	if err != nil {
+		return err
+	}
+
+	return exec.Stream(remotecommand.StreamOptions{
+		Tty:   false,
+		Stdin: strings.NewReader("./signal.sh"),
+	})
+}
+
 // StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
 // TODO: Support Jobs
 func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error {
@@ -417,6 +485,7 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 		a.Clientset,
 		0,
 	)
+
 	var informer cache.SharedInformer
 
 	// Spins up an informer depending on kind. Convert to lowercase for robustness
@@ -431,6 +500,8 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 		informer = factory.Apps().V1().DaemonSets().Informer()
 	case "job":
 		informer = factory.Batch().V1().Jobs().Informer()
+	case "cronjob":
+		informer = factory.Batch().V1beta1().CronJobs().Informer()
 	}
 
 	stopper := make(chan struct{})

+ 15 - 12
internal/models/templates.go

@@ -42,18 +42,21 @@ type FormSection struct {
 
 // FormContent is a form's atomic unit
 type FormContent struct {
-	Context  *FormContext `yaml:"context" json:"context"`
-	Type     string       `yaml:"type" json:"type"`
-	Label    string       `yaml:"label" json:"label"`
-	Required bool         `json:"required"`
-	Name     string       `yaml:"name,omitempty" json:"name,omitempty"`
-	Variable string       `yaml:"variable,omitempty" json:"variable,omitempty"`
-	Value    interface{}  `yaml:"value,omitempty" json:"value,omitempty"`
-	Settings struct {
-		Default     interface{} `yaml:"default,omitempty" json:"default,omitempty"`
-		Unit        interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
-		Options     interface{} `yaml:"options,omitempty" json:"options,omitempty"`
-		Placeholder string      `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
+	Context     *FormContext `yaml:"context" json:"context"`
+	Type        string       `yaml:"type" json:"type"`
+	Label       string       `yaml:"label" json:"label"`
+	Required    bool         `json:"required"`
+	Name        string       `yaml:"name,omitempty" json:"name,omitempty"`
+	Variable    string       `yaml:"variable,omitempty" json:"variable,omitempty"`
+	Placeholder string       `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
+	Value       interface{}  `yaml:"value,omitempty" json:"value,omitempty"`
+	Settings    struct {
+		Default            interface{} `yaml:"default,omitempty" json:"default,omitempty"`
+		Unit               interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
+		OmitUnitFromValue  bool        `yaml:"omitUnitFromValue,omitempty" json:"omitUnitFromValue,omitempty"`
+		DisableAfterLaunch bool        `yaml:"disableAfterLaunch,omitempty" json:"disableAfterLaunch,omitempty"`
+		Options            interface{} `yaml:"options,omitempty" json:"options,omitempty"`
+		Placeholder        string      `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
 	} `yaml:"settings,omitempty" json:"settings,omitempty"`
 }
 

+ 0 - 15
scripts/release.sh

@@ -1,15 +0,0 @@
-#!/bin/bash
-
-# Step 0 -- ensure that:
-# (1) GITHUB_TOKEN exists as an env variable
-# (2) Apple ID password exists in keychain
-
-# Step 1 -- build for linux/windows inside a docker container
-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 \
-mailchain/goreleaser-xcgo "--rm-dist --skip-validate"
-
-# Step 2 -- build for MacOS using notarization tool
-goreleaser --rm-dist --config .darwin.goreleaser.yml --skip-validate

+ 5 - 0
server/api/api.go

@@ -42,6 +42,7 @@ type AppConfig struct {
 	ServerConf config.ServerConf
 	RedisConf  *config.RedisConf
 	DBConf     config.DBConf
+	CapConf config.CapConf
 
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
@@ -71,6 +72,9 @@ type App struct {
 	// config for db
 	DBConf config.DBConf
 
+	// config for capabilities
+	CapConf config.CapConf
+
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
@@ -102,6 +106,7 @@ func New(conf *AppConfig) (*App, error) {
 		ServerConf: conf.ServerConf,
 		RedisConf:  conf.RedisConf,
 		DBConf:     conf.DBConf,
+		CapConf: 	conf.CapConf,
 		TestAgents: conf.TestAgents,
 		db:         conf.DB,
 		validator:  validator,

+ 28 - 0
server/api/capability_handler.go

@@ -0,0 +1,28 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+// CapabilitiesExternal represents the Capabilities struct that will be sent over REST
+type CapabilitiesExternal struct {
+	Provisioner bool `json:"provisioner"`
+	GitHub bool	`json:"github"`
+}
+
+// HandleGetCapabilities gets the capabilities of the server
+func (app *App) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
+
+	cap := app.CapConf
+
+	capExternal := &CapabilitiesExternal{
+		Provisioner: cap.Provisioner,
+		GitHub: cap.Github,
+	}
+
+	if err := json.NewEncoder(w).Encode(capExternal); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}

+ 49 - 0
server/api/k8s_handler.go

@@ -691,6 +691,55 @@ func (app *App) HandleListJobsByChart(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleStopJob stops a running job
+func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	err = agent.StopJobWithJobSidecar(namespace, name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleListJobPods lists all pods belonging to a specific job
 func (app *App) HandleListJobPods(w http.ResponseWriter, r *http.Request) {
 	// get path parameters

+ 4 - 2
server/api/release_handler.go

@@ -258,7 +258,7 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	}
 
 	yamlArr := grapher.ImportMultiDocYAML([]byte(release.Manifest))
-	objects := grapher.ParseObjs(yamlArr)
+	objects := grapher.ParseObjs(yamlArr, release.Namespace)
 
 	parsed := grapher.ParsedObjs{
 		Objects: objects,
@@ -879,7 +879,9 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 
 	gitAction := release.GitActionConfig
 
-	if gitAction.ID != 0 && repository == "porterdev/hello-porter" {
+	if gitAction.ID != 0 && (repository == "porterdev/hello-porter" || repository == "public.ecr.aws/o1j4x7p4/hello-porter") {
+		repository = gitAction.ImageRepoURI
+	} else if gitAction.ID != 0 && (repository == "porterdev/hello-porter-job" || repository == "public.ecr.aws/o1j4x7p4/hello-porter-job") {
 		repository = gitAction.ImageRepoURI
 	}
 

+ 21 - 0
server/router/router.go

@@ -1338,6 +1338,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/jobs/{namespace}/{name}/stop",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleStopJob, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/subdomain routes
 		r.Method(
 			"POST",
@@ -1352,6 +1366,13 @@ func New(a *api.App) *chi.Mux {
 				mw.ReadAccess,
 			),
 		)
+
+		// capabilities
+		r.Method(
+			"GET",
+			"/capabilities",
+			http.HandlerFunc(a.HandleGetCapabilities),
+		)
 	})
 
 	staticFilePath := a.ServerConf.StaticFilePath

+ 10 - 0
services/job_sidecar_container/Dockerfile

@@ -0,0 +1,10 @@
+FROM alpine:latest
+
+# fetch procps for ps command, coreutils for tail with -pid flag
+RUN apk --no-cache add procps coreutils
+
+COPY *.sh .
+
+RUN ["chmod", "+x", "./job_killer.sh", "./signal.sh"]
+
+ENTRYPOINT ["./job_killer.sh"]

+ 90 - 0
services/job_sidecar_container/job_killer.sh

@@ -0,0 +1,90 @@
+#!/bin/sh
+
+# Usage: job_killer.sh [-c]? [grace_period_seconds] [process_pattern]
+#
+# This script waits for a termination signal and gracefully terminates another process before exiting. 
+# 
+# Attempts to gracefully kill a process by sending SIGTERM to the first process that matches 
+# the pattern. If "-c" is set, it will also signal all child processes of the main process. 
+# All processes are forcibly killed if they have not exited after the grace period. 
+#
+# Example: if process that should be killed has start command "./run_job.sh", and grace 
+# period should be 30s, would run "./job_killer.sh 30 ./run_job.sh".
+
+kill_child_procs=false
+
+while getopts ":c" opt; do
+  case $opt in
+    c)
+      kill_child_procs=true
+  esac
+done
+
+if $kill_child_procs
+then
+  grace_period_seconds=$2
+  target=$3
+else
+  grace_period_seconds=$1
+  target=$2
+fi  
+
+pattern="$(printf '[%s]%s' $(echo $target | cut -c 1) $(echo $target | cut -c 2-))"
+
+graceful_shutdown() {
+    echo "starting graceful shutdown..."
+
+    local timeout=$1
+
+    echo "searching for process pattern: $pattern"
+
+    local target_pid_arr=$(ps x | grep -v './job_killer.sh' | grep "$pattern" | awk '{ printf "%d ", $1 }' | sort)
+    local target_pid=$target_pid_arr
+    local list="$target_pid"
+
+    # request graceful shutdown from target_pid
+    kill -0 ${target_pid} 2>/dev/null && kill -TERM ${target_pid}
+
+    if $kill_child_procs
+    then
+        for c in $(ps -o pid= --ppid $target_pid); do
+          # request graceful shutdown of all children, and append to process list
+          kill -0 $c 2>/dev/null && kill -TERM $c && list="$list $c" || true
+        done
+    fi
+
+    if [ -n "$target_pid" ]; then
+        # schedule hard kill after timeout
+        (sleep ${timeout}; kill -9 -${target_pid} 2>/dev/null || true) &
+        local killer=${!}
+
+        # wait for processes to finish
+        for c in $list; do
+          echo "waiting for process $c"
+          tail --pid=$c -f /dev/null 
+        done
+
+        wait ${list} 2>/dev/null || true
+
+        # children exited gracefully - cancel timer
+        sleep 0.1 && kill -9 ${killer} 2>/dev/null && target_pid="" || true
+    fi
+
+    [ -z "$target_pid" ] && echo "Exit Gracefully (0)" && exit 0 || echo "Dirty Exit (1)" && exit 1
+}
+
+trap 'graceful_shutdown $grace_period_seconds $target' SIGTERM SIGINT SIGHUP
+
+echo "waiting for job to start..."
+
+sleep 10
+
+target_pid_arr=$(ps x | grep -v './job_killer.sh' | grep "$pattern" | awk '{ printf "%d ", $1 }' | sort)
+target_pid=$target_pid_arr
+
+if [ -n "$target_pid" ]; then
+    tail --pid=$target_pid -f /dev/null &
+    child=$!
+
+    wait "$child"
+fi

+ 6 - 0
services/job_sidecar_container/signal.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+
+# Sends termination signal to job_killer.sh, which triggers the job shutdown process.
+
+pid=$(ps x | grep "[.]/job_killer.sh" | awk '{ printf "%d ", $1 }'); 
+kill -TERM $pid

+ 0 - 11
staging.sh

@@ -1,11 +0,0 @@
-{
-export API_SERVER_CONTAINER=porter-server-657f5c594c-nvdd2; 
-export WEBPACK_SERVER_CONTAINER=porter-webpack-64d48578b5-vnk7x; 
-kubectl port-forward $API_SERVER_CONTAINER 8081:8080 & 
-kubectl port-forward $WEBPACK_SERVER_CONTAINER 8082:8080 & 
-devspace sync --upload-only --container-path /webpack --local-path ./dashboard/ --pod $WEBPACK_SERVER_CONTAINER & 
-devspace sync --upload-only --exclude dashboard --pod $API_SERVER_CONTAINER & 
-kubectl logs $API_SERVER_CONTAINER -f & 
-kubectl logs $WEBPACK_SERVER_CONTAINER -f & 
-nginx -c $(pwd)/docker/nginx_remote.conf;
-}

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov