Просмотр исходного кода

Merge branch 'beta.3.integration-frontend' of https://github.com/porter-dev/porter into sean-testing

Sean Rhee 5 лет назад
Родитель
Сommit
24464d4035
36 измененных файлов с 2133 добавлено и 967 удалено
  1. BIN
      dashboard/src/assets/loading-dots.gif
  2. 5 5
      dashboard/src/components/Selector.tsx
  3. 91 0
      dashboard/src/components/values-form/CheckboxList.tsx
  4. 4 4
      dashboard/src/components/values-form/Heading.tsx
  5. 7 4
      dashboard/src/components/values-form/SelectRow.tsx
  6. 16 6
      dashboard/src/main/CurrentError.tsx
  7. 5 2
      dashboard/src/main/Login.tsx
  8. 8 3
      dashboard/src/main/Main.tsx
  9. 192 82
      dashboard/src/main/home/Home.tsx
  10. 1 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  11. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  12. 102 105
      dashboard/src/main/home/dashboard/Dashboard.tsx
  13. 19 472
      dashboard/src/main/home/new-project/NewProject.tsx
  14. 381 0
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  15. 283 0
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  16. 101 0
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  17. 182 0
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  18. 69 0
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  19. 82 0
      dashboard/src/main/home/provisioner/ProvisionerContainer.tsx
  20. 281 0
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  21. 101 69
      dashboard/src/main/home/provisioner/ProvisionerStatus.tsx
  22. 20 2
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  23. 5 31
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  24. 1 1
      dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx
  25. 28 32
      dashboard/src/main/home/sidebar/Sidebar.tsx
  26. 1 1
      dashboard/src/shared/Context.tsx
  27. 31 1
      dashboard/src/shared/api.tsx
  28. 66 0
      dashboard/src/shared/common.tsx
  29. 1 1
      dashboard/src/shared/types.tsx
  30. 9 13
      internal/helm/postrenderer.go
  31. 1 0
      internal/kubernetes/agent.go
  32. 25 40
      server/api/invite_handler.go
  33. 3 83
      server/api/release_handler.go
  34. 1 1
      server/api/release_handler_test.go
  35. 9 2
      server/api/user_handler.go
  36. 0 6
      server/router/router.go

BIN
dashboard/src/assets/loading-dots.gif


+ 5 - 5
dashboard/src/components/Selector.tsx

@@ -78,7 +78,7 @@ export default class Selector extends Component<PropsType, StateType> {
   render() {
     let { activeValue } = this.props;
     return (
-      <StyledSelector>
+      <StyledSelector width={this.props.width}>
         <MainSelector
           onClick={() => this.setState({ expanded: !this.state.expanded })}
           expanded={this.state.expanded}
@@ -100,7 +100,7 @@ const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  z-index: 999;
+  z-index: 0;
 `;
 
 const DropdownLabel = styled.div`
@@ -146,7 +146,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,8 +154,9 @@ 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`
@@ -165,7 +166,6 @@ const MainSelector = styled.div`
   font-size: 13px;
   padding: 5px 10px;
   padding-left: 12px;
-  border-radius: 3px;
   display: flex;
   align-items: center;
   justify-content: space-between;

+ 91 - 0
dashboard/src/components/values-form/CheckboxList.tsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  options: { disabled?: boolean, value: string, label: string }[],
+  selected: { value: string, label: string }[],
+  setSelected: (x: { value: string, label: string }[]) => void,
+};
+
+const CheckboxList = ({ 
+  label, options, selected, setSelected,
+}: PropsType) => {
+  let onSelectOption = (option: { value: string, label: string }) => {
+    if (!selected.includes(option)) {
+      selected.push(option);
+      setSelected(selected);
+    } else {
+      selected.splice(selected.indexOf(option), 1);
+      setSelected(selected);
+    }
+  }
+  
+  return (
+    <StyledCheckboxList>
+      {label && <Label>{label}</Label>}
+      {options.map((option: { value: string, label: string }, i: number) => {
+        return (
+          <CheckboxOption 
+            isLast={i === options.length - 1}
+            onClick={() => onSelectOption(option)}
+            key={i}
+          >
+            <Checkbox checked={selected.includes(option)}>
+              <i className="material-icons">done</i>
+            </Checkbox>
+            {option.label}
+          </CheckboxOption>
+        );
+      })}
+    </StyledCheckboxList>
+  );
+}
+export default CheckboxList;
+
+const Checkbox = styled.div`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 15px 0px 1px;
+  border-radius: 3px;
+  background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : '#ffffff11'};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
+  }
+`;
+
+const CheckboxOption = styled.div<{ isLast: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  border-bottom: ${props => props.isLast ? '' : '1px solid #ffffff22'};
+  font-size: 13px;
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledCheckboxList = styled.div`
+  border-radius: 3px;
+  border: 1px solid #ffffff55;
+  padding: 0;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 4 - 4
dashboard/src/components/values-form/Heading.tsx

@@ -1,15 +1,15 @@
 import React from 'react';  
 import styled from 'styled-components';
 
-export default function Heading(props: { children: any }) {
-  return <StyledHeading>{props.children}</StyledHeading>;
+export default function Heading(props: { isAtTop?: boolean, children: any }) {
+  return <StyledHeading isAtTop={props.isAtTop}>{props.children}</StyledHeading>;
 }
 
-const StyledHeading = styled.div`
+const StyledHeading = styled.div<{ isAtTop: boolean }>`
   color: white;
   font-weight: 500;
   font-size: 16px;
-  margin-top: 30px;
+  margin-top: ${props => props.isAtTop ? '0': '30px'};
   margin-bottom: 5px;
   display: flex;
   align-items: center;

+ 7 - 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 = {
@@ -16,6 +18,7 @@ type StateType = {
 
 export default class SelectRow extends Component<PropsType, StateType> {
   render() {
+    console.log(this.props.width)
     return (
       <StyledSelectRow>
         <Label>{this.props.label}</Label>
@@ -25,8 +28,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 +39,6 @@ export default class SelectRow extends Component<PropsType, StateType> {
 }
 
 const SelectWrapper = styled.div`
-  display: flex;
 `;
 
 const Label = styled.div`

+ 16 - 6
dashboard/src/main/CurrentError.tsx

@@ -5,6 +5,7 @@ import close from '../assets/close.png';
 import { Context } from '../shared/Context';
 
 type PropsType = {
+  currentError: string,
 };
 
 type StateType = {
@@ -12,15 +13,24 @@ type StateType = {
 
 export default class CurrentError extends Component<PropsType, StateType> {
   state = {
-    expanded: false
+    expanded: false,
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (
+      prevProps.currentError !== this.props.currentError
+      && this.props.currentError === 'Provisioning failed. Check your credentials and try again.'
+    ) {
+      this.setState({ expanded: true });
+    }
   }
   
   render() {
-    if (this.context.currentError) {
+    if (this.props.currentError) {
       if (!this.state.expanded) {
         return (
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
-            <ErrorText>Error: {this.context.currentError}</ErrorText>
+            <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton onClick={(e) => {
               this.context.setCurrentError(null);
               e.stopPropagation();
@@ -33,7 +43,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
 
       return (
         <ExpandedError onClick={() => this.setState({ expanded: false })}>
-          Error: {this.context.currentError}
+          Error: {this.props.currentError}
           <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
             <CloseButtonImg src={close} />
           </CloseButtonAlt>
@@ -80,9 +90,9 @@ const ErrorText = styled.div`
 
 const StyledCurrentError = styled.div`
   position: fixed;
-  bottom: 20px;
+  bottom: 22px;
   width: 300px;
-  left: 17px;
+  left: 100px;
   padding: 15px;
   padding-right: 0px;
   font-family: 'Work Sans', sans-serif;

+ 5 - 2
dashboard/src/main/Login.tsx

@@ -43,7 +43,7 @@ export default class Login extends Component<PropsType, StateType> {
   handleLogin = (): void => {
     let { email, password } = this.state;
     let { authenticate } = this.props;
-    let { setCurrentError, setUser } = this.context;
+    let { setUser } = this.context;
 
     // Check for valid input
     if (!emailRegex.test(email)) {
@@ -55,8 +55,11 @@ export default class Login extends Component<PropsType, StateType> {
         password: password
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
+        if (err) {
+          this.context.setCurrentError(err.response.data.errors[0])
+        }
         setUser(res?.data?.id, res?.data?.email)
-        err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        err ? console.log(err.response.data) : authenticate();
       });
     }
   }

+ 8 - 3
dashboard/src/main/Main.tsx

@@ -32,7 +32,6 @@ export default class Main extends Component<PropsType, StateType> {
   componentDidMount() {
     let { setUser } = this.context;
     api.checkAuth('', {}, {}, (err: any, res: any) => {    
-      console.log(err)  
       if (err && err.response?.status == 403) {
         this.setState({ isLoggedIn: false, loading: false })
       }
@@ -88,7 +87,13 @@ export default class Main extends Component<PropsType, StateType> {
 
         <Route path='/dashboard' render={() => {
           if (this.state.isLoggedIn && this.state.initialized) {
-            return <Home logOut={this.handleLogOut} />
+            return (
+              <Home 
+                currentProject={this.context.currentProject}
+                currentCluster={this.context.currentCluster} 
+                logOut={this.handleLogOut} 
+              />
+            );
           } else {
             return <Redirect to='/' />
           }
@@ -114,7 +119,7 @@ export default class Main extends Component<PropsType, StateType> {
         <BrowserRouter>
           {this.renderMain()}
         </BrowserRouter>
-        <CurrentError />
+        <CurrentError currentError={this.context.currentError} />
       </StyledMain>
     );
   }

+ 192 - 82
dashboard/src/main/home/Home.tsx

@@ -1,9 +1,11 @@
 import React, { Component } from 'react';
+import posthog from 'posthog-js';
 import styled from 'styled-components';
 
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
-import { InfraType, ClusterType } from '../../shared/types';
+import { ClusterType, ProjectType } from '../../shared/types';
+import { includesCompletedInfraSet } from '../../shared/common';
 
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
@@ -17,21 +19,22 @@ import IntegrationsModal from './modals/IntegrationsModal';
 import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
 import NewProject from './new-project/NewProject';
 import Navbar from './navbar/Navbar';
-import Provisioner from './new-project/Provisioner';
+import ProvisionerStatus from './provisioner/ProvisionerStatus';
 import ProjectSettings from './project-settings/ProjectSettings';
-import posthog from 'posthog-js';
 import ConfirmOverlay from '../../components/ConfirmOverlay';
 import Modal from './modals/Modal';
 
 type PropsType = {
-  logOut: () => void
+  logOut: () => void,
+  currentProject: ProjectType,
+  currentCluster: ClusterType,
 };
 
 type StateType = {
   forceSidebar: boolean,
   showWelcome: boolean,
   currentView: string,
-  viewData: any[],
+  handleDO: boolean, // Trigger DO infra calls after oauth flow if needed
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
   // Track last project id for refreshing clusters on project change
@@ -39,78 +42,177 @@ type StateType = {
   sidebarReady: boolean, // Fixes error where ~1/3 times reloading to provisioner fails
 };
 
+// TODO: Handle cluster connected but with some failed infras (no successful set)
 export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
     showWelcome: false,
     currentView: 'dashboard',
     prevProjectId: null as number | null,
-    viewData: null as any,
     forceRefreshClusters: false,
     sidebarReady: false,
+    handleDO: false,
+  }
+
+  // TODO: Refactor and prevent flash + multiple reload
+  initializeView = () => {
+    let { currentProject } = this.props;
+    if (currentProject) {
+      let { currentCluster } = this.context;
+      
+      // Check if current project is provisioning
+      api.getInfra('<token>', {}, { project_id: currentProject.id }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        }
+        if (res.data.length > 0 && !(currentCluster || includesCompletedInfraSet(res.data))) {
+          this.setState({ currentView: 'provisioner', sidebarReady: true, });
+        } else {
+          this.setState({ currentView: 'dashboard', sidebarReady: true });
+        }
+      });
+    }
   }
 
-  // Possibly consolidate into context (w/ ProjectSection + NewProject)
-  getProjects = () => {
-    let { user, currentProject, projects, setProjects } = this.context;
+  getProjects = (id?: number) => {
+    let { user, setProjects } = this.context;
+    let { currentProject } = this.props;
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
       if (err) {
         console.log(err);
       } else if (res.data) {
-        setProjects(res.data);
-        if (res.data.length > 0 && !currentProject) {
-          this.context.setCurrentProject(res.data[0]);
-
-          // Check if current project is provisioning
-          api.getInfra('<token>', {}, { project_id: res.data[0].id }, (err: any, res: any) => {
-            if (err) {
-              console.log(err);
-            } else if (res.data) {
-
-              let viewData = [] as any[]
-              // TODO: separately handle non meta-provisioning case
-              res.data.forEach((el: InfraType) => {
-                if (el.status === 'creating') {
-                  viewData.push({
-                    infra_id: el.id,
-                    kind: el.kind,
-                  });
-                }
-              });
-              
-              if (viewData.length > 0) {
-                this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, });
-              } else {
-                this.setState({ sidebarReady: true });
-              }
-            }
-          });
-        } else if (res.data.length === 0) {
+        if (res.data.length === 0) {
           this.setState({ currentView: 'new-project', sidebarReady: true, });
+        } else if (res.data.length > 0 && !currentProject) {
+          setProjects(res.data);
+          if (!id) {
+            this.context.setCurrentProject(res.data[0]);
+            this.initializeView();
+          } else {
+            let foundProject = null;
+            res.data.forEach((project: ProjectType, i: number) => {
+              if (project.id === id) {
+                foundProject = project;
+              } 
+            });
+            this.context.setCurrentProject(foundProject);
+            this.setState({ currentView: 'provisioner' });
+          }
         }
       }
     });
   }
 
+  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() {
+
+    // 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('projectId'));
+      this.setState({ handleDO: true });
+      this.checkDO();
+    }
+    
     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) }
+      loaded: function(posthog: any) { posthog.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 !== this.props && this.context.currentProject) {
-
-      // Set view to dashboard on project change
-      if (this.state.prevProjectId && this.state.prevProjectId !== this.context.currentProject.id) {
-        this.setState({
-          prevProjectId: this.context.currentProject.id,
-          currentView: 'dashboard'
-        });
+    if (
+      prevProps.currentProject !== this.props.currentProject
+      || (!prevProps.currentCluster && this.props.currentCluster)
+    ) {
+      if (this.state.handleDO) {
+        this.checkDO();
+      } else {
+        this.initializeView();
       }
     }
   }
@@ -118,7 +220,7 @@ export default class Home extends Component<PropsType, StateType> {
   // TODO: move into ClusterDashboard
   renderDashboard = () => {
     let { currentCluster, setCurrentModal } = this.context;
-    if (this.state.showWelcome || currentCluster && !currentCluster.name) {
+    if (currentCluster && !currentCluster.name) {
       return (
         <DashboardWrapper>
           <Placeholder>
@@ -149,46 +251,54 @@ 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 })} />
-        </DashboardWrapper>
-      );
-    } else if (currentView === 'integrations') {
-      return <Integrations />;
-    } else if (currentView === 'new-project') {
-      return (
-        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x, viewData: data })} />
-      );
-    } else if (currentView === 'provisioner') {
+    let { currentView, handleDO } = this.state;
+    if (this.context.currentProject) {
+      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 === 'new-project') {
+        return (
+          <NewProject 
+            setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} 
+          />
+        );
+      } else if (currentView === 'provisioner') {
+        return (
+          <ProvisionerStatus
+            setCurrentView={(x: string) => this.setState({ currentView: x })} 
+          />
+        );
+      } else if (currentView === 'project-settings') {
+        return (
+          <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
+        )
+      }
+
       return (
-        <Provisioner 
+        <Templates
           setCurrentView={(x: string) => this.setState({ currentView: x })}
-          viewData={this.state.viewData}
         />
       );
-    } else if (currentView === 'project-settings') {
-      return (
-        <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
-      )
-    }
+    } else {
 
-    return (
-      <Templates
-        setCurrentView={(x: string) => this.setState({ currentView: x })}
-      />
-    );
+    }
   }
 
-  setCurrentView = (x: string, viewData?: any) => {
-    if (!viewData) {
-      this.setState({ currentView: x });
+  setCurrentView = (x: string) => {
+    if (x === 'dashboard') {
+      this.initializeView();
     } else {
-      this.setState({ currentView: x, viewData });
+      this.setState({ currentView: x });
     }
   }
 
@@ -198,7 +308,7 @@ export default class Home extends Component<PropsType, StateType> {
       // Force sidebar closed on first provision
       if (this.state.currentView === 'provisioner' && this.state.forceSidebar) {
         this.setState({ forceSidebar: false });
-      } else if (this.state.sidebarReady) {
+      } else {
         return (
           <Sidebar
             forceSidebar={this.state.forceSidebar}

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

@@ -71,6 +71,7 @@ export default class ChartList extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string) => {
       let { currentCluster, currentProject } = this.context;
+      console.log(currentCluster)
       let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws';
       let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`);
       ws.onopen = () => {

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

@@ -49,7 +49,6 @@ export default class Logs extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod.metadata?.name) return
-
     let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
     this.ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
 
@@ -188,6 +187,8 @@ const LogStream = styled.div`
   height: 100%;
   background: #202227;
   user-select: text;
+  max-width: 65%;
+  overflow-wrap: break-word; 
 `;
 
 const Message = styled.div`

+ 102 - 105
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,68 +1,111 @@
+import { render } from '@testing-library/react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
 
+import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
-import StatusPlaceholderContainer from './StatusPlaceholderContainer';
+import { InfraType } from '../../../shared/types';
+import api from '../../../shared/api';
+
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 type PropsType = {
   setCurrentView: (x: string) => void,
+  projectId: number | null,
 };
 
 type StateType = {
+  infras: InfraType[],
 };
 
 export default class Dashboard extends Component<PropsType, StateType> {
-  renderDashboardIcon = () => {
-    let { currentProject } = this.context;
-    return (
-      <DashboardIcon>
-        <DashboardImage src={gradient} />
-        <Overlay>{currentProject && currentProject.name[0].toUpperCase()}</Overlay>
-      </DashboardIcon>
-    );
+  state = {
+    infras: [] as InfraType[],
   }
 
-  renderContents = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
-      return (
-        <div>
-          <TitleSection>
-            {this.renderDashboardIcon()}
-            <Title>{currentProject && currentProject.name}</Title>
-            <i
-              className="material-icons"
-              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
-                currentProject: currentProject,
-                setCurrentView: this.props.setCurrentView,
-              })}
-            >
-              more_vert
-            </i>
-          </TitleSection>
-
-          <InfoSection>
-            <TopRow>
-              <InfoLabel>
-                <i className="material-icons">info</i> Info
-            </InfoLabel>
-            </TopRow>
-            <Description>Project overview for {currentProject && currentProject.name}.</Description>
-          </InfoSection>
-
-          <LineBreak />
-
-          <StatusPlaceholderContainer setCurrentView={this.props.setCurrentView} />
-        </div>
-      );
+  refreshInfras = () => {
+    if (this.props.projectId) {
+      api.getInfra('<token>', {}, { 
+        project_id: this.props.projectId,
+      }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        } 
+        this.setState({ infras: res.data });
+      });
     }
   }
+  
+  componentDidMount() {
+    this.refreshInfras();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
+      this.refreshInfras();
+    }
+  }
+
+  onShowProjectSettings = () => {
+    let { currentProject, setCurrentModal } = this.context;
+    let { setCurrentView } = this.props;
+    setCurrentModal('UpdateProjectModal', { 
+      currentProject: currentProject,
+      setCurrentView: setCurrentView,
+    });
+  }
 
   render() {
+    let { currentProject, currentCluster } = this.context;
+    let { setCurrentView } = this.props;
+    let { infras } = this.state;
+    let { onShowProjectSettings } = this;
     return (
       <>
-        {this.renderContents()}
+        {currentProject && (
+          <DashboardWrapper>
+            <TitleSection>
+            <DashboardIcon>
+              <DashboardImage src={gradient} />
+              <Overlay>
+                {currentProject && currentProject.name[0].toUpperCase()}
+              </Overlay>
+            </DashboardIcon>
+              <Title>{currentProject && currentProject.name}</Title>
+              <i
+                className="material-icons"
+                onClick={onShowProjectSettings}
+              >
+                more_vert
+              </i>
+            </TitleSection>
+
+            <InfoSection>
+              <TopRow>
+                <InfoLabel>
+                  <i className="material-icons">info</i> Info
+              </InfoLabel>
+              </TopRow>
+              <Description>
+                Project overview for {currentProject && currentProject.name}.
+              </Description>
+            </InfoSection>
+
+            <LineBreak />
+
+            {!currentCluster && (
+              <Banner>
+                <i className="material-icons">error_outline</i>
+                This project currently has no clusters connected.
+              </Banner>
+            )}
+            <ProvisionerSettings 
+              setCurrentView={setCurrentView} 
+              infras={infras}
+            />
+          </DashboardWrapper>
+        )}
       </>
     );
   }
@@ -70,20 +113,24 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
 Dashboard.contextType = Context;
 
-const Placeholder = styled.div`
+const DashboardWrapper = styled.div`
+  padding-bottom: 100px;
+`;
+
+const Banner = styled.div`
+  height: 40px;
   width: 100%;
-  height: calc(100vh - 380px);
-  margin-top: 30px;
+  margin: 10px 0 30px;
+  font-size: 13px;
   display: flex;
-  padding-bottom: 20px;
-  align-items: center;
-  justify-content: center;
-  color: #aaaabb;
   border-radius: 5px;
-  text-align: center;
-  font-size: 13px;
-  background: #ffffff08;
-  font-family: 'Work Sans', sans-serif;
+  padding-left: 15px;
+  align-items: center;
+  background: #616FEEcc;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
 `;
 
 const TopRow = styled.div`
@@ -119,56 +166,6 @@ const InfoSection = styled.div`
   margin-bottom: 35px;
 `;
 
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: 'Work Sans', sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 30px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: not-allowed;
-
-  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
-  :hover {
-    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7A838Fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;

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

@@ -1,466 +1,40 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
-import close from '../../../assets/close.png';
 
-import api from '../../../shared/api';
+import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
-import { integrationList } from '../../../shared/common';
-import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
 
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
-import Heading from '../../../components/values-form/Heading';
-import SaveButton from '../../../components/SaveButton';
-
-const providers = ['aws', 'gcp', 'do',];
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 type PropsType = {
   setCurrentView: (x: string, data?: any) => void,
 };
 
 type StateType = {
-  projectExists: boolean,
   projectName: string,
   selectedProvider: string | null,
-  awsRegion: string | null,
-  awsAccessId: string | null,
-  awsSecretKey: string | null,
-  gcpRegion: string | null,
-  gcpProjectId: string | null,
-  gcpKeyData: string | null,
-  status: string | null,
 };
 
 export default class NewProject extends Component<PropsType, StateType> {
   state = {
-    projectExists: false,
     projectName: '',
     selectedProvider: null as string | null,
-    awsRegion: '' as string | null,
-    awsAccessId: '' as string | null,
-    awsSecretKey: '' as string | null,
-    gcpRegion: '' as string | null,
-    gcpProjectId: '' as string | null,
-    gcpKeyData: '' as string | null,
-    status: null as string | null,
-  }
-
-  isAlphanumeric = (x: string) => {
-    let re = /^[a-z0-9-]+$/;
-    if (x.length == 0 || x.search(re) === -1) {
-      return false;
-    }
-    return true;
-  }
-
-  handleSelectProvider = (provider: string) => {
-    this.setState({ selectedProvider: provider });
-  }
-
-  renderProviderList = () => {
-    return providers.map((provider: string, i: number) => {
-      let providerInfo = integrationList[provider];
-      return (
-        <Block
-          key={i} 
-          onClick={() => this.handleSelectProvider(provider)}
-        >
-          <Icon src={providerInfo.icon} />
-          <BlockTitle>
-            {providerInfo.label}
-          </BlockTitle>
-          <BlockDescription>
-            Hosted in your own cloud.
-          </BlockDescription>
-        </Block>
-      )
-    });
-  }
-
-  // TODO: split this out into a separate component
-  renderProvisioners = () => {
-    if (this.state.selectedProvider === 'aws') {
-
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <DarkMatter />
-          <Heading>
-            AWS Credentials
-            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' target='_blank'>
-              <i className="material-icons-outlined">help</i> 
-              Guide
-            </GuideButton>
-          </Heading>
-          <InputRow
-            type='text'
-            value={this.state.awsRegion}
-            setValue={(x: string) => this.setState({ awsRegion: x })}
-            label='📍 AWS Region'
-            placeholder='ex: mars-north-12'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='text'
-            value={this.state.awsAccessId}
-            setValue={(x: string) => this.setState({ awsAccessId: x })}
-            label='👤 AWS Access ID'
-            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='password'
-            value={this.state.awsSecretKey}
-            setValue={(x: string) => this.setState({ awsSecretKey: x })}
-            label='🔒 AWS Secret Key'
-            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
-            width='100%'
-            isRequired={true}
-          />
-        </FormSection>
-      );
-    } else if (this.state.selectedProvider === 'gcp') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <DarkMatter />
-          <Heading>
-            GCP Credentials
-            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' target='_blank'>
-              <i className="material-icons-outlined">help</i> 
-              Guide
-            </GuideButton>
-          </Heading>
-          <InputRow
-            type='text'
-            value={this.state.gcpRegion}
-            setValue={(x: string) => this.setState({ gcpRegion: x })}
-            label='📍 GCP Region'
-            placeholder='ex: us-central1-a'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='text'
-            value={this.state.gcpProjectId}
-            setValue={(x: string) => this.setState({ gcpProjectId: x })}
-            label='🏷️ GCP Project ID'
-            placeholder='ex: pale-moon-24601'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='password'
-            value={this.state.gcpKeyData}
-            setValue={(x: string) => this.setState({ gcpKeyData: x })}
-            label='🔒 GCP Key Data'
-            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
-            width='100%'
-            isRequired={true}
-          />
-        </FormSection>
-      );
-    } else if (this.state.selectedProvider === 'do') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Flex>
-            DigitalOcean support is in closed beta. If you would like to run Porter in your own DO account, email <Highlight>contact@getporter.dev</Highlight>.
-          </Flex>
-        </FormSection>
-      );
-    }
-
-    return (
-      <BlockList>
-        {this.renderProviderList()}
-      </BlockList>
-    );
-  }
-
-  renderHostingSection = () => {
-    if (this.state.selectedProvider === 'skipped') {
-      return (
-        <>
-          <Helper>Select your hosting backend:</Helper>
-          <Placeholder>
-            You can manually link to an existing cluster once this project has been created.
-          </Placeholder>
-          <Helper>
-            Don't have a Kubernetes cluster?
-            <Highlight onClick={() => this.setState({ selectedProvider: null })}>
-              Provision through Porter
-            </Highlight>
-          </Helper>
-        </>
-      )
-    }
-
-    return (
-      <>
-        <Helper>
-          Select your hosting backend: <Required>*</Required>
-        </Helper>
-        {this.renderProvisioners()}
-        <Helper>
-          Already have a Kubernetes cluster? 
-          <Highlight onClick={() => {
-            if (this.state.projectExists) {
-              this.props.setCurrentView('dashboard');
-            } else {
-              this.setState({ selectedProvider: 'skipped' });
-            }
-          }}>
-            Skip
-          </Highlight>
-        </Helper>
-      </>
-    )
-  }
-
-  validateForm = () => {
-    let { 
-      projectName,
-      selectedProvider, 
-      awsAccessId, 
-      awsSecretKey, 
-      awsRegion,
-      gcpRegion,
-      gcpKeyData,
-      gcpProjectId,
-    } = this.state;
-    if (!this.isAlphanumeric(projectName) || projectName === '') {
-      return false;
-    } else if (selectedProvider === 'aws') {
-      return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    } else if (selectedProvider === 'gcp') {
-      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
-    } else if (selectedProvider === 'skipped') {
-      return true;
-    }
-    return false;
   }
 
-  provisionECR = (proj: ProjectType, callback: (proj: ProjectType, ecr: any) => void) => {
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-
-    api.createAWSIntegration('<token>', {
-      aws_region: awsRegion,
-      aws_access_key_id: awsAccessId,
-      aws_secret_access_key: awsSecretKey,
-    }, { id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-        return;
-      }
-
-      api.provisionECR('<token>', {
-        aws_integration_id: res.data.id,
-        ecr_name: `${proj.name}-registry`
-      }, {id: proj.id}, (err: any, ecr:any) => {
-        if (err) {
-          this.setState({ 
-            projectExists: true,
-            status: 'Please provide valid credentials.',
-          });
-          return;
-        }
-
-        callback(proj, ecr);
-      })
-      
-    });
-  }
-
-  provisionEKS = (proj: ProjectType, ecr: any) => {
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-    let clusterName = `${proj.name}-cluster`
-
-    api.createAWSIntegration('<token>', {
-      aws_region: awsRegion,
-      aws_access_key_id: awsAccessId,
-      aws_secret_access_key: awsSecretKey,
-      aws_cluster_id: clusterName,
-    }, { id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-        return;
-      }
-
-      api.provisionEKS('<token>', {
-        aws_integration_id: res.data.id,
-        eks_name: clusterName,
-      }, { id: proj.id}, (err: any, eks: any) => {
-        if (err) {
-          this.setState({ 
-            projectExists: true,
-            status: 'Please provide valid credentials.',
-          });
-          return;
-        }
-
-        this.props.setCurrentView('provisioner', [
-          { infra_id: ecr?.data?.id, kind: ecr?.data?.kind },
-          { infra_id: eks?.data?.id, kind: eks?.data?.kind },
-        ]);
-      })
-    })
-  }
-
-  provisionGKE = (proj: ProjectType, id: number) => {
-    let clusterName = `${proj.name}-cluster`
-    console.log('provisioning gke...');
-    api.createGKE('<token>', {
-      gke_name: clusterName,
-      gcp_integration_id: id,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        
-        // TODO: set to provisioner
-        alert('success');
-      }
-    });
-  }
-
-  provisionGCR = (proj: ProjectType, id: number) => {
-    console.log('provisioning gcr...');
-    api.createGCR('<token>', {
-      gcp_integration_id: id,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        console.log('gcr provisioned with response: ', res.data);
-        this.provisionGKE(proj, id);
-      }
-    });
-  }
-
-  provisionGCP = (proj: ProjectType) => {
-    this.setState({ status: 'loading' });
-
-    let { gcpRegion, gcpKeyData, gcpProjectId } = this.state;
-    console.log('provisioning gcp...');
-    api.createGCPIntegration('<token>', {
-      gcp_region: gcpRegion,
-      gcp_key_data: gcpKeyData,
-      gcp_project_id: gcpProjectId,
-    }, { project_id: proj.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;
-        this.provisionGCR(proj, id);
-      }
-    });
-  }
-
-  createProject = () => {
-    this.setState({ status: 'loading' });
-    api.createProject('<token>', {
-      name: this.state.projectName
-    }, {}, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        let { user } = this.context;
-        api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-          if (err) {
-            console.log(err)
-          } else if (res.data) {
-            this.context.setProjects(res.data);
-            if (res.data.length > 0) {
-              let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
-              this.context.setCurrentProject(proj);
-              
-              if (this.state.selectedProvider === 'aws') {
-                this.provisionECR(proj, this.provisionEKS);
-              } else if (this.state.selectedProvider === 'gcp') { 
-                this.provisionGCP(proj);
-              } else {
-                this.props.setCurrentView('dashboard', null);
-              }
-            } 
-          }
-        });
-      }
-    });
-  }
-
-  createInfra = () => {
-    this.setState({ status: 'loading' });
-    let { user } = this.context;
-    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else if (res.data) {
-        this.context.setProjects(res.data);
-        if (res.data.length > 0) {
-          let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
-          this.context.setCurrentProject(proj);
-          if (this.state.selectedProvider === 'aws') {
-            this.provisionECR(proj, this.provisionEKS)
-
-          } else {
-            this.props.setCurrentView('dashboard', null);
-          }
-        } 
-      }
-    });
-  }
-
-  renderHeaderSection = () => {
-    if (this.state.projectExists) {
-      return (
-        <>
-          <TitleSection>
-            <Title>Configure Hosting</Title>
-          </TitleSection>
-          <Helper>     
-            <Warning highlight={true} makeFlush={true}>
-              There was an issue configuring your cloud provider.
-            </Warning>
-          </Helper>
-          <Helper>     
-            You can refer to our docs for instructions on 
-            <Link 
-              href="https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-              target="_blank"
-            >
-              creating AWS credentials for Porter
-            </Link>.
-          </Helper>
-          <br />
-        </>
-      );
-    }
-
+  render() {
+    let { setCurrentView } = this.props;
+    let { projectName } = this.state;
     return (
-      <>
+      <StyledNewProject>
         <TitleSection>
           <Title>New Project</Title>
         </TitleSection>
         <Helper>
           Project name
-          <Warning highlight={!this.isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
+          <Warning highlight={!isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
             (lowercase letters, numbers, and "-" only)
           </Warning>
           <Required>*</Required>
@@ -478,43 +52,12 @@ export default class NewProject extends Component<PropsType, StateType> {
             width='470px'
           />
         </InputWrapper>
-      </>
-    );
-  }
-
-  renderButton = () => {
-    if (this.state.projectExists) {
-      return (
-        <SaveButton
-          text='Submit'
-          disabled={!this.validateForm()}
-          onClick={this.createInfra}
-          makeFlush={true}
-          helper='Note: Provisioning can take up to 15 minutes'
-          status={this.state.status}
+        <ProvisionerSettings 
+          isInNewProject={true}
+          setCurrentView={setCurrentView} 
+          projectName={projectName}
         />
-      );
-    }
-
-    return (
-      <SaveButton
-        text='Create Project'
-        disabled={!this.validateForm()}
-        onClick={this.createProject}
-        makeFlush={true}
-        helper='Note: Provisioning can take up to 15 minutes'
-        status={this.state.status}
-      />
-    );
-  }
-  
-  render() {
-    let { selectedProvider } = this.state;
-    return (
-      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
-        {this.renderHeaderSection()}
-        {this.renderHostingSection()}
-        {this.renderButton()}
+        <Br />
       </StyledNewProject>
     );
   }
@@ -522,6 +65,11 @@ export default class NewProject extends Component<PropsType, StateType> {
 
 NewProject.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 100px;
+`;
+
 const Link = styled.a`
   cursor: pointer;
   margin-left: 5px;
@@ -797,8 +345,7 @@ const TitleSection = styled.div`
 const StyledNewProject = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  height: ${(props: { height: string }) => props.height};
   position: relative;
   padding-top: 50px;
-  margin-top: ${(props: { height: string }) => props.height === '600px' ? 'calc(50vh - 350px)' : 'calc(50vh - 400px)'};
+  margin-top: calc(50vh - 340px);
 `;

+ 381 - 0
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -0,0 +1,381 @@
+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 InputRow from '../../../components/values-form/InputRow';
+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,
+  setCurrentView: (x: string | null, data?: any) => void,
+  infras: InfraType[],
+};
+
+type StateType = {
+  awsRegion: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+  selectedInfras: { value: string, label: string }[],
+  buttonStatus: string,
+};
+
+const provisionOptions = [
+  { value: 'ecr', label: 'Elastic Container Registry (ECR)' },
+  { value: 'eks', label: 'Elastic Kubernetes Service (EKS)' },
+];
+
+// TODO: Consolidate across forms w/ HOC
+export default class AWSFormSection extends Component<PropsType, StateType> {
+  state = {
+    awsRegion: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+    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 { 
+      awsRegion,
+      awsAccessId, 
+      awsSecretKey, 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return (
+        !isAlphanumeric(projectName) 
+          || !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '')
+          || selectedInfras.length === 0
+      );
+    } else {
+      return (
+        !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '')
+          || selectedInfras.length === 0
+      );
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+      currentProject 
+    } = 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();
+        });
+      }
+    });
+  }
+
+  provisionECR = (callback?: any) => {
+    console.log('Provisioning ECR')
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { currentProject } = this.context;
+    let { handleError } = this.props;
+
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+
+      api.provisionECR('<token>', {
+        aws_integration_id: res.data.id,
+        ecr_name: `${currentProject.name}-registry`
+      }, {id: currentProject.id}, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          handleError();
+          return;
+        }
+        callback && callback();
+      })
+      
+    });
+  }
+
+  provisionEKS = () => {
+    console.log('Provisioning EKS');
+    let { setCurrentView, handleError } = this.props;
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { currentProject } = this.context;
+
+    let clusterName = `${currentProject.name}-cluster`
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+      aws_cluster_id: clusterName,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      api.provisionEKS('<token>', {
+        aws_integration_id: res.data.id,
+        eks_name: clusterName,
+      }, { id: currentProject.id}, (err: any, eks: any) => {
+        if (err) {
+          console.log(err);
+          handleError();
+          return;
+        }
+        setCurrentView('provisioner');
+      })
+    })
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateAWS = () => {
+    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);
+      } else if (selectedInfras[0].value === 'ecr') {
+        // Case: project exists, only provision ECR
+        this.provisionECR(() => setCurrentView('provisioner'));
+      } else {
+        // Case: project exists, only provision EKS
+        this.provisionEKS();
+      }
+    } else {
+      if (selectedInfras.length === 2) {
+        // Case: project DNE, provision ECR + EKS 
+        this.createProject(() => this.provisionECR(this.provisionEKS));
+      } else if (selectedInfras[0].value === 'ecr') {
+        // Case: project DNE, only provision ECR
+        this.createProject(() => this.provisionECR(() => {
+          setCurrentView('provisioner');
+        }));
+      } else {
+        // Case: project DNE, only provision EKS
+        this.createProject(this.provisionEKS);
+      }
+    }
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      awsRegion,
+      awsAccessId,
+      awsSecretKey,
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledAWSFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>
+            AWS Credentials
+            <GuideButton 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' 
+              target='_blank'
+            >
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={awsRegion}
+            setValue={(x: string) => this.setState({ awsRegion: x })}
+            label='📍 AWS Region'
+            placeholder='ex: us-east-2'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+          <Br />
+          <Heading>AWS Resources</Heading>
+          <Helper>Porter will provision the following AWS 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.onCreateAWS}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledAWSFormSection>
+    );
+  }
+}
+
+AWSFormSection.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;
+`;

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

+ 101 - 0
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
+import { Context } from '../../../shared/Context';
+
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  projectName: string,
+  setCurrentView: (x: string, data?: any) => void,
+};
+
+type StateType = {
+  buttonStatus: string,
+};
+
+export default class ExistingClusterSection extends Component<PropsType, StateType> {
+  state = {
+    buttonStatus: '',
+  }
+
+  onCreateProject = () => {
+    let { projectName, setCurrentView } = this.props;
+    let { user, setProjects, setCurrentProject } = this.context;
+
+    this.setState({ buttonStatus: 'loading' });
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err)
+          } else if (res.data) {
+            setProjects(res.data);
+            if (res.data.length > 0) {
+              let proj = res.data.find((el: ProjectType) => {
+                return el.name === projectName;
+              });
+              setCurrentProject(proj);
+              setCurrentView('dashboard', null);
+            } 
+          }
+        });
+      }
+    });
+  }
+
+  render() {
+    let { children, projectName } = this.props;
+    let { buttonStatus } = this.state;
+    return (
+      <StyledExistingClusterSection>
+        <Placeholder>
+          You can manually link to an existing cluster once this project has
+          been created.
+        </Placeholder>
+        {children ? children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={!isAlphanumeric(projectName)}
+          onClick={this.onCreateProject}
+          status={buttonStatus}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledExistingClusterSection>
+    );
+  }
+}
+
+ExistingClusterSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const StyledExistingClusterSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const Placeholder = styled.div`
+  margin-top: 25px;
+  background: #26282f;
+  margin-bottom: 27px;
+  border-radius: 5px;
+  height: 170px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;

+ 182 - 0
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -0,0 +1,182 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+
+import InputRow from '../../../components/values-form/InputRow';
+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,
+};
+
+type StateType = {
+  gcpRegion: string,
+  gcpProjectId: string,
+  gcpKeyData: string,
+  selectedInfras: { value: string, label: string }[],
+};
+
+const dummyOptions = [
+  { value: 'gcr', label: 'Google Container Registry (GCR)' },
+  { value: 'gke', label: 'Googke Kubernetes Engine (GKE)' },
+];
+
+export default class GCPFormSection extends Component<PropsType, StateType> {
+  state = {
+    gcpRegion: '',
+    gcpProjectId: '',
+    gcpKeyData: '',
+    selectedInfras: [] as { value: string, label: string }[],
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      gcpRegion,
+      gcpProjectId,
+      gcpKeyData,
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledGCPFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>
+            GCP Credentials
+            <GuideButton 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' 
+              target='_blank'
+            >
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={gcpRegion}
+            setValue={(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'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={gcpKeyData}
+            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+            label='🔒 GCP Key Data'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+          <Br />
+          <Heading>Resources</Heading>
+          <Helper>Porter will provision the following resources</Helper>
+          <CheckboxList
+            options={dummyOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        <SaveButton
+          text='Submit'
+          disabled={true}
+          onClick={() => console.log('oolala')}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledGCPFormSection>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledGCPFormSection = styled.div`
+  position: relative;
+  padding-bottom: 70px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  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;
+`;

+ 69 - 0
dashboard/src/main/home/provisioner/InfraStatuses.tsx

@@ -0,0 +1,69 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import loadingDots from '../../../assets/loading-dots.gif';
+import { InfraType } from '../../../shared/types';
+import { infraNames } from '../../../shared/common';
+
+type PropsType = {
+  infras: InfraType[],
+};
+
+type StateType = {
+};
+
+export default class InfraStatuses extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderStatusIcon = (status: string) => {
+    if (status === 'created') {
+      return <StatusIcon>✓</StatusIcon>;
+    } else if (status === 'creating') {
+      return <StatusIcon><img src={loadingDots} /></StatusIcon>
+    } else if (status === 'error') {
+      return <StatusIcon color='#e3366d'>✗</StatusIcon>
+    }
+  }
+
+  render() {
+    return (
+      <StyledInfraStatuses>
+        {this.props.infras.map((infra: InfraType, i: number) => {
+          return (
+            <InfraRow>
+              {this.renderStatusIcon(infra.status)}
+              {infraNames[infra.kind]}
+            </InfraRow>
+          )
+        })}
+      </StyledInfraStatuses>
+    );
+  }
+}
+
+const StatusIcon = styled.div<{ color?: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  font-size: 16px;
+  color: ${props => props.color ? props.color : '#68c49c'};
+  margin-right: 10px;
+`;
+
+const InfraRow = styled.div`
+  width: 100%;
+  height: 25px;
+  padding-left: 2px;
+  margin-top: 10px;
+  font-size: 13px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+`;
+
+const StyledInfraStatuses = styled.div`
+  margin-top: 20px;
+  margin-bottom: 0;
+`;

+ 82 - 0
dashboard/src/main/home/provisioner/ProvisionerContainer.tsx

@@ -0,0 +1,82 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType } from '../../../shared/types';
+
+import ProvisionerStatus from './ProvisionerStatus';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+  handleDO: boolean,
+  setHandleDO: (x: boolean) => void,
+  currentProject: ProjectType,
+}
+
+type StateType = {
+};
+
+export default class ProvisionerContainer extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  provisionDOCR = (integrationId: number, tier: string) => {
+    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;
+      }
+      console.log(res.data);
+    });
+  }
+
+  checkDO = () => {
+    let { currentProject } = this.props;
+    if (this.props.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');
+        console.log(infras, 'oof');
+        // this.provisionDOCR(tgtIntegration.id, tier);
+      });
+      this.props.setHandleDO(false);
+    }
+  }
+
+  componentDidMount() {
+    this.checkDO();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
+      this.checkDO();
+    }
+  }
+
+  render() {
+    return (
+      <ProvisionerStatus setCurrentView={this.props.setCurrentView} />
+    );
+  }
+}
+
+ProvisionerStatus.contextType = Context;

+ 281 - 0
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -0,0 +1,281 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import { integrationList } from '../../../shared/common';
+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';
+
+type PropsType = {
+  setCurrentView: (x: string, data?: any) => void,
+  isInNewProject?: boolean,
+  projectName?: string,
+  infras?: InfraType[],
+};
+
+type StateType = {
+  selectedProvider: string | null,
+  infras: InfraType[],
+};
+
+const providers = ['aws', 'gcp', 'do',];
+
+export default class NewProject extends Component<PropsType, StateType> {
+  state = {
+    selectedProvider: null as string | null,
+    infras: [] as InfraType[],
+  }
+
+  // Handle any submission (pre-status) error
+  handleError = () => {
+    let { setCurrentView } = this.props;
+    let { setCurrentError } = this.context;
+    setCurrentView('dashboard');
+    this.setState({ selectedProvider: null });
+    setCurrentError('Provisioning failed. Check your credentials and try again.');
+  }
+
+  renderSelectedProvider = () => {
+    let { selectedProvider } = this.state;
+    let { projectName, setCurrentView, infras } = this.props;
+
+    let renderSkipHelper = () => {
+      return (
+        <>
+          {selectedProvider === 'skipped' 
+            ? (
+              <Helper>
+                Don't have a Kubernetes cluster?
+                <Highlight 
+                  onClick={() => this.setState({ selectedProvider: null })}
+                >
+                  Provision through Porter
+                </Highlight>
+              </Helper>
+            ) : (
+              <PositionWrapper selectedProvider={selectedProvider}>
+                <Helper>
+                  Already have a Kubernetes cluster? 
+                  <Highlight 
+                    onClick={() => this.setState({ 
+                      selectedProvider: 'skipped' 
+                    })}
+                  >
+                    Skip
+                  </Highlight>
+                </Helper>
+              </PositionWrapper>
+            )
+          }
+        </>
+      );
+    }
+
+    switch (selectedProvider) {
+      case 'aws':
+        return (
+          <AWSFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setCurrentView={setCurrentView}
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          >
+            {renderSkipHelper()}
+          </AWSFormSection>
+        );
+      case 'gcp':
+        return (
+          <GCPFormSection 
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          />
+        );
+      case 'do':
+        return (
+          <DOFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          />
+        )
+      default:
+        return (
+          <ExistingClusterSection 
+            projectName={projectName}
+            setCurrentView={setCurrentView}
+          >
+            {renderSkipHelper()}
+          </ExistingClusterSection>
+        );
+    }
+  }
+  
+  render() {
+    let { selectedProvider } = this.state;
+    let { isInNewProject } = this.props;
+    return (
+      <StyledProvisionerSettings>
+        <Helper>
+          {isInNewProject 
+            ? <>Select your hosting backend:<Required>*</Required></>
+            : 'Need a cluster? Provision through Porter:'
+          }
+        </Helper>
+        {!selectedProvider ? (
+          <BlockList>
+            {providers.map((provider: string, i: number) => {
+              let providerInfo = integrationList[provider];
+              return (
+                <Block
+                  key={i} 
+                  onClick={() => {
+                    this.setState({ selectedProvider: provider });
+                  }}
+                >
+                  <Icon src={providerInfo.icon} />
+                  <BlockTitle>
+                    {providerInfo.label}
+                  </BlockTitle>
+                  <BlockDescription>
+                    Hosted in your own cloud.
+                  </BlockDescription>
+                </Block>
+              );
+            })}
+          </BlockList>
+        ) : (
+          <>{this.renderSelectedProvider()}</>
+        )}
+        {(isInNewProject && !selectedProvider) && (
+          <>
+            <Helper>
+              Already have a Kubernetes cluster? 
+              <Highlight 
+                onClick={() => this.setState({ selectedProvider: 'skipped' })}
+              >
+                Skip
+              </Highlight>
+            </Helper>
+            <Br />
+            <SaveButton
+              text='Submit'
+              disabled={true}
+              onClick={() => {}}
+              makeFlush={true}
+              helper='Note: Provisioning can take up to 15 minutes'
+            />
+          </>
+        )}
+      </StyledProvisionerSettings>
+    );
+  }
+}
+
+NewProject.contextType = Context;
+
+const Br = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const StyledProvisionerSettings = styled.div`
+  position: relative;
+`;
+
+const PositionWrapper = styled.div<{ selectedProvider: string | null}>`
+`;
+
+const Highlight = styled.div`
+  margin-left: 5px;
+  color: #8590ff;
+  cursor: pointer;
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const Icon = styled.img<{ bw?: boolean }>`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${props => props.bw ? 'grayscale(1)' : ''};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;  
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${props => props.disabled ? '' : 'pointer'};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${props => props.disabled ? '' : '#ffffff11'};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;

+ 101 - 69
dashboard/src/main/home/new-project/Provisioner.tsx → dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -6,13 +6,13 @@ import { Context } from '../../../shared/Context';
 import ansiparse from '../../../shared/ansiparser'
 import loading from '../../../assets/loading.gif';
 import warning from '../../../assets/warning.png';
+import { InfraType } from '../../../shared/types';
+import { filterOldInfras } from '../../../shared/common';
 
 import Helper from '../../../components/values-form/Helper';
-import { eventNames } from 'process';
-import { inflateRaw, inflateRawSync } from 'zlib';
+import InfraStatuses from './InfraStatuses';
 
 type PropsType = {
-  viewData: any,
   setCurrentView: (x: string) => void,
 }
 
@@ -23,9 +23,18 @@ type StateType = {
   maxStep : Record<string, number>,
   currentStep: Record<string, number>,
   triggerEnd: boolean,
+  infras: InfraType[],
 };
 
-export default class Provisioner extends Component<PropsType, StateType> {
+const dummyInfras = [
+  { kind: 'ecr', status: 'creating', id: 5, project_id: 1 }, 
+  { kind: 'eks', status: 'error', id: 3, project_id: 1 },
+  { kind: 'eks', status: 'error', id: 1, project_id: 1 },
+  { kind: 'eks', status: 'error', id: 4, project_id: 1 },
+  { kind: 'ecr', status: 'created', id: 2, project_id: 1 },
+];
+
+export default class ProvisionerStatus extends Component<PropsType, StateType> {
   state = {
     error: false,
     logs: [] as string[],
@@ -33,6 +42,44 @@ export default class Provisioner extends Component<PropsType, StateType> {
     maxStep: {} as Record<string, any>,
     currentStep: {} as Record<string, number>,
     triggerEnd: false,
+    infras: [] as InfraType[],
+  }
+
+  componentDidMount() {
+    let { currentProject } = this.context;
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+
+    // Check if current project is provisioning
+    api.getInfra('<token>', {}, { 
+      project_id: currentProject.id 
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } 
+      let infras = filterOldInfras(res.data);
+      let error = false;
+      infras.forEach((infra: InfraType, i: number) => {
+        if (infra.status === 'error') {
+          error = true;
+        }
+      });
+
+      // 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`)
+        return this.setupWebsocket(ws, infra)
+      });
+  
+      this.setState({ error, infras, websockets, logs: ["Provisioning resources..."] });
+    });
+  }
+
+  componentWillUnmount() {
+    if (!this.state.websockets) { return; }
+
+    this.state.websockets.forEach((ws: any) => {
+      ws.close()
+    })
   }
 
   scrollToBottom = () => {
@@ -127,69 +174,19 @@ export default class Provisioner extends Component<PropsType, StateType> {
     return ws
   }
 
-  componentDidMount() {
-    let { currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
-    let viewData = this.props.viewData || []
-
-    let websockets = viewData.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 EKS cluster and ECR registry..."] });
-  }
-
-  componentWillUnmount() {
-    if (!this.state.websockets) { return; }
-
-    this.state.websockets.forEach((ws: any) => {
-      ws.close()
-    })
-  }
-
   scrollRef = React.createRef<HTMLDivElement>();
 
   renderLogs = () => {
     return this.state.logs.map((log, i) => {
-      return <Log key={i}>{log}</Log>
+      return <Log key={i}>{log}</Log>;
     });
   }
 
-  renderHeadingSection = () => {
-    if (this.state.error) {
-      return (
-        <>
-          <TitleSection>
-            <Title><img src={warning} /> Provisioning Error</Title>
-          </TitleSection>
-
-          <Helper>
-            Porter encountered an error while provisioning.
-            <Link onClick={() => this.props.setCurrentView('dashboard')}>
-              Exit to dashboard
-            </Link> 
-            to try again with new credentials.
-          </Helper>
-        </>
-      );
-    }
-
-    return (
-      <>
-        <TitleSection>
-          <Title><img src={loading} /> Setting Up Porter</Title>
-        </TitleSection>
-        <Helper>
-          Porter is currently being provisioned to your AWS account:
-        </Helper>
-      </>
-    )
-  }
-
   onEnd = () => {
     let myInterval = setInterval(() => {
-      api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
+      api.getClusters('<token>', {}, { 
+        id: this.context.currentProject.id 
+      }, (err: any, res: any) => {
         if (err) {
           console.log(err);
         } else if (res.data) {
@@ -204,6 +201,9 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
   
   render() {
+    let { error, triggerEnd, infras } = this.state;
+    let { setCurrentView } = this.props;
+    
     let maxStep = 0;
     let currentStep = 0;
 
@@ -219,23 +219,55 @@ export default class Provisioner extends Component<PropsType, StateType> {
       }
     }
 
-    if (maxStep !== 0 && currentStep === maxStep && !this.state.triggerEnd) {
+    if (maxStep !== 0 && currentStep === maxStep && !triggerEnd) {
       this.onEnd()
       this.setState({ triggerEnd: true });
     }
 
     return (
       <StyledProvisioner>
-        {this.renderHeadingSection()}
-
+        {error 
+          ? (
+            <>
+              <TitleSection>
+                <Title><img src={warning} /> Provisioning Error</Title>
+              </TitleSection>
+    
+              <Helper>
+                Porter encountered an error while provisioning.
+                <Link onClick={() => setCurrentView('dashboard')}>
+                  Exit to dashboard
+                </Link> 
+                to try again with new credentials.
+              </Helper>
+            </>
+          ) : (
+            <>
+              <TitleSection>
+                <Title><img src={loading} /> Setting Up Porter</Title>
+              </TitleSection>
+              <Helper>
+                Porter is currently provisioning resources in your cloud provider:
+              </Helper>
+            </>
+          )
+        }
+      
         <LoadingBar>
-          <Loaded progress={((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%'} />
+          <Loaded 
+            progress={
+              error ? (
+                '0%'
+              ) : (
+                (((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%')
+              )
+            }
+          />
         </LoadingBar>
+        <InfraStatuses infras={infras} />
 
         <LogStream ref={this.scrollRef}>
-          <Wrapper>
-            {this.renderLogs()}
-          </Wrapper>
+          <Wrapper>{this.renderLogs()}</Wrapper>
         </LogStream>
 
         <Helper>
@@ -246,7 +278,7 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
 }
 
-Provisioner.contextType = Context;
+ProvisionerStatus.contextType = Context;
 
 const Link = styled.a`
   cursor: pointer;
@@ -273,7 +305,7 @@ const Log = styled.div`
 
 const LogStream = styled.div`
   height: 300px;
-  margin-top: 30px;
+  margin-top: 20px;
   font-size: 13px;
   border: 2px solid #ffffff55;
   border-radius: 10px;
@@ -292,8 +324,8 @@ const Message = styled.div`
   font-size: 13px;
 `;
 
-const Loaded = styled.div`
-  width: ${(props: { progress: string }) => props.progress};
+const Loaded = styled.div<{ progress: string }>`
+  width: ${props => props.progress};
   height: 100%;
   background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
   background-size: 400% 400%;

+ 20 - 2
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -51,12 +51,30 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         this.props.setWelcome(false);
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
+          console.log(res.data);
           let clusters = res.data;
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
             this.setState({ clusters });
-            setCurrentCluster(clusters[0]);
-          } else if (this.props.currentView !== 'provisioner') {
+            let saved = JSON.parse(localStorage.getItem('currentCluster'));
+            if (localStorage.getItem('currentCluster') !== '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
+                ) {
+                  setCurrentCluster(clusters[i]);
+                  break;
+                }
+              }
+            } else {
+              setCurrentCluster(clusters[0]);
+            }
+          } else if (
+            this.props.currentView !== 'provisioner'
+            && this.props.currentView !== 'new-project'
+          ) {
             this.setState({ clusters: [] });
             setCurrentCluster(null);
             this.props.setCurrentView('dashboard');

+ 5 - 31
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -2,13 +2,12 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
-import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 
 type PropsType = {
   currentProject: ProjectType,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   projects: ProjectType[],
 };
 
@@ -21,40 +20,15 @@ export default class ProjectSection extends Component<PropsType, StateType> {
     expanded: false,
   };
 
-  handleSelectProject = (project: ProjectType) => {
-    this.context.setCurrentProject(project);
-    
-    api.getInfra('<token>', {}, { project_id: project.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res.data) {
-
-        let viewData = [] as any[]
-        res.data.forEach((el: InfraType) => {
-          if (el.status === 'creating') {
-            viewData.push({
-              infra_id: el.id,
-              kind: el.kind,
-            });
-          }
-        });
-
-        if (viewData.length > 0) {
-          this.props.setCurrentView('provisioner', viewData);
-        } else {
-          this.props.setCurrentView('dashboard');
-        }
-      }
-    });
-  }
-
   renderOptionList = () => {
+    let { setCurrentProject } = this.context;
+
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => this.handleSelectProject(project)}
+          onClick={() => setCurrentProject(project)}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -170,7 +144,7 @@ const Option = styled.div`
   font-size: 13px;
   align-items: center;
   padding-left: 10px;
-  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};
+  cursor: pointer;
   padding-right: 10px;
   background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
   :hover {

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

@@ -5,7 +5,7 @@ import { Context } from '../../../shared/Context';
 import ProjectSection from './ProjectSection';
 
 type PropsType = {
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {

+ 28 - 32
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -15,7 +15,7 @@ import posthog from 'posthog-js';
 type PropsType = {
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   currentView: string,
   forceRefreshClusters: boolean,
   setRefreshClusters: (x: boolean) => void,
@@ -94,37 +94,33 @@ export default class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderProjectContents = () => {
-    if (this.props.currentView === 'provisioner') {
-      return (
-        <ProjectPlaceholder>
-          <img src={loading} /> Creating . . .
-        </ProjectPlaceholder>
-      )
-    } else if (this.context.currentProject) {
+    let { currentView, setCurrentView } = this.props;
+    let { currentProject, setCurrentModal } = this.context;
+    if (currentProject) {
       return (
         <>
           <SidebarLabel>Home</SidebarLabel>
           <NavButton
-            onClick={() => this.props.setCurrentView('dashboard')}
-            selected={this.props.currentView === 'dashboard'}
+            onClick={() => (currentView !== 'provisioner') && setCurrentView('dashboard')}
+            selected={currentView === 'dashboard' || currentView === 'provisioner'}
           >
-            <img src={category} />
+            <Img src={category} />
             Dashboard
           </NavButton>
           <NavButton
-            onClick={() => this.props.setCurrentView('templates')}
-            selected={this.props.currentView === 'templates'}
+            onClick={() => setCurrentView('templates')}
+            selected={currentView === 'templates'}
           >
-            <img src={filter} />
+            <Img src={filter} />
             Templates
           </NavButton>
           <NavButton
-            selected={this.props.currentView === 'integrations'}
+            selected={currentView === 'integrations'}
             onClick={() => {
-              this.context.setCurrentModal('IntegrationsInstructionsModal', {})
+              setCurrentModal('IntegrationsInstructionsModal', {})
             }}
           >
-            <img src={integrations} />
+            <Img src={integrations} />
             Integrations
           </NavButton>
           {this.context.currentProject.roles.filter((obj: any) => {
@@ -134,7 +130,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
               onClick={() => this.props.setCurrentView('project-settings')}
               selected={this.props.currentView === 'project-settings'}
             >
-              <img src={settings} />
+              <Img enlarge={true} src={settings} />
               Settings
             </NavButton>
           }
@@ -146,9 +142,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
-            currentView={this.props.currentView}
-            setCurrentView={this.props.setCurrentView}
-            isSelected={this.props.currentView === 'cluster-dashboard'}
+            currentView={currentView}
+            setCurrentView={setCurrentView}
+            isSelected={currentView === 'cluster-dashboard'}
             forceRefreshClusters={this.props.forceRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
           />
@@ -167,7 +163,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
   // SidebarBg is separate to cover retracted drawer
   render() {
     return (
-      <div>
+      <>
         {this.renderPullTab()}
         <StyledSidebar showSidebar={this.state.showSidebar}>
           <SidebarBg />
@@ -188,7 +184,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
 
           {this.renderProjectContents()}
         </StyledSidebar>
-      </div>
+      </>
     );
   }
 }
@@ -244,16 +240,16 @@ const NavButton = styled.div`
     left: 19px;
     top: 8px;
   }
+`;
 
-  > img {
-    padding: 4px 4px;
-    height: 23px;
-    width: 23px;
-    border-radius: 3px;
-    position: absolute;
-    left: 20px;
-    top: 9px;
-  }
+const Img = styled.img<{ enlarge?: boolean }>`
+  padding: 4px 4px;
+  height: ${props => props.enlarge ? '27px' : '23px'};
+  width: ${props => props.enlarge ? '27px' : '23px'};
+  border-radius: 3px;
+  position: absolute;
+  left: ${props => props.enlarge ? '19px' : '20px'};
+  top: 9px;
 `;
 
 const BottomSection = styled.div`

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

@@ -49,7 +49,7 @@ class ContextProvider extends Component {
     },
     user: null as any,
     setUser: (userId: number, email: string) => {
-      this.setState({ user: {userId, email} });
+      this.setState({ user: { userId, email } });
     },
     devOpsMode: true,
     setDevOpsMode: (devOpsMode: boolean) => {

+ 31 - 1
dashboard/src/shared/api.tsx

@@ -346,10 +346,40 @@ const createInvite = baseApi<{
   id: number
 }>('POST', pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
-})
+});
+
+const getOAuthIds = baseApi<{
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/integrations/oauth`;
+});
+
+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 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`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  createDOKS,
+  createDOCR,
+  getOAuthIds,
   checkAuth,
   createAWSIntegration,
   createECR,

+ 66 - 0
dashboard/src/shared/common.tsx

@@ -1,6 +1,16 @@
 import aws from '../assets/aws.png';
 import digitalOcean from '../assets/do.png';
 import gcp from '../assets/gcp.png';
+import { InfraType } from '../shared/types';
+
+export const infraNames: any = {
+  'ecr': 'Elastic Container Registry (ECR)',
+  'eks': 'Elastic Kubernetes Service (EKS)',
+  'gcr': 'Google Container Registry (GCR)',
+  'gke': 'Google Kubernetes Engine (GKE)',
+  'docr': 'Digital Ocean Container Registry',
+  'doks': 'Digital Ocean Kubernetes Service'
+};
 
 export const integrationList: any = {
   'kubernetes': {
@@ -56,8 +66,64 @@ export const integrationList: any = {
   }
 };
 
+export const isAlphanumeric = (x: string | null) => {
+  let re = /^[a-z0-9-]+$/;
+  if (!x || x.length == 0 || x.search(re) === -1) {
+    return false;
+  }
+  return true;
+}
+
 export const getIgnoreCase = (object: any, key: string) => {
   return object[Object.keys(object)
     .find(k => k.toLowerCase() === key.toLowerCase())
   ];
+}
+
+export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
+  if (infras.length === 0) {
+    return false;
+  }
+
+  let infraSets = [
+    ['ecr', 'eks'],
+    ['gcr', 'gke'],
+    ['docr', 'doks']
+  ];
+
+  let completed = [] as string[];
+  infras.forEach((infra: InfraType, i: number) => {
+    if (infra.status === 'created') {
+      completed.push(infra.kind);
+    }
+  });
+
+  completed.forEach((kind: string, i: number) => {
+    infraSets.forEach((infraSet: string[], i: number) => {
+      infraSet.includes(kind) && infraSet.splice(infraSet.indexOf(kind), 1);
+    });
+  });
+
+  let anyCompleted = false;
+  infraSets.forEach((infraSet: string[], i: number) => {
+    if (infraSet.length === 0) {
+      anyCompleted = true;
+    }
+  })
+  return anyCompleted;
+}
+
+export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
+  let newestInstances = {} as any;
+  infras.forEach((infra: InfraType, i: number) => {
+    if (!newestInstances[infra.kind]) {
+      newestInstances[infra.kind] = infra;
+    } else {
+      let existingId = newestInstances[infra.kind].id;
+      if (infra.id > existingId) {
+        newestInstances[infra.kind] = infra;
+      }
+    }
+  });
+  return Object.values(newestInstances);
 }

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

@@ -145,7 +145,7 @@ export interface ImageType {
 
 export interface InfraType {
   id: number,
-  project_d: number,
+  project_id: number,
   kind: string,
   status: string,
 }

+ 9 - 13
internal/helm/postrenderer.go

@@ -266,16 +266,11 @@ func (d *DockerSecretsPostRenderer) updatePodSpecs(secrets map[string]string) {
 			continue
 		}
 
-		var imagePullSecrets []map[string]interface{}
-		existingNames := map[string]bool{}
+		imagePullSecrets := make([]map[string]interface{}, 0)
+
 		if existingPullSecrets, ok := podSpec["imagePullSecrets"]; ok {
-			imagePullSecrets = existingPullSecrets.([]map[string]interface{})
-			for _, s := range imagePullSecrets {
-				if name, ok := s["name"]; ok {
-					if n, ok := name.(string); ok {
-						existingNames[n] = true
-					}
-				}
+			if existing, ok := existingPullSecrets.([]map[string]interface{}); ok {
+				imagePullSecrets = existing
 			}
 		}
 
@@ -307,15 +302,16 @@ func (d *DockerSecretsPostRenderer) updatePodSpecs(secrets map[string]string) {
 				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
 			}
 
-			imagePullSecrets = append(imagePullSecrets, map[string]interface{}{
-				"name": secrets[regName],
-			})
+			if secretName, ok := secrets[regName]; ok && secretName != "" {
+				imagePullSecrets = append(imagePullSecrets, map[string]interface{}{
+					"name": secretName,
+				})
+			}
 		}
 
 		if len(imagePullSecrets) > 0 {
 			podSpec["imagePullSecrets"] = imagePullSecrets
 		}
-
 	}
 }
 

+ 1 - 0
internal/kubernetes/agent.go

@@ -160,6 +160,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 				return
 			default:
 			}
+
 			bytes, err := r.ReadBytes('\n')
 			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
 				errorchan <- writeErr

+ 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 - 83
server/api/release_handler.go

@@ -512,86 +512,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")
@@ -653,8 +573,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))
 
@@ -668,7 +588,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{

+ 9 - 2
server/api/user_handler.go

@@ -45,12 +45,15 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
+		redirect := session.Values["redirect"]
+
 		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 != "" {
+		if val, ok := redirect.(string); ok && val != "" {
 			http.Redirect(w, r, val, 302)
 			return
 		}
@@ -119,15 +122,19 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	redirect := session.Values["redirect"]
+
 	// 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 != "" {
+	if val, ok := redirect.(string); ok && val != "" {
 		http.Redirect(w, r, val, 302)
 		return
 	}

+ 0 - 6
server/router/router.go

@@ -885,12 +885,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}",