Przeglądaj źródła

Merge branch 'master' of https://github.com/porter-dev/porter into beta.3.key-rotation

mergin
Alexander Belanger 5 lat temu
rodzic
commit
b54d655d3c
71 zmienionych plików z 2784 dodań i 1043 usunięć
  1. 1 0
      .gitignore
  2. 11 4
      README.md
  3. BIN
      dashboard/src/assets/github-icon.png
  4. 1 2
      dashboard/src/components/ResourceTab.tsx
  5. 1 1
      dashboard/src/components/SaveButton.tsx
  6. 37 17
      dashboard/src/components/Selector.tsx
  7. 1 1
      dashboard/src/components/image-selector/ImageSelector.tsx
  8. 11 1
      dashboard/src/components/repo-selector/BranchList.tsx
  9. 28 4
      dashboard/src/components/repo-selector/ContentsList.tsx
  10. 99 0
      dashboard/src/components/repo-selector/NewGHAction.tsx
  11. 121 19
      dashboard/src/components/repo-selector/RepoSelector.tsx
  12. 2 3
      dashboard/src/components/values-form/InputRow.tsx
  13. 6 4
      dashboard/src/components/values-form/SelectRow.tsx
  14. 15 21
      dashboard/src/components/values-form/ValuesForm.tsx
  15. 13 0
      dashboard/src/components/values-form/ValuesWrapper.tsx
  16. 9 3
      dashboard/src/main/Login.tsx
  17. 6 2
      dashboard/src/main/Register.tsx
  18. 269 150
      dashboard/src/main/home/Home.tsx
  19. 2 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  20. 80 26
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  21. 0 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  22. 62 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  23. 4 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  24. 4 5
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  25. 4 4
      dashboard/src/main/home/dashboard/ClusterPlaceholderContainer.tsx
  26. 31 22
      dashboard/src/main/home/dashboard/Dashboard.tsx
  27. 4 2
      dashboard/src/main/home/integrations/IntegrationList.tsx
  28. 2 1
      dashboard/src/main/home/integrations/Integrations.tsx
  29. 37 1
      dashboard/src/main/home/integrations/integration-form/GCRForm.tsx
  30. 81 0
      dashboard/src/main/home/modals/Modal.tsx
  31. 60 12
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  32. 1 1
      dashboard/src/main/home/new-project/NewProject.tsx
  33. 119 106
      dashboard/src/main/home/project-settings/InviteList.tsx
  34. 74 107
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  35. 42 31
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  36. 283 0
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  37. 221 19
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  38. 1 1
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  39. 22 4
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  40. 105 24
      dashboard/src/main/home/provisioner/ProvisionerStatus.tsx
  41. 7 12
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  42. 3 0
      dashboard/src/main/home/sidebar/Sidebar.tsx
  43. 4 9
      dashboard/src/main/home/templates/Templates.tsx
  44. 1 1
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  45. 52 5
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  46. 23 11
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  47. 12 0
      dashboard/src/main/home/templates/hardcodedNameDict.tsx
  48. 11 4
      dashboard/src/shared/Context.tsx
  49. 302 208
      dashboard/src/shared/api.tsx
  50. 26 5
      dashboard/src/shared/common.tsx
  51. 15 25
      dashboard/src/shared/feedback.tsx
  52. 11 2
      dashboard/src/shared/types.tsx
  53. 23 0
      helm/.helmignore
  54. 23 0
      helm/Chart.yaml
  55. 21 0
      helm/templates/NOTES.txt
  56. 63 0
      helm/templates/_helpers.tpl
  57. 60 0
      helm/templates/deployment.yaml
  58. 28 0
      helm/templates/hpa.yaml
  59. 43 0
      helm/templates/ingress.yaml
  60. 15 0
      helm/templates/service.yaml
  61. 12 0
      helm/templates/serviceaccount.yaml
  62. 15 0
      helm/templates/tests/test-connection.yaml
  63. 79 0
      helm/values.yaml
  64. 1 1
      internal/config/config.go
  65. 0 1
      internal/forms/release.go
  66. 4 2
      internal/models/templates.go
  67. 25 40
      server/api/invite_handler.go
  68. 3 84
      server/api/release_handler.go
  69. 1 1
      server/api/release_handler_test.go
  70. 31 18
      server/api/user_handler.go
  71. 0 6
      server/router/router.go

+ 1 - 0
.gitignore

@@ -9,6 +9,7 @@ gon.hcl
 internal/local_templates
 gon*.hcl
 *prod.Dockerfile
+staging.sh
 
 # Local .terraform directories
 **/.terraform/*

+ 11 - 4
README.md

@@ -1,5 +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)
+[![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/MhYNuWwqum)
+
 
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
 
@@ -18,9 +19,9 @@ Porter brings the simplicity of a traditional PaaS to your own cloud provider wh
 ## Features
 ### Basics
 - One-click provisioning of a Kubernetes cluster in your own cloud console
-  - AWS
-  - 🚧 GCP
-  - 🚧 Digital Ocean
+  - AWS
+  - GCP
+  - Digital Ocean
   
 - Simple deploy of any public or private Docker image
 
@@ -74,6 +75,12 @@ For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/do
 
 5. From the Templates tab on the Dashboard, select the Docker template. Click on the image you have just pushed, configure the port, then hit deploy.
 
+## Keep Updated
+We are iterating fast and will be regularly announcing new releases in this repository. If you'd like to follow our progress, please watch the repo for releases (**Watch > Custom > Releases**) and leave us a star!
+
+## Want Help?
+We are always hanging out in our [Discord community](https://discord.gg/MhYNuWwqum). Join us there if you need help or have any questions!
+
 ## Want to Help?
 We welcome all contributions. Submit an issue or a pull request to help us improve Porter!
 ![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)

BIN
dashboard/src/assets/github-icon.png


+ 1 - 2
dashboard/src/components/ResourceTab.tsx

@@ -176,7 +176,6 @@ const Tooltip = styled.div`
 `;
 
 const ExpandWrapper = styled.div`
-  overflow: hidden;
 `;
 
 const ResourceHeader = styled.div`
@@ -213,7 +212,7 @@ const Metadata = styled.div`
   display: flex;
   align-items: center;
   position: relative;
-  max-width: ${(props: { hasStatus: boolean }) => props.hasStatus ? 'calc(100% - 50px)' : '100%'};
+  max-width: ${(props: { hasStatus: boolean }) => props.hasStatus ? 'calc(100% - 20px)' : '100%'};
 `;
 
 const Status = styled.div`

+ 1 - 1
dashboard/src/components/SaveButton.tsx

@@ -123,7 +123,7 @@ const ButtonWrapper = styled.div`
 `;
 
 const Button = styled.button`
-  height: 40px;
+  height: 35px;
   font-size: 13px;
   font-weight: 500;
   font-family: 'Work Sans', sans-serif;

+ 37 - 17
dashboard/src/components/Selector.tsx

@@ -21,6 +21,26 @@ export default class Selector extends Component<PropsType, StateType> {
     expanded: false
   }
 
+  wrapperRef: any = React.createRef();
+  parentRef: any = React.createRef();
+
+  componentDidMount() {
+    document.addEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  handleClickOutside = (event: any) => {
+    if (
+      (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target)) &&
+      (this.parentRef && this.parentRef.current && !this.parentRef.current.contains(event.target))
+    ) {
+      this.setState({ expanded: false })
+    }
+  }
+
   handleOptionClick = (option: { value: string, label: string }) => {
     this.props.setActiveValue(option.value);
     this.props.closeOverlay ? null : this.setState({ expanded: false });
@@ -53,17 +73,15 @@ export default class Selector extends Component<PropsType, StateType> {
   renderDropdown = () => {
     if (this.state.expanded) {
       return (
-        <div>
-          {this.props.closeOverlay ? <CloseOverlay onClick={() => this.setState({ expanded: false })} /> : null}
-          <Dropdown
-            dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
-            dropdownMaxHeight={this.props.dropdownMaxHeight}
-            onClick={() => this.setState({ expanded: false })}
-          >
-            {this.renderDropdownLabel()}
-            {this.renderOptionList()}
-          </Dropdown>
-        </div>
+        <Dropdown
+          ref={this.wrapperRef}
+          dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
+          dropdownMaxHeight={this.props.dropdownMaxHeight}
+          onClick={() => this.setState({ expanded: false })}
+        >
+          {this.renderDropdownLabel()}
+          {this.renderOptionList()}
+        </Dropdown>
       )
     }
   }
@@ -78,8 +96,9 @@ export default class Selector extends Component<PropsType, StateType> {
   render() {
     let { activeValue } = this.props;
     return (
-      <StyledSelector>
+      <StyledSelector width={this.props.width}>
         <MainSelector
+          ref={this.parentRef}
           onClick={() => this.setState({ expanded: !this.state.expanded })}
           expanded={this.state.expanded}
           width={this.props.width}
@@ -100,7 +119,7 @@ const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  z-index: 999;
+  z-index: 0;
 `;
 
 const DropdownLabel = styled.div`
@@ -114,7 +133,7 @@ const Option = styled.div`
   width: 100%;
   border-top: 1px solid #00000000;
   border-bottom: 1px solid ${(props: { selected: boolean, lastItem: boolean }) => props.lastItem ? '#ffffff00' : '#ffffff15'};
-  height: 35px;
+  height: 37px;
   font-size: 13px;
   padding-top: 9px;
   align-items: center;
@@ -146,7 +165,7 @@ const Dropdown = styled.div`
   top: calc(100% + 5px);
   background: #26282f;
   width: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownWidth};
-  max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownMaxHeight ? props.dropdownMaxHeight : '300px'};
+  max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownMaxHeight || '300px'};
   border-radius: 3px;
   z-index: 999;
   overflow-y: auto;
@@ -154,13 +173,14 @@ const Dropdown = styled.div`
   box-shadow: 0 8px 20px 0px #00000088;
 `;
 
-const StyledSelector = styled.div`
+const StyledSelector = styled.div<{ width: string }>`
   position: relative;
+  width: ${props => props.width};
 `;
 
 const MainSelector = styled.div`
   width: ${(props: { expanded: boolean, width: string, height?: string }) => props.width};
-  height: ${(props: { expanded: boolean, width: string, height?: string }) => props.height ? props.height : '30px'};
+  height: ${(props: { expanded: boolean, width: string, height?: string }) => props.height ? props.height : '35px'};
   border: 1px solid #ffffff55;
   font-size: 13px;
   padding: 5px 10px;

+ 1 - 1
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -62,7 +62,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
               if (err) {
                 errors.push(1);
               } else {
-
+                res.data.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
                 // Loop over found image repositories
                 let newImg = res.data.map((img: any) => {
                   if (this.props.selectedImageUrl === img.uri) {

+ 11 - 1
dashboard/src/components/repo-selector/BranchList.tsx

@@ -3,11 +3,14 @@ import styled from 'styled-components';
 import branch_icon from '../../assets/branch.png';
 
 import api from '../../shared/api';
+import { Context } from '../../shared/Context';
 
 import Loading from '../Loading';
 
 type PropsType = {
+  grid: number,
   repoName: string,
+  owner: string,
   setSelectedBranch: (x: string) => void,
   selectedBranch: string
 };
@@ -26,13 +29,18 @@ export default class BranchList extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
+    let { currentProject } = this.context;
 
     // Get branches
     api.getBranches('<token>', {}, {
+      project_id: currentProject.id,
+      git_repo_id: this.props.grid,
       kind: 'github',
-      repo: this.props.repoName
+      owner: this.props.owner,
+      name: this.props.repoName,
     }, (err: any, res: any) => {
       if (err) {
+        console.log(err);
         this.setState({ loading: false, error: true });
       } else {
         this.setState({ branches: res.data, loading: false, error: false });
@@ -71,6 +79,8 @@ export default class BranchList extends Component<PropsType, StateType> {
   }
 }
 
+BranchList.contextType = Context;
+
 const BranchName = styled.div`
   display: flex;
   width: 100%;

+ 28 - 4
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -6,15 +6,19 @@ import folder from '../../assets/folder.svg';
 import info from '../../assets/info.svg';
 
 import api from '../../shared/api';
+import { Context } from '../../shared/Context';
 import { FileType } from '../../shared/types';
 
 import Loading from '../Loading';
 
 type PropsType = {
+  grid: number,
   repoName: string,
+  owner: string,
   selectedBranch: string,
   subdirectory: string,
   setSubdirectory: (x: string) => void,
+  setDockerfile: () => void,
 };
 
 type StateType = {
@@ -31,10 +35,15 @@ export default class ContentsList extends Component<PropsType, StateType> {
   }
 
   updateContents = () => {
+    let { currentProject } = this.context;
+
     // Get branch contents
     api.getBranchContents('<token>', { dir: this.props.subdirectory }, {
+      project_id: currentProject.id,
+      git_repo_id: this.props.grid,
       kind: 'github',
-      repo: this.props.repoName,
+      owner: this.props.owner,
+      name: this.props.repoName,
       branch: this.props.selectedBranch
     }, (err: any, res: any) => {
       if (err) {
@@ -91,6 +100,19 @@ export default class ContentsList extends Component<PropsType, StateType> {
         );
       }
 
+      if (fileName === 'Dockerfile') {
+        return (
+          <FileItem
+            key={i}
+            lastItem={i === contents.length - 1}
+            isADocker
+            onClick={() => this.props.setDockerfile()}
+          >
+            <img src={file} />
+            {fileName}
+          </FileItem>
+        );
+      }
       return (
         <FileItem
           key={i}
@@ -145,6 +167,8 @@ export default class ContentsList extends Component<PropsType, StateType> {
   }
 }
 
+ContentsList.contextType = Context;
+
 const BackLabel = styled.div`
   font-size: 16px;
   padding-left: 16px;
@@ -180,10 +204,10 @@ const Item = styled.div`
 `;
 
 const FileItem = styled(Item)`
-  cursor: default;
-  color: #ffffff55;
+  cursor: ${(props: {isADocker?: boolean}) => props.isADocker ? 'pointer' : 'default'};
+  color: ${(props: {isADocker?: boolean}) => props.isADocker ? '#fff' : '#ffffff55'};
   :hover {
-    background: #ffffff11;
+    background: ${(props: {isADocker?: boolean}) => props.isADocker ? '#ffffff22' : '#ffffff11'};
   }
 `;
 

+ 99 - 0
dashboard/src/components/repo-selector/NewGHAction.tsx

@@ -0,0 +1,99 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ChartType } from '../../shared/types';
+import api from '../../shared/api';
+import { Context } from '../../shared/Context';
+import InputRow from '../../components/values-form/InputRow';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  repoName: string,
+  dockerPath: string,
+  grid: number,
+  chart: ChartType,
+  imgURL: string,
+  setURL: (x: string) => void,
+};
+
+type StateType = {
+  trueDockerPath: string,
+  loading: boolean,
+  error: boolean,
+};
+
+export default class NewGHAction extends Component<PropsType, StateType> {
+  state = {
+    dockerRepo: '',
+    trueDockerPath: this.props.dockerPath,
+    loading: false,
+    error: false,
+  }
+
+  componentDidMount() {
+    if (this.props.dockerPath[0] === '/') {
+      this.setState({ trueDockerPath: this.props.dockerPath.substring(1, this.props.dockerPath.length) });
+    }
+  }
+
+  renderConfirmation = () => {
+    let { loading } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    }
+
+    return (
+      <Holder>
+        <InputRow
+          disabled={true}
+          label='Git Repository'
+          type='text'
+          width='100%'
+          value={this.props.repoName}
+          setValue={(x: string) => console.log(x)}
+        />
+        <InputRow
+          disabled={true}
+          label='Dockerfile Path'
+          type='text'
+          width='100%'
+          value={this.state.trueDockerPath}
+          setValue={(x: string) => console.log(x)}
+        />
+        <InputRow
+          label='Docker Image Repository'
+          placeholder='Image Repo URL (ex. gcr.io/porter/mr-p)'
+          type='text'
+          width='100%'
+          value={this.props.imgURL}
+          setValue={(x: string) => this.props.setURL(x)}
+        />
+      </Holder>
+    )
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderConfirmation()}
+      </div>
+    );
+  }
+}
+
+NewGHAction.contextType = Context;
+
+const Holder = styled.div`
+  padding: 0px 12px;
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+`;

+ 121 - 19
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -4,14 +4,16 @@ import github from '../../assets/github.png';
 import info from '../../assets/info.svg';
 
 import api from '../../shared/api';
-import { RepoType } from '../../shared/types';
+import { RepoType, ChartType } from '../../shared/types';
 import { Context } from '../../shared/Context';
 
 import Loading from '../../components/Loading';
 import BranchList from './BranchList';
 import ContentsList from './ContentsList';
+import NewGHAction from './NewGHAction';
 
 type PropsType = {
+  chart: ChartType | null,
   forceExpanded?: boolean,
   selectedRepo: RepoType | null,
   selectedBranch: string,
@@ -26,6 +28,9 @@ type StateType = {
   loading: boolean,
   error: boolean,
   repos: RepoType[]
+  branchGrID: number,
+  dockerfileSelected: boolean,
+  imageURL: string,
 };
 
 export default class RepoSelector extends Component<PropsType, StateType> {
@@ -33,11 +38,14 @@ export default class RepoSelector extends Component<PropsType, StateType> {
     isExpanded: this.props.forceExpanded,
     loading: true,
     error: false,
-    repos: [] as RepoType[]
+    repos: [] as RepoType[],
+    branchGrID: null as number,
+    dockerfileSelected: false,
+    imageURL: null as string,
   }
 
   componentDidMount() {
-    let { currentProject, currentCluster } = this.context;
+    let { currentProject } = this.context;
 
     // Get repos
     api.getGitRepos('<token>', {
@@ -45,7 +53,50 @@ export default class RepoSelector extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
-        this.setState({ repos: res.data, loading: false, error: false });
+        var allRepos: any = [];
+        let counter = 0;
+        for (let i = 0; i < res.data.length; i++) {
+          var grid = res.data[i].id;
+          api.getGitRepoList('<token>', {}, { project_id: currentProject.id, git_repo_id: grid }, (err: any, res: any) => {
+            if (err) {
+              console.log(err);
+              this.setState({ loading: false, error: true });
+            } else {
+              res.data.forEach((repo: any, id: number) => {
+                repo.GHRepoID = grid;
+              })
+              allRepos = allRepos.concat(res.data);
+              this.setState({ repos: allRepos, loading: false, error: false });
+            }
+          })
+        }
+      }
+    });
+  }
+
+  createGHAction = () => {
+    let { currentProject, currentCluster } = this.context;
+    let path = this.props.subdirectory + '/Dockerfile';
+    if (path[0] === '/') {
+      path = path.substring(1, path.length);
+    }
+
+    api.createGHAction('<token>', {
+      git_repo: this.props.selectedRepo.FullName,
+      image_repo_uri: this.state.imageURL,
+      dockerfile_path: path,
+      git_repo_id: this.props.selectedRepo.GHRepoID,
+    }, {
+      project_id: currentProject.id,
+      CLUSTER_ID: currentCluster.id,
+      RELEASE_NAME: this.props.chart.name,
+      RELEASE_NAMESPACE: this.props.chart.namespace,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        this.setState({ error: true });
+      } else {
+        console.log(res.data);
       }
     });
   }
@@ -95,38 +146,81 @@ export default class RepoSelector extends Component<PropsType, StateType> {
         <div>
           <ExpandedWrapperAlt>
             <BranchList
-              setSelectedBranch={(branch: string) => setSelectedBranch(branch)}
+              grid={selectedRepo.GHRepoID}
+              setSelectedBranch={(branch: string) => {
+                this.setState({ branchGrID: selectedRepo.GHRepoID });
+                setSelectedBranch(branch);
+              }}
               repoName={selectedRepo.FullName.split('/')[1]}
+              owner={selectedRepo.FullName.split('/')[0]}
               selectedBranch={selectedBranch}
             />
           </ExpandedWrapperAlt>
-          <BackButton
-            width='130px'
-            onClick={() => setSelectedRepo(null)}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Repo
-          </BackButton>
+          <ButtonTray>
+            <BackButton
+              width='130px'
+              onClick={() => setSelectedRepo(null)}
+            >
+              <i className="material-icons">keyboard_backspace</i>
+              Select Repo
+            </BackButton>
+          </ButtonTray>
         </div>
       );
+    } else if (this.state.dockerfileSelected) {
+      return (
+        <div>
+          <ExpandedWrapperAlt>
+            <NewGHAction
+              repoName={selectedRepo.FullName}
+              dockerPath={subdirectory + '/Dockerfile'}
+              grid={this.state.branchGrID}
+              chart={this.props.chart}
+              imgURL={this.state.imageURL}
+              setURL={(x: string) => this.setState({ imageURL: x })}
+            />
+          </ExpandedWrapperAlt>
+          <ButtonTray>
+            <BackButton
+              width='130px'
+              onClick={() => this.setState({ dockerfileSelected: false })}
+            >
+              <i className='material-icons'>keyboard_backspace</i>
+              Select Dockerfile
+            </BackButton>
+            <BackButton
+              width='146px'
+              onClick={() => this.createGHAction()}
+            >
+              <i className='material-icons'>play_circle_outline</i>
+              Create Github Action
+            </BackButton>
+          </ButtonTray>
+        </div>
+      )
     }
     return (
       <div>
         <ExpandedWrapperAlt>
           <ContentsList
+            grid={this.state.branchGrID}
             setSubdirectory={(subdirectory: string) => setSubdirectory(subdirectory)}
             repoName={selectedRepo.FullName.split('/')[1]}
+            owner={selectedRepo.FullName.split('/')[0]}
             selectedBranch={selectedBranch}
             subdirectory={subdirectory}
+            setDockerfile={() => this.setState({ dockerfileSelected: true })}
           />
         </ExpandedWrapperAlt>
-        <BackButton
-          onClick={() => setSelectedBranch('')}
-          width='140px'
-        >
-          <i className="material-icons">keyboard_backspace</i>
-          Select Branch
-        </BackButton>
+        <ButtonTray>
+          <BackButton
+            onClick={() => {setSelectedBranch(''); setSubdirectory(''); this.setState({ imageURL: '' })}}
+            width='140px'
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Branch
+          </BackButton>
+        </ButtonTray>
       </div>
     );
   }
@@ -208,6 +302,14 @@ const BackButton = styled.div`
   }
 `;
 
+const ButtonTray = styled.div`
+  margin-top: 10px;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+`;
+
 const RepoName = styled.div`
   display: flex;
   width: 100%;

+ 2 - 3
dashboard/src/components/values-form/InputRow.tsx

@@ -58,7 +58,7 @@ const Required = styled.div`
 `;
 
 const Unit = styled.div`
-  margin-right: 8px;
+  margin-left: 8px;
 `;
 
 const InputWrapper = styled.div`
@@ -77,8 +77,7 @@ const Input = styled.input`
   width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
   color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
   padding: 5px 10px;
-  margin-right: 8px;
-  height: 30px;
+  height: 35px;
 `;
 
 const Label = styled.div`

+ 6 - 4
dashboard/src/components/values-form/SelectRow.tsx

@@ -8,7 +8,9 @@ type PropsType = {
   value: string,
   setActiveValue: (x: string) => void,
   options: { value: string, label: string }[],
-  dropdownLabel?: string
+  dropdownLabel?: string,
+  width?: string,
+  dropdownMaxHeight?: string,
 };
 
 type StateType = {
@@ -25,8 +27,9 @@ export default class SelectRow extends Component<PropsType, StateType> {
             setActiveValue={this.props.setActiveValue}
             options={this.props.options}
             dropdownLabel={this.props.dropdownLabel}
-            width='270px'
-            dropdownMaxHeight={'210px'}
+            width={this.props.width || '270px'}
+            dropdownWidth={this.props.width}
+            dropdownMaxHeight={this.props.dropdownMaxHeight}
           />
         </SelectWrapper>
       </StyledSelectRow>
@@ -35,7 +38,6 @@ export default class SelectRow extends Component<PropsType, StateType> {
 }
 
 const SelectWrapper = styled.div`
-  display: flex;
 `;
 
 const Label = styled.div`

+ 15 - 21
dashboard/src/components/values-form/ValuesForm.tsx

@@ -15,27 +15,6 @@ import Heading from './Heading';
 import ExpandableResource from '../ExpandableResource';
 import VeleroForm from '../forms/VeleroForm';
 
- let dummySections = [
-   {
-    "name":"section_one",
-    "show_if":"",
-    "contents":[
-      {
-        "type":"heading",
-        "label":"Polyphia",
-      },
-      {
-        "type":"subtitle",
-        "label":"Tim Hendrix",
-      },
-      {
-        "type":"velero-create-backup",
-        "label":"Tim Hendrix",
-      },
-    ]
-  }
-];
-
 type PropsType = {
   sections?: Section[],
   metaState?: any,
@@ -159,6 +138,21 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               label={item.label}
             />
           );
+        case 'provider-select':
+          return (
+            <SelectRow
+              key={i}
+              value={this.props.metaState[key]}
+              setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
+              options={[
+                { value: 'gcp', label: 'Google Cloud Platform (GCP)' },
+                { value: 'aws', label: 'Amazon Web Services (AWS)' },
+                { value: 'do', label: 'DigitalOcean' },
+              ]}
+              dropdownLabel=''
+              label={item.label}
+            />
+          );
         case 'velero-create-backup':
           return (
             <VeleroForm

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

@@ -2,6 +2,7 @@ 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';
 
@@ -16,6 +17,12 @@ type PropsType = {
 
 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> {
 
@@ -56,6 +63,10 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
               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':
@@ -128,6 +139,8 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
   }
 }
 
+ValuesWrapper.contextType = Context;
+
 const StyledValuesWrapper = styled.div`
   width: 100%;
   padding: 0;

+ 9 - 3
dashboard/src/main/Login.tsx

@@ -55,9 +55,15 @@ export default class Login extends Component<PropsType, StateType> {
         password: password
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
-        console.log(res.data);
-        setUser(res?.data?.id, res?.data?.email)
-        err ? console.log(err) : authenticate();
+        if (err) {
+          this.context.setCurrentError(err.response.data.errors[0])
+        }
+        if (res?.data?.redirect) {
+          window.location.href = res.data.redirect;
+        } else {
+          setUser(res?.data?.id, res?.data?.email)
+          err ? console.log(err.response.data) : authenticate();
+        }
       });
     }
   }

+ 6 - 2
dashboard/src/main/Register.tsx

@@ -60,8 +60,12 @@ export default class Register extends Component<PropsType, StateType> {
         email: email,
         password: password
       }, {}, (err: any, res: any) => {
-        setUser(res?.data?.id, res?.data?.email)
-        err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        if (res?.data?.redirect) {
+          window.location.href = res.data.redirect;
+        } else {
+          setUser(res?.data?.id, res?.data?.email)
+          err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        }
       });
     } 
   };

+ 269 - 150
dashboard/src/main/home/Home.tsx

@@ -1,8 +1,6 @@
 import React, { Component } from 'react';
 import posthog from 'posthog-js';
 import styled from 'styled-components';
-import ReactModal from 'react-modal';
-import * as FullStory from '@fullstory/browser';
 
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
@@ -24,6 +22,8 @@ import Navbar from './navbar/Navbar';
 import ProvisionerStatus from './provisioner/ProvisionerStatus';
 import ProjectSettings from './project-settings/ProjectSettings';
 import ConfirmOverlay from '../../components/ConfirmOverlay';
+import Modal from './modals/Modal';
+import * as FullStory from '@fullstory/browser';
 
 type PropsType = {
   logOut: () => void,
@@ -35,6 +35,8 @@ type StateType = {
   forceSidebar: boolean,
   showWelcome: boolean,
   currentView: string,
+  handleDO: boolean, // Trigger DO infra calls after oauth flow if needed
+  ghRedirect: boolean,
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
   // Track last project id for refreshing clusters on project change
@@ -51,27 +53,40 @@ export default class Home extends Component<PropsType, StateType> {
     prevProjectId: null as number | null,
     forceRefreshClusters: false,
     sidebarReady: false,
+    handleDO: false,
+    ghRedirect: false,
   }
 
+  // TODO: Refactor and prevent flash + multiple reload
   initializeView = () => {
-    let { currentCluster } = this.context;
     let { currentProject } = this.props;
+    let { currentCluster } = this.context;
+    
+    if (!currentProject) return;
+
     // Check if current project is provisioning
     api.getInfra('<token>', {}, { project_id: currentProject.id }, (err: any, res: any) => {
       if (err) {
         console.log(err);
         return;
       }
-      console.log(currentCluster);
-      if (!currentCluster && !includesCompletedInfraSet(res.data)) {
-        this.setState({ currentView: 'provisioner', sidebarReady: true, });
+
+      if (res.data.length > 0 && (!currentCluster && !includesCompletedInfraSet(res.data))) {
+        // force refresh if currentView is identical set to provisioner. Tentative solution before refactoring.
+        this.setState({ currentView: 'dashboard'}, () => {
+          this.setState({ currentView: 'provisioner', sidebarReady: true, });
+        });
+      } else if (this.state.ghRedirect) {
+        this.setState({ currentView: 'integrations', sidebarReady: true, ghRedirect: false });
       } else {
-        this.setState({ currentView: 'dashboard', sidebarReady: true });
+        this.setState({ currentView: 'provisioner'}, () => {
+          this.setState({ currentView: 'dashboard', sidebarReady: true });
+        })
       }
     });
   }
 
-  getProjects = () => {
+  getProjects = (id?: number) => {
     let { user, setProjects } = this.context;
     let { currentProject } = this.props;
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
@@ -82,39 +97,157 @@ export default class Home extends Component<PropsType, StateType> {
           this.setState({ currentView: 'new-project', sidebarReady: true, });
         } else if (res.data.length > 0 && !currentProject) {
           setProjects(res.data);
-          this.context.setCurrentProject(res.data[0]);
 
-          this.initializeView();
+          let foundProject = null;
+          if (id) {
+            res.data.forEach((project: ProjectType, i: number) => {
+              if (project.id === id) {
+                foundProject = project;
+              } 
+            });
+            this.context.setCurrentProject(foundProject);
+            this.setState({ currentView: 'provisioner' });
+          }
+
+          if (!foundProject) {
+            res.data.forEach((project: ProjectType, i: number) => {
+              if (project.id.toString() === localStorage.getItem('currentProject')) {
+                foundProject = project;
+              }
+            })
+            this.context.setCurrentProject(foundProject ? foundProject : res.data[0]);
+            this.initializeView();
+          }
         }
       }
     });
   }
 
+  provisionDOCR = (integrationId: number, tier: string, callback?: any) => {
+    console.log('Provisioning DOCR...');
+    api.createDOCR('<token>', {
+      do_integration_id: integrationId,
+      docr_name: this.props.currentProject.name,
+      docr_subscription_tier: tier,
+    }, { 
+      project_id: this.props.currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+      callback && callback();
+    });
+  }
+
+  provisionDOKS = (integrationId: number, region: string) => {
+    console.log('Provisioning DOKS...');
+    api.createDOKS('<token>', {
+      do_integration_id: integrationId,
+      doks_name: this.props.currentProject.name,
+      do_region: region,
+    }, { 
+      project_id: this.props.currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+      this.setState({ currentView: 'provisioner' });
+    });
+  }
+
+  checkDO = () => {
+    let { currentProject } = this.props;
+    if (this.state.handleDO && currentProject?.id) {
+      api.getOAuthIds('<token>', {}, { 
+        project_id: currentProject.id
+      }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        }
+        let tgtIntegration = res.data.find((integration: any) => {
+          return integration.client === 'do'
+        });
+        let queryString = window.location.search;
+        let urlParams = new URLSearchParams(queryString);
+        let tier = urlParams.get('tier');
+        let region = urlParams.get('region');
+        let infras = urlParams.getAll('infras');
+        if (infras.length === 2) {
+          this.provisionDOCR(tgtIntegration.id, tier, () => {
+            this.provisionDOKS(tgtIntegration.id, region);
+          });
+        } else if (infras[0] === 'docr') {
+          this.provisionDOCR(tgtIntegration.id, tier, () => {
+            this.setState({ currentView: 'provisioner' });
+          });
+        } else {
+          this.provisionDOKS(tgtIntegration.id, region);
+        }
+      });
+      this.setState({ handleDO: false });
+    }
+  }
+
   componentDidMount() {
-    console.log('newest release')
     let { user } = this.context;
-    window.location.href.indexOf('127.0.0.1') === -1 && posthog.init(process.env.POSTHOG_API_KEY, {
-      api_host: process.env.POSTHOG_HOST,
-      loaded: function(posthog) { posthog.identify(user.email) }
+    FullStory.identify(user.email)
+
+    // Handle redirect from DO
+    let queryString = window.location.search;
+    let urlParams = new URLSearchParams(queryString);
+
+    let err = urlParams.get('error');
+    if (err) {
+      this.context.setCurrentError(err);
+    }
+
+    let provision = urlParams.get('provision');
+    let defaultProjectId = null;
+    if (provision === 'do') {
+      defaultProjectId = parseInt(urlParams.get('project_id'));
+      this.setState({ handleDO: true });
+      this.checkDO();
+    }
+
+    // initialize posthog on non-localhosts. Gracefully fail when env vars are not set.
+    this.setState({ ghRedirect: urlParams.get('gh_oauth') !== null });
+    urlParams.delete('gh_oauth');
+    
+    window.location.href.indexOf('127.0.0.1') === -1 && posthog.init(process.env.POSTHOG_API_KEY || 'placeholder', {
+      api_host: process.env.POSTHOG_HOST || 'placeholder',
+      loaded: function(posthog: any) { 
+        posthog.identify(user.userId) 
+        posthog.people.set({ email: user.email })
+      }
     })
 
-    FullStory.identify(user.email)
-    this.getProjects();
+    this.getProjects(defaultProjectId);
   }
 
+  // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
+  // 1. Make sure clicking cluster in course drawer shows cluster-dashboard
+  // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
+  // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
     if (
       prevProps.currentProject !== this.props.currentProject
-      || prevProps.currentCluster !== this.props.currentCluster
+      || (!prevProps.currentCluster && this.props.currentCluster)
     ) {
-      this.initializeView();
+      if (this.state.handleDO) {
+        this.checkDO();
+      } else {
+        this.initializeView();
+      }
     }
   }
 
   // TODO: move into ClusterDashboard
   renderDashboard = () => {
     let { currentCluster, setCurrentModal } = this.context;
-    if (this.state.showWelcome || currentCluster && !currentCluster.name) {
+    if (currentCluster && !currentCluster.name) {
       return (
         <DashboardWrapper>
           <Placeholder>
@@ -145,41 +278,45 @@ export default class Home extends Component<PropsType, StateType> {
   }
 
   renderContents = () => {
-    let { currentView } = this.state;
-    if (currentView === 'cluster-dashboard') {
-      return this.renderDashboard();
-    } else if (currentView === 'dashboard') {
-      return (
-        <DashboardWrapper>
-          <Dashboard 
-            setCurrentView={(x: string) => this.setState({ currentView: x })}
-            projectId={this.context.currentProject?.id}
+    let { currentView, handleDO } = this.state;
+    if (this.context.currentProject && currentView !== 'new-project') {
+      if (currentView === 'cluster-dashboard') {
+        return this.renderDashboard();
+      } else if (currentView === 'dashboard') {
+        return (
+          <DashboardWrapper>
+            <Dashboard 
+              setCurrentView={(x: string) => this.setState({ currentView: x })}
+              projectId={this.context.currentProject?.id}
+            />
+          </DashboardWrapper>
+        );
+      } else if (currentView === 'integrations') {
+        return <Integrations />;
+      } else if (currentView === 'provisioner') {
+        return (
+          <ProvisionerStatus
+            setCurrentView={(x: string) => this.setState({ currentView: x })} 
           />
-        </DashboardWrapper>
-      );
-    } else if (currentView === 'integrations') {
-      return <Integrations />;
-    } else if (currentView === 'new-project') {
-      return (
-        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} />
-      );
-    } else if (currentView === 'provisioner') {
+        );
+      } else if (currentView === 'project-settings') {
+        return (
+          <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
+        )
+      }
+
       return (
-        <ProvisionerStatus
+        <Templates
           setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       );
-    } else if (currentView === 'project-settings') {
+    } else if (currentView === 'new-project') {
       return (
-        <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
-      )
+        <NewProject 
+          setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} 
+        />
+      );
     }
-
-    return (
-      <Templates
-        setCurrentView={(x: string) => this.setState({ currentView: x })}
-      />
-    );
   }
 
   setCurrentView = (x: string) => {
@@ -221,7 +358,8 @@ export default class Home extends Component<PropsType, StateType> {
         if (res.data.length > 0) {
           this.context.setCurrentProject(res.data[0]);
         } else {
-          this.context.currentModalData.setCurrentView('new-project');
+          this.context.setCurrentProject(null);
+          this.setState({ currentView: 'new-project' });
         }
         this.context.setCurrentModal(null, null);
       }
@@ -230,9 +368,10 @@ export default class Home extends Component<PropsType, StateType> {
 
   handleDelete = () => {
     let { setCurrentModal, currentProject } = this.context;
+    localStorage.removeItem(currentProject.id + '-cluster');
     api.deleteProject('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
       if (err) {
-        // console.log(err)
+        console.log(err)
       } else {
         this.projectOverlayCall();
       }
@@ -240,15 +379,21 @@ export default class Home extends Component<PropsType, StateType> {
 
     // Loop through and delete infra of all clusters we've provisioned
     api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        res.data.forEach((cluster: ClusterType) => {
 
-          // Handle destroying infra we've provisioned
-          if (cluster.infra_id) {
-            console.log('destroying provisioned infra...', cluster.infra_id);
-            api.destroyCluster('<token>', { eks_name: cluster.name }, { 
+      if (err) { 
+        console.log(err); 
+        return; 
+      }
+      
+      for (var i = 0; i < res.data.length; i++) {
+        let cluster = res.data[i];
+        if (!cluster.infra_id) continue;
+
+        // Handle destroying infra we've provisioned
+        switch (cluster.service) {
+
+          case "eks":
+            api.destroyEKS('<token>', { eks_name: cluster.name }, { 
               project_id: currentProject.id,
               infra_id: cluster.infra_id,
             }, (err: any, res: any) => {
@@ -258,8 +403,34 @@ export default class Home extends Component<PropsType, StateType> {
                 console.log('destroyed provisioned infra:', cluster.infra_id);
               }
             });
-          }
-        });
+            break;
+
+          case 'gke':
+            api.destroyGKE('<token>', { gke_name: cluster.name }, { 
+              project_id: currentProject.id,
+              infra_id: cluster.infra_id,
+            }, (err: any, res: any) => {
+              if (err) {
+                console.log(err)
+              } else {
+                console.log('destroyed provisioned infra.');
+              }
+            });
+            break;
+
+          case 'doks':
+            api.destroyDOKS('<token>', { doks_name: cluster.name }, { 
+              project_id: currentProject.id,
+              infra_id: cluster.infra_id,
+            }, (err: any, res: any) => {
+              if (err) {
+                console.log(err)
+              } else {
+                console.log('destroyed provisioned infra.');
+              }
+            });
+            break;
+        }
       }
     });
     setCurrentModal(null, null)
@@ -268,42 +439,47 @@ export default class Home extends Component<PropsType, StateType> {
 
   render() {
     let { currentModal, setCurrentModal, currentProject } = this.context;
+
     return (
       <StyledHome>
-        <ReactModal
-          isOpen={currentModal === 'ClusterInstructionsModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={TallModalStyles}
-          ariaHideApp={false}
-        >
-          <ClusterInstructionsModal />
-        </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'UpdateClusterModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={ProjectModalStyles}
-          ariaHideApp={false}
-        >
-          <UpdateClusterModal 
-            setRefreshClusters={(x: boolean) => this.setState({ forceRefreshClusters: x })} 
-          />
-        </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'IntegrationsModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={SmallModalStyles}
-          ariaHideApp={false}
-        >
-          <IntegrationsModal />
-        </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'IntegrationsInstructionsModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={TallModalStyles}
-          ariaHideApp={false}
-        >
-          <IntegrationsInstructionsModal />
-        </ReactModal>
+        {currentModal === 'ClusterInstructionsModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='760px'
+            height='650px'
+          >
+            <ClusterInstructionsModal />
+          </Modal>
+        }
+        {currentModal === 'UpdateClusterModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='565px'
+            height='275px'
+          >
+            <UpdateClusterModal 
+              setRefreshClusters={(x: boolean) => this.setState({ forceRefreshClusters: x })} 
+            />
+          </Modal>
+        }
+        {currentModal === 'IntegrationsModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='760px'
+            height='725px'
+          >
+            <IntegrationsModal />
+          </Modal>
+        }
+        {currentModal === 'IntegrationsInstructionsModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='760px'
+            height='650px'
+          >
+            <IntegrationsInstructionsModal />
+          </Modal>
+        }
 
         {this.renderSidebar()}
 
@@ -328,63 +504,6 @@ export default class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-const SmallModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '760px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '425px',
-    top: 'calc(50% - 214px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
-const ProjectModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '565px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '275px',
-    top: 'calc(50% - 160px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
-const TallModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '760px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '650px',
-    top: 'calc(50% - 325px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
 const ViewWrapper = styled.div`
   height: 100%;
   width: 100vw;

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

@@ -190,7 +190,7 @@ const Button = styled.div`
   font-family: 'Work Sans', sans-serif;
   border-radius: 20px;
   color: white;
-  height: 30px;
+  height: 35px;
   padding: 0px 8px;
   padding-bottom: 1px;
   margin-right: 10px;
@@ -211,6 +211,7 @@ const Button = styled.div`
     color: white;
     width: 18px;
     height: 18px;
+    font-weight: 600;
     font-size: 12px;
     border-radius: 20px;
     display: flex;

+ 80 - 26
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 import api from '../../../../shared/api';
 import yaml from 'js-yaml';
 
-import { ChartType, RepoType, StorageType } from '../../../../shared/types';
+import { ChartType, RepoType, StorageType, ActionConfigType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 
 import ImageSelector from '../../../../components/image-selector/ImageSelector';
@@ -31,6 +31,7 @@ type StateType = {
   subdirectory: string,
   webhookToken: string,
   highlightCopyButton: boolean,
+  action: ActionConfigType;
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
@@ -45,6 +46,12 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     subdirectory: '',
     webhookToken: '',
     highlightCopyButton: false,
+    action: {
+      git_repo: '',
+      image_repo_uri: '',
+      git_repo_id: 0,
+      dockerfile_path: '',
+    } as ActionConfigType,
   }
 
   // TODO: read in set image from form context instead of config
@@ -65,7 +72,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       if (err) {
         console.log(err)
       } else {
-        this.setState({ webhookToken: res.data.webhook_token })
+        this.setState({ action: res.data.git_action_config, webhookToken: res.data.webhook_token });
       }
     });
   }
@@ -109,15 +116,21 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     });
   }
 
+  /*
+    <Helper>
+      Specify a container image and tag or
+      <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
+        link a repo
+      </Highlight>.
+    </Helper>
+  */
   renderSourceSection = () => {
     if (this.state.sourceType === 'registry') {
       return (
         <>
+          <Heading>Connected Source</Heading>
           <Helper>
-            Specify a container image and tag or
-            <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
-              link a repo
-            </Highlight>.
+            Specify a container image and tag.
           </Helper>
           <ImageSelector
             selectedImageUrl={this.state.selectedImageUrl}
@@ -134,24 +147,61 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     let { currentProject } = this.context;
     return (
       <>
-        <Helper>
-          Select a repo to connect to. You can 
-          <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
-            log in with GitHub
-          </A> or
-          <Highlight onClick={() => this.setState({ sourceType: 'registry' })}>
-            link an image registry
-          </Highlight>.
-        </Helper>
-        <RepoSelector
-          forceExpanded={true}
-          selectedRepo={this.state.selectedRepo}
-          selectedBranch={this.state.selectedBranch}
-          subdirectory={this.state.subdirectory}
-          setSelectedRepo={(x: RepoType) => this.setState({ selectedRepo: x })}
-          setSelectedBranch={(x: string) => this.setState({ selectedBranch: x })}
-          setSubdirectory={(x: string) => this.setState({ subdirectory: x })}
-        />
+        {this.state.action.git_repo.length > 0
+          ?
+          <>
+            <Heading>Connected Source</Heading>
+            <Holder>
+              <InputRow
+                disabled={true}
+                label='Git Repository'
+                type='text'
+                width='100%'
+                value={this.state.action.git_repo}
+                setValue={(x: string) => console.log(x)}
+              />
+              <InputRow
+                disabled={true}
+                label='Dockerfile Path'
+                type='text'
+                width='100%'
+                value={this.state.action.dockerfile_path}
+                setValue={(x: string) => console.log(x)}
+              />
+              <InputRow
+                disabled={true}
+                label='Docker Image Repository'
+                type='text'
+                width='100%'
+                value={this.state.action.image_repo_uri}
+                setValue={(x: string) => console.log(x)}
+              />
+            </Holder>
+          </>
+          :
+          <>
+            <Heading>Connect a Source</Heading>
+            <Helper>
+              Select a repo to connect to. You can 
+              <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
+                log in with GitHub
+              </A> or
+              <Highlight onClick={() => this.setState({ sourceType: 'registry' })}>
+                link an image registry
+              </Highlight>.
+            </Helper>
+            <RepoSelector
+              chart={this.props.currentChart}
+              forceExpanded={true}
+              selectedRepo={this.state.selectedRepo}
+              selectedBranch={this.state.selectedBranch}
+              subdirectory={this.state.subdirectory}
+              setSelectedRepo={(x: RepoType) => this.setState({ selectedRepo: x })}
+              setSelectedBranch={(x: string) => this.setState({ selectedBranch: x })}
+              setSubdirectory={(x: string) => this.setState({ subdirectory: x })}
+            />
+          </>
+        }
       </>
     );
   }
@@ -185,7 +235,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     return (
       <Wrapper>
         <StyledSettingsSection>
-          <Heading>Connected Source</Heading>
           {this.renderSourceSection()}
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
@@ -208,9 +257,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 SettingsSection.contextType = Context;
 
 const Button = styled.button`
-  height: 40px;
+  height: 35px;
   font-size: 13px;
   margin-top: 20px;
+  margin-bottom: 30px;
   font-weight: 500;
   font-family: 'Work Sans', sans-serif;
   color: white;
@@ -292,4 +342,8 @@ const StyledSettingsSection = styled.div`
   position: relative;
   border-radius: 5px;
   overflow: auto;
+`;
+
+const Holder = styled.div`
+  padding: 0px 12px;
 `;

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -430,7 +430,6 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           node.x = cursorX + scale * (node.x - cursorX);
           node.y = cursorY + scale * (node.y - cursorY);
         } else {
-          console.log('hi')
           node.x = midX + scale * (node.x - midX);
           node.y = midY + scale * (node.y - midY);
         }

+ 62 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -16,6 +16,7 @@ type PropsType = {
 type StateType = {
   pods: any[],
   raw: any[],
+  showTooltip: boolean[],
 };
 
 // Controller tab in log section that displays list of pods on click.
@@ -23,6 +24,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   state = {
     pods: [] as any[],
     raw: [] as any[],
+    showTooltip: [] as boolean[],
   }
 
   componentDidMount() {
@@ -60,8 +62,12 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           phase: pod?.status?.phase,
         }
       });
+      let showTooltip = new Array(pods.length);
+      for (let j = 0; j < pods.length; j ++) {
+        showTooltip[j] = false;
+      }
       
-      this.setState({ pods, raw: res.data });
+      this.setState({ pods, raw: res.data, showTooltip });
       
       if (isFirst) {
         selectPod(res.data[0])
@@ -108,6 +114,12 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     }
   }
 
+  renderTooltip = (x: string, ind: number): JSX.Element | undefined => {
+    if (this.state.showTooltip[ind]) {
+      return <Tooltip>{x}</Tooltip>;
+    }
+  }
+
   render() {
     let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
     let [available, total] = this.getAvailability(controller.kind, controller);
@@ -134,9 +146,21 @@ export default class ControllerTab extends Component<PropsType, StateType> {
                   <Circle />
                   <Rail lastTab={i === this.state.raw.length - 1} />
                 </Gutter>
-                <Name>
+                <Name
+                  onMouseOver={() => {
+                    let showTooltip = this.state.showTooltip;
+                    showTooltip[i] = true;
+                    this.setState({ showTooltip });
+                  }}
+                  onMouseOut={() => {
+                    let showTooltip = this.state.showTooltip;
+                    showTooltip[i] = false;
+                    this.setState({ showTooltip });
+                  }}
+                >
                   {pod.metadata?.name}
                 </Name>
+                {this.renderTooltip(pod.metadata?.name, i)}
                 <Status>
                   <StatusColor status={status} />
                   {status}
@@ -204,13 +228,45 @@ const StatusColor = styled.div`
 `;
 
 const Name = styled.div`
-  width: 50%;
+  max-width: calc(100% - 75px);
   overflow: hidden;
-`
+  text-overflow: ellipsis;
+  line-height: 16px;
+  word-wrap: break-word;
+  max-height: 32px;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 35px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
+  padding: 2px 5px;
+  background: #383842dd;
+  display: flex;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
 
 const Tab = styled.div`
   width: 100%;
-  overflow: hidden;
   height: 50px;
   position: relative;
   display: flex;
@@ -221,6 +277,7 @@ const Tab = styled.div`
   font-size: 13px;
   padding: 20px 19px 20px 42px;
   text-shadow: 0px 0px 8px none;
+  overflow: visible;
   cursor: pointer;
   :hover {
     color: white;

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

@@ -21,7 +21,6 @@ export default class Logs extends Component<PropsType, StateType> {
   }
 
   ws = null as any;
-  scrollRef = React.createRef<HTMLDivElement>()
   parentRef = React.createRef<HTMLDivElement>()
 
   scrollToBottom = (smooth: boolean) => {
@@ -58,7 +57,7 @@ export default class Logs extends Component<PropsType, StateType> {
 
     this.ws.onmessage = (evt: MessageEvent) => {
       this.setState({ logs: [...this.state.logs, evt.data] }, () => {
-        if (this.state.scroll && this.state.logs.length >50) {
+        if (this.state.scroll) {
           this.scrollToBottom(false)
         }
       })
@@ -99,7 +98,6 @@ export default class Logs extends Component<PropsType, StateType> {
       <LogStream>
         <Wrapper ref={this.parentRef}>
           {this.renderLogs()}
-          <div ref={this.scrollRef} />
         </Wrapper>
         <Options>
           <Scroll onClick={()=> {
@@ -187,6 +185,9 @@ const LogStream = styled.div`
   height: 100%;
   background: #202227;
   user-select: text;
+  max-width: 65%;
+  overflow-y: auto;
+  overflow-wrap: break-word; 
 `;
 
 const Message = styled.div`

+ 4 - 5
dashboard/src/main/home/dashboard/StatusPlaceholder.tsx → dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -17,7 +17,7 @@ type StateType = {
   loading: boolean,
 };
 
-export default class StatusPlaceholder extends Component<PropsType, StateType> {
+export default class ClusterPlaceholder extends Component<PropsType, StateType> {
   state = {
     loading: true,
   }
@@ -52,7 +52,7 @@ export default class StatusPlaceholder extends Component<PropsType, StateType> {
             <Highlight onClick={() => {
               this.context.setCurrentModal('ClusterInstructionsModal', {});
             }}>
-              + Add a Cluster
+              + Connect a Cluster
             </Highlight>
           </StyledStatusPlaceholder>
         </>
@@ -65,7 +65,7 @@ export default class StatusPlaceholder extends Component<PropsType, StateType> {
   }
 }
 
-StatusPlaceholder.contextType = Context;
+ClusterPlaceholder.contextType = Context;
 
 const LoadingWrapper = styled.div`
   height: calc(100vh - 450px);
@@ -77,7 +77,6 @@ const LoadingWrapper = styled.div`
 const Highlight = styled.div`
   color: #8590ff;
   cursor: pointer;
-  text-decoration: underline;
   margin-left: 10px;
   margin-right: 10px;
 `;
@@ -91,7 +90,7 @@ const Banner = styled.div`
   border-radius: 5px;
   padding-left: 15px;
   align-items: center;
-  background: #616FEEcc;
+  background: #ffffff11;
   > i {
     margin-right: 10px;
     font-size: 18px;

+ 4 - 4
dashboard/src/main/home/dashboard/StatusPlaceholderContainer.tsx → dashboard/src/main/home/dashboard/ClusterPlaceholderContainer.tsx

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { Context } from '../../../shared/Context';
-import StatusPlaceholder from './StatusPlaceholder';
+import ClusterPlaceholder from './ClusterPlaceholder';
 
 type PropsType = {
   setCurrentView: (x: string) => void,
@@ -12,13 +12,13 @@ type StateType = {
 };
 
 // Props in context to project section to trigger update on context change
-export default class StatusPlaceholderContainer extends Component<PropsType, StateType> {
+export default class ClusterPlaceholderContainer extends Component<PropsType, StateType> {
   state = {
   }
 
   render() {
     return (
-      <StatusPlaceholder
+      <ClusterPlaceholder
         setCurrentView={this.props.setCurrentView}
         currentCluster={this.context.currentCluster}
       />
@@ -26,4 +26,4 @@ export default class StatusPlaceholderContainer extends Component<PropsType, Sta
   }
 }
 
-StatusPlaceholderContainer.contextType = Context;
+ClusterPlaceholderContainer.contextType = Context;

+ 31 - 22
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -8,6 +8,7 @@ import { InfraType } from '../../../shared/types';
 import api from '../../../shared/api';
 
 import ProvisionerSettings from '../provisioner/ProvisionerSettings';
+import ClusterPlaceholderContainer from './ClusterPlaceholderContainer';
 
 type PropsType = {
   setCurrentView: (x: string) => void,
@@ -48,12 +49,8 @@ export default class Dashboard extends Component<PropsType, StateType> {
   }
 
   onShowProjectSettings = () => {
-    let { currentProject, setCurrentModal } = this.context;
     let { setCurrentView } = this.props;
-    setCurrentModal('UpdateProjectModal', { 
-      currentProject: currentProject,
-      setCurrentView: setCurrentView,
-    });
+    setCurrentView('project-settings');
   }
 
   render() {
@@ -73,12 +70,16 @@ export default class Dashboard extends Component<PropsType, StateType> {
               </Overlay>
             </DashboardIcon>
               <Title>{currentProject && currentProject.name}</Title>
-              <i
-                className="material-icons"
-                onClick={onShowProjectSettings}
-              >
-                more_vert
-              </i>
+              {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>
@@ -94,16 +95,24 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
             <LineBreak />
 
-            {!currentCluster && (
-              <Banner>
-                <i className="material-icons">error_outline</i>
-                This project currently has no clusters connected.
-              </Banner>
-            )}
-            <ProvisionerSettings 
-              setCurrentView={setCurrentView} 
-              infras={infras}
-            />
+            {!currentCluster 
+              ? (
+                <>
+                  <Banner>
+                    <i className="material-icons">error_outline</i>
+                    This project currently has no clusters conncted.
+                    </Banner>
+                  <ProvisionerSettings 
+                    setCurrentView={setCurrentView} 
+                    infras={infras}
+                  />
+                </>
+              ) : (
+                <ClusterPlaceholderContainer
+                  setCurrentView={this.props.setCurrentView} 
+                />
+              )
+            }
           </DashboardWrapper>
         )}
       </>
@@ -126,7 +135,7 @@ const Banner = styled.div`
   border-radius: 5px;
   padding-left: 15px;
   align-items: center;
-  background: #616FEEcc;
+  background: #ffffff11;
   > i {
     margin-right: 10px;
     font-size: 18px;

+ 4 - 2
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -18,12 +18,14 @@ type StateType = {
 export default class IntegrationList extends Component<PropsType, StateType> {
   renderContents = () => {
     let { integrations, titles, setCurrent, isCategory } = this.props;
+    console.log(`titles: ${titles}`);
+    console.log(`integrations: ${integrations}`);
     if (titles && titles.length > 0) {
       return integrations.map((integration: string, i: number) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         let subtitle = integrationList[integration] && integrationList[integration].label;
         let label = titles[i];
-        let disabled = integration === 'repo' || integration === 'kubernetes';
+        let disabled = integration === 'kubernetes' || integration === 'repo';
         return (
           <Integration
             key={i}
@@ -46,7 +48,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
       return integrations.map((integration: string, i: number) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         let label = integrationList[integration] && integrationList[integration].label;
-        let disabled = integration === 'repo' || integration === 'kubernetes';
+        let disabled = integration === 'kubernetes' || integration === 'repo';
         return (
           <Integration
             key={i}

+ 2 - 1
dashboard/src/main/home/integrations/Integrations.tsx

@@ -32,6 +32,7 @@ export default class Integrations extends Component<PropsType, StateType> {
   // TODO: implement once backend is restructured
   getIntegrations = (categoryType: string) => {
     let { currentProject } = this.context;
+    this.setState({ currentOptions: [], currentTitles: [], currentIntegrationData: [] });
     switch (categoryType) {
       case 'kubernetes':
         api.getProjectClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
@@ -99,7 +100,7 @@ export default class Integrations extends Component<PropsType, StateType> {
             {
               items.map((item: any, i: number) => {
                 return (
-                  <Credential>
+                  <Credential key={i}>
                     <i className="material-icons">admin_panel_settings</i> {item.name}
                   </Credential>
                 );

+ 37 - 1
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx

@@ -16,13 +16,17 @@ type PropsType = {
 
 type StateType = {
   credentialsName: string,
+  gcpRegion: string,
   serviceAccountKey: string,
+  gcpProjectID: string,
 };
 
 export default class GCRForm extends Component<PropsType, StateType> {
   state = {
     credentialsName: '',
+    gcpRegion: '',
     serviceAccountKey: '',
+    gcpProjectID: '',
   }
 
   isDisabled = (): boolean => {
@@ -34,7 +38,21 @@ export default class GCRForm extends Component<PropsType, StateType> {
   }
   
   handleSubmit = () => {
-    // TODO: implement once api is restructured
+    let { currentProject } = this.context;
+
+    api.createGCPIntegration('<token>', {
+      gcp_region: this.state.gcpRegion,
+      gcp_key_data: this.state.serviceAccountKey,
+      gcp_project_id: this.state.gcpProjectID,
+    }, {
+      project_id: currentProject.id,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        console.log(res.data);
+      }
+    })
   }
 
   render() {
@@ -53,6 +71,14 @@ export default class GCRForm extends Component<PropsType, StateType> {
           />
           <Heading>GCP Settings</Heading>
           <Helper>Service account credentials for GCP permissions.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: uranus-north-12'
+            width='100%'
+          />
           <TextArea
             value={this.state.serviceAccountKey}
             setValue={(x: string) => this.setState({ serviceAccountKey: x })}
@@ -60,6 +86,14 @@ export default class GCRForm extends Component<PropsType, StateType> {
             placeholder='(Paste your JSON service account key here)'
             width='100%'
           />
+          <InputRow
+            type='text'
+            value={this.state.gcpProjectID}
+            setValue={(x: string) => this.setState({ gcpProjectID: x })}
+            label='GCP Project ID'
+            placeholder='ex: porter-dev-273614'
+            width='100%'
+          />
         </CredentialWrapper>
         <SaveButton
           text='Save Settings'
@@ -72,6 +106,8 @@ export default class GCRForm extends Component<PropsType, StateType> {
   }
 }
 
+GCRForm.contextType = Context;
+
 const CredentialWrapper = styled.div`
   padding: 5px 40px 25px;
   background: #ffffff11;

+ 81 - 0
dashboard/src/main/home/modals/Modal.tsx

@@ -0,0 +1,81 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  onRequestClose: () => void,
+  width?: string,
+  height?: string,
+}
+
+type StateType = {
+}
+
+export default class Modal extends Component<PropsType, StateType> {
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    document.addEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  handleClickOutside = (event: any) => {
+    if (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target)) {
+      this.props.onRequestClose();
+    }
+  }
+
+  render() {
+    let { width, height } = this.props;
+    return (
+      <Overlay>
+        <StyledModal
+          ref={this.wrapperRef}
+          width={width}
+          height={height}
+        >
+          {this.props.children}
+        </StyledModal>
+      </Overlay>
+    );
+  }
+}
+
+const Overlay = styled.div`
+  position: absolute;
+  margin: 0;
+  padding: 0;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0,0,0,0.6);
+  z-index: 3;
+`;
+
+const StyledModal = styled.div`
+  position: absolute;
+  top: calc(50% - (${(props: { width?: string, height?: string }) => props.height ? props.height : '425px'} / 2));
+  left: calc(50% - (${(props: { width?: string, height?: string }) => props.width ? props.width : '760px'} / 2));
+  display: flex;
+  justify-content: center;
+  width: ${(props: { width?: string, height?: string }) => props.width ? props.width : '760px'};
+  max-width: 80vw;
+  height: ${(props: { width?: string, height?: string }) => props.height ? props.height : '425px'};
+  border-radius: 7px;
+  border: 0;
+  background-color: #202227;
+  overflow: visible;
+  padding: 25px 32px;
+  animation: floatInModal 0.5s 0s;
+  @keyframes floatInModal {
+    from {
+      opacity: 0; transform: translateY(30px);
+    }
+    to {
+      opacity: 1; transform: translateY(0px);
+    }
+  }
+`;

+ 60 - 12
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -35,15 +35,33 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
       project_id: currentProject.id,
       cluster_id: currentCluster.id,
     }, (err: any, res: any) => {
+
       if (err) {
         this.setState({ status: 'error' });
         console.log(err)
-      } else {
+        return;
+      }
+
+      if (!currentCluster?.infra_id) return;
+
+      // Handle destroying infra we've provisioned
+      switch (currentCluster.service) {
+        case 'eks':
+          api.destroyEKS('<token>', { eks_name: currentCluster.name }, { 
+            project_id: currentProject.id,
+            infra_id: currentCluster.infra_id,
+          }, (err: any, res: any) => {
+            if (err) {
+              this.setState({ status: 'error' });
+              console.log(err)
+            } else {
+              console.log('destroyed provisioned infra.');
+            }
+          });
+          break;
 
-        // Handle destroying infra we've provisioned
-        if (currentCluster.infra_id) {
-          console.log('destroying provisioned infra...');
-          api.destroyCluster('<token>', { eks_name: currentCluster.name }, { 
+        case 'gke':
+          api.destroyGKE('<token>', { gke_name: currentCluster.name }, { 
             project_id: currentProject.id,
             infra_id: currentCluster.infra_id,
           }, (err: any, res: any) => {
@@ -54,15 +72,46 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
               console.log('destroyed provisioned infra.');
             }
           });
-        }
+          break;
 
-        this.props.setRefreshClusters(true);
-        this.setState({ status: 'successful', showDeleteOverlay: false });
-        this.context.setCurrentModal(null, null);
+        case 'doks':
+          api.destroyDOKS('<token>', { doks_name : currentCluster.name }, { 
+            project_id: currentProject.id,
+            infra_id: currentCluster.infra_id,
+          }, (err: any, res: any) => {
+            if (err) {
+              this.setState({ status: 'error' });
+              console.log(err)
+            } else {
+              console.log('destroyed provisioned infra.');
+            }
+          });
+          break;
       }
+        
+      this.props.setRefreshClusters(true);
+      this.setState({ status: 'successful', showDeleteOverlay: false });
+      this.context.setCurrentModal(null, null);
     });
   }
 
+  renderWarning = () => {
+    let { currentCluster } = this.context;
+    if (!currentCluster?.infra_id || !currentCluster.service) {
+      return(
+        <Warning highlight={true}>
+          ⚠️ Since this cluster was not provisioned by Porter, deleting the cluster will only detach this cluster from your project. To delete the cluster itself, you must do so manually.
+        </Warning>
+      )    
+    }
+
+    return(
+      <Warning highlight={true}>
+        ⚠️ Deletion may result in dangling resources. Please visit your cloud provider's console to ensure that all resources have been removed. Note that deleting the cluster does not delete your registries.
+      </Warning>
+    )    
+  }
+
   render() {
     return (
       <StyledUpdateProjectModal>
@@ -91,9 +140,8 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
           />
         </InputWrapper>
 
-        <Warning highlight={true}>
-          ⚠️ Deletion may result in dangling resources. Please visit the AWS console to ensure that all resources have been removed.
-        </Warning>
+        {this.renderWarning()}
+
         <Help 
           href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws#deleting-provisioned-resources'
           target='_blank'

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

@@ -343,7 +343,7 @@ const TitleSection = styled.div`
 `;
 
 const StyledNewProject = styled.div`
-  width: calc(90% - 150px);
+  width: calc(90% - 130px);
   min-width: 300px;
   position: relative;
   padding-top: 50px;

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

@@ -2,12 +2,14 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { InviteType } from '../../../shared/types';
-import Loading from '../../../components/Loading';
 import api from '../../../shared/api';
-import InputRow from '../../../components/values-form/InputRow';
-
 import { Context } from '../../../shared/Context';
 
+import Loading from '../../../components/Loading';
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+
 type PropsType = {
 }
 
@@ -16,14 +18,18 @@ type StateType = {
   invites: InviteType[],
   email: string,
   invalidEmail: boolean,
+  isHTTPS: boolean,
 }
 
+const dummyInvites = [];
+
 export default class InviteList extends Component<PropsType, StateType> {
   state = {
     loading: true,
     invites: [] as InviteType[],
     email: '',
     invalidEmail: false,
+    isHTTPS: (process.env.API_SERVER === 'dashboard.getporter.dev'),
   }
 
   componentDidMount() {
@@ -40,21 +46,7 @@ export default class InviteList extends Component<PropsType, StateType> {
       if (err) {
         console.log(err);
       } else {
-        this.setState({ invites: res.data, loading: false }, () => {
-          for (let i = this.state.invites.length - 1; i >= 0; i--) {
-            if (this.state.invites[i].expired && !this.state.invites[i].accepted) {
-              api.deleteInvite('<token>', {}, {
-                id: currentProject.id, invId: this.state.invites[i].id
-              }, (err: any, res: any) => {
-                if (err) {
-                  console.log(`Error deleting invite: ${err}`);
-                } else {
-                  this.state.invites.splice(i, 1);
-                }
-              })
-            }
-          }
-        });
+        this.setState({ invites: res.data, loading: false });
       }
     });
   }
@@ -116,7 +108,7 @@ export default class InviteList extends Component<PropsType, StateType> {
   copyToClip = (index: number) => {
     let { currentProject } = this.context;
     navigator.clipboard.writeText(
-      `${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}`
+      `${this.state.isHTTPS ? 'https://' : ''}${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}`
     ).then(function() {
     }, function() {
       console.log("couldn't copy link to clipboard");
@@ -126,14 +118,15 @@ export default class InviteList extends Component<PropsType, StateType> {
   renderInvitations = () => {
     let { currentProject } = this.context;
     if (this.state.loading) {
-      return (
-        <Loading />
-      )
+      return <Loading />;
     } else {
       var invContent: any[] = [];
+      var collabList: any[] = [];
+      this.state.invites.sort((a: any, b: any) => (a.email > b.email) ? 1 : -1);
+      this.state.invites.sort((a: any, b: any) => (a.accepted > b.accepted) ? 1 : -1);
       for (let i = 0; i < this.state.invites.length; i++) {
         if (this.state.invites[i].accepted) {
-          invContent.push(
+          collabList.push(
             <Tr key={i}>
               <MailTd isTop={i === 0}>
                 {this.state.invites[i].email}
@@ -142,13 +135,13 @@ export default class InviteList extends Component<PropsType, StateType> {
               </LinkTd>
               <Td isTop={i === 0}>
                 <CopyButton
-                  onClick={() => this.deleteInvite(i)}
+                  invis={true}
                 >
                   Remove
                 </CopyButton>
               </Td>
             </Tr>
-          )
+          );
         } else if (this.state.invites[i].expired) {
           invContent.push(
             <Tr key={i}>
@@ -157,16 +150,12 @@ export default class InviteList extends Component<PropsType, StateType> {
               </MailTd>
               <LinkTd isTop={i === 0}>
                 <Rower>
-                  <ShareLink
-                    disabled={true}
-                    type='string'
-                    placeholder='Link expired'
-                  />
-                  <CopyButton
+                  Link Expired.
+                  <NewLinkButton
                     onClick={() => this.replaceInvite(i)}
                   >
-                    Get New Link
-                  </CopyButton>
+                    <u>Generate a new link</u>
+                  </NewLinkButton>
                 </Rower>
               </LinkTd>
               <Td isTop={i === 0}>
@@ -177,7 +166,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                 </CopyButton>
               </Td>
             </Tr>
-          )
+          );
         } else {
           invContent.push(
             <Tr key={i}>
@@ -189,7 +178,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                   <ShareLink
                     disabled={true}
                     type='string'
-                    value={`${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[i].token}`}
+                    value={`${this.state.isHTTPS ? 'https://' : ''}${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[i].token}`}
                     placeholder='Unable to retrieve link'
                   />
                   <CopyButton
@@ -210,12 +199,14 @@ export default class InviteList extends Component<PropsType, StateType> {
           )
         }
       }
+
       return (
         <>
-          <Subsubtitle>Collaborators</Subsubtitle>
-          {invContent.length > 0
-            ? <Table><tbody>{invContent}</tbody></Table>
-            : <BodyText>This project currently has no collaborators.</BodyText>
+          <Heading>Invites & Collaborators</Heading>
+          <Helper>Manage pending invites and view collaborators.</Helper>
+          {((invContent.length > 0) || (collabList.length > 0))
+            ? <Table><tbody>{invContent}{collabList}</tbody></Table>
+            : <Placeholder>This project currently has no invites or collaborators.</Placeholder>
           }
         </>
       )
@@ -225,27 +216,29 @@ export default class InviteList extends Component<PropsType, StateType> {
   render() {
     return (
       <>
-        <Subtitle>Manage Access</Subtitle>
-        <CreateInvite>
-          <InputRow
-            label='Invite Collaborators'
-            value={this.state.email}
-            type='text'
-            setValue={(x: string) => this.setState({ email: x })}
-            width='324px'
-            placeholder='ex. mrp@getporter.dev'
-          />
+        <Heading isAtTop={true}>Share Project</Heading>
+        <Helper>Generate a project invite for another admin user.</Helper>
+        <DarkMatter />
+        <InputRow
+          value={this.state.email}
+          type='text'
+          setValue={(x: string) => this.setState({ email: x })}
+          width='calc(100%)'
+          placeholder='ex: mrp@getporter.dev'
+        />
+        <ButtonWrapper>
           <InviteButton
+            disabled={false}
             onClick={() => this.validateEmail()}
           >
-            Invite!
+            Create Invite
           </InviteButton>
-        </CreateInvite>
-        {this.state.invalidEmail &&
-          <Invalid>
-            Invalid Email Address. Try Again.
-          </Invalid>
-        }
+          {this.state.invalidEmail &&
+            <Invalid>
+              Invalid email address. Please try again.
+            </Invalid>
+          }
+        </ButtonWrapper>
         {this.renderInvitations()}
       </>
     )
@@ -254,43 +247,40 @@ export default class InviteList extends Component<PropsType, StateType> {
 
 InviteList.contextType = Context;
 
-const Subtitle = styled.div`
-  font-size: 18px;
-  font-weight: 700;
-  font-family: 'Work Sans', sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-bottom: 24px;
-  margin-top: 32px;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  margin-top: 23px;
+  justify-content: center;
+  background: #ffffff11;
+  border-radius: 5px;
+  color: #ffffff44;
+  font-size: 13px;
 `;
 
-const Subsubtitle = styled.div`
-  font-size: 13px;
-  font-family: 'Work Sans', sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-bottom: 12px;
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
 `;
 
-const BodyText = styled.div`
-  color: #ffffff66;
-  font-weight: 400;
-  font-size: 13px;
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -10px;
 `;
 
 const CopyButton = styled.div`
+  visibility: ${(props: { invis?: boolean }) => props.invis ? 'hidden' : 'visible'};
   color: #ffffff;
   font-weight: 400;
   font-size: 13px;
   margin-left: 12px;
   float: right;
-  width: 128px;
+  width: 120px;
   padding-top: 7px;
   padding-bottom: 6px;
+  cursor: pointer;
   border-radius: 5px;
   border: 1px solid #ffffff20;
   background-color: #ffffff10;
@@ -303,33 +293,58 @@ const CopyButton = styled.div`
   }
 `;
 
-const InviteButton = styled(CopyButton)`
-  margin-bottom: 14px;
+const NewLinkButton = styled(CopyButton)`
+  border: none;
+  width: auto;
+  background-color: transparent;
+  :hover {
+    border: none;
+    background-color: transparent;
+  }
 `;
 
-const Rower = styled.div`
+const InviteButton = styled.div<{ disabled: boolean }>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
   display: flex;
-  flex-direction: row;
   align-items: center;
+  padding: 0 15px;
+  margin-top: 10px;
+  text-align: left;
+  background: red;
+  float: left;
+  margin-left: 0;
   justify-content: center;
+  border: 0;
+  border-radius: 5px;
+  background: ${props => !props.disabled ? '#616FEEcc' : '#aaaabb'};
+  box-shadow: ${props => !props.disabled ? '0 2px 5px 0 #00000030' : 'none'};
+  cursor: ${props => !props.disabled ? 'pointer' : 'default'};
+  user-select: none;
+  :focus { outline: 0 }
+  :hover {
+    filter: ${props => !props.disabled ? 'brightness(120%)' : ''};
+  }
+  margin-bottom: 10px;
 `;
 
-const CreateInvite = styled.div`
+const Rower = styled.div`
   display: flex;
   flex-direction: row;
-  align-items: flex-end;
-  margin-top: -20px;
-  margin-bottom: 14px;
+  align-items: center;
 `;
 
 const ShareLink = styled.input`
   outline: none;
   border: none;
   font-size: 13px;
-  background: #ffffff11;
-  border: 1px solid #ffffff55;
-  width: 50%;
+  background: none;
+  width: 60%;
   color: #74a5f7;
+  margin-left: -10px;
   padding: 5px 10px;
   height: 30px;
   text-overflow: ellipsis;
@@ -341,21 +356,22 @@ const ShareLink = styled.input`
   }
 `;
 
-const Spacer = styled.div`
-  height: 24px;
-`;
-
 const Table = styled.table`
   width: 100%;
   border-spacing: 0px;
   border: 1px solid #ffffff55;
+  margin-top: 22px;
   border-radius: 5px;
+  background: #ffffff11;
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
 `;
 
 const Td = styled.td`
   white-space: nowrap;
-  padding: 20px 0px;
-  border-top: ${(props: {isTop: boolean}) => (props.isTop ? 'none' : '1px solid #ffffff55')};
+  padding: 6px 0px;
+  border-top: ${(props: { isTop: boolean }) => (props.isTop ? 'none' : '1px solid #ffffff55')};
   &:last-child {
     padding-right: 16px;
   }
@@ -365,24 +381,21 @@ const Tr = styled.tr`
 `;
 
 const MailTd = styled(Td)`
-  padding-left: 16px;
-  max-width: 242px;
-  min-width: 242px;
+  padding: 0 12px;
+  max-width: 186px;
+  min-width: 186px;
   overflow: hidden;
   text-overflow: ellipsis;
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
 `;
 
 const LinkTd = styled(Td)`
-  width: 100%;
+  width: calc(100% - 40px);
+  padding-left: 40px;
 `;
 
 const Invalid = styled.div`
-  margin-top: -26px;
-  margin-bottom: 26px;
-  color: #fa0a26;
+  color: #f5cb42;
+  margin-left: 15px;
   font-size: 13px;
   font-family: 'Work Sans', sans-serif;
 `;

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

@@ -1,80 +1,80 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import InviteList from './InviteList';
-
 import { Context } from '../../../shared/Context';
 
+import InviteList from './InviteList';
+import TabRegion from '../../../components/TabRegion';
+import Heading from '../../../components/values-form/Heading';
+import Helper from '../../../components/values-form/Helper';
+
 type PropsType = {
   setCurrentView: (x: string) => void,
 }
 
 type StateType = {
   projectName: string,
+  currentTab: string,
 }
 
+const tabOptions = [
+  { value: 'manage-access', label: 'Manage Access' },
+  { value: 'additional-settings', label: 'Additional Settings' }
+];
+
 export default class ProjectSettings extends Component<PropsType, StateType> {
   state = {
     projectName: '',
+    currentTab: 'manage-access',
   }
 
   componentDidMount() {
-    let { currentProject, user } = this.context;
+    let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
   }
 
-  renderTitle = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
+  renderTabContents = () => {
+    if (this.state.currentTab === 'manage-access') {
+      return <InviteList />;
+    } else {
       return (
         <>
-          <TitleSection>
-            <Title>Project Settings</Title>
-          </TitleSection>
-          <LineBreak />
-        </>
-      );
-    }
-  }
+          <Heading isAtTop={true}>Delete Project</Heading>
+          <Helper>
+            Permanently delete this project. This will destroy all clusters tied to this project that have been provisioned by Porter. Note that this will not delete the image registries provisioned by Porter. To delete the registries, please do so manually in your cloud console.
+          </Helper>
 
-  renderDelete = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
-      return (
-        <>
-          <Subtitle>Other Settings</Subtitle>
-          <Rower>
-            <BodyText>
-              Delete this project: 
-            </BodyText>
-            <DeleteButton
-              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
-                currentProject: currentProject,
+          <Warning highlight={true}>This action cannot be undone.</Warning>
+
+
+          <DeleteButton
+            onClick={() => {
+              this.context.setCurrentModal('UpdateProjectModal', {
+                currentProject: this.context.currentProject,
                 setCurrentView: this.props.setCurrentView,
-              })}
-            >
-              Delete
-            </DeleteButton>
-          </Rower>
+              });
+            }}
+          >
+            Delete Project
+          </DeleteButton>
         </>
-      )
+      );
     }
   }
 
-  renderContents = () => {
-    return (
-      <ContentHolder>
-          <InviteList />
-          {this.renderDelete()}
-      </ContentHolder>
-    )
-  }
-
   render () {
     return (
       <StyledProjectSettings>
-        {this.renderTitle()}
-        {this.renderContents()}
+        <TitleSection>
+          <Title>Project Settings</Title>
+        </TitleSection>
+        <TabRegion
+          currentTab={this.state.currentTab}
+          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          options={tabOptions}
+        >
+          {this.renderTabContents()}
+        </TabRegion>
       </StyledProjectSettings>
     );
   }
@@ -82,6 +82,12 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
 
 ProjectSettings.contextType = Context;
 
+const Warning = styled.div`
+  font-size: 13px;
+  color: ${(props: { highlight: boolean, makeFlush?: boolean }) => props.highlight ? '#f5cb42' : ''};
+  margin-bottom: 20px;
+`;
+
 const Title = styled.div`
   font-size: 24px;
   font-weight: 600;
@@ -93,7 +99,7 @@ const Title = styled.div`
 `;
 
 const TitleSection = styled.div`
-  margin-bottom: 20px;
+  margin-bottom: 13px;
   display: flex;
   flex-direction: row;
   align-items: center;
@@ -101,77 +107,38 @@ const TitleSection = styled.div`
 `;
 
 const StyledProjectSettings = styled.div`
-  width: calc(90% - 150px);
+  width: calc(90% - 130px);
   min-width: 300px;
-  padding-top: 45px;
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px -20px;
+  padding-top: 70px;
+  height: 100vh;
 `;
 
-const Subtitle = styled.div`
-  font-size: 18px;
-  font-weight: 700;
-  font-family: 'Work Sans', sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-bottom: 24px;
-  margin-top: 32px;
-`;
-
-const BodyText = styled.div`
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-`;
-
-const CopyButton = styled.div`
-  color: #ffffff;
-  font-weight: 400;
+const DeleteButton = styled.div`
+  height: 35px;
   font-size: 13px;
-  margin-left: 12px;
-  float: right;
-  width: 128px;
-  padding-top: 8px;
-  padding-bottom: 8px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 10px;
+  text-align: left;
+  background: red;
+  float: left;
+  margin-left: 0;
+  justify-content: center;
   border-radius: 5px;
-  border: 1px solid #ffffff20;
-  background-color: #ffffff10;
-  text-align: center;
-  overflow: hidden;
-  transition: all 0.1s ease-out;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: pointer;
+  user-select: none;
+  :focus { outline: 0 }
   :hover {
-    border: 1px solid #ffffff66;
-    background-color: #ffffff20;
+    filter: brightness(120%);
   }
-`;
-
-const DeleteButton = styled(CopyButton)`
-  background-color: #b91133;
+  background: #b91133;
   border: none;
-  width: 88px;
-  margin-left: 20px;
   :hover {
-    background-color: #b91133;
     filter: brightness(120%);
-    border: none;
   }
-`;
-
-const ContentHolder = styled.div`
-  min-width: 420px;
-  width: 100%;
-  margin-bottom: 55px;
-`;
-
-const Rower = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
 `;

+ 42 - 31
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -7,6 +7,7 @@ import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 
+import SelectRow from '../../../components/values-form/SelectRow';
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
 import Heading from '../../../components/values-form/Heading';
@@ -34,10 +35,33 @@ const provisionOptions = [
   { value: 'eks', label: 'Elastic Kubernetes Service (EKS)' },
 ];
 
+const regionOptions = [
+  { value: 'us-east-1', label: 'US East (N. Virginia) us-east-1' },
+  { value: 'us-east-2', label: 'US East (Ohio) us-east-2' },
+  { value: 'us-west-1', label: 'US West (N. California) us-west-1' },
+  { value: 'us-west-2', label: 'US West (Oregon) us-west-2' },
+  { value: 'af-south-1', label: 'Africa (Cape Town) af-south-1' },
+  { value: 'ap-east-1', label: 'Asia Pacific (Hong Kong)ap-east-1' },
+  { value: 'ap-south-1', label: 'Asia Pacific (Mumbai) ap-south-1' },
+  { value: 'ap-northeast-2', label: 'Asia Pacific (Seoul) ap-northeast-2' },
+  { value: 'ap-southeast-1', label: 'Asia Pacific (Singapore) ap-southeast-1' },
+  { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney) ap-southeast-2' },
+  { value: 'ap-northeast-1', label: 'Asia Pacific (Tokyo) ap-northeast-1' },
+  { value: 'ca-central-1', label: 'Canada (Central) ca-central-1' },
+  { value: 'eu-central-1', label: 'Europe (Frankfurt) eu-central-1' },
+  { value: 'eu-west-1', label: 'Europe (Ireland) eu-west-1' },
+  { value: 'eu-west-2', label: 'Europe (London) eu-west-2' },
+  { value: 'eu-south-1', label: 'Europe (Milan) eu-south-1' },
+  { value: 'eu-west-3', label: 'Europe (Paris) eu-west-3' },
+  { value: 'eu-north-1', label: 'Europe (Stockholm) eu-north-1' },
+  { value: 'me-south-1', label: 'Middle East (Bahrain) me-south-1' },
+  { value: 'sa-east-1', label: 'South America (São Paulo) sa-east-1' },
+];
+
 // TODO: Consolidate across forms w/ HOC
 export default class AWSFormSection extends Component<PropsType, StateType> {
   state = {
-    awsRegion: '',
+    awsRegion: 'us-east-1',
     awsAccessId: '',
     awsSecretKey: '',
     selectedInfras: [...provisionOptions],
@@ -55,19 +79,9 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
       infras.forEach(
         (infra: InfraType, i: number) => {
           let { kind, status } = infra;
-          if (
-            kind === 'ecr'
-            && (status === 'creating' || status === 'created')
-          ) {
-            filtered = filtered.filter((item: any) => {
-              return item.value !== 'ecr';
-            });
-          } else if (
-            kind === 'eks'
-            && (status === 'creating' || status === 'created')
-          ) {
+          if (status === 'creating' || status === 'created') {
             filtered = filtered.filter((item: any) => {
-              return item.value !== 'eks';
+              return item.value !== kind;
             });
           }
         }
@@ -116,6 +130,10 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
         handleError();
         return;
       } else {
+        let proj = res.data;
+
+        // Need to set project list for dropdown
+        // TODO: consolidate into ProjectSection (case on exists in list on set)
         api.getProjects('<token>', {}, { 
           id: user.userId 
         }, (err: any, res: any) => {
@@ -125,20 +143,16 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
             return;
           }
           setProjects(res.data);
-          if (res.data.length > 0) {
-            let tgtProject = res.data.find((el: ProjectType) => {
-              return el.name === projectName;
-            });
-            setCurrentProject(tgtProject);
-            callback && callback();
-          } 
+          setCurrentProject(proj, () => {
+            callback && callback()
+          });
         });
       }
     });
   }
 
   provisionECR = (callback?: any) => {
-    console.log('Provisioning ECR')
+    console.log('Provisioning ECR');
     let { awsAccessId, awsSecretKey, awsRegion } = this.state;
     let { currentProject } = this.context;
     let { handleError } = this.props;
@@ -206,9 +220,7 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
     let { projectName, setCurrentView } = this.props;
     let { selectedInfras } = this.state;
 
-    console.log(selectedInfras);
     if (!projectName) {
-      console.log(selectedInfras)
       if (selectedInfras.length === 2) {
         // Case: project exists, provision ECR + EKS
         this.provisionECR(this.provisionEKS);
@@ -260,14 +272,13 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
               Guide
             </GuideButton>
           </Heading>
-          <InputRow
-            type='text'
+          <SelectRow
+            options={regionOptions}
+            width='100%'
             value={awsRegion}
-            setValue={(x: string) => this.setState({ awsRegion: x })}
+            dropdownMaxHeight='240px'
+            setActiveValue={(x: string) => this.setState({ awsRegion: x })}
             label='📍 AWS Region'
-            placeholder='ex: us-east-2'
-            width='100%'
-            isRequired={true}
           />
           <InputRow
             type='text'
@@ -288,8 +299,8 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
             isRequired={true}
           />
           <Br />
-          <Heading>Resources</Heading>
-          <Helper>Porter will provision the following resources</Helper>
+          <Heading>AWS Resources</Heading>
+          <Helper>Porter will provision the following AWS resources</Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}

+ 283 - 0
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -0,0 +1,283 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+import { isAlphanumeric } from '../../../shared/common';
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType, InfraType } from '../../../shared/types';
+
+import SelectRow from '../../../components/values-form/SelectRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void,
+  handleError: () => void,
+  projectName: string,
+  infras: InfraType[],
+};
+
+type StateType = {
+  selectedInfras: { value: string, label: string }[],
+  subscriptionTier: string,
+  doRegion: string,
+};
+
+const provisionOptions = [
+  { value: 'docr', label: 'Digital Ocean Container Registry' },
+  { value: 'doks', label: 'Digital Ocean Kubernetes Service' },
+];
+
+const tierOptions = [
+  { value: 'basic', label: 'Basic' },
+  { value: 'starter', label: 'Starter' },
+  { value: 'professional', label: 'Professional' },
+];
+
+const regionOptions = [
+  { value: 'ams3', label: 'Amsterdam 3' },
+  { value: 'blr1', label: 'Bangalore 1' },
+  { value: 'fra1', label: 'Frankfurt 1' },
+  { value: 'lon1', label: 'London 1' },
+  { value: 'nyc1', label: 'New York 1' },
+  { value: 'nyc3', label: 'New York 3' },
+  { value: 'sfo2', label: 'San Francisco 2' },
+  { value: 'sfo3', label: 'San Francisco 3' },
+  { value: 'sgp1', label: 'Singapore 1' },
+  { value: 'tor1', label: 'Toronto 1' },
+];
+
+// TODO: Consolidate across forms w/ HOC
+export default class DOFormSection extends Component<PropsType, StateType> {
+  state = {
+    selectedInfras: [...provisionOptions],
+    subscriptionTier: 'starter',
+    doRegion: 'nyc1',
+  }
+
+  componentDidMount = () => {
+    let { infras } = this.props;
+    let { selectedInfras } = this.state;
+
+    if (infras) {
+      
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      infras.forEach(
+        (infra: InfraType, i: number) => {
+          let { kind, status } = infra;
+          if (status === 'creating' || status === 'created') {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== kind;
+            });
+          }
+        }
+      );
+      this.setState({ selectedInfras: filtered });
+    }
+  }
+
+  checkFormDisabled = () => {
+    let { 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return !isAlphanumeric(projectName) || selectedInfras.length === 0;
+    } else {
+      return selectedInfras.length === 0;
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+    } = this.context;
+
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      } else {
+        let proj = res.data;
+
+        // Need to set project list for dropdown
+        // TODO: consolidate into ProjectSection (case on exists in list on set)
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+            handleError();
+            return;
+          }
+          setProjects(res.data);
+          setCurrentProject(proj);
+          callback && callback(proj.id);
+        });
+      }
+    });
+  }
+
+  doRedirect = (projectId: number) => {
+    let { subscriptionTier, doRegion, selectedInfras } = this.state;
+    let redirectUrl = `/api/oauth/projects/${projectId}/digitalocean?project_id=${projectId}&provision=do`;
+    redirectUrl += `&tier=${subscriptionTier}&region=${doRegion}`;
+    selectedInfras.forEach((option: { value: string, label: string }) => {
+      redirectUrl += `&infras=${option.value}`;
+    });
+    window.location.href = redirectUrl;
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateDO = () => {
+    let { projectName } = this.props;
+    let { selectedInfras } = this.state;
+    let { currentProject } = this.context;
+
+    if (!projectName) {
+      this.doRedirect(currentProject.id);
+    } else {
+      this.createProject((projectId: number) => this.doRedirect(projectId));
+    }
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let { selectedInfras, subscriptionTier, doRegion } = this.state;
+
+    return (
+      <StyledAWSFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>DigitalOcean Settings</Heading>
+          <SelectRow
+            options={tierOptions}
+            width='100%'
+            value={subscriptionTier}
+            setActiveValue={(x: string) => this.setState({ subscriptionTier: x })}
+            label='💰 Subscription Tier'
+          />
+          <SelectRow
+            options={regionOptions}
+            width='100%'
+            dropdownMaxHeight='240px'
+            value={doRegion}
+            setActiveValue={(x: string) => this.setState({ doRegion: x })}
+            label='📍 DigitalOcean Region'
+          />
+          <Br />
+          <Heading>DigitalOcean Resources</Heading>
+          <Helper>Porter will provision the following DigitalOcean resources</Helper>
+          <CheckboxList
+            options={provisionOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        {this.props.children ? this.props.children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={this.checkFormDisabled()}
+          onClick={this.onCreateDO}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledAWSFormSection>
+    );
+  }
+}
+
+DOFormSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledAWSFormSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  margin-bottom: 25px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;

+ 221 - 19
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -2,7 +2,12 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import close from '../../../assets/close.png';
+import { isAlphanumeric } from '../../../shared/common';
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType, InfraType } from '../../../shared/types';
 
+import SelectRow from '../../../components/values-form/SelectRow';
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
 import Heading from '../../../components/values-form/Heading';
@@ -11,6 +16,10 @@ import CheckboxList from '../../../components/values-form/CheckboxList';
 
 type PropsType = {
   setSelectedProvisioner: (x: string | null) => void,
+  handleError: () => void,
+  projectName: string,
+  setCurrentView: (x: string | null, data?: any) => void,
+  infras: InfraType[],
 };
 
 type StateType = {
@@ -18,19 +27,205 @@ type StateType = {
   gcpProjectId: string,
   gcpKeyData: string,
   selectedInfras: { value: string, label: string }[],
+  buttonStatus: string,
 };
 
-const dummyOptions = [
+const provisionOptions = [
   { value: 'gcr', label: 'Google Container Registry (GCR)' },
-  { value: 'gke', label: 'Googke Kubernetes Engine (GKE)' },
+  { value: 'gke', label: 'Google Kubernetes Engine (GKE)' },
 ];
 
+const regionOptions = [
+  { value: 'asia-east1', label: 'asia-east1' },
+  { value: 'asia-east2', label: 'asia-east2' },
+  { value: 'asia-northeast1', label: 'asia-northeast1' },
+  { value: 'asia-northeast2', label: 'asia-northeast2' },
+  { value: 'asia-northeast3', label: 'asia-northeast3' },
+  { value: 'asia-south1', label: 'asia-south1' },
+  { value: 'asia-southeast1', label: 'asia-southeast1' },
+  { value: 'asia-southeast2', label: 'asia-southeast2' },
+  { value: 'australia-southeast1', label: 'australia-southeast1' },
+  { value: 'europe-north1', label: 'europe-north1' },
+  { value: 'europe-west1', label: 'europe-west1' },
+  { value: 'europe-west2', label: 'europe-west2' },
+  { value: 'europe-west3', label: 'europe-west3' },
+  { value: 'europe-west4', label: 'europe-west4' },
+  { value: 'europe-west6', label: 'europe-west6' },
+  { value: 'northamerica-northeast1', label: 'northamerica-northeast1' },
+  { value: 'southamerica-east1', label: 'southamerica-east1' },
+  { value: 'us-central1', label: 'us-central1' },
+  { value: 'us-east1', label: 'us-east1' },
+  { value: 'us-east4', label: 'us-east4' },
+  { value: 'us-west1', label: 'us-west1' },
+  { value: 'us-west2', label: 'us-west2' },
+  { value: 'us-west3', label: 'us-west3' },
+  { value: 'us-west4', label: 'us-west4' },
+]
+
 export default class GCPFormSection extends Component<PropsType, StateType> {
   state = {
-    gcpRegion: '',
+    gcpRegion: 'us-east1',
     gcpProjectId: '',
     gcpKeyData: '',
-    selectedInfras: [] as { value: string, label: string }[],
+    selectedInfras: [...provisionOptions],
+    buttonStatus: '',
+  }
+
+  componentDidMount = () => {
+    let { infras } = this.props;
+    let { selectedInfras } = this.state;
+
+    if (infras) {
+      
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      infras.forEach(
+        (infra: InfraType, i: number) => {
+          let { kind, status } = infra;
+          if (status === 'creating' || status === 'created') {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== kind;
+            });
+          }
+        }
+      );
+      this.setState({ selectedInfras: filtered });
+    }
+  }
+
+  checkFormDisabled = () => {
+    let { 
+      gcpRegion,
+      gcpProjectId, 
+      gcpKeyData, 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return (
+        !isAlphanumeric(projectName) 
+          || !(gcpProjectId !== '' && gcpKeyData !== '' && gcpRegion !== '')
+          || selectedInfras.length === 0
+      );
+    } else {
+      return (
+        !(gcpProjectId !== '' && gcpKeyData !== '' && gcpRegion !== '')
+          || selectedInfras.length === 0
+      );
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+    } = this.context;
+
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      } else {
+        let proj = res.data;
+
+        // Need to set project list for dropdown
+        // TODO: consolidate into ProjectSection (case on exists in list on set)
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+            handleError();
+            return;
+          }
+          setProjects(res.data);
+          setCurrentProject(proj);
+          callback && callback();
+        });
+      }
+    });
+  }
+
+  provisionGCR = (id: number, callback?: any) => {
+    console.log('Provisioning GCR')
+    let { currentProject } = this.context;
+    let { handleError } = this.props;
+
+    api.createGCR('<token>', {
+      gcp_integration_id: id,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      callback && callback();
+    });
+  }
+
+  provisionGKE = (id: number) => {
+    console.log('Provisioning GKE');
+    let { setCurrentView, handleError } = this.props;
+    let { currentProject } = this.context;
+
+    let clusterName = `${currentProject.name}-cluster`
+    api.createGKE('<token>', {
+      gke_name: clusterName,
+      gcp_integration_id: id,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      setCurrentView('provisioner');
+    })
+  }
+
+  handleCreateFlow = () => {
+    let { setCurrentView } = this.props;
+    let { selectedInfras, gcpKeyData, gcpProjectId, gcpRegion } = this.state;
+    let { currentProject } = this.context;
+    api.createGCPIntegration('<token>', {
+      gcp_region: gcpRegion,
+      gcp_key_data: gcpKeyData,
+      gcp_project_id: gcpProjectId,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcp provisioned with response: ', res.data);
+        let { id } = res.data;
+
+        if (selectedInfras.length === 2) {
+          // Case: project exists, provision GCR + GKE
+          this.provisionGCR(id, () => this.provisionGKE(id));
+        } else if (selectedInfras[0].value === 'gcr') {
+          // Case: project exists, only provision GCR
+          this.provisionGCR(id, () => setCurrentView('provisioner'));
+        } else {
+          // Case: project exists, only provision GKE
+          this.provisionGKE(id);
+        }
+      }
+    });
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateGCP = () => {
+    let { projectName } = this.props;
+
+    if (!projectName) {
+      this.handleCreateFlow();
+    } else {
+      this.createProject(this.handleCreateFlow);
+    }
   }
 
   render() {
@@ -51,28 +246,27 @@ export default class GCPFormSection extends Component<PropsType, StateType> {
           <Heading isAtTop={true}>
             GCP Credentials
             <GuideButton 
-              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' 
+              href='https://docs.getporter.dev/docs/getting-started-on-gcp'
               target='_blank'
             >
               <i className="material-icons-outlined">help</i> 
               Guide
             </GuideButton>
           </Heading>
-          <InputRow
-            type='text'
+          <SelectRow
+            options={regionOptions}
+            width='100%'
             value={gcpRegion}
-            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            dropdownMaxHeight='240px'
+            setActiveValue={(x: string) => this.setState({ gcpRegion: x })}
             label='📍 GCP Region'
-            placeholder='ex: us-central1-a'
-            width='100%'
-            isRequired={true}
           />
           <InputRow
             type='text'
             value={gcpProjectId}
             setValue={(x: string) => this.setState({ gcpProjectId: x })}
             label='🏷️ GCP Project ID'
-            placeholder='ex: pale-moon-24601'
+            placeholder='ex: blindfold-ceiling-24601'
             width='100%'
             isRequired={true}
           />
@@ -80,26 +274,27 @@ export default class GCPFormSection extends Component<PropsType, StateType> {
             type='password'
             value={gcpKeyData}
             setValue={(x: string) => this.setState({ gcpKeyData: x })}
-            label='🔒 GCP Key Data'
+            label='🔒 GCP Key Data (JSON)'
             placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
             width='100%'
             isRequired={true}
           />
           <Br />
-          <Heading>Resources</Heading>
-          <Helper>Porter will provision the following resources</Helper>
+          <Heading>GCP Resources</Heading>
+          <Helper>Porter will provision the following GCP resources</Helper>
           <CheckboxList
-            options={dummyOptions}
+            options={provisionOptions}
             selected={selectedInfras}
             setSelected={(x: { value: string, label: string }[]) => {
               this.setState({ selectedInfras: x });
             }}
           />
         </FormSection>
+        {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text='Submit'
-          disabled={true}
-          onClick={() => console.log('oolala')}
+          disabled={this.checkFormDisabled()}
+          onClick={this.onCreateGCP}
           makeFlush={true}
           helper='Note: Provisioning can take up to 15 minutes'
         />
@@ -108,6 +303,12 @@ export default class GCPFormSection extends Component<PropsType, StateType> {
   }
 }
 
+GCPFormSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 2px;
@@ -115,7 +316,7 @@ const Br = styled.div`
 
 const StyledGCPFormSection = styled.div`
   position: relative;
-  padding-bottom: 70px;
+  padding-bottom: 35px;
 `;
 
 const FormSection = styled.div`
@@ -123,6 +324,7 @@ const FormSection = styled.div`
   margin-top: 25px;
   background: #26282f;
   border-radius: 5px;
+  margin-bottom: 25px;
   padding: 25px;
   padding-bottom: 16px;
   font-size: 13px;

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

@@ -31,7 +31,7 @@ export default class InfraStatuses extends Component<PropsType, StateType> {
       <StyledInfraStatuses>
         {this.props.infras.map((infra: InfraType, i: number) => {
           return (
-            <InfraRow>
+            <InfraRow key={infra.id}>
               {this.renderStatusIcon(infra.status)}
               {infraNames[infra.kind]}
             </InfraRow>

+ 22 - 4
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -8,6 +8,7 @@ import { InfraType } from '../../../shared/types';
 import Helper from '../../../components/values-form/Helper';
 import AWSFormSection from './AWSFormSection';
 import GCPFormSection from './GCPFormSection';
+import DOFormSection from './DOFormSection';
 import SaveButton from '../../../components/SaveButton';
 import ExistingClusterSection from './ExistingClusterSection';
 
@@ -94,13 +95,28 @@ export default class NewProject extends Component<PropsType, StateType> {
       case 'gcp':
         return (
           <GCPFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setCurrentView={setCurrentView}
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
             }}
-          />
+          >
+            {renderSkipHelper()}
+          </GCPFormSection>
         );
       case 'do':
-        return <h1>most</h1>;
+        return (
+          <DOFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          />
+        )
       default:
         return (
           <ExistingClusterSection 
@@ -119,8 +135,10 @@ export default class NewProject extends Component<PropsType, StateType> {
     return (
       <StyledProvisionerSettings>
         <Helper>
-          Need a cluster? Provision through Porter: 
-          {isInNewProject && <Required>*</Required>}
+          {isInNewProject 
+            ? <>Select your hosting backend:<Required>*</Required></>
+            : 'Need a cluster? Provision through Porter:'
+          }
         </Helper>
         {!selectedProvider ? (
           <BlockList>

+ 105 - 24
dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -1,5 +1,6 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
+import posthog from 'posthog-js';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
@@ -34,7 +35,7 @@ const dummyInfras = [
   { kind: 'ecr', status: 'created', id: 2, project_id: 1 },
 ];
 
-export default class Provisioner extends Component<PropsType, StateType> {
+export default class ProvisionerStatus extends Component<PropsType, StateType> {
   state = {
     error: false,
     logs: [] as string[],
@@ -45,7 +46,18 @@ export default class Provisioner extends Component<PropsType, StateType> {
     infras: [] as InfraType[],
   }
 
+  parentRef = React.createRef<HTMLDivElement>()
+
+  scrollToBottom = (smooth: boolean) => {
+    if (smooth) {
+      this.parentRef.current.lastElementChild.scrollIntoView({ behavior: "smooth" })
+    } else {
+      this.parentRef.current.lastElementChild.scrollIntoView({ behavior: "auto" })
+    }
+  }
+
   componentDidMount() {
+    console.log('mounting provisioner')
     let { currentProject } = this.context;
     let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
 
@@ -56,9 +68,14 @@ export default class Provisioner extends Component<PropsType, StateType> {
       if (err) {
         console.log(err);
       } 
+      
       let infras = filterOldInfras(res.data);
       let error = false;
+
+      let maxStep = {} as Record<string, number>
+
       infras.forEach((infra: InfraType, i: number) => {
+        maxStep[infra.kind] = null;
         if (infra.status === 'error') {
           error = true;
         }
@@ -66,26 +83,22 @@ export default class Provisioner extends Component<PropsType, StateType> {
 
       // Filter historical infras list for most current instances of each
       let websockets = infras.map((infra: any) => {
-        let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
+        let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.id}/logs`)
         return this.setupWebsocket(ws, infra)
       });
   
-      this.setState({ error, infras, websockets, logs: ["Provisioning resources..."] });
+      this.setState({ error, infras, websockets, maxStep, logs: ["Provisioning resources..."] });
     });
   }
 
   componentWillUnmount() {
-    if (!this.state.websockets) { return; }
+    if (this.state.websockets.length == 0) { return; }
 
     this.state.websockets.forEach((ws: any) => {
       ws.close()
     })
   }
 
-  scrollToBottom = () => {
-    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
-  }
-
   isJSON = (str: string) => {
     try {
       JSON.parse(str);
@@ -123,17 +136,24 @@ export default class Provisioner extends Component<PropsType, StateType> {
       }
 
       if (err) {
+        posthog.capture('Provisioning Error', {error: err});
+
         let e = ansiparse(err).map((el: any) => {
           return el.text;
         })
-        this.setState({ logs: e, error: true });
+
+        let index = this.state.infras.findIndex(el => el.kind === infra.kind)
+        infra.status = "error"
+        let infras = this.state.infras
+        infras[index] = infra
+        this.setState({ logs: [...this.state.logs, ...e], error: true, infras });
         return;
       }
 
       if (validEvents.length == 0) {
         return;
       }
-      
+
       if (!this.state.maxStep[infra.kind] || !this.state.maxStep[infra.kind]["total_resources"]) {
         this.setState({
           maxStep: {
@@ -159,12 +179,12 @@ export default class Provisioner extends Component<PropsType, StateType> {
           [infra.kind] : validEvents[validEvents.length - 1]["created_resources"]
         },
       }, () => {
-        this.scrollToBottom()
+        this.scrollToBottom(false)
       })
     }
 
     ws.onerror = (err: ErrorEvent) => {
-      console.log(err)
+      console.log('websocket err', err)
     }
 
     ws.onclose = () => {
@@ -174,8 +194,6 @@ export default class Provisioner extends Component<PropsType, StateType> {
     return ws
   }
 
-  scrollRef = React.createRef<HTMLDivElement>();
-
   renderLogs = () => {
     return this.state.logs.map((log, i) => {
       return <Log key={i}>{log}</Log>;
@@ -192,13 +210,44 @@ export default class Provisioner extends Component<PropsType, StateType> {
         } else if (res.data) {
           let clusters = res.data;
           if (clusters.length > 0) {
+            // console.log('response :', res.data);
             this.props.setCurrentView('dashboard');
+            // console.log('provision end project: ', this.context.currentProject);
+            // console.log('provision end cluster: ', this.context.currentCluster);
             clearInterval(myInterval);
+          } else {
+            // console.log('looped!')
+            // console.log('response :', res.data);
+            // console.log('provision end project: ', this.context.currentProject);
+            // console.log('provision end cluster: ', this.context.currentCluster);
           }
         }
       });
     }, 1000);
   }
+
+  refreshLogs = () => {
+    if (this.state.websockets.length == 0) { return; }
+    let { currentProject } = this.context;
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+
+    this.state.websockets.forEach((ws: any) => {
+      ws.close()
+    })
+
+    this.setState({ 
+      websockets: [],
+      logs: []
+    })
+
+    let websockets = this.state.infras.map((infra: any) => {
+      let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
+      return this.setupWebsocket(ws, infra)
+    });
+
+    this.setState({ websockets, logs: ["Provisioning resources..."] });
+    
+  }
   
   render() {
     let { error, triggerEnd, infras } = this.state;
@@ -206,20 +255,23 @@ export default class Provisioner extends Component<PropsType, StateType> {
     
     let maxStep = 0;
     let currentStep = 0;
-
-    for (let key in this.state.maxStep) {
-      if (key == 'eks') {
-        maxStep += this.state.maxStep[key]
+    let skip = false;
+    
+    for (let i = 0; i < infras.length; i++) {
+      if (!this.state.maxStep[infras[i].kind]) {
+        skip = true;
       }
     }
 
-    for (let key in this.state.currentStep) {
-      if (key == 'eks') {
+    if (!skip) {
+      for (let key in this.state.maxStep) {
+        maxStep += this.state.maxStep[key]
         currentStep += this.state.currentStep[key]
-      }
+      }  
     }
 
     if (maxStep !== 0 && currentStep === maxStep && !triggerEnd) {
+      posthog.capture('Provisioning complete!')
       this.onEnd()
       this.setState({ triggerEnd: true });
     }
@@ -266,8 +318,8 @@ export default class Provisioner extends Component<PropsType, StateType> {
         </LoadingBar>
         <InfraStatuses infras={infras} />
 
-        <LogStream ref={this.scrollRef}>
-          <Wrapper>{this.renderLogs()}</Wrapper>
+        <LogStream>
+          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
         </LogStream>
 
         <Helper>
@@ -278,7 +330,36 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
 }
 
-Provisioner.contextType = Context;
+ProvisionerStatus.contextType = Context;
+
+const Options = styled.div`
+  width: 100%;
+  height: 25px;
+  background: #397ae3;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+`
+
+const Refresh = styled.div`
+  display: flex;
+  align-items: center;
+  width: 87px;
+  user-select: none;
+  cursor: pointer;
+  height: 100%;
+
+  > i {
+    margin-left: 6px;
+    font-size: 17px;
+    margin-right: 6px;
+  }
+
+  :hover {
+    background: #2468d6;
+  }
+`
 
 const Link = styled.a`
   cursor: pointer;

+ 7 - 12
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -55,13 +55,14 @@ export default class ClusterSection extends Component<PropsType, StateType> {
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
             this.setState({ clusters });
-            let saved = JSON.parse(localStorage.getItem('currentCluster'));
-            if (localStorage.getItem('currentCluster') !== 'null') {
+            let saved = JSON.parse(localStorage.getItem(currentProject.id + '-cluster'));
+            if (saved !== 'null') {
               setCurrentCluster(clusters[0]);
               for (let i = 0; i < clusters.length; i++) {
-                if (clusters[i].id = saved.id 
-                  && clusters[i].project_id === saved.project_id 
-                  && clusters[i].name === saved.name
+                if (
+                  clusters[i].id === saved.id &&
+                  clusters[i].project_id === saved.project_id && 
+                  clusters[i].name === saved.name
                 ) {
                   setCurrentCluster(clusters[i]);
                   break;
@@ -150,19 +151,13 @@ export default class ClusterSection extends Component<PropsType, StateType> {
           </DrawerButton>
         </ClusterSelector>
       );
-    } else if (false) {
-      return (
-        <InitializeButton onClick={this.showClusterConfigModal}>
-          <Plus>+</Plus> Add a Cluster
-        </InitializeButton>
-      );
     }
 
     return (
       <InitializeButton
         onClick={() => this.context.setCurrentModal('ClusterInstructionsModal', {})}
       >
-        <Plus>+</Plus> Add a Cluster
+        <Plus>+</Plus> Connect a Cluster
       </InitializeButton>
     )
   };

+ 3 - 0
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -116,6 +116,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
           </NavButton>
           <NavButton
             selected={currentView === 'integrations'}
+            //onClick={() => {
+            //  setCurrentView('integrations')
+           // }}
             onClick={() => {
               setCurrentModal('IntegrationsInstructionsModal', {})
             }}

+ 4 - 9
dashboard/src/main/home/templates/Templates.tsx

@@ -9,17 +9,12 @@ import TabSelector from '../../../components/TabSelector';
 import ExpandedTemplate from './expanded-template/ExpandedTemplate';
 import Loading from '../../../components/Loading';
 
+import hardcodedNames from './hardcodedNameDict';
+
 const tabOptions = [
   { label: 'Community Templates', value: 'community' }
 ];
 
-// TODO: read in from metadata
-const hardcodedNames: any = {
-  'postgresql': 'PostgreSQL',
-  'docker': 'Docker',
-  'https-issuer': 'HTTPS Issuer'
-};
-
 type PropsType = {
   setCurrentView: (x: string) => void, // Link to add integration from source selector
 };
@@ -266,7 +261,7 @@ const TitleSection = styled.div`
 `;
 
 const TemplatesWrapper = styled.div`
-  width: calc(90% - 150px);
+  width: calc(90% - 130px);
   min-width: 300px;
-  padding-top: 50px;
+  padding-top: 75px;
 `;

+ 1 - 1
dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx

@@ -105,5 +105,5 @@ const LoadingWrapper = styled.div`
 const StyledExpandedTemplate = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  padding-top: 50px;
+  padding-top: 75px;
 `;

+ 52 - 5
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import randomWords from 'random-words';
+import posthog from 'posthog-js';
 import _ from 'lodash';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
@@ -9,6 +10,7 @@ import { PorterTemplate, ChoiceType, ClusterType, StorageType } from '../../../.
 import Selector from '../../../../components/Selector';
 import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import TabRegion from '../../../../components/TabRegion';
+import InputRow from '../../../../components/values-form/InputRow';
 import SaveButton from '../../../../components/SaveButton';
 import ValuesWrapper from '../../../../components/values-form/ValuesWrapper';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
@@ -30,6 +32,7 @@ type StateType = {
   selectedCluster: string,
   selectedImageUrl: string | null,
   selectedTag: string | null,
+  templateName: string,
   tabOptions: ChoiceType[],
   currentTab: string | null,
   tabContents: any
@@ -44,6 +47,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     selectedCluster: this.context.currentCluster.name,
     selectedNamespace: "default",
     selectedImageUrl: '' as string | null,
+    templateName: '',
     selectedTag: '' as string | null,
     tabOptions: [] as ChoiceType[],
     currentTab: null as string | null,
@@ -53,7 +57,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   onSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject } = this.context;
-    let name = randomWords({ exactly: 3, join: '-' });
+    let name = this.state.templateName || randomWords({ exactly: 3, join: '-' });
     this.setState({ saveValuesStatus: 'loading' });
 
     let values = {};
@@ -75,15 +79,27 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }, (err: any, res: any) => {
       if (err) {
         this.setState({ saveValuesStatus: 'error' });
+        posthog.capture('Failed to deploy template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+          error: err
+        })
       } else {
+        // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: 'successful' });
+        posthog.capture('Deployed template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+        })
       }
     });
   }
 
   onSubmit = (rawValues: any) => {
     let { currentCluster, currentProject } = this.context;
-    let name = randomWords({ exactly: 3, join: '-' });
+    let name = this.state.templateName || randomWords({ exactly: 3, join: '-' });
     this.setState({ saveValuesStatus: 'loading' });
 
     // Convert dotted keys to nested objects
@@ -121,8 +137,20 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }, (err: any, res: any) => {
       if (err) {
         this.setState({ saveValuesStatus: 'error' });
+        posthog.capture('Failed to deploy template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+          error: err
+        })
       } else {
+        // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: 'successful' });
+        posthog.capture('Deployed template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+        })
       }
     });
   }
@@ -259,7 +287,10 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     if (this.props.form?.hasSource) {
       return (
         <>
-          <Subtitle>Select the container image you would like to connect to this template.</Subtitle>
+          <Subtitle>
+            Select the container image you would like to connect to this template.
+            <Required>*</Required>
+          </Subtitle>
           <DarkMatter />
           <ImageSelector
             selectedTag={this.state.selectedTag}
@@ -319,6 +350,15 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
+        <Subtitle>Give a unique name to this template (optional).</Subtitle>
+        <DarkMatter antiHeight='-27px' />
+        <InputRow
+          type='text'
+          value={this.state.templateName}
+          setValue={(x: string) => this.setState({ templateName: x })}
+          placeholder='ex: doctor-scientist'
+          width='100%'
+        />
         {this.renderSourceSelector()}
         {this.renderTabRegion()}
       </StyledLaunchTemplate>
@@ -328,6 +368,11 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
 LaunchTemplate.contextType = Context;
 
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
 const Link = styled.a`
   margin-left: 5px;
 `;
@@ -359,9 +404,9 @@ const Placeholder = styled.div`
   justify-content: center;
 `;
 
-const DarkMatter = styled.div`
+const DarkMatter = styled.div<{ antiHeight?: string }>`
   width: 100%;
-  margin-top: -15px;
+  margin-top: ${props => props.antiHeight || '-15px'};
 `;
 
 const Subtitle = styled.div`
@@ -370,6 +415,8 @@ const Subtitle = styled.div`
   font-size: 13px;
   color: #aaaabb;
   line-height: 1.6em;
+  display: flex;
+  align-items: center;
 `;
 
 const ClusterLabel = styled.div`

+ 23 - 11
dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx

@@ -9,11 +9,7 @@ import Loading from '../../../../components/Loading';
 import { PorterTemplate } from '../../../../shared/types';
 import Helper from '../../../../components/values-form/Helper';
 
-// TODO: read in from metadata
-const hardcodedNames: any = {
-  'postgresql': 'PostgreSQL',
-  'docker': 'Docker',
-};
+import hardcodedNames from '../hardcodedNameDict';
 
 type PropsType = {
   currentTemplate: any,
@@ -99,6 +95,22 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
           </Banner>
         </>
       );
+    } else if (this.props.currentTemplate.name.toLowerCase() === 'https-issuer') {
+      return (
+        <>
+          <Br />
+          <Banner>
+            <i className="material-icons-outlined">info</i>
+            To use this template you must first follow
+            <Link 
+              target="_blank"
+              href="https://docs.getporter.dev/docs/https-and-custom-domains"
+            >
+              Porter's HTTPS setup guide
+            </Link> (5 minutes).
+          </Banner>
+        </>
+      );
     }
   }
 
@@ -144,8 +156,8 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
 TemplateInfo.contextType = Context;
 
 const Link = styled.a`
-  text-decoration: underline;
-  color: white;
+  color: #8590ff;
+  margin-right: 5px;
   cursor: pointer;
   margin-left: 5px;
 `;
@@ -164,7 +176,7 @@ const Banner = styled.div`
   border-radius: 5px;
   padding-left: 15px;
   align-items: center;
-  background: #616FEEcc;
+  background: #ffffff11;
   > i {
     margin-right: 10px;
     font-size: 18px;
@@ -226,7 +238,7 @@ const Flex = styled.div`
 `;
 
 const Button = styled.div`
-  height: 100%;
+  height: 35px;
   background: ${(props: { isDisabled: boolean }) => (!props.isDisabled ? '#616feecc' : '#aaaabb')};
   :hover {
     background: ${(props: { isDisabled: boolean }) => (!props.isDisabled ? '#505edddd' : '#aaaabb')};
@@ -243,8 +255,8 @@ const Button = styled.div`
   align-items: center;
 
   > img {
-    width: 20px;
-    height: 20px;
+    width: 16px;
+    height: 16px;
     display: flex;
     align-items: center;
     margin-right: 10px;

+ 12 - 0
dashboard/src/main/home/templates/hardcodedNameDict.tsx

@@ -0,0 +1,12 @@
+const hardcodedNames: any = {
+  'docker': 'Docker',
+  'https-issuer': 'HTTPS Issuer',
+  'metabase': 'Metabase',
+  'mongodb': 'MongoDB',
+  'mysql': 'MySQL',
+  'postgresql': 'PostgreSQL',
+  'redis': 'Redis',
+  'ubuntu': 'Ubuntu',
+};
+
+export default hardcodedNames;

+ 11 - 4
dashboard/src/shared/Context.tsx

@@ -36,15 +36,22 @@ class ContextProvider extends Component {
       this.setState({ currentError });
     },
     currentCluster: null as ClusterType | null,
-    setCurrentCluster: (currentCluster: ClusterType) => {
-      this.setState({ currentCluster });
+    setCurrentCluster: (currentCluster: ClusterType, callback?: any) => {
+      localStorage.setItem(this.state.currentProject.id + '-cluster', JSON.stringify(currentCluster));
+      this.setState({ currentCluster }, () => {
+        callback && callback();
+      });
     },
     currentProject: null as ProjectType | null,
-    setCurrentProject: (currentProject: ProjectType) => {
-      this.setState({ currentProject });
+    setCurrentProject: (currentProject: ProjectType, callback?: any) => {
+      localStorage.setItem('currentProject', currentProject.id.toString());
+      this.setState({ currentProject }, () => {
+        callback && callback();
+      });
     },
     projects: [] as ProjectType[],
     setProjects: (projects: ProjectType[]) => {
+      projects.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
       this.setState({ projects });
     },
     user: null as any,

+ 302 - 208
dashboard/src/shared/api.tsx

@@ -13,174 +13,112 @@ import { StorageType } from './types';
 
 const checkAuth = baseApi('GET', '/api/auth/check');
 
-const registerUser = baseApi<{ 
-  email: string,
-  password: string
-}>('POST', '/api/users');
-
-const logInUser = baseApi<{
-  email: string,
-  password: string
-}>('POST', '/api/login');
-
-const logOutUser = baseApi('POST', '/api/logout');
-
-const getUser = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}`;
-});
-
-const updateUser = baseApi<{
-  rawKubeConfig?: string,
-  allowedContexts?: string[]
-}, { id: number }>('PUT', pathParams => {
-  return `/api/users/${pathParams.id}`;
-});
-
-const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/clusters`;
-});
-
-const getCharts = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType,
-  limit: number,
-  skip: number,
-  byDate: boolean,
-  statusFilter: string[]
-}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases`;
-});
-
-const getChart = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string, revision: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
-});
-
-const getChartComponents = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string, revision: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
-});
-
-const getChartControllers = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string, revision: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
-});
-
-const getNamespaces = baseApi<{
-  cluster_id: number,
-}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/k8s/namespaces`;
-});
-
-const getMatchingPods = baseApi<{
-  cluster_id: number,
-  selectors: string[]
-}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/k8s/pods`;
+const createAWSIntegration = baseApi<{
+  aws_region: string,
+  aws_cluster_id?: string,
+  aws_access_key_id: string,
+  aws_secret_access_key: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
-const getIngress = baseApi<{
-  cluster_id: number,
-}, { name: string, namespace: string, id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
+const createDOCR = baseApi<{
+  do_integration_id: number,
+  docr_name: string,
+  docr_subscription_tier: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/docr`;
 });
 
-const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/invites`;
+const createDOKS = baseApi<{
+  do_integration_id: number,
+  doks_name: string,
+  do_region: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
-const getRevisions = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
+const createECR = baseApi<{
+  name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
 });
 
-const rollbackChart = baseApi<{
-  namespace: string,
-  storage: StorageType,
-  revision: number
+const createGCPIntegration = baseApi<{
+  gcp_region: string,
+  gcp_key_data: string,
+  gcp_project_id: string,
 }, {
-  id: number,
-  name: string,
-  cluster_id: number,
+  project_id: number,
 }>('POST', pathParams => {
-  let { id, name, cluster_id } = pathParams;
-  return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
+  return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
-const upgradeChartValues = baseApi<{
-  namespace: string,
-  storage: StorageType,
-  values: string
+const createGCR = baseApi<{
+  gcp_integration_id: number,
 }, {
-  id: number,
-  name: string,
-  cluster_id: number,
+  project_id: number,
 }>('POST', pathParams => {
-  let { id, name, cluster_id } = pathParams;
-  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
-});
-
-const getTemplates = baseApi('GET', '/api/templates');
-
-const getTemplateInfo = baseApi<{}, { name: string, version: string }>('GET', pathParams => {
-  return `/api/templates/${pathParams.name}/${pathParams.version}`;
+  return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 
-const getRepos = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/repos`;
-});
+const createGHAction = baseApi<{
+  git_repo: string,
+  image_repo_uri: string,
+  dockerfile_path: string,
+  git_repo_id: number,
+}, {
+  project_id: number,
+  CLUSTER_ID: number,
+  RELEASE_NAME: string,
+  RELEASE_NAMESPACE: string,
+}>('POST', pathParams => {
+  let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
+  return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
+})
 
-const getBranches = baseApi<{}, { kind: string, repo: string }>('GET', pathParams => {
-  return `/api/repos/${pathParams.kind}/${pathParams.repo}/branches`;
+const createGKE = baseApi<{
+  gcp_integration_id: number,
+  gke_name: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 
-const getBranchContents = baseApi<{ 
-  dir: string 
+const createInvite = baseApi<{
+  email: string
 }, {
-  kind: string,
-  repo: string,
-  branch: string
-}>('GET', pathParams => {
-  return `/api/repos/github/${pathParams.repo}/${pathParams.branch}/contents`;
+  id: number
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
 });
 
-const getProjects = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}/projects`;
+const createProject = baseApi<{ name: string }, {}>('POST', pathParams => {
+  return `/api/projects`;
 });
 
-const getReleaseToken = baseApi<{ 
-  namespace: string,
+const deleteCluster = baseApi<{
+}, {
+  project_id: number,
   cluster_id: number,
-  storage: StorageType,
-}, { name: string, id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
+}>('DELETE', pathParams => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
-const createProject = baseApi<{ name: string }, {}>('POST', pathParams => {
-  return `/api/projects`;
+const deleteInvite = baseApi<{}, { id: number, invId: number }>('DELETE', pathParams => {
+  return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
 });
 
 const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
-const deleteInvite = baseApi<{}, { id: number, invId: number }>('DELETE', pathParams => {
-  return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
-});
-
 const deployTemplate = baseApi<{
   templateName: string,
   imageURL?: string,
@@ -198,64 +136,94 @@ const deployTemplate = baseApi<{
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
 
-const uninstallTemplate = baseApi<{
+const destroyCluster = baseApi<{
+  eks_name: string,
 }, {
-  id: number,
-  name: string, 
-  cluster_id: number,
-  namespace: string,
-  storage: StorageType,
+  project_id: number,
+  infra_id: number,
 }>('POST', pathParams => {
-  let { id, name, cluster_id, storage, namespace } = pathParams;
-  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
+  return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
-const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
-
-const getRegistryIntegrations = baseApi('GET', '/api/integrations/registry');
+const getBranchContents = baseApi<{ 
+  dir: string 
+}, {
+  project_id: number,
+  git_repo_id: number
+  kind: string,
+  owner: string,
+  name: string,
+  branch: string
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
+});
 
-const getRepoIntegrations = baseApi('GET', '/api/integrations/repo');
+const getBranches = baseApi<{
+}, {
+  project_id: number,
+  git_repo_id: number,
+  kind: string,
+  owner: string,
+  name: string
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/branches`;
+});
 
-const getProjectClusters = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/clusters`;
+const getChart = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
-const getProjectRegistries = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/registries`;
+const getCharts = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType,
+  limit: number,
+  skip: number,
+  byDate: boolean,
+  statusFilter: string[]
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases`;
 });
 
-const getProjectRepos = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/repos`;
+const getChartComponents = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
-const createAWSIntegration = baseApi<{
-  aws_region: string,
-  aws_cluster_id?: string,
-  aws_access_key_id: string,
-  aws_secret_access_key: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/integrations/aws`;
+const getChartControllers = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
 });
 
-const provisionECR = baseApi<{
-  ecr_name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/provision/ecr`;
+const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
+
+const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/clusters`;
 });
 
-const provisionEKS = baseApi<{
-  eks_name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/provision/eks`;
+const getGitRepoList = baseApi<{
+}, {
+  project_id: number,
+  git_repo_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos`;
 });
 
-const createECR = baseApi<{
-  name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/registries`;
+const getGitRepos = baseApi<{
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos`;
 });
 
 const getImageRepos = baseApi<{
@@ -275,28 +243,70 @@ const getImageTags = baseApi<{
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
-const linkGithubProject = baseApi<{
+const getInfra = baseApi<{
 }, {
   project_id: number,
 }>('GET', pathParams => {
-  return `/api/oauth/projects/${pathParams.project_id}/github`;
+  return `/api/projects/${pathParams.project_id}/infra`;
 });
 
-const getGitRepos = baseApi<{  
-}, {
-  project_id: number,
-}>('GET', pathParams => {
-  return `/api/projects/${pathParams.project_id}/gitrepos`;
+const getIngress = baseApi<{
+  cluster_id: number,
+}, { name: string, namespace: string, id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 
-const getInfra = baseApi<{
+const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+});
+
+const getMatchingPods = baseApi<{
+  cluster_id: number,
+  selectors: string[]
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/pods`;
+});
+
+const getNamespaces = baseApi<{
+  cluster_id: number,
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/namespaces`;
+});
+
+const getOAuthIds = baseApi<{
 }, {
   project_id: number,
 }>('GET', pathParams => {
-  return `/api/projects/${pathParams.project_id}/infra`;
+  return `/api/projects/${pathParams.project_id}/integrations/oauth`;
 });
 
-const destroyCluster = baseApi<{
+const getProjectClusters = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/clusters`;
+});
+
+const getProjectRegistries = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
+
+const getProjectRepos = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/repos`;
+});
+
+const getProjects = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/users/${pathParams.id}/projects`;
+});
+
+const getRegistryIntegrations = baseApi('GET', '/api/integrations/registry');
+
+const getReleaseToken = baseApi<{ 
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType,
+}, { name: string, id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
+});
+
+const destroyEKS = baseApi<{
   eks_name: string,
 }, {
   project_id: number,
@@ -305,56 +315,136 @@ const destroyCluster = baseApi<{
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
-const deleteCluster = baseApi<{
+const destroyGKE = baseApi<{
+  gke_name: string,
 }, {
   project_id: number,
-  cluster_id: number,
-}>('DELETE', pathParams => {
-  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
+  infra_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/gke/destroy`;
 });
 
-const createGCPIntegration = baseApi<{
-  gcp_region: string,
-  gcp_key_data: string,
-  gcp_project_id: string,
+const destroyDOKS = baseApi<{
+  doks_name: string,
 }, {
   project_id: number,
+  infra_id: number,
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/integrations/gcp`;
+  return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/doks/destroy`;
 });
 
-const createGCR = baseApi<{
-  gcp_integration_id: number,
+const getRepoIntegrations = baseApi('GET', '/api/integrations/repo');
+
+const getRepos = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/repos`;
+});
+
+const getRevisions = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
+});
+
+const getTemplateInfo = baseApi<{}, { name: string, version: string }>('GET', pathParams => {
+  return `/api/templates/${pathParams.name}/${pathParams.version}`;
+});
+
+const getTemplates = baseApi('GET', '/api/templates');
+
+const getUser = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/users/${pathParams.id}`;
+});
+
+const linkGithubProject = baseApi<{
 }, {
   project_id: number,
+}>('GET', pathParams => {
+  return `/api/oauth/projects/${pathParams.project_id}/github`;
+});
+
+const logInUser = baseApi<{
+  email: string,
+  password: string
+}>('POST', '/api/login');
+
+const logOutUser = baseApi('POST', '/api/logout');
+
+const provisionECR = baseApi<{
+  ecr_name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/provision/ecr`;
+});
+
+const provisionEKS = baseApi<{
+  eks_name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/provision/eks`;
+});
+
+const registerUser = baseApi<{ 
+  email: string,
+  password: string
+}>('POST', '/api/users');
+
+const rollbackChart = baseApi<{
+  namespace: string,
+  storage: StorageType,
+  revision: number
+}, {
+  id: number,
+  name: string,
+  cluster_id: number,
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/provision/gcr`;
+  let { id, name, cluster_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
 });
 
-const createGKE = baseApi<{
-  gcp_integration_id: number,
-  gke_name: string,
+const uninstallTemplate = baseApi<{
 }, {
-  project_id: number,
+  id: number,
+  name: string, 
+  cluster_id: number,
+  namespace: string,
+  storage: StorageType,
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/provision/gke`;
+  let { id, name, cluster_id, storage, namespace } = pathParams;
+  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
 
-const createInvite = baseApi<{
-  email: string
+const updateUser = baseApi<{
+  rawKubeConfig?: string,
+  allowedContexts?: string[]
+}, { id: number }>('PUT', pathParams => {
+  return `/api/users/${pathParams.id}`;
+});
+
+const upgradeChartValues = baseApi<{
+  namespace: string,
+  storage: StorageType,
+  values: string
 }, {
-  id: number
+  id: number,
+  name: string,
+  cluster_id: number,
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/invites`;
-})
+  let { id, name, cluster_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
   createAWSIntegration,
+  createDOCR,
+  createDOKS,
   createECR,
   createGCPIntegration,
   createGCR,
+  createGHAction,
   createGKE,
   createInvite,
   createProject,
@@ -362,7 +452,9 @@ export default {
   deleteInvite,
   deleteProject,
   deployTemplate,
-  destroyCluster,
+  destroyEKS,
+  destroyGKE,
+  destroyDOKS,
   getBranchContents,
   getBranches,
   getChart,
@@ -371,6 +463,7 @@ export default {
   getChartControllers,
   getClusterIntegrations,
   getClusters,
+  getGitRepoList,
   getGitRepos,
   getImageRepos,
   getImageTags,
@@ -379,6 +472,7 @@ export default {
   getInvites,
   getMatchingPods,
   getNamespaces,
+  getOAuthIds,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,

+ 26 - 5
dashboard/src/shared/common.tsx

@@ -81,15 +81,15 @@ export const getIgnoreCase = (object: any, key: string) => {
 }
 
 export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
-  if (infras.length === 0) {
-    return true;
-  }
-
+  // TODO: declare globally while avoidiing changes to the array on helper call
   let infraSets = [
     ['ecr', 'eks'],
     ['gcr', 'gke'],
     ['docr', 'doks']
   ];
+  if (infras.length === 0) {
+    return false;
+  }
 
   let completed = [] as string[];
   infras.forEach((infra: InfraType, i: number) => {
@@ -114,8 +114,24 @@ export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
 }
 
 export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
+  let infraSets = [
+    ['ecr', 'eks'],
+    ['gcr', 'gke'],
+    ['docr', 'doks']
+  ];
   let newestInstances = {} as any;
+  let newestId = -1;
+  let whitelistedInfras = [] as string[];
   infras.forEach((infra: InfraType, i: number) => {
+
+    // Determine the most recent set for which provisioning was attempted
+    if (infra.id > newestId) {
+      newestId = infra.id;
+      infraSets.forEach((infraSet: string[]) => {
+        infraSet.includes(infra.kind) ? whitelistedInfras = infraSet : null;
+      });
+    }
+
     if (!newestInstances[infra.kind]) {
       newestInstances[infra.kind] = infra;
     } else {
@@ -125,5 +141,10 @@ export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
       }
     }
   });
-  return Object.values(newestInstances);
+
+  let newestInfras = Object.values(newestInstances) as InfraType[];
+  let result = newestInfras.filter((x: InfraType) => {
+    return whitelistedInfras.includes(x.kind)
+  });
+  return result;
 }

+ 15 - 25
dashboard/src/shared/feedback.tsx

@@ -1,29 +1,19 @@
 import axios from 'axios';
 
-const ignoreUsers = [
-  'justin@getporter.dev',
-  'trevor@getporter.dev',
-  'belanger@getporter.dev',
-  'seanr112593@gmail.com',
-];
-
 export const handleSubmitFeedback = (msg: string, callback?: (err: any, res: any) => void) => {
-  let splits = msg.split(' ');
-  if (!window.location.href.includes('localhost:8080') && !ignoreUsers.includes(splits[1])) {
-    axios.post(process.env.FEEDBACK_ENDPOINT, {
-      key: process.env.DISCORD_KEY,
-      cid: process.env.DISCORD_CID,
-      message: msg,
-    }, {
-      headers: {
-        Authorization: `Bearer <>`
-      }
-    })
-    .then(res => {
-      callback && callback(null, res);
-    })
-    .catch(err => {
-      callback && callback(err, null);
-    });
-  }
+  axios.post(process.env.FEEDBACK_ENDPOINT, {
+    key: process.env.DISCORD_KEY,
+    cid: process.env.DISCORD_CID,
+    message: msg,
+  }, {
+    headers: {
+      Authorization: `Bearer <>`
+    }
+  })
+  .then(res => {
+    callback && callback(null, res);
+  })
+  .catch(err => {
+    callback && callback(err, null);
+  });
 }

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

@@ -3,7 +3,8 @@ export interface ClusterType {
   name: string,
   server: string,
   service_account_id: number
-  infra_id?: number
+  infra_id?: number,
+  service?: string,
 }
 
 export interface ChartType {
@@ -112,7 +113,8 @@ export interface FormElement {
 
 export interface RepoType {
   FullName: string,
-  kind: string
+  kind: string,
+  GHRepoID: number,
 }
 
 export interface FileType {
@@ -156,4 +158,11 @@ export interface InviteType {
   email: string,
   accepted: boolean,
   id: number,
+}
+
+export interface ActionConfigType {
+  git_repo: string,
+  image_repo_uri: string,
+  git_repo_id: number,
+  dockerfile_path: string,
 }

+ 23 - 0
helm/.helmignore

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

+ 23 - 0
helm/Chart.yaml

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

+ 21 - 0
helm/templates/NOTES.txt

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

+ 63 - 0
helm/templates/_helpers.tpl

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

+ 60 - 0
helm/templates/deployment.yaml

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

+ 28 - 0
helm/templates/hpa.yaml

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

+ 43 - 0
helm/templates/ingress.yaml

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

+ 15 - 0
helm/templates/service.yaml

@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "porter-prod.fullname" . }}
+  labels:
+    {{- include "porter-prod.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.service.type }}
+  ports:
+    - port: {{ .Values.service.port }}
+      targetPort: http
+      protocol: TCP
+      name: http
+  selector:
+    {{- include "porter-prod.selectorLabels" . | nindent 4 }}

+ 12 - 0
helm/templates/serviceaccount.yaml

@@ -0,0 +1,12 @@
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ include "porter-prod.serviceAccountName" . }}
+  labels:
+    {{- include "porter-prod.labels" . | nindent 4 }}
+  {{- with .Values.serviceAccount.annotations }}
+  annotations:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+{{- end }}

+ 15 - 0
helm/templates/tests/test-connection.yaml

@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Pod
+metadata:
+  name: "{{ include "porter-prod.fullname" . }}-test-connection"
+  labels:
+    {{- include "porter-prod.labels" . | nindent 4 }}
+  annotations:
+    "helm.sh/hook": test-success
+spec:
+  containers:
+    - name: wget
+      image: busybox
+      command: ['wget']
+      args: ['{{ include "porter-prod.fullname" . }}:{{ .Values.service.port }}']
+  restartPolicy: Never

+ 79 - 0
helm/values.yaml

@@ -0,0 +1,79 @@
+# Default values for porter-prod.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+
+replicaCount: 1
+
+image:
+  repository: porteralpha/porter-prod
+  pullPolicy: Always
+  # Overrides the image tag whose default is the chart appVersion.
+  tag: latest
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+serviceAccount:
+  # Specifies whether a service account should be created
+  create: false
+  # Annotations to add to the service account
+  annotations: {}
+  # The name of the service account to use.
+  # If not set and create is true, a name is generated using the fullname template
+  name: ""
+
+podAnnotations: {}
+
+podSecurityContext: {}
+  # fsGroup: 2000
+
+securityContext: {}
+  # capabilities:
+  #   drop:
+  #   - ALL
+  # readOnlyRootFilesystem: true
+  # runAsNonRoot: true
+  # runAsUser: 1000
+
+service:
+  type: NodePort
+  port: 443
+
+ingress:
+  enabled: true
+  annotations: {}
+    # kubernetes.io/ingress.class: nginx
+    # kubernetes.io/tls-acme: "true"
+  hosts:
+    - host: dashboard.getporter.dev
+      paths: ['/*']
+  tls:
+    - secretName: ingress-dashboard
+      hosts:
+        - dashboard.getporter.dev
+
+resources: {}
+  # We usually recommend not to specify default resources and to leave this as a conscious
+  # choice for the user. This also increases chances charts run on environments with little
+  # resources, such as Minikube. If you do want to specify resources, uncomment the following
+  # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
+  # limits:
+  #   cpu: 100m
+  #   memory: 128Mi
+  # requests:
+  #   cpu: 100m
+  #   memory: 128Mi
+
+autoscaling:
+  enabled: false
+  minReplicas: 1
+  maxReplicas: 100
+  targetCPUUtilizationPercentage: 80
+  # targetMemoryUtilizationPercentage: 80
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}

+ 1 - 1
internal/config/config.go

@@ -30,7 +30,7 @@ type ServerConf struct {
 	IsLocal              bool          `env:"IS_LOCAL,default=false"`
 	IsTesting            bool          `env:"IS_TESTING,default=false"`
 
-	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
+	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo-dev/"`
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`

+ 0 - 1
internal/forms/release.go

@@ -20,7 +20,6 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 	vals url.Values,
 	repo repository.ClusterRepository,
 ) error {
-	fmt.Println(vals)
 	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
 		id, err := strconv.ParseUint(clusterID[0], 10, 64)
 

+ 4 - 2
internal/models/templates.go

@@ -50,8 +50,10 @@ type FormContent struct {
 	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"`
+		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"`
 	} `yaml:"settings,omitempty" json:"settings,omitempty"`
 }
 

+ 25 - 40
server/api/invite_handler.go

@@ -2,7 +2,9 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"net/url"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -69,7 +71,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -78,72 +80,48 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	user, err := app.Repo.User.ReadUser(userID)
 
 	if err != nil {
-		app.handleErrorDataRead(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
 	token := chi.URLParam(r, "token")
 
 	if token == "" {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		acceptInviteError(w, r)
 		return
 	}
 
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		acceptInviteError(w, r)
 		return
 	}
 
 	invite, err := app.Repo.Invite.ReadInviteByToken(token)
 
 	if err != nil || invite.ProjectID != uint(projID) {
-		app.sendExternalError(
-			err,
-			http.StatusForbidden,
-			HTTPError{
-				Code: http.StatusForbidden,
-				Errors: []string{
-					"Invalid invite token",
-				},
-			},
-			w,
-		)
+		vals := url.Values{}
+		vals.Add("error", "Invalid invite token")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 
 		return
 	}
 
 	// check that the invite has not expired and has not been accepted
 	if invite.IsExpired() || invite.IsAccepted() {
-		app.sendExternalError(
-			err,
-			http.StatusForbidden,
-			HTTPError{
-				Code: http.StatusForbidden,
-				Errors: []string{
-					"Invite has expired",
-				},
-			},
-			w,
-		)
+		vals := url.Values{}
+		vals.Add("error", "Invite has expired")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 
 		return
 	}
 
 	// check that the invite email matches the user's email
 	if user.Email != invite.Email {
-		app.sendExternalError(
-			err,
-			http.StatusForbidden,
-			HTTPError{
-				Code: http.StatusForbidden,
-				Errors: []string{
-					"Cannot accept this invite",
-				},
-			},
-			w,
-		)
+		vals := url.Values{}
+		vals.Add("error", "Wrong email for invite")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 
 		return
 	}
@@ -152,7 +130,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	projModel, err := app.Repo.Project.ReadProject(uint(projID))
 
 	if err != nil {
-		app.handleErrorDataWrite(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -164,7 +142,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	})
 
 	if err != nil {
-		app.handleErrorDataWrite(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -174,7 +152,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	_, err = app.Repo.Invite.UpdateInvite(invite)
 
 	if err != nil {
-		app.handleErrorDataWrite(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -182,6 +160,13 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
+func acceptInviteError(w http.ResponseWriter, r *http.Request) {
+	vals := url.Values{}
+	vals.Add("error", "could not accept invite")
+	http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
+	return
+}
+
 // HandleListProjectInvites returns a list of invites for a project
 func (app *App) HandleListProjectInvites(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)

+ 3 - 84
server/api/release_handler.go

@@ -446,7 +446,6 @@ func (app *App) HandleGetReleaseToken(w http.ResponseWriter, r *http.Request) {
 
 // HandleUpgradeRelease upgrades a release with new values.yaml
 func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
-	fmt.Println("=========================================UPGRADE RELEASE============================================")
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -522,86 +521,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
-// HandleReleaseDeployHook upgrades a release with new image commit
-func (app *App) HandleReleaseDeployHook(w http.ResponseWriter, r *http.Request) {
-	name := chi.URLParam(r, "name")
-	vals, err := url.ParseQuery(r.URL.RawQuery)
-
-	commit := vals["commit"][0]
-	repository := vals["repository"][0]
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
-		return
-	}
-
-	form := &forms.UpgradeReleaseForm{
-		ReleaseForm: &forms.ReleaseForm{
-			Form: &helm.Form{
-				Repo:              app.Repo,
-				DigitalOceanOAuth: app.DOConf,
-			},
-		},
-		Name: name,
-	}
-
-	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
-		vals,
-		app.Repo.Cluster,
-	)
-
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
-		return
-	}
-
-	agent, err := app.getAgentFromReleaseForm(
-		w,
-		r,
-		form.ReleaseForm,
-	)
-
-	// errors are handled in app.getAgentFromBodyParams
-	if err != nil {
-		return
-	}
-
-	image := map[string]interface{}{}
-	image["repository"] = repository
-	image["tag"] = commit
-
-	newval := map[string]interface{}{}
-	newval["image"] = image
-
-	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	conf := &helm.UpgradeReleaseConfig{
-		Name:       form.Name,
-		Cluster:    form.ReleaseForm.Cluster,
-		Repo:       *app.Repo,
-		Registries: registries,
-		Values:     newval,
-	}
-
-	_, err = agent.UpgradeReleaseByValues(conf, app.DOConf)
-
-	if err != nil {
-		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
-			Code:   ErrReleaseDeploy,
-			Errors: []string{"error upgrading release " + err.Error()},
-		}, w)
-
-		return
-	}
-
-	w.WriteHeader(http.StatusOK)
-}
-
 // HandleReleaseDeployWebhook upgrades a release when a chart specific webhook is called.
 func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Request) {
 	token := chi.URLParam(r, "token")
@@ -663,8 +582,8 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	image["repository"] = repository
 	image["tag"] = commit
 
-	newval := map[string]interface{}{}
-	newval["image"] = image
+	rel, err := agent.GetRelease(form.Name, 0)
+	rel.Config["image"] = image
 
 	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
 
@@ -678,7 +597,7 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		Cluster:    form.ReleaseForm.Cluster,
 		Repo:       *app.Repo,
 		Registries: registries,
-		Values:     newval,
+		Values:     rel.Config,
 	}
 
 	_, err = agent.UpgradeReleaseByValues(conf, app.DOConf)

+ 1 - 1
server/api/release_handler_test.go

@@ -320,7 +320,7 @@ var rollbackReleaseTests = []*releaseTest{
 		initializers: []func(tester *tester){
 			initHistoryReleases,
 		},
-		msg:       "Rollback relase",
+		msg:       "Rollback release",
 		method:    "POST",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/wordpress/rollback?" + url.Values{

+ 31 - 18
server/api/user_handler.go

@@ -45,19 +45,21 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
+		var redirect string
+
+		if valR := session.Values["redirect"]; valR != nil {
+			redirect = session.Values["redirect"].(string)
+		}
+
 		session.Values["authenticated"] = true
 		session.Values["user_id"] = user.ID
 		session.Values["email"] = user.Email
+		session.Values["redirect"] = ""
 		session.Save(r, w)
 
-		if val, ok := session.Values["redirect"].(string); ok && val != "" {
-			http.Redirect(w, r, val, 302)
-			return
-		}
-
 		w.WriteHeader(http.StatusCreated)
 
-		if err := app.sendUser(w, user.ID, user.Email); err != nil {
+		if err := app.sendUser(w, user.ID, user.Email, redirect); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
@@ -77,7 +79,7 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 	email, _ := session.Values["email"].(string)
 	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, userID, email); err != nil {
+	if err := app.sendUser(w, userID, email, ""); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -119,22 +121,25 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	var redirect string
+
+	if valR := session.Values["redirect"]; valR != nil {
+		redirect = session.Values["redirect"].(string)
+	}
+
 	// Set user as authenticated
 	session.Values["authenticated"] = true
 	session.Values["user_id"] = storedUser.ID
 	session.Values["email"] = storedUser.Email
+	session.Values["redirect"] = ""
+
 	if err := session.Save(r, w); err != nil {
 		app.Logger.Warn().Err(err)
 	}
 
-	if val, ok := session.Values["redirect"].(string); ok && val != "" {
-		http.Redirect(w, r, val, 302)
-		return
-	}
-
-	w.WriteHeader(http.StatusCreated)
+	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, storedUser.ID, storedUser.Email); err != nil {
+	if err := app.sendUser(w, storedUser.ID, storedUser.Email, redirect); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -335,11 +340,19 @@ func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
 	return nil
 }
 
-func (app *App) sendUser(w http.ResponseWriter, userID uint, email string) error {
-	resUser := &models.UserExternal{
-		ID:    userID,
-		Email: email,
+type SendUserExt struct {
+	ID       uint   `json:"id"`
+	Email    string `json:"email"`
+	Redirect string `json:"redirect,omitempty"`
+}
+
+func (app *App) sendUser(w http.ResponseWriter, userID uint, email, redirect string) error {
+	resUser := &SendUserExt{
+		ID:       userID,
+		Email:    email,
+		Redirect: redirect,
 	}
+
 	if err := json.NewEncoder(w).Encode(resUser); err != nil {
 		return err
 	}

+ 0 - 6
server/router/router.go

@@ -903,12 +903,6 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
-		// r.Method(
-		// 	"POST",
-		// 	"/projects/{project_id}/releases/{name}/upgrade/hook",
-		// 	requestlog.NewHandler(a.HandleReleaseDeployHook, l),
-		// )
-
 		r.Method(
 			"POST",
 			"/webhooks/deploy/{token}",