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

Merge branch 'beta.3.integration-frontend' into main

sunguroku 5 лет назад
Родитель
Сommit
fe6746ccf7
46 измененных файлов с 2531 добавлено и 1685 удалено
  1. 7 0
      dashboard/package.json
  2. BIN
      dashboard/src/assets/135838220_453682579008540_3631330216063463363_n.jpg
  3. BIN
      dashboard/src/assets/135843858_160478529194149_2718194807911771299_n.jpg
  4. BIN
      dashboard/src/assets/DaGsIs8VwAAGHM1.jpg
  5. 24 0
      dashboard/src/assets/GithubIcon.tsx
  6. 0 0
      dashboard/src/components/LineGraph.tsx
  7. 313 0
      dashboard/src/components/image-selector/ImageList.tsx
  8. 15 3
      dashboard/src/components/image-selector/ImageSelector.tsx
  9. 104 0
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  10. 91 0
      dashboard/src/components/repo-selector/ActionDetails.tsx
  11. 22 32
      dashboard/src/components/repo-selector/BranchList.tsx
  12. 171 0
      dashboard/src/components/repo-selector/ButtonTray.tsx
  13. 53 59
      dashboard/src/components/repo-selector/ContentsList.tsx
  14. 187 0
      dashboard/src/components/repo-selector/RepoList.tsx
  15. 46 301
      dashboard/src/components/repo-selector/RepoSelector.tsx
  16. 4 3
      dashboard/src/components/values-form/ValuesForm.tsx
  17. 2 3
      dashboard/src/components/values-form/ValuesWrapper.tsx
  18. 0 1
      dashboard/src/main/Main.tsx
  19. 33 56
      dashboard/src/main/home/Home.tsx
  20. 82 105
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  21. 18 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx
  22. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  23. 9 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  24. 10 3
      dashboard/src/main/home/dashboard/ClusterList.tsx
  25. 51 15
      dashboard/src/main/home/dashboard/Dashboard.tsx
  26. 287 49
      dashboard/src/main/home/integrations/IntegrationList.tsx
  27. 99 66
      dashboard/src/main/home/integrations/Integrations.tsx
  28. 19 29
      dashboard/src/main/home/integrations/integration-form/ECRForm.tsx
  29. 62 44
      dashboard/src/main/home/integrations/integration-form/GCRForm.tsx
  30. 2 7
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  31. 1 1
      dashboard/src/main/home/project-settings/InviteList.tsx
  32. 5 7
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  33. 24 30
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  34. 37 15
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  35. 129 0
      dashboard/src/main/home/provisioner/Provisioner.tsx
  36. 247 0
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  37. 0 528
      dashboard/src/main/home/provisioner/ProvisionerStatus.tsx
  38. 23 20
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  39. 10 9
      dashboard/src/main/home/sidebar/Sidebar.tsx
  40. 276 170
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  41. 5 1
      dashboard/src/shared/Context.tsx
  42. 31 31
      dashboard/src/shared/api.tsx
  43. 19 79
      dashboard/src/shared/common.tsx
  44. 0 1
      dashboard/src/shared/urls.tsx
  45. 1 1
      internal/config/config.go
  46. 11 0
      staging.sh

+ 7 - 0
dashboard/package.json

@@ -9,6 +9,13 @@
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
+    "@visx/curve": "^1.0.0",
+    "@visx/event": "^1.3.0",
+    "@visx/gradient": "^1.0.0",
+    "@visx/grid": "^1.4.0",
+    "@visx/scale": "^1.4.0",
+    "@visx/shape": "^1.4.0",
+    "@visx/tooltip": "^1.3.0",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "dotenv": "^8.2.0",

BIN
dashboard/src/assets/135838220_453682579008540_3631330216063463363_n.jpg


BIN
dashboard/src/assets/135843858_160478529194149_2718194807911771299_n.jpg


BIN
dashboard/src/assets/DaGsIs8VwAAGHM1.jpg


+ 24 - 0
dashboard/src/assets/GithubIcon.tsx

@@ -0,0 +1,24 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+};
+
+type StateType = {
+};
+
+
+export default class GHIcon extends Component<PropsType, StateType> {
+  render() {
+    return(
+      <Svg height='18' width='18' viewBox='0 0 16 16'>
+        <path fillRule='evenodd' d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
+      </Svg>
+    );
+  }
+}
+
+const Svg = styled.svg`
+  fill: white;
+  margin-right: 6px;
+`;

+ 0 - 0
dashboard/src/components/LineGraph.tsx


+ 313 - 0
dashboard/src/components/image-selector/ImageList.tsx

@@ -0,0 +1,313 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../shared/api';
+import { integrationList } from '../../shared/common';
+import { Context } from '../../shared/Context';
+import { ImageType } from '../../shared/types';
+
+import Loading from '../Loading';
+import TagList from './TagList';
+
+type PropsType = {
+  selectedImageUrl: string | null,
+  selectedTag: string | null,
+  clickedImage: ImageType | null,
+  registry?: any,
+  setSelectedImageUrl: (x: string) => void,
+  setSelectedTag: (x: string) => void,
+  setClickedImage: (x: ImageType) => void,
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+  images: ImageType[],
+};
+
+export default class ImageSelector extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: false,
+    images: [] as ImageType[],
+  }
+
+  componentDidMount() {
+    const { currentProject, setCurrentError } = this.context;
+    let images = [] as ImageType[]
+    let errors = [] as number[]
+    if (!this.props.registry) {
+      api.getProjectRegistries('<token>', {}, { id: currentProject.id }, async (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          this.setState({ loading: false, error: true });
+        } else {
+          let registries = res.data;
+          if (registries.length === 0) {
+            this.setState({ loading: false });
+          }
+          // Loop over connected image registries
+          registries.forEach(async (registry: any, i: number) => {
+            await new Promise((nextController: (res?: any) => void) => {           
+              api.getImageRepos('<token>', {}, 
+                { 
+                  project_id: currentProject.id,
+                  registry_id: registry.id,
+                }, (err: any, res: any) => {
+                if (err) {
+                  errors.push(1);
+                } else {
+                  res.data.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
+                  // Loop over found image repositories
+                  let newImg = res.data.map((img: any) => {
+                    if (this.props.selectedImageUrl === img.uri) {
+                      this.props.setClickedImage(
+                        {
+                          kind: registry.service,
+                          source: img.uri,
+                          name: img.name,
+                          registryId: registry.id,
+                        }
+                      );
+                    }
+                    return {
+                      kind: registry.service, 
+                      source: img.uri,
+                      name: img.name,
+                      registryId: registry.id,
+                    }
+                  })
+                  images.push(...newImg)
+                  errors.push(0);
+                }
+                
+                if (i == registries.length - 1) {
+                  let error = errors.reduce((a, b) => {
+                    return a + b;
+                  }) == registries.length ? true : false; 
+  
+                  this.setState({
+                    images,
+                    loading: false,
+                    error,
+                  });
+                }
+  
+                nextController()
+              });
+            })
+          });
+        }
+      });
+    } else {
+      api.getImageRepos('<token>', {}, 
+      { 
+        project_id: currentProject.id,
+        registry_id: this.props.registry.id,
+      }, (err: any, res: any) => {
+        if (err) {
+          this.setState({
+            loading: false,
+            error: true,
+          });
+        } else {
+          res.data.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
+          // Loop over found image repositories
+          let newImg = res.data.map((img: any) => {
+            if (this.props.selectedImageUrl === img.uri) {
+              this.props.setClickedImage(
+                {
+                  kind: this.props.registry.service,
+                  source: img.uri,
+                  name: img.name,
+                  registryId: this.props.registry.id,
+                }
+              );
+            }
+            return {
+              kind: this.props.registry.service, 
+              source: img.uri,
+              name: img.name,
+              registryId: this.props.registry.id,
+            }
+          })
+          images.push(...newImg)
+
+          this.setState({
+            images,
+            loading: false,
+            error: false,
+          });
+        }
+      });
+    }
+  }
+
+  /*
+  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
+    Link your registry.
+  </Highlight>
+  */
+  renderImageList = () => {
+    let { images, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !images) {
+      return <LoadingWrapper>Error loading repos</LoadingWrapper>
+    } else if (images.length === 0) {
+      return (
+        <LoadingWrapper>
+          No registries found. 
+        </LoadingWrapper>
+      );
+    }
+
+    return images.map((image: ImageType, i: number) => {
+      let icon = integrationList[image.kind] && integrationList[image.kind].icon;
+      if (!icon) {
+        icon = integrationList['docker'].icon;
+      }
+      return (
+        <ImageItem
+          key={i}
+          isSelected={image.source === this.props.selectedImageUrl}
+          lastItem={i === images.length - 1}
+          onClick={() => { 
+            this.props.setSelectedImageUrl(image.source);
+            this.props.setClickedImage(image);
+          }}
+        >
+          <img src={icon && icon} />{image.source}
+        </ImageItem>
+      );
+    });
+  }
+
+  renderBackButton = () => {
+    let { setSelectedImageUrl } = this.props;
+    if (this.props.clickedImage) {
+      return (
+        <BackButton
+          width='175px'
+          onClick={() => {
+            setSelectedImageUrl('');
+            this.props.setClickedImage(null);
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Image Repo
+        </BackButton>
+      );
+    }
+  }
+
+  renderExpanded = () => {
+    let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
+    if (!this.props.clickedImage) {
+      return (
+        <div>
+          <ExpandedWrapper>
+            {this.renderImageList()}
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    } else {
+      return (
+        <div>
+          <ExpandedWrapper>
+            <TagList
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedTag={setSelectedTag}
+              registryId={this.props.clickedImage.registryId}
+            />
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    }
+  }
+
+  render() {
+    return (
+      <>
+        {this.renderExpanded()}
+      </>
+    );
+  }
+}
+
+ImageSelector.contextType = Context;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const ImageItem = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem: boolean, isSelected: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected: boolean, lastItem: boolean }) => props.isSelected ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    filter: grayscale(100%);
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  background: #ffffff11;
+  overflow-y: auto;
+`;

+ 15 - 3
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -8,8 +8,9 @@ import { integrationList } from "shared/common";
 import { Context } from "shared/Context";
 import { ImageType } from "shared/types";
 
-import Loading from "../Loading";
-import TagList from "./TagList";
+import Loading from '../Loading';
+import TagList from './TagList';
+import ImageList from './ImageList';
 
 type PropsType = {
   forceExpanded?: boolean;
@@ -258,7 +259,18 @@ export default class ImageSelector extends Component<PropsType, StateType> {
           )}
         </StyledImageSelector>
 
-        {this.state.isExpanded ? this.renderExpanded() : null}
+        {this.state.isExpanded
+          ?
+          <ImageList
+            selectedImageUrl={this.props.selectedImageUrl}
+            selectedTag={this.props.selectedTag}
+            clickedImage={this.state.clickedImage}
+            setSelectedImageUrl={this.props.setSelectedImageUrl}
+            setSelectedTag={this.props.setSelectedTag}
+            setClickedImage={(x: ImageType) => this.setState({ clickedImage: x })}
+          />
+          : null
+        }
       </div>
     );
   }

+ 104 - 0
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -0,0 +1,104 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ActionConfigType } from '../../shared/types';
+import { Context } from '../../shared/Context';
+
+import RepoList from './RepoList';
+import BranchList from './BranchList';
+import ContentsList from './ContentsList';
+import ActionDetails from './ActionDetails';
+
+type PropsType = {
+  actionConfig: ActionConfigType | null,
+  branch: string,
+  pathIsSet: boolean,
+  setActionConfig: (x: ActionConfigType) => void,
+  setBranch: (x: string) => void,
+  setPath: (x: boolean) => void,
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+};
+
+export default class ActionConfEditor extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: false,
+  }
+
+  renderExpanded = () => {
+    let {
+      actionConfig,
+      branch,
+      pathIsSet,
+      setActionConfig,
+      setBranch,
+      setPath,
+    } = this.props;
+
+    if (!actionConfig.git_repo) {
+      return (
+        <ExpandedWrapper>
+          <RepoList
+            actionConfig={actionConfig}
+            setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+            readOnly={false}
+          />
+        </ExpandedWrapper>
+      );
+    } else if (!branch) {
+      return (
+        <ExpandedWrapperAlt>
+          <BranchList
+            actionConfig={actionConfig}
+            setBranch={(branch: string) => setBranch(branch)}
+          />
+        </ExpandedWrapperAlt>
+      );
+    } else if (!pathIsSet) {
+      return (
+        <ExpandedWrapperAlt>
+          <ContentsList
+            actionConfig={actionConfig}
+            branch={branch}
+            setActionConfig={setActionConfig}
+            setPath={() => setPath(true)}
+          />
+        </ExpandedWrapperAlt>
+      );
+    }
+    return (
+      <ExpandedWrapperAlt>
+        <ActionDetails
+          actionConfig={actionConfig}
+          setActionConfig={setActionConfig}
+        />
+      </ExpandedWrapperAlt>
+    )
+  }
+
+  render() {
+    return (
+      <>
+        {this.renderExpanded()}
+      </>
+    );
+  }
+}
+
+ActionConfEditor.contextType = Context;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  overflow-y: auto;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+`;

+ 91 - 0
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -0,0 +1,91 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../shared/Context';
+import { ActionConfigType } from '../../shared/types';
+import InputRow from '../values-form/InputRow';
+
+type PropsType = {
+  actionConfig: ActionConfigType | null,
+  setActionConfig: (x: ActionConfigType) => void,
+};
+
+type StateType = {
+  dockerRepo: string,
+  error: boolean,
+};
+
+export default class ActionDetails extends Component<PropsType, StateType> {
+  state = {
+    dockerRepo: '',
+    error: false,
+  }
+
+  componentDidMount() {
+    if (this.props.actionConfig.dockerfile_path) {
+      this.setPath('/Dockerfile');
+    } else {
+      this.setPath('Dockerfile');
+    }
+  }
+
+  setPath = (x: string) => {
+    let { actionConfig, setActionConfig } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.concat(x);
+    setActionConfig(updatedConfig);
+  }
+
+  setURL = (x: string) => {
+    let { actionConfig, setActionConfig } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.image_repo_uri = x;
+    setActionConfig(updatedConfig);
+  }
+
+  renderConfirmation = () => {
+    let { actionConfig } = this.props;
+    return (
+      <Holder>
+        <InputRow
+          disabled={true}
+          label='Git Repository'
+          type='text'
+          width='100%'
+          value={actionConfig.git_repo}
+          setValue={(x: string) => console.log(x)}
+        />
+        <InputRow
+          disabled={true}
+          label='Dockerfile Path'
+          type='text'
+          width='100%'
+          value={actionConfig.dockerfile_path}
+          setValue={(x: string) => console.log(x)}
+        />
+        <InputRow
+          label='Docker Image Repository'
+          placeholder='Image Repo URI (ex. my-repo/image)'
+          type='text'
+          width='100%'
+          value={actionConfig.image_repo_uri}
+          setValue={(x: string) => this.setURL(x)}
+        />
+      </Holder>
+    )
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderConfirmation()}
+      </div>
+    );
+  }
+}
+
+ActionDetails.contextType = Context;
+
+const Holder = styled.div`
+  padding: 0px 12px;
+`;

+ 22 - 32
dashboard/src/components/repo-selector/BranchList.tsx

@@ -2,17 +2,15 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import branch_icon from "assets/branch.png";
 
-import api from "shared/api";
-import { Context } from "shared/Context";
+import api from '../../shared/api';
+import { Context } from '../../shared/Context';
+import { ActionConfigType } from '../..//shared/types';
 
 import Loading from "../Loading";
 
 type PropsType = {
-  grid: number;
-  repoName: string;
-  owner: string;
-  setSelectedBranch: (x: string) => void;
-  selectedBranch: string;
+  actionConfig: ActionConfigType,
+  setBranch: (x: string) => void,
 };
 
 type StateType = {
@@ -29,28 +27,24 @@ export default class BranchList extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
+    let { actionConfig } = this.props;
     let { currentProject } = this.context;
 
     // Get branches
-    api.getBranches(
-      "<token>",
-      {},
-      {
-        project_id: currentProject.id,
-        git_repo_id: this.props.grid,
-        kind: "github",
-        owner: this.props.owner,
-        name: this.props.repoName,
-      },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-          this.setState({ loading: false, error: true });
-        } else {
-          this.setState({ branches: res.data, loading: false, error: false });
-        }
+    api.getBranches('<token>', {}, {
+      project_id: currentProject.id,
+      git_repo_id: actionConfig.git_repo_id,
+      kind: 'github',
+      owner: actionConfig.git_repo.split('/')[0],
+      name: actionConfig.git_repo.split('/')[1],
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        this.setState({ loading: false, error: true });
+      } else {
+        this.setState({ branches: res.data, loading: false, error: false });
       }
-    );
+    });
   }
 
   renderBranchList = () => {
@@ -69,9 +63,8 @@ export default class BranchList extends Component<PropsType, StateType> {
       return (
         <BranchName
           key={i}
-          isSelected={branch === this.props.selectedBranch}
           lastItem={i === branches.length - 1}
-          onClick={() => this.props.setSelectedBranch(branch)}
+          onClick={() => this.props.setBranch(branch)}
         >
           <img src={branch_icon} />
           {branch}
@@ -91,16 +84,13 @@ const BranchName = styled.div`
   display: flex;
   width: 100%;
   font-size: 13px;
-  border-bottom: 1px solid
-    ${(props: { lastItem: boolean; isSelected: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+  border-bottom: 1px solid ${(props: { lastItem: boolean }) => props.lastItem ? '#00000000' : '#606166'};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
   cursor: pointer;
-  background: ${(props: { isSelected: boolean; lastItem: boolean }) =>
-    props.isSelected ? "#ffffff22" : "#ffffff11"};
+  background: #ffffff11;
   :hover {
     background: #ffffff22;
 

+ 171 - 0
dashboard/src/components/repo-selector/ButtonTray.tsx

@@ -0,0 +1,171 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../shared/api';
+import { ActionConfigType } from '../../shared/types';
+import { Context } from '../../shared/Context';
+
+type PropsType = {
+  chartName: string | null,
+  chartNamespace: string | null,
+  pathIsSet: boolean,
+  branch: string,
+  actionConfig: ActionConfigType | null,
+  setBranch: (x: string) => void,
+  setActionConfig: (x: ActionConfigType) => void,
+  setPath: (x: boolean) => void,
+};
+
+type StateType = {
+};
+
+export default class RepoSelector extends Component<PropsType, StateType> {
+  createGHAction = () => {
+    let { currentProject, currentCluster } = this.context;
+    let { actionConfig, chartName, chartNamespace } = this.props;
+
+    api.createGHAction('<token>', {
+      git_repo: actionConfig.git_repo,
+      image_repo_uri: actionConfig.image_repo_uri,
+      dockerfile_path: actionConfig.dockerfile_path,
+      git_repo_id: actionConfig.git_repo_id,
+    }, {
+      project_id: currentProject.id,
+      CLUSTER_ID: currentCluster.id,
+      RELEASE_NAME: chartName,
+      RELEASE_NAMESPACE: chartNamespace,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        // Exit to initial settings tab
+        console.log(res.data);
+      }
+    });
+  }
+
+  setSelectedRepo = () => {
+    let { actionConfig, setActionConfig } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.git_repo = '';
+    updatedConfig.git_repo_id = null as number;
+    setActionConfig(updatedConfig);
+  }
+
+  goToBranchSelect = () => {
+    let { actionConfig, setActionConfig, setBranch } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.dockerfile_path = '';
+    setBranch('');
+    setActionConfig(updatedConfig);
+  }
+
+  goToPathSelect = () => {
+    let { actionConfig, setActionConfig, setPath } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.image_repo_uri = '';
+    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.slice(0, -11);
+    setPath(false);
+    setActionConfig(updatedConfig);
+  }
+
+  renderExpanded = () => {
+    let { actionConfig, pathIsSet, branch } = this.props;
+
+    if (!actionConfig.git_repo) {
+      return (
+        <></>
+      );
+    } else if (!branch) {
+      return (
+        <ButtonTray>
+          <BackButton
+            width='130px'
+            onClick={() => this.setSelectedRepo()}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
+        </ButtonTray>
+      );
+    } else if (!pathIsSet) {
+      return (
+        <ButtonTray>
+          <BackButton
+            onClick={() => this.goToBranchSelect()}
+            width='140px'
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Branch
+          </BackButton>
+        </ButtonTray>  
+      )
+    }
+    return (
+      <ButtonTray>
+        <BackButton
+          width='130px'
+          onClick={() => this.goToPathSelect()}
+        >
+          <i className='material-icons'>keyboard_backspace</i>
+          Select Dockerfile
+        </BackButton>
+        <BackButton
+          disabled={
+            (!actionConfig.git_repo) ||
+            (!actionConfig.dockerfile_path) ||
+            (!actionConfig.image_repo_uri)
+          }
+          width='146px'
+          onClick={() => this.createGHAction()}
+        >
+          <i className='material-icons'>local_shipping</i>
+          Create Github Action
+        </BackButton>
+      </ButtonTray>
+    );
+  }
+
+  render() {
+    return (
+      <>
+        {this.renderExpanded()}
+      </>
+    );
+  }
+}
+
+RepoSelector.contextType = Context;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 10px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string, disabled?: boolean }) => props.width};
+  color: ${(props: { width: string, disabled?: boolean }) => props.disabled ? '#ffffff55' : 'white'};
+  pointer-events: ${(props: { width: string, disabled?: boolean }) => props.disabled ? 'none' : 'auto'};
+
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: ${(props: { width: string, disabled?: boolean }) => props.disabled ? '#ffffff55' : 'white'};
+    font-size: 18px;
+    margin-right: 10px;
+  }
+`;
+
+const ButtonTray = styled.div`
+  margin-top: 10px;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+`;

+ 53 - 59
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -4,20 +4,17 @@ import file from "assets/file.svg";
 import folder from "assets/folder.svg";
 import info from "assets/info.svg";
 
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { FileType } from "shared/types";
+import api from '../../shared/api';
+import { Context } from '../../shared/Context';
+import { FileType, ActionConfigType } from '../../shared/types';
 
 import Loading from "../Loading";
 
 type PropsType = {
-  grid: number;
-  repoName: string;
-  owner: string;
-  selectedBranch: string;
-  subdirectory: string;
-  setSubdirectory: (x: string) => void;
-  setDockerfile: () => void;
+  actionConfig: ActionConfigType | null,
+  branch: string,
+  setActionConfig: (x: ActionConfigType) => void,
+  setPath: () => void,
 };
 
 type StateType = {
@@ -33,56 +30,50 @@ export default class ContentsList extends Component<PropsType, StateType> {
     contents: [] as FileType[],
   };
 
+  setSubdirectory = (x: string) => {
+    let { actionConfig, setActionConfig } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.dockerfile_path = x;
+    setActionConfig(updatedConfig);
+    this.updateContents();
+  }
+
   updateContents = () => {
+    let { actionConfig, branch } = this.props;
     let { currentProject } = this.context;
 
     // Get branch contents
-    api.getBranchContents(
-      "<token>",
-      { dir: this.props.subdirectory },
-      {
-        project_id: currentProject.id,
-        git_repo_id: this.props.grid,
-        kind: "github",
-        owner: this.props.owner,
-        name: this.props.repoName,
-        branch: this.props.selectedBranch,
-      },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-          this.setState({ loading: false, error: true });
-        } else {
-          let files = [] as FileType[];
-          let folders = [] as FileType[];
-          res.data.map((x: FileType, i: number) => {
-            x.Type === "dir" ? folders.push(x) : files.push(x);
-          });
-
-          folders.sort((a: FileType, b: FileType) => {
-            return a.Path < b.Path ? 1 : 0;
-          });
-          files.sort((a: FileType, b: FileType) => {
-            return a.Path < b.Path ? 1 : 0;
-          });
-          let contents = folders.concat(files);
-
-          this.setState({ contents, loading: false, error: false });
-        }
+    api.getBranchContents('<token>', { dir: actionConfig.dockerfile_path }, {
+      project_id: currentProject.id,
+      git_repo_id: actionConfig.git_repo_id,
+      kind: 'github',
+      owner: actionConfig.git_repo.split('/')[0],
+      name: actionConfig.git_repo.split('/')[1],
+      branch: branch,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        this.setState({ loading: false, error: true });
+      } else {
+        let files = [] as FileType[];
+        let folders = [] as FileType[];
+        res.data.map((x: FileType, i: number) => {
+          x.Type === 'dir' ? folders.push(x) : files.push(x);
+        });
+
+        folders.sort((a: FileType, b: FileType) => { return a.Path < b.Path ? 1 : 0 });
+        files.sort((a: FileType, b: FileType) => { return a.Path < b.Path ? 1 : 0 });
+        let contents = folders.concat(files);
+        
+        this.setState({ contents, loading: false, error: false });
       }
-    );
+    });
   };
 
   componentDidMount() {
     this.updateContents();
   }
 
-  componentDidUpdate(prevProps: PropsType) {
-    if (this.props.subdirectory !== prevProps.subdirectory) {
-      this.updateContents();
-    }
-  }
-
   renderContentList = () => {
     let { contents, loading, error } = this.state;
     if (loading) {
@@ -102,9 +93,9 @@ export default class ContentsList extends Component<PropsType, StateType> {
         return (
           <Item
             key={i}
-            isSelected={item.Path === this.props.subdirectory}
+            isSelected={item.Path === this.props.actionConfig.dockerfile_path}
             lastItem={i === contents.length - 1}
-            onClick={() => this.props.setSubdirectory(item.Path)}
+            onClick={() => this.setSubdirectory(item.Path)}
           >
             <img src={folder} />
             {fileName}
@@ -118,7 +109,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
             key={i}
             lastItem={i === contents.length - 1}
             isADocker
-            onClick={() => this.props.setDockerfile()}
+            onClick={() => this.props.setPath()}
           >
             <img src={file} />
             {fileName}
@@ -135,19 +126,22 @@ export default class ContentsList extends Component<PropsType, StateType> {
   };
 
   renderJumpToParent = () => {
-    let { subdirectory, setSubdirectory } = this.props;
-    if (subdirectory !== "") {
-      let splits = subdirectory.split("/");
-      let subdir = "";
+    let { actionConfig } = this.props;
+    if (actionConfig.dockerfile_path !== '') {
+      let splits = actionConfig.dockerfile_path.split('/');
+      let subdir = '';
       if (splits.length !== 1) {
-        subdir = subdirectory.replace(splits[splits.length - 1], "");
-        if (subdir.charAt(subdir.length - 1) === "/") {
+        subdir = actionConfig.dockerfile_path.replace(splits[splits.length - 1], '');
+        if (subdir.charAt(subdir.length - 1) === '/') {
           subdir = subdir.slice(0, subdir.length - 1);
         }
       }
 
       return (
-        <Item lastItem={false} onClick={() => setSubdirectory(subdir)}>
+        <Item
+          lastItem={false}
+          onClick={() => this.setSubdirectory(subdir)}
+        >
           <BackLabel>..</BackLabel>
         </Item>
       );

+ 187 - 0
dashboard/src/components/repo-selector/RepoList.tsx

@@ -0,0 +1,187 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import github from '../../assets/github.png';
+
+import api from '../../shared/api';
+import { RepoType, ActionConfigType } from '../../shared/types';
+import { Context } from '../../shared/Context';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  actionConfig: ActionConfigType | null,
+  setActionConfig: (x: ActionConfigType) => void,
+  userId?: number,
+  readOnly: boolean,
+};
+
+type StateType = {
+  repos: RepoType[],
+  loading: boolean,
+  error: boolean,
+};
+
+export default class ActionConfEditor extends Component<PropsType, StateType> {
+  state = {
+    repos: [] as RepoType[],
+    loading: true,
+    error: false,
+  }
+
+  componentDidMount() {
+    let { currentProject } = this.context;
+
+    // Get repos
+    if (!this.props.userId && this.props.userId !== 0) {
+      api.getGitRepos('<token>', {
+      }, { project_id: currentProject.id }, (err: any, res: any) => {
+        if (err) {
+          this.setState({ loading: false, error: true });
+        } else {
+          var allRepos: any = [];
+          for (let i = 0; i < res.data.length; i++) {
+            var grid = res.data[i].id;
+            api.getGitRepoList('<token>', {}, { project_id: currentProject.id, git_repo_id: grid }, (err: any, res: any) => {
+              if (err) {
+                console.log(err);
+                this.setState({ loading: false, error: true });
+              } else {
+                res.data.forEach((repo: any, id: number) => {
+                  repo.GHRepoID = grid;
+                })
+                allRepos = allRepos.concat(res.data);
+                this.setState({ repos: allRepos, loading: false, error: false });
+              }
+            })
+          }
+          if (res.data.length < 1) {
+            this.setState({ loading: false, error: false });
+          }
+        }
+      });
+    } else {
+      let grid = this.props.userId;
+      api.getGitRepoList('<token>', {}, { project_id: currentProject.id, git_repo_id: grid }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          this.setState({ loading: false, error: true });
+        } else {
+          res.data.forEach((repo: any, id: number) => {
+            repo.GHRepoID = grid;
+          })
+          this.setState({ repos: res.data, loading: false, error: false });
+        }
+      })
+    }
+  }
+
+  setRepo = (x: RepoType) => {
+    let { actionConfig, setActionConfig } = this.props;
+    let updatedConfig = actionConfig;
+    updatedConfig.git_repo = x.FullName;
+    updatedConfig.git_repo_id = x.GHRepoID;
+    setActionConfig(updatedConfig);
+  }
+
+  renderRepoList = () => {
+    let { repos, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !repos) {
+      return <LoadingWrapper>Error loading repos.</LoadingWrapper>
+    } else if (repos.length == 0) {
+      return <LoadingWrapper>No connected repos found.</LoadingWrapper>
+    }
+
+    return repos.map((repo: RepoType, i: number) => {
+      return (
+        <RepoName
+          key={i}
+          isSelected={repo.FullName === this.props.actionConfig.git_repo}
+          lastItem={i === repos.length - 1}
+          onClick={() => this.setRepo(repo)}
+          readOnly={this.props.readOnly}
+        >
+          <img src={github} />{repo.FullName}
+        </RepoName>
+      );
+    });
+  }
+
+  renderExpanded = () => {
+    if (this.props.readOnly) {
+      return (
+        <ExpandedWrapperAlt>
+          {this.renderRepoList()}
+        </ExpandedWrapperAlt>
+      );
+    } else {
+      return (
+        <ExpandedWrapper>
+          {this.renderRepoList()}
+        </ExpandedWrapper>
+      );
+    }
+  }
+
+  render() {
+    return (
+      <>
+        {this.renderExpanded()}
+      </>
+    );
+  }
+}
+
+ActionConfEditor.contextType = Context;
+
+const RepoName = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem: boolean, isSelected: boolean, readOnly: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: ${(props: { lastItem: boolean, isSelected: boolean, readOnly: boolean }) => props.readOnly ? 'default' : 'pointer'};
+  pointer-events: ${(props: { lastItem: boolean, isSelected: boolean, readOnly: boolean }) => props.readOnly ? 'none' : 'auto'};
+  background: ${(props: { lastItem: boolean, isSelected: boolean, readOnly: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  width: 100%;
+  border-radius: 3px;
+  border: 0px solid #ffffff44;
+  max-height: 275px;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  overflow-y: auto;
+`;

+ 46 - 301
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -1,259 +1,78 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import github from "assets/github.png";
-import info from "assets/info.svg";
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import github from 'assets/github.png';
+import info from 'assets/info.svg';
+import { RepoType, ChartType, ActionConfigType } from '../../shared/types';
+import { Context } from '../../shared/Context';
 
-import api from "shared/api";
-import { RepoType, ChartType } from "shared/types";
-import { Context } from "shared/Context";
-
-import Loading from "components/Loading";
-import BranchList from "./BranchList";
-import ContentsList from "./ContentsList";
-import NewGHAction from "./NewGHAction";
+import ButtonTray from './ButtonTray';
+import ActionConfEditor from './ActionConfEditor';
 
 type PropsType = {
-  chart: ChartType | null;
-  forceExpanded?: boolean;
-  selectedRepo: RepoType | null;
-  selectedBranch: string;
-  subdirectory: string;
-  setSelectedRepo: (x: RepoType) => void;
-  setSelectedBranch: (x: string) => void;
-  setSubdirectory: (x: string) => void;
+  chart: ChartType | null,
+  forceExpanded?: boolean,
+  actionConfig: ActionConfigType | null,
+  setActionConfig: (x: ActionConfigType) => void,
 };
 
 type StateType = {
-  isExpanded: boolean;
-  loading: boolean;
-  error: boolean;
-  repos: RepoType[];
-  branchGrID: number;
-  dockerfileSelected: boolean;
-  imageURL: string;
+  isExpanded: boolean,
+  repos: RepoType[]
+  branch: string,
+  pathIsSet: boolean,
+  dockerfileSelected: boolean,
 };
 
 export default class RepoSelector extends Component<PropsType, StateType> {
   state = {
     isExpanded: this.props.forceExpanded,
-    loading: true,
-    error: false,
     repos: [] as RepoType[],
-    branchGrID: null as number,
+    branch: '',
+    pathIsSet: false,
     dockerfileSelected: false,
-    imageURL: null as string,
-  };
-
-  componentDidMount() {
-    let { currentProject } = this.context;
-
-    // Get repos
-    api.getGitRepos(
-      "<token>",
-      {},
-      { project_id: currentProject.id },
-      (err: any, res: any) => {
-        if (err) {
-          this.setState({ loading: false, error: true });
-        } else {
-          var allRepos: any = [];
-          let counter = 0;
-          for (let i = 0; i < res.data.length; i++) {
-            var grid = res.data[i].id;
-            api.getGitRepoList(
-              "<token>",
-              {},
-              { project_id: currentProject.id, git_repo_id: grid },
-              (err: any, res: any) => {
-                if (err) {
-                  console.log(err);
-                  this.setState({ loading: false, error: true });
-                } else {
-                  res.data.forEach((repo: any, id: number) => {
-                    repo.GHRepoID = grid;
-                  });
-                  allRepos = allRepos.concat(res.data);
-                  this.setState({
-                    repos: allRepos,
-                    loading: false,
-                    error: false,
-                  });
-                }
-              }
-            );
-          }
-        }
-      }
-    );
   }
 
-  createGHAction = () => {
-    let { currentProject, currentCluster } = this.context;
-    let path = this.props.subdirectory + "/Dockerfile";
-    if (path[0] === "/") {
-      path = path.substring(1, path.length);
-    }
-
-    api.createGHAction(
-      "<token>",
-      {
-        git_repo: this.props.selectedRepo.FullName,
-        image_repo_uri: this.state.imageURL,
-        dockerfile_path: path,
-        git_repo_id: this.props.selectedRepo.GHRepoID,
-      },
-      {
-        project_id: currentProject.id,
-        CLUSTER_ID: currentCluster.id,
-        RELEASE_NAME: this.props.chart.name,
-        RELEASE_NAMESPACE: this.props.chart.namespace,
-      },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-          this.setState({ error: true });
-        } else {
-          console.log(res.data);
-        }
-      }
-    );
-  };
-
-  renderRepoList = () => {
-    let { repos, loading, error } = this.state;
-    if (loading) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (error || !repos) {
-      return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
-    } else if (repos.length == 0) {
-      return <LoadingWrapper>No connected repos found.</LoadingWrapper>;
-    }
-
-    return repos.map((repo: RepoType, i: number) => {
-      return (
-        <RepoName
-          key={i}
-          isSelected={repo === this.props.selectedRepo}
-          lastItem={i === repos.length - 1}
-          onClick={() => this.props.setSelectedRepo(repo)}
-        >
-          <img src={github} />
-          {repo.FullName}
-        </RepoName>
-      );
-    });
-  };
-
   renderExpanded = () => {
     let {
-      selectedRepo,
-      selectedBranch,
-      subdirectory,
-      setSelectedRepo,
-      setSelectedBranch,
-      setSubdirectory,
+      actionConfig,
+      setActionConfig,
+      chart,
     } = this.props;
-
-    if (!selectedRepo) {
-      return <ExpandedWrapper>{this.renderRepoList()}</ExpandedWrapper>;
-    } else if (selectedBranch === "") {
-      return (
-        <div>
-          <ExpandedWrapperAlt>
-            <BranchList
-              grid={selectedRepo.GHRepoID}
-              setSelectedBranch={(branch: string) => {
-                this.setState({ branchGrID: selectedRepo.GHRepoID });
-                setSelectedBranch(branch);
-              }}
-              repoName={selectedRepo.FullName.split("/")[1]}
-              owner={selectedRepo.FullName.split("/")[0]}
-              selectedBranch={selectedBranch}
-            />
-          </ExpandedWrapperAlt>
-          <ButtonTray>
-            <BackButton width="130px" onClick={() => setSelectedRepo(null)}>
-              <i className="material-icons">keyboard_backspace</i>
-              Select Repo
-            </BackButton>
-          </ButtonTray>
-        </div>
-      );
-    } else if (this.state.dockerfileSelected) {
-      return (
-        <div>
-          <ExpandedWrapperAlt>
-            <NewGHAction
-              repoName={selectedRepo.FullName}
-              dockerPath={subdirectory + "/Dockerfile"}
-              grid={this.state.branchGrID}
-              chart={this.props.chart}
-              imgURL={this.state.imageURL}
-              setURL={(x: string) => this.setState({ imageURL: x })}
-            />
-          </ExpandedWrapperAlt>
-          <ButtonTray>
-            <BackButton
-              width="130px"
-              onClick={() => this.setState({ dockerfileSelected: false })}
-            >
-              <i className="material-icons">keyboard_backspace</i>
-              Select Dockerfile
-            </BackButton>
-            <BackButton width="146px" onClick={() => this.createGHAction()}>
-              <i className="material-icons">play_circle_outline</i>
-              Create Github Action
-            </BackButton>
-          </ButtonTray>
-        </div>
-      );
-    }
+    
     return (
       <div>
-        <ExpandedWrapperAlt>
-          <ContentsList
-            grid={this.state.branchGrID}
-            setSubdirectory={(subdirectory: string) =>
-              setSubdirectory(subdirectory)
-            }
-            repoName={selectedRepo.FullName.split("/")[1]}
-            owner={selectedRepo.FullName.split("/")[0]}
-            selectedBranch={selectedBranch}
-            subdirectory={subdirectory}
-            setDockerfile={() => this.setState({ dockerfileSelected: true })}
-          />
-        </ExpandedWrapperAlt>
-        <ButtonTray>
-          <BackButton
-            onClick={() => {
-              setSelectedBranch("");
-              setSubdirectory("");
-              this.setState({ imageURL: "" });
-            }}
-            width="140px"
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </ButtonTray>
+        <ActionConfEditor
+          actionConfig={actionConfig}
+          branch={this.state.branch}
+          pathIsSet={this.state.pathIsSet}
+          setActionConfig={setActionConfig}
+          setBranch={(branch: string) => this.setState({ branch })}
+          setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+        />
+        <ButtonTray
+          chartName={chart.name}
+          chartNamespace={chart.namespace}
+          pathIsSet={this.state.pathIsSet}
+          branch={this.state.branch}
+          actionConfig={actionConfig}
+          setBranch={(branch: string) => this.setState({ branch })}
+          setActionConfig={setActionConfig}
+          setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+        />
       </div>
     );
   };
 
   renderSelected = () => {
-    let { selectedRepo, subdirectory, selectedBranch } = this.props;
-    if (selectedRepo) {
-      let subdir = subdirectory === "" ? "" : "/" + subdirectory;
+    let { actionConfig } = this.props;
+    if (actionConfig.git_repo) {
+      let subdir = actionConfig.dockerfile_path === '' ? '' : '/' + actionConfig.dockerfile_path;
       return (
         <RepoLabel>
           <img src={github} />
-          {selectedRepo.FullName + subdir}
+          {actionConfig.git_repo + subdir}
           <SelectedBranch>
-            {!selectedBranch ? "(Select Branch)" : selectedBranch}
+            {!this.state.branch ? '(Select Branch)' : this.state.branch}
           </SelectedBranch>
         </RepoLabel>
       );
@@ -301,78 +120,6 @@ const SelectedBranch = styled.div`
   margin-left: 10px;
 `;
 
-const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-top: 10px;
-  cursor: pointer;
-  font-size: 13px;
-  padding: 5px 10px;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { width: string }) => props.width};
-  color: white;
-
-  :hover {
-    background: #ffffff11;
-  }
-
-  > i {
-    color: white;
-    font-size: 18px;
-    margin-right: 10px;
-  }
-`;
-
-const ButtonTray = styled.div`
-  margin-top: 10px;
-  display: flex;
-  flex-direction: row;
-  justify-content: space-between;
-  align-items: center;
-`;
-
-const RepoName = styled.div`
-  display: flex;
-  width: 100%;
-  font-size: 13px;
-  border-bottom: 1px solid
-    ${(props: { lastItem: boolean; isSelected: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
-  color: #ffffff;
-  user-select: none;
-  align-items: center;
-  padding: 10px 0px;
-  cursor: pointer;
-  background: ${(props: { isSelected: boolean; lastItem: boolean }) =>
-    props.isSelected ? "#ffffff22" : "#ffffff11"};
-  :hover {
-    background: #ffffff22;
-
-    > i {
-      background: #ffffff22;
-    }
-  }
-
-  > img {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-  }
-`;
-
-const LoadingWrapper = styled.div`
-  padding: 30px 0px;
-  background: #ffffff11;
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  justify-content: center;
-  color: #ffffff44;
-`;
-
 const ExpandedWrapper = styled.div`
   margin-top: 10px;
   width: 100%;
@@ -382,8 +129,6 @@ const ExpandedWrapper = styled.div`
   overflow-y: auto;
 `;
 
-const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
-
 const RepoLabel = styled.div`
   display: flex;
   align-items: center;

+ 4 - 3
dashboard/src/components/values-form/ValuesForm.tsx

@@ -25,6 +25,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
   getInputValue = (item: FormElement) => {
     let key = item.name || item.variable;
     let value = this.props.metaState[key];
+    
     if (item.settings && item.settings.unit && value) {
       value = value.split(item.settings.unit)[0];
     }
@@ -142,9 +143,9 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               value={this.props.metaState[key]}
               setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
               options={[
-                { value: "gcp", label: "Google Cloud Platform (GCP)" },
-                { value: "aws", label: "Amazon Web Services (AWS)" },
-                { value: "do", label: "DigitalOcean" },
+                { value: 'aws', label: 'Amazon Web Services (AWS)' },
+                { value: 'gcp', label: 'Google Cloud Platform (GCP)' },
+                { value: 'do', label: 'DigitalOcean' },
               ]}
               dropdownLabel=""
               label={item.label}

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

@@ -37,9 +37,8 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
           section.contents.forEach((item: FormElement, i: number) => {
             // If no name is assigned use values.yaml variable as identifier
             let key = item.name || item.variable;
-            let def =
-              (item.value && item.value[0]) ||
-              (item.settings && item.settings.default);
+            let def = item.settings && item.settings.unit ? `${item.settings.default}${item.settings.unit}` : item.settings.default
+            def = (item.value && item.value[0]) || def;
 
             // Handle add to list of required fields
             if (item.required) {

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

@@ -71,7 +71,6 @@ export default class Main extends Component<PropsType, StateType> {
       "integrations",
       "new-project",
       "cluster-dashboard",
-      "provisioner",
       "project-settings",
     ];
 

+ 33 - 56
dashboard/src/main/home/Home.tsx

@@ -5,7 +5,6 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { ClusterType, ProjectType } from "shared/types";
-import { includesCompletedInfraSet } from "shared/common";
 
 import Sidebar from "./sidebar/Sidebar";
 import Dashboard from "./dashboard/Dashboard";
@@ -19,7 +18,6 @@ import IntegrationsModal from "./modals/IntegrationsModal";
 import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
 import NewProject from "./new-project/NewProject";
 import Navbar from "./navbar/Navbar";
-import ProvisionerStatus from "./provisioner/ProvisionerStatus";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Modal from "./modals/Modal";
@@ -43,7 +41,6 @@ type StateType = {
 
   // Track last project id for refreshing clusters on project change
   prevProjectId: number | null;
-  sidebarReady: boolean; // Fixes error where ~1/3 times reloading to provisioner fails
 };
 
 // TODO: Handle cluster connected but with some failed infras (no successful set)
@@ -61,38 +58,29 @@ class Home extends Component<PropsType, StateType> {
   // TODO: Refactor and prevent flash + multiple reload
   initializeView = () => {
     let { currentProject } = this.props;
-    let { currentCluster } = this.context;
 
     if (!currentProject) return;
 
-    // Check if current project is provisioning
-    api.getInfra(
-      "<token>",
-      {},
-      { project_id: currentProject.id },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-          return;
-        }
+    api.getInfra('<token>', {}, { 
+      project_id: currentProject.id 
+    }, (err: any, res: any) => {
+      if (err) return;
+      
+      let creating = false;
 
-        if (
-          res.data.length > 0 &&
-          !currentCluster &&
-          !includesCompletedInfraSet(res.data)
-        ) {
-          this.props.history.push("provisioner");
-          this.setState({ sidebarReady: true });
-        } else if (this.state.ghRedirect) {
-          this.props.history.push("integrations");
-          this.setState({ sidebarReady: true, ghRedirect: false });
-        } else {
-          // TODO: figure out when exactly in flow we need to send user to dashboard
-          // this.props.history.push("dashboard");
-          this.setState({ sidebarReady: true });
-        }
+      for (var i = 0; i < res.data.length; i++) {
+        creating = res.data[i].status === "creating"
       }
-    );
+
+      if (creating) {
+        this.props.history.push("dashboard?tab=provisioner");
+      } else if (this.state.ghRedirect) {
+        this.props.history.push("integrations");
+        this.setState({ ghRedirect: false });
+      } else if (this.props.currentRoute !== "dashboard") {
+          this.props.history.push("dashboard");
+      }
+    });
   };
 
   getProjects = (id?: number) => {
@@ -119,7 +107,6 @@ class Home extends Component<PropsType, StateType> {
                 }
               });
               this.context.setCurrentProject(foundProject);
-              <Redirect to="provisioner"></Redirect>;
             }
 
             if (!foundProject) {
@@ -181,7 +168,7 @@ class Home extends Component<PropsType, StateType> {
           console.log(err);
           return;
         }
-        <Redirect to="provisioner"></Redirect>;
+        this.props.history.push("dashboard?tab=provisioner");
       }
     );
   };
@@ -214,7 +201,7 @@ class Home extends Component<PropsType, StateType> {
             });
           } else if (infras[0] === "docr") {
             this.provisionDOCR(tgtIntegration.id, tier, () => {
-              <Redirect to="provisioner"></Redirect>;
+              this.props.history.push("dashboard?tab=provisioner");
             });
           } else {
             this.provisionDOKS(tgtIntegration.id, region);
@@ -263,7 +250,7 @@ class Home extends Component<PropsType, StateType> {
   }
 
   // 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
+  // 1. Make sure clicking cluster in 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) {
@@ -342,8 +329,6 @@ class Home extends Component<PropsType, StateType> {
         );
       } else if (currentView === "integrations") {
         return <Integrations />;
-      } else if (currentView === "provisioner") {
-        return <ProvisionerStatus />;
       } else if (currentView === "project-settings") {
         return <ProjectSettings />;
       }
@@ -356,26 +341,18 @@ class Home extends Component<PropsType, StateType> {
 
   renderSidebar = () => {
     if (this.context.projects.length > 0) {
-      // Force sidebar closed on first provision
-      if (
-        this.props.currentRoute === "provisioner" &&
-        this.state.forceSidebar
-      ) {
-        this.setState({ forceSidebar: false });
-      } else {
-        return (
-          <Sidebar
-            key="sidebar"
-            forceSidebar={this.state.forceSidebar}
-            setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
-            currentView={this.props.currentRoute}
-            forceRefreshClusters={this.state.forceRefreshClusters}
-            setRefreshClusters={(x: boolean) =>
-              this.setState({ forceRefreshClusters: x })
-            }
-          />
-        );
-      }
+      return (
+        <Sidebar
+          key="sidebar"
+          forceSidebar={this.state.forceSidebar}
+          setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+          currentView={this.props.currentRoute}
+          forceRefreshClusters={this.state.forceRefreshClusters}
+          setRefreshClusters={(x: boolean) =>
+            this.setState({ forceRefreshClusters: x })
+          }
+        />
+      );
     }
   };
 

+ 82 - 105
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -25,30 +25,31 @@ type PropsType = {
 };
 
 type StateType = {
-  sourceType: string;
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  saveValuesStatus: string | null;
-  values: string;
-  selectedRepo: RepoType | null;
-  selectedBranch: string;
-  subdirectory: string;
-  webhookToken: string;
-  highlightCopyButton: boolean;
+  actionConfig: ActionConfigType,
+  sourceType: string,
+  selectedImageUrl: string | null,
+  selectedTag: string | null,
+  saveValuesStatus: string | null,
+  values: string,
+  webhookToken: string,
+  highlightCopyButton: boolean,
   action: ActionConfigType;
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    sourceType: "registry",
-    selectedImageUrl: "",
-    selectedTag: "",
-    values: "",
-    saveValuesStatus: null as string | null,
-    selectedRepo: null as RepoType | null,
-    selectedBranch: "",
-    subdirectory: "",
-    webhookToken: "",
+    actionConfig: {
+      git_repo: '',
+      image_repo_uri: '',
+      git_repo_id: 0,
+      dockerfile_path: '',
+    } as ActionConfigType,
+    sourceType: '',
+    selectedImageUrl: '',
+    selectedTag: '',
+    values: '',
+    saveValuesStatus: null as (string | null),
+    webhookToken: '',
     highlightCopyButton: false,
     action: {
       git_repo: "",
@@ -68,25 +69,18 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       selectedTag: image?.tag,
     });
 
-    api.getReleaseToken(
-      "<token>",
-      {
-        namespace: this.props.currentChart.namespace,
-        cluster_id: currentCluster.id,
-        storage: StorageType.Secret,
-      },
-      { id: currentProject.id, name: this.props.currentChart.name },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-        } else {
-          this.setState({
-            action: res.data.git_action_config,
-            webhookToken: res.data.webhook_token,
-          });
-        }
+    api.getReleaseToken('<token>', {
+      namespace: this.props.currentChart.namespace,
+      cluster_id: currentCluster.id,
+      storage: StorageType.Secret
+    }, { id: currentProject.id, name: this.props.currentChart.name }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        console.log(res.data);
+        this.setState({ action: res.data.git_action_config, webhookToken: res.data.webhook_token });
       }
-    );
+    });
   }
 
   redeployWithNewImage = (img: string, tag: string) => {
@@ -142,7 +136,41 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     </Helper>
   */
   renderSourceSection = () => {
-    if (this.state.sourceType === "registry") {
+    if (this.state.action.git_repo.length > 0) {
+      return (
+        <>
+          <Heading>Connected Source</Heading>
+          <Holder>
+            <InputRow
+              disabled={true}
+              label='Git Repository'
+              type='text'
+              width='100%'
+              value={this.state.action.git_repo}
+              setValue={(x: string) => console.log(x)}
+            />
+            <InputRow
+              disabled={true}
+              label='Dockerfile Path'
+              type='text'
+              width='100%'
+              value={this.state.action.dockerfile_path}
+              setValue={(x: string) => console.log(x)}
+            />
+            <InputRow
+              disabled={true}
+              label='Docker Image Repository'
+              type='text'
+              width='100%'
+              value={this.state.action.image_repo_uri}
+              setValue={(x: string) => console.log(x)}
+            />
+          </Holder>
+        </>
+      )
+    }
+
+    if (this.state.sourceType === 'registry') {
       return (
         <>
           <Heading>Connected Source</Heading>
@@ -163,73 +191,22 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     let { currentProject } = this.context;
     return (
       <>
-        {this.state.action.git_repo.length > 0 ? (
-          <>
-            <Heading>Connected Source</Heading>
-            <Holder>
-              <InputRow
-                disabled={true}
-                label="Git Repository"
-                type="text"
-                width="100%"
-                value={this.state.action.git_repo}
-                setValue={(x: string) => console.log(x)}
-              />
-              <InputRow
-                disabled={true}
-                label="Dockerfile Path"
-                type="text"
-                width="100%"
-                value={this.state.action.dockerfile_path}
-                setValue={(x: string) => console.log(x)}
-              />
-              <InputRow
-                disabled={true}
-                label="Docker Image Repository"
-                type="text"
-                width="100%"
-                value={this.state.action.image_repo_uri}
-                setValue={(x: string) => console.log(x)}
-              />
-            </Holder>
-          </>
-        ) : (
-          <>
-            <Heading>Connect a Source</Heading>
-            <Helper>
-              Select a repo to connect to. You can
-              <A
-                padRight={true}
-                href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
-              >
-                log in with GitHub
-              </A>{" "}
-              or
-              <Highlight
-                onClick={() => this.setState({ sourceType: "registry" })}
-              >
-                link an image registry
-              </Highlight>
-              .
-            </Helper>
-            <RepoSelector
-              chart={this.props.currentChart}
-              forceExpanded={true}
-              selectedRepo={this.state.selectedRepo}
-              selectedBranch={this.state.selectedBranch}
-              subdirectory={this.state.subdirectory}
-              setSelectedRepo={(x: RepoType) =>
-                this.setState({ selectedRepo: x })
-              }
-              setSelectedBranch={(x: string) =>
-                this.setState({ selectedBranch: x })
-              }
-              setSubdirectory={(x: string) =>
-                this.setState({ subdirectory: x })
-              }
-            />
-          </>
-        )}
+        <Heading>Connect a Source</Heading>
+        <Helper>
+          Select a repo to connect to. You can 
+          <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
+            log in with GitHub
+          </A> or
+          <Highlight onClick={() => this.setState({ sourceType: 'registry' })}>
+            link an image registry
+          </Highlight>.
+        </Helper>
+        <RepoSelector
+          chart={this.props.currentChart}
+          forceExpanded={true}
+          actionConfig={this.state.actionConfig}
+          setActionConfig={(actionConfig: ActionConfigType) => this.setState({ actionConfig })}
+        />
       </>
     );
   };

+ 18 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx

@@ -37,8 +37,12 @@ export default class Node extends Component<PropsType, StateType> {
         w={Math.round(w)}
         h={Math.round(h)}
       >
-        <Kind>{this.props.showKindLabels ? kind : null}</Kind>
-        <NodeBlock
+        <Kind>
+          <StyledMark>
+            {this.props.showKindLabels ? kind : null}
+          </StyledMark>
+        </Kind>
+        <NodeBlock 
           onMouseDown={nodeMouseDown}
           onMouseUp={nodeMouseUp}
           onMouseEnter={() => this.props.setCurrentNode(this.props.node)}
@@ -48,7 +52,11 @@ export default class Node extends Component<PropsType, StateType> {
         >
           <i className="material-icons">{icon}</i>
         </NodeBlock>
-        <NodeLabel>{name}</NodeLabel>
+        <NodeLabel>
+          <StyledMark>
+            {name}
+          </StyledMark>
+        </NodeLabel>
       </StyledNode>
     );
   }
@@ -68,7 +76,7 @@ const Kind = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  z-index: 0;
+  z-index: 101;
 `;
 
 const NodeLabel = styled.div`
@@ -83,7 +91,7 @@ const NodeLabel = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  z-index: 0;
+  z-index: 101;
 `;
 
 const NodeBlock = styled.div`
@@ -124,3 +132,8 @@ const StyledNode: any = styled.div.attrs((props: NodeType) => ({
   flex-direction: column;
   align-items: center;
 `;
+
+const StyledMark = styled.mark`
+  background-color: #202227aa;
+  color: #aaaabb;
+`;

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

@@ -249,7 +249,7 @@ const StatusColor = styled.div`
 `;
 
 const Name = styled.div`
-  max-width: calc(100% - 75px);
+  max-width: calc(100% - 106px);
   overflow: hidden;
   text-overflow: ellipsis;
   line-height: 16px;

+ 9 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -75,17 +75,16 @@ export default class StatusSection extends Component<PropsType, StateType> {
           <TabWrapper>{this.renderTabs()}</TabWrapper>
           {this.renderLogs()}
         </Wrapper>
-      );
-    } else {
-      return (
-        <NoControllers>
-          <i className="material-icons">category</i>
-          No objects to display. This might happen while your app is still
-          deploying.
-        </NoControllers>
-      );
+      )
     }
-  };
+
+    return (
+      <NoControllers> 
+        <i className="material-icons">category</i> 
+        No objects to display. This might happen while your app is still deploying.
+      </NoControllers>
+    )
+  }
 
   componentDidMount() {
     const { selectors, currentChart } = this.props;

+ 10 - 3
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -66,10 +66,12 @@ class Templates extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <>
+      <StyledClusterList>
         <Helper>Clusters connected to this project:</Helper>
-        <TemplateList>{this.renderClusters()}</TemplateList>
-      </>
+        <TemplateList>
+          {this.renderClusters()}
+        </TemplateList>
+      </StyledClusterList>
     );
   }
 }
@@ -78,6 +80,11 @@ Templates.contextType = Context;
 
 export default withRouter(Templates);
 
+const StyledClusterList = styled.div`
+  margin-top: -17px;
+  padding-left: 2px;
+`;
+
 const DashboardIcon = styled.div`
   position: relative;
   height: 45px;

+ 51 - 15
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,3 +1,4 @@
+import { render } from "@testing-library/react";
 import React, { Component } from "react";
 import styled from "styled-components";
 
@@ -9,18 +10,30 @@ import api from "shared/api";
 import ProvisionerSettings from "../provisioner/ProvisionerSettings";
 import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
 import { Redirect, RouteComponentProps, withRouter } from "react-router";
+import TabRegion from "components/TabRegion";
+import Provisioner from "../provisioner/Provisioner";
 
 type PropsType = RouteComponentProps & {
   projectId: number | null;
 };
 
+const tabOptions = [
+  { label: "Project Overview", value: "overview" },
+  { label: "Provisioner Status", value: "provisioner" },
+];
+// TODO: rethink this typing, should be coupled with tabOptions
+type TabType = "overview" | "provisioner"
+
 type StateType = {
   infras: InfraType[];
+  currentTab: TabType;
 };
 
+
 class Dashboard extends Component<PropsType, StateType> {
   state = {
     infras: [] as InfraType[],
+    currentTab: "overview" as TabType,
   };
 
   refreshInfras = () => {
@@ -56,6 +69,35 @@ class Dashboard extends Component<PropsType, StateType> {
     this.props.history.push("project-settings");
   };
 
+  renderTabContents = () => {
+    const currentTab = new URLSearchParams(this.props.location.search).get("tab")
+    if (
+      currentTab && currentTab !== this.state.currentTab
+    ) {
+      this.setState({ currentTab: currentTab as TabType });
+    }
+
+    if (this.state.currentTab === "provisioner") {
+      return <Provisioner />;
+    } else {
+      return (
+        <>
+          {!this.context.currentCluster ? (
+            <>
+              <Banner>
+                <i className="material-icons">error_outline</i>
+                This project currently has no clusters conncted.
+              </Banner>
+              <ProvisionerSettings infras={this.state.infras} />
+            </>
+          ) : (
+            <ClusterPlaceholderContainer />
+          )}
+        </>
+      );
+    }
+  };
+
   render() {
     let { currentProject, currentCluster } = this.context;
     let { infras } = this.state;
@@ -92,19 +134,13 @@ class Dashboard extends Component<PropsType, StateType> {
               </Description>
             </InfoSection>
 
-            <LineBreak />
-
-            {!currentCluster ? (
-              <>
-                <Banner>
-                  <i className="material-icons">error_outline</i>
-                  This project currently has no clusters connected.
-                </Banner>
-                <ProvisionerSettings infras={infras} />
-              </>
-            ) : (
-              <ClusterPlaceholderContainer />
-            )}
+            <TabRegion
+              currentTab={this.state.currentTab}
+              setCurrentTab={(x: TabType) => this.props.history.push(`dashboard?tab=${x}`)}
+              options={tabOptions}
+            >
+              {this.renderTabContents()}
+            </TabRegion>
           </DashboardWrapper>
         )}
       </>
@@ -123,7 +159,7 @@ const DashboardWrapper = styled.div`
 const Banner = styled.div`
   height: 40px;
   width: 100%;
-  margin: 10px 0 30px;
+  margin: 5px 0 30px;
   font-size: 13px;
   display: flex;
   border-radius: 5px;
@@ -166,7 +202,7 @@ const InfoSection = styled.div`
   margin-top: 20px;
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
-  margin-bottom: 35px;
+  margin-bottom: 30px;
 `;
 
 const LineBreak = styled.div`

+ 287 - 49
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -1,22 +1,98 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import { integrationList } from "shared/common";
+import { Context } from '../../../shared/Context';
+import { integrationList } from '../../../shared/common';
+import { ImageType, ActionConfigType } from '../../..//shared/types';
+import ImageList from '../../../components/image-selector/ImageList';
+import RepoList from '../../../components/repo-selector/RepoList';
 
 type PropsType = {
-  setCurrent: (x: any) => void;
-  integrations: string[];
-  titles?: string[];
-  isCategory?: boolean;
+  setCurrent: (x: any) => void,
+  currentCategory: string,
+  integrations: string[],
+  itemIdentifier?: any[],
+  titles?: string[],
+  isCategory?: boolean
 };
 
-type StateType = {};
+type StateType = {
+  displayImages: boolean[],
+  allCollapsed: boolean,
+};
 
 export default class IntegrationList extends Component<PropsType, StateType> {
+  state = {
+    displayImages: [] as boolean[],
+    allCollapsed: false,
+  }
+
+  componentDidMount() {
+    let x: boolean[] = [];
+    for (let i = 0; i < this.props.integrations.length; i++) {
+      x.push(true);
+    }
+    this.setState({ displayImages: x });
+
+    this.toggleDisplay = this.toggleDisplay.bind(this);
+    this.handleParent = this.handleParent.bind(this);
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.integrations !== this.props.integrations) {
+      let x: boolean[] = [];
+      for (let i = 0; i < this.props.integrations.length; i++) {
+        x.push(true);
+      }
+      this.setState({ displayImages: x });
+    }
+  }
+
+  collapseAll = () => {
+    let x = [];
+    for (let i = 0; i < this.state.displayImages.length; i++) {
+      x.push(false);
+    }
+    this.setState({ displayImages: x, allCollapsed: true });
+  }
+
+  expandAll = () => {
+    let x = [];
+    for (let i = 0; i < this.state.displayImages.length; i++) {
+      x.push(true);
+    }
+    this.setState({ displayImages: x, allCollapsed: false });
+  }
+
+  toggleDisplay = (event: any, index: number) => {
+    event.stopPropagation();
+    let x = this.state.displayImages;
+    x[index] = !x[index];
+    if (x[index]) {
+      this.setState({ allCollapsed: false });
+    } else {
+      let collapsed = true;
+      for (let i = 0; i < x.length; i++) {
+        if (x[i]) {
+          collapsed = false;
+          break
+        }
+      }
+      if (collapsed) {
+        this.setState({ allCollapsed: true });
+      } else {
+        this.setState({ allCollapsed: false });
+      }
+    }
+    this.setState({ displayImages: x });
+  }
+
+  handleParent = (event: any, integration: string) => {
+    this.props.setCurrent(integration);
+  }
+
   renderContents = () => {
-    let { integrations, titles, setCurrent, isCategory } = this.props;
-    console.log(`titles: ${titles}`);
-    console.log(`integrations: ${integrations}`);
+    let { integrations, titles, setCurrent, isCategory, currentCategory } = this.props;
     if (titles && titles.length > 0) {
       return integrations.map((integration: string, i: number) => {
         let icon =
@@ -24,34 +100,80 @@ export default class IntegrationList extends Component<PropsType, StateType> {
         let subtitle =
           integrationList[integration] && integrationList[integration].label;
         let label = titles[i];
-        let disabled = integration === "kubernetes" || integration === "repo";
         return (
           <Integration
             key={i}
-            onClick={() => (disabled ? null : setCurrent(integration))}
             isCategory={isCategory}
-            disabled={disabled}
+            disabled={false}
           >
-            <Flex>
-              <Icon src={icon && icon} />
-              <Description>
-                <Label>{label}</Label>
-                <Subtitle>{subtitle}</Subtitle>
-              </Description>
-            </Flex>
-            <i className="material-icons">
-              {isCategory ? "launch" : "more_vert"}
-            </i>
+            <MainRow
+              onClick={(e: any) => {
+                this.handleParent(e, integration);
+              }}
+              isCategory={isCategory}
+              disabled={false}
+            >
+              <Flex>
+                <Icon src={icon && icon} />
+                <Description>
+                  <Label>{label}</Label>
+                  <Subtitle>{subtitle}</Subtitle>
+                </Description>
+              </Flex>
+              <MaterialIconTray
+                isCategory={isCategory}
+                disabled={false}
+              >
+                <i className="material-icons">more_vert</i>
+                <I
+                  className="material-icons"
+                  showList={this.state.displayImages[i]}
+                  onClick={(e) => {
+                    this.toggleDisplay(e, i);
+                  }}
+                >
+                  {isCategory ? 'launch' : 'expand_more'}
+                </I>
+              </MaterialIconTray>
+            </MainRow>
+            {this.state.displayImages[i] &&
+              <ImageHodler
+                adjustMargin={currentCategory !== 'repo'}
+              >
+                {currentCategory !== 'repo'
+                  ?
+                  <ImageList
+                    selectedImageUrl={null}
+                    selectedTag={null}
+                    clickedImage={null}
+                    registry={this.props.itemIdentifier[i]}
+                    setSelectedImageUrl={(x: string) => {}}
+                    setSelectedTag={(x: string) => {}}
+                    setClickedImage={(x: ImageType) => {}}
+                  />
+                  :
+                  <RepoList
+                    actionConfig={{
+                      git_repo: '',
+                      image_repo_uri: '',
+                      git_repo_id: 0,
+                      dockerfile_path: '',
+                    } as ActionConfigType}
+                    setActionConfig={(x: ActionConfigType) => {}}
+                    readOnly={true}
+                    userId={this.props.itemIdentifier[i]}
+                  />
+                }
+              </ImageHodler>
+            }
           </Integration>
         );
       });
     } else if (integrations && integrations.length > 0) {
       return integrations.map((integration: string, i: number) => {
-        let icon =
-          integrationList[integration] && integrationList[integration].icon;
-        let label =
-          integrationList[integration] && integrationList[integration].label;
-        let disabled = integration === "kubernetes" || integration === "repo";
+        let icon = integrationList[integration] && integrationList[integration].icon;
+        let label = integrationList[integration] && integrationList[integration].label;
+        let disabled = integration === 'kubernetes';
         return (
           <Integration
             key={i}
@@ -59,13 +181,16 @@ export default class IntegrationList extends Component<PropsType, StateType> {
             isCategory={isCategory}
             disabled={disabled}
           >
-            <Flex>
-              <Icon src={icon && icon} />
-              <Label>{label}</Label>
-            </Flex>
-            <i className="material-icons">
-              {isCategory ? "launch" : "more_vert"}
-            </i>
+            <MainRow
+              isCategory={isCategory}
+              disabled={disabled}
+            >
+              <Flex>
+                <Icon src={icon && icon} />
+                <Label>{label}</Label>
+              </Flex>
+              <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
+            </MainRow>
           </Integration>
         );
       });
@@ -74,39 +199,76 @@ export default class IntegrationList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return (
-      <StyledIntegrationList>{this.renderContents()}</StyledIntegrationList>
+    return ( 
+      <StyledIntegrationList>
+        {(this.props.titles && this.props.titles.length > 0) &&
+          <ControlRow>
+            <Button
+              onClick={() => {
+                if (this.state.allCollapsed) {
+                  this.expandAll()
+                } else {
+                  this.collapseAll()
+                }
+              }}
+            >
+              {this.state.allCollapsed
+                ? <><i className="material-icons">expand_more</i> Expand All</>
+                : <><i className="material-icons">expand_less</i> Collapse All</>
+              }
+            </Button>
+          </ControlRow>
+        }
+        {this.renderContents()}
+      </StyledIntegrationList>
     );
   }
 }
 
+IntegrationList.contextType = Context;
+
 const Flex = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
 `;
 
-const Integration = styled.div`
+const ImageHodler = styled.div`
+  width: 100%;
+  padding: 12px;
+  margin-top: ${(props: {adjustMargin: boolean}) => props.adjustMargin ? '-10px' : '0px'};
+`;
+
+const MaterialIconTray = styled.div`
+  width: 64px;
+  margin-right: -7px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: ${(props: { isCategory: boolean, disabled: boolean }) => props.isCategory ? '#616feecc' : '#ffffff44'};
+    :hover {
+      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+    }
+  }
+`;
+
+const MainRow = styled.div`
   height: 70px;
-  width: calc(100% + 4px);
-  margin-left: -2px;
+  width: 100%;
   display: flex;
   align-items: center;
   justify-content: space-between;
   padding: 25px;
-  background: #26282f;
-  cursor: ${(props: { isCategory: boolean; disabled: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-  margin-bottom: 15px;
   border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
   :hover {
-    background: ${(props: { isCategory: boolean; disabled: boolean }) =>
-      props.disabled ? "" : "#ffffff11"};
-
+    background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
     > i {
-      background: ${(props: { isCategory: boolean; disabled: boolean }) =>
-        props.disabled ? "" : "#ffffff11"};
+      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11' };
     }
   }
 
@@ -117,9 +279,23 @@ const Integration = styled.div`
     color: ${(props: { isCategory: boolean; disabled: boolean }) =>
       props.isCategory ? "#616feecc" : "#ffffff44"};
     margin-right: -7px;
+    :hover {
+      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+    }
   }
 `;
 
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+  margin-bottom: 15px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+`;
+
 const Description = styled.div`
   display: flex;
   flex-direction: column;
@@ -163,3 +339,65 @@ const Placeholder = styled.div`
 const StyledIntegrationList = styled.div`
   margin-top: 20px;
 `;
+
+const I = styled.i`
+  transform: ${(props: { showList: boolean }) => props.showList ? 'rotate(180deg)' : ''};
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24px;
+  padding-left: 0px;
+`;
+
+const ButtonTray = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  &:first-child {
+    margin-right: 14px;
+  }
+`;
+
+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: 8px;
+  color: white;
+  height: 35px;
+  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: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+
+  background: ${(props: { disabled?: boolean }) => props.disabled ? '#aaaabbee' : '#616FEEcc'};
+  :hover {
+    background: ${(props: { disabled?: boolean }) => props.disabled ? '' : '#505edddd'};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 99 - 66
dashboard/src/main/home/integrations/Integrations.tsx

@@ -1,21 +1,26 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { integrationList } from "shared/common";
+import { Context } from 'shared/Context';
+import api from 'shared/api';
+import { integrationList } from 'shared/common';
+import { ActionConfigType, ChoiceType } from 'shared/types';
 
 import IntegrationList from "./IntegrationList";
 import IntegrationForm from "./integration-form/IntegrationForm";
 
-type PropsType = {};
+import GHIcon from 'assets/GithubIcon';
+
+type PropsType = {
+};
 
 type StateType = {
-  currentCategory: string | null;
-  currentIntegration: string | null;
-  currentOptions: any[];
-  currentTitles: any[];
-  currentIntegrationData: any[];
+  currentCategory: string | null,
+  currentIntegration: string | null,
+  currentOptions: any[],
+  currentTitles: any[],
+  currentIds: any[],
+  currentIntegrationData: any[],
 };
 
 export default class Integrations extends Component<PropsType, StateType> {
@@ -24,6 +29,7 @@ export default class Integrations extends Component<PropsType, StateType> {
     currentIntegration: null as string | null,
     currentOptions: [] as any[],
     currentTitles: [] as any[],
+    currentIds: [] as any[],
     currentIntegrationData: [] as any[],
   };
 
@@ -90,19 +96,23 @@ export default class Integrations extends Component<PropsType, StateType> {
           }
         );
         break;
-      case "repo":
-        api.getProjectRepos(
-          "<token>",
-          {},
-          { id: currentProject.id },
-          (err: any, res: any) => {
-            if (err) {
-              console.log(err);
-            } else {
-              // console.log(res.data);
-            }
+      case 'repo':
+        api.getGitRepos('<token>', {
+        }, { project_id: currentProject.id }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            let currentOptions = [] as string[];
+            let currentTitles = [] as string[];
+            let currentIds = [] as any[];
+            res.data.forEach((item: any) => {
+              currentOptions.push(item.service);
+              currentTitles.push(item.repo_entity);
+              currentIds.push(item.id);
+            })
+            this.setState({ currentOptions, currentTitles, currentIds, currentIntegrationData: res.data })
           }
-        );
+        });
         break;
       default:
         console.log("Unknown integration category.");
@@ -143,6 +153,7 @@ export default class Integrations extends Component<PropsType, StateType> {
   };
 
   renderContents = () => {
+    let { currentProject } = this.context;
     let { currentCategory, currentIntegration } = this.state;
 
     // TODO: Split integration page into separate component
@@ -176,52 +187,73 @@ export default class Integrations extends Component<PropsType, StateType> {
         </div>
       );
     } else if (currentCategory) {
-      let icon =
-        integrationList[currentCategory] &&
-        integrationList[currentCategory].icon;
-      let label =
-        integrationList[currentCategory] &&
-        integrationList[currentCategory].label;
-      let buttonText =
-        integrationList[currentCategory] &&
-        integrationList[currentCategory].buttonText;
-      return (
-        <div>
-          <TitleSectionAlt>
-            <Flex>
-              <i
-                className="material-icons"
-                onClick={() => this.setState({ currentCategory: null })}
-              >
-                keyboard_backspace
-              </i>
-              <Icon src={icon && icon} />
-              <Title>{label}</Title>
-            </Flex>
-
-            <Button
-              onClick={() =>
-                this.context.setCurrentModal("IntegrationsModal", {
+      let icon = integrationList[currentCategory] && integrationList[currentCategory].icon;
+      let label = integrationList[currentCategory] && integrationList[currentCategory].label;
+      let buttonText = integrationList[currentCategory] && integrationList[currentCategory].buttonText;
+      if (currentCategory !== 'repo') {
+        return (
+          <div>
+            <TitleSectionAlt>
+              <Flex>
+                <i className="material-icons" onClick={() => this.setState({ currentCategory: null })}>
+                  keyboard_backspace
+                </i>
+                <Icon src={icon && icon} />
+                <Title>{label}</Title>
+              </Flex>
+              <Button 
+                onClick={() => this.context.setCurrentModal('IntegrationsModal', { 
                   category: currentCategory,
-                  setCurrentIntegration: (x: string) =>
-                    this.setState({ currentIntegration: x }),
-                })
-              }
-            >
-              <i className="material-icons">add</i>
-              {buttonText}
-            </Button>
-          </TitleSectionAlt>
-
-          <LineBreak />
+                  setCurrentIntegration: (x: string) => this.setState({ currentIntegration: x })
+                })}
+              >
+                <i className="material-icons">add</i>
+                {buttonText}
+              </Button>
+            </TitleSectionAlt>
+  
+            <LineBreak />
+  
+            <IntegrationList
+              currentCategory={currentCategory}
+              integrations={this.state.currentOptions}
+              titles={this.state.currentTitles}
+              setCurrent={(x: string) => this.setState({ currentIntegration: x })}
+              itemIdentifier={this.state.currentIntegrationData}
+            />
+          </div>
+        );
+      } else {
+        return (
+          <div>
+            <TitleSectionAlt>
+              <Flex>
+                <i className="material-icons" onClick={() => this.setState({ currentCategory: null })}>
+                  keyboard_backspace
+                </i>
+                <Icon src={icon && icon} />
+                <Title>{label}</Title>
+              </Flex>
+              <Button 
+                onClick={() => window.open(`/api/oauth/projects/${currentProject.id}/github`)}
+              >
+                <GHIcon />
+                {buttonText}
+              </Button>
+            </TitleSectionAlt>
+  
+            <LineBreak />
 
-          <IntegrationList
-            integrations={this.state.currentOptions}
-            titles={this.state.currentTitles}
-            setCurrent={(x: string) => this.setState({ currentIntegration: x })}
-          />
-        </div>
-      );
+            <IntegrationList
+              currentCategory={currentCategory}
+              integrations={this.state.currentOptions}
+              titles={this.state.currentTitles}
+              setCurrent={(x: string) => this.setState({ currentIntegration: x })}
+              itemIdentifier={this.state.currentIds}
+            />
+          </div>
+        );
+      }
     }
     return (
       <div>
@@ -230,7 +262,8 @@ export default class Integrations extends Component<PropsType, StateType> {
         </TitleSection>
 
         <IntegrationList
-          integrations={["kubernetes", "registry", "repo"]}
+          currentCategory={''}
+          integrations={['kubernetes', 'registry', 'repo']}
           setCurrent={(x: any) => this.setState({ currentCategory: x })}
           isCategory={true}
         />

+ 19 - 29
dashboard/src/main/home/integrations/integration-form/ECRForm.tsx

@@ -45,36 +45,26 @@ export default class ECRForm extends Component<PropsType, StateType> {
     let { awsRegion, awsAccessId, awsSecretKey, credentialsName } = this.state;
     let { currentProject } = this.context;
 
-    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);
-        } else {
-          api.createECR(
-            "<token>",
-            {
-              name: credentialsName,
-              aws_integration_id: res.data.id,
-            },
-            { id: currentProject.id },
-            (err: any, res: any) => {
-              if (err) {
-                console.log(err);
-              } else {
-                this.props.closeForm();
-              }
-            }
-          );
-        }
+    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);
+      } else {
+        api.connectECRRegistry('<token>', {
+          name: credentialsName,
+          aws_integration_id: res.data.id,
+        }, { id: currentProject.id }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            this.props.closeForm();
+          }
+        });
       }
-    );
+    });
   };
 
   render() {

+ 62 - 44
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx

@@ -15,23 +15,25 @@ type PropsType = {
 };
 
 type StateType = {
-  credentialsName: string;
-  gcpRegion: string;
-  serviceAccountKey: string;
-  gcpProjectID: string;
+  credentialsName: string,
+  gcpRegion: string,
+  serviceAccountKey: string,
+  gcpProjectID: string,
+  url: string,
 };
 
 export default class GCRForm extends Component<PropsType, StateType> {
   state = {
-    credentialsName: "",
-    gcpRegion: "",
-    serviceAccountKey: "",
-    gcpProjectID: "",
-  };
+    credentialsName: '',
+    gcpRegion: '',
+    serviceAccountKey: '',
+    gcpProjectID: '',
+    url: '',
+  }
 
   isDisabled = (): boolean => {
-    let { credentialsName, serviceAccountKey } = this.state;
-    if (credentialsName === "" || serviceAccountKey === "") {
+    let { credentialsName, gcpRegion, gcpProjectID, serviceAccountKey } = this.state;
+    if (credentialsName === '' || gcpRegion  === '' || serviceAccountKey === '' || gcpProjectID === '') {
       return true;
     }
     return false;
@@ -40,24 +42,32 @@ export default class GCRForm extends Component<PropsType, StateType> {
   handleSubmit = () => {
     let { currentProject } = this.context;
 
-    api.createGCPIntegration(
-      "<token>",
-      {
-        gcp_region: this.state.gcpRegion,
-        gcp_key_data: this.state.serviceAccountKey,
-        gcp_project_id: this.state.gcpProjectID,
-      },
-      {
-        project_id: currentProject.id,
-      },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-        } else {
-          console.log(res.data);
-        }
+    api.createGCPIntegration('<token>', {
+      gcp_region: this.state.gcpRegion,
+      gcp_key_data: this.state.serviceAccountKey,
+      gcp_project_id: this.state.gcpProjectID,
+    }, {
+      project_id: currentProject.id,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.connectGCRRegistry('<token>', {
+          name: this.state.credentialsName,
+          gcp_integration_id: res.data.id,
+          url: this.state.url,
+        }, {
+          id: currentProject.id,
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            console.log(res.data);
+            this.props.closeForm();
+          }
+        })
       }
-    );
+    });
   };
 
   render() {
@@ -71,35 +81,43 @@ export default class GCRForm extends Component<PropsType, StateType> {
           <InputRow
             type="text"
             value={this.state.credentialsName}
-            setValue={(x: string) => this.setState({ credentialsName: x })}
-            label="🏷️ Registry Name"
-            placeholder="ex: paper-straw"
-            width="100%"
+            setValue={(credentialsName: string) => this.setState({ credentialsName })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
           />
           <Heading>GCP Settings</Heading>
           <Helper>Service account credentials for GCP permissions.</Helper>
           <InputRow
             type="text"
             value={this.state.gcpRegion}
-            setValue={(x: string) => this.setState({ gcpRegion: x })}
-            label="📍 GCP Region"
-            placeholder="ex: uranus-north-12"
-            width="100%"
+            setValue={(gcpRegion: string) => this.setState({ gcpRegion })}
+            label='📍 GCP Region'
+            placeholder='ex: uranus-north3'
+            width='100%'
           />
           <TextArea
             value={this.state.serviceAccountKey}
-            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
-            label="🔑 Service Account Key (JSON)"
-            placeholder="(Paste your JSON service account key here)"
-            width="100%"
+            setValue={(serviceAccountKey: string) => this.setState({ serviceAccountKey })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
           />
           <InputRow
             type="text"
             value={this.state.gcpProjectID}
-            setValue={(x: string) => this.setState({ gcpProjectID: x })}
-            label="GCP Project ID"
-            placeholder="ex: porter-dev-273614"
-            width="100%"
+            setValue={(gcpProjectID: string) => this.setState({ gcpProjectID })}
+            label='📝 GCP Project ID'
+            placeholder='ex: skynet-dev-172969'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.url}
+            setValue={(url: string) => this.setState({ url })}
+            label='🔗 GCR URL'
+            placeholder='ex: gcr.io/skynet-dev-172969'
+            width='100%'
           />
         </CredentialWrapper>
         <SaveButton

+ 2 - 7
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -51,13 +51,8 @@ export default class IntegrationsModal extends Component<PropsType, StateType> {
     if (this.context.currentModalData) {
       let { setCurrentIntegration } = this.context.currentModalData;
       return this.state.integrations.map((integration: any, i: number) => {
-        let icon =
-          integrationList[integration.service] &&
-          integrationList[integration.service].icon;
-        let disabled =
-          integration.service === "kube" ||
-          integration.service === "docker" ||
-          integration.service === "gcr";
+        let icon = integrationList[integration.service] && integrationList[integration.service].icon;
+        let disabled = integration.service === 'kube' || integration.service === 'docker';
         return (
           <IntegrationOption
             key={i}

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

@@ -337,7 +337,7 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   display: flex;
   align-items: center;
   padding: 0 15px;
-  margin-top: 10px;
+  margin-top: 13px;
   text-align: left;
   background: red;
   float: left;

+ 5 - 7
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -223,7 +223,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
               handleError();
               return;
             }
-            this.props.history.push("provisioner");
+            this.props.history.push("dashboard?tab=provisioner");
           }
         );
       }
@@ -241,7 +241,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
         this.provisionECR(this.provisionEKS);
       } else if (selectedInfras[0].value === "ecr") {
         // Case: project exists, only provision ECR
-        this.provisionECR(() => this.props.history.push("provisioner"));
+        this.provisionECR(() => this.props.history.push("dashboard?tab=provisioner"));
       } else {
         // Case: project exists, only provision EKS
         this.provisionEKS();
@@ -252,11 +252,9 @@ class AWSFormSection extends Component<PropsType, StateType> {
         this.createProject(() => this.provisionECR(this.provisionEKS));
       } else if (selectedInfras[0].value === "ecr") {
         // Case: project DNE, only provision ECR
-        this.createProject(() =>
-          this.provisionECR(() => {
-            this.props.history.push("provisioner");
-          })
-        );
+        this.createProject(() => this.provisionECR(() => {
+          this.props.history.push("dashboard?tab=provisioner");
+        }));
       } else {
         // Case: project DNE, only provision EKS
         this.createProject(this.provisionEKS);

+ 24 - 30
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -190,42 +190,36 @@ class GCPFormSection extends Component<PropsType, StateType> {
           handleError();
           return;
         }
-        this.props.history.push("provisioner");
-      }
-    );
-  };
+        this.props.history.push("dashboard?tab=provisioner");
+    });
+  }
 
   handleCreateFlow = () => {
     let { selectedInfras, gcpKeyData, gcpProjectId, gcpRegion } = this.state;
     let { currentProject } = this.context;
-    api.createGCPIntegration(
-      "<token>",
-      {
-        gcp_region: gcpRegion,
-        gcp_key_data: gcpKeyData,
-        gcp_project_id: gcpProjectId,
-      },
-      { project_id: currentProject.id },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-        } else if (res?.data) {
-          console.log("gcp provisioned with response: ", res.data);
-          let { id } = res.data;
-
-          if (selectedInfras.length === 2) {
-            // Case: project exists, provision GCR + GKE
-            this.provisionGCR(id, () => this.provisionGKE(id));
-          } else if (selectedInfras[0].value === "gcr") {
-            // Case: project exists, only provision GCR
-            this.provisionGCR(id, () => this.props.history.push("provisioner"));
-          } else {
-            // Case: project exists, only provision GKE
-            this.provisionGKE(id);
-          }
+    api.createGCPIntegration('<token>', {
+      gcp_region: gcpRegion,
+      gcp_key_data: gcpKeyData,
+      gcp_project_id: gcpProjectId,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcp provisioned with response: ', res.data);
+        let { id } = res.data;
+
+        if (selectedInfras.length === 2) {
+          // Case: project exists, provision GCR + GKE
+          this.provisionGCR(id, () => this.provisionGKE(id));
+        } else if (selectedInfras[0].value === 'gcr') {
+          // Case: project exists, only provision GCR
+          this.provisionGCR(id, () => this.props.history.push("dashboard?tab=provisioner"));
+        } else {
+          // Case: project exists, only provision GKE
+          this.provisionGKE(id);
         }
       }
-    );
+    });
   };
 
   // TODO: handle generically (with > 2 steps)

+ 37 - 15
dashboard/src/main/home/provisioner/InfraStatuses.tsx

@@ -6,7 +6,9 @@ import { InfraType } from "shared/types";
 import { infraNames } from "shared/common";
 
 type PropsType = {
-  infras: InfraType[];
+  infras: InfraType[],
+  selectInfra: (infra: InfraType) => void,
+  selectedInfra: InfraType,
 };
 
 type StateType = {};
@@ -17,14 +19,10 @@ export default class InfraStatuses extends Component<PropsType, StateType> {
   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>;
+    } else if (status === 'creating' || status === 'destroying') {
+      return <StatusIcon><img src={loadingDots} /></StatusIcon>
+    } else if (status === 'error' || status === 'destroyed') {
+      return <StatusIcon color='#e3366d'>✗</StatusIcon>
     }
   };
 
@@ -33,9 +31,13 @@ export default class InfraStatuses extends Component<PropsType, StateType> {
       <StyledInfraStatuses>
         {this.props.infras.map((infra: InfraType, i: number) => {
           return (
-            <InfraRow key={infra.id}>
-              {this.renderStatusIcon(infra.status)}
+            <InfraRow 
+              key={infra.id}
+              selected={(infra.id === this.props.selectedInfra?.id)}
+              onClick={() => this.props.selectInfra(infra)}
+            >
               {infraNames[infra.kind]}
+              {this.renderStatusIcon(infra.status)}
             </InfraRow>
           );
         })}
@@ -50,22 +52,42 @@ const StatusIcon = styled.div<{ color?: string }>`
   justify-content: center;
   width: 20px;
   font-size: 16px;
-  color: ${(props) => (props.color ? props.color : "#68c49c")};
-  margin-right: 10px;
+  color: ${props => props.color ? props.color : '#68c49c'};
+  margin-left: 10px;
 `;
 
-const InfraRow = styled.div`
+const Tab = styled.div`
   width: 100%;
   height: 25px;
   padding-left: 2px;
   margin-top: 10px;
   font-size: 13px;
   color: #aaaabb;
+  color: white;
   display: flex;
   align-items: center;
 `;
 
+const InfraRow = styled.div`
+  width: 100%;
+  height: 50px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
+  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : ''};
+  font-size: 13px;
+  padding: 20px 19px 20px 42px;
+  text-shadow: 0px 0px 8px none;
+  overflow: visible;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
 const StyledInfraStatuses = styled.div`
-  margin-top: 20px;
   margin-bottom: 0;
 `;

+ 129 - 0
dashboard/src/main/home/provisioner/Provisioner.tsx

@@ -0,0 +1,129 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from 'shared/api';
+import { Context } from 'shared/Context';
+import loading from 'assets/loading.gif';
+import warning from 'assets/warning.png';
+import { InfraType, ProjectType } from 'shared/types';
+import Loading from 'components/Loading';
+
+import Helper from 'components/values-form/Helper';
+import InfraStatuses from './InfraStatuses';
+import ProvisionerLogs from './ProvisionerLogs'
+import { RouteComponentProps, withRouter } from "react-router";
+import { Link } from "react-router-dom";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {
+  error: boolean,
+  logs: string[],
+  websockets: any[],
+  maxStep : Record<string, number>,
+  currentStep: Record<string, number>,
+  triggerEnd: boolean,
+  infras: InfraType[],
+  loading: boolean,
+  selectedInfra: InfraType,
+  currentProject: ProjectType,
+};
+
+class Provisioner extends Component<PropsType, StateType> {
+  state = {
+    error: false,
+    logs: [] as string[],
+    websockets : [] as any[],
+    maxStep: {} as Record<string, any>,
+    currentStep: {} as Record<string, number>,
+    triggerEnd: false,
+    infras: [] as InfraType[],
+    selectedInfra: null as InfraType,
+    loading: true,
+    currentProject: this.context.currentProject,
+  }
+
+  selectInfra = (infra: InfraType) => {
+    this.setState({ selectedInfra: infra })
+  }
+
+  componentDidMount() {
+    let { currentProject } = this.state;
+
+    api.getInfra('<token>', {}, { 
+      project_id: currentProject.id 
+    }, (err: any, res: any) => {
+      if (err) return;
+
+      let infras = res.data.sort((a: InfraType, b: InfraType) => {
+        return b.id - a.id
+      });
+
+      this.setState({ 
+        error: false, 
+        infras, 
+        loading: false,
+        selectedInfra: infras[0],
+      });
+    });
+  }
+
+  render() {
+    if (this.state.loading) {
+      return (
+        <StyledProvisioner> 
+          <Loading />
+        </StyledProvisioner>
+      )
+    }
+
+    if (this.state.infras.length > 0) {
+      return (
+        <StyledProvisioner>
+          <TabWrapper>
+            <InfraStatuses 
+              infras={this.state.infras} 
+              selectInfra={this.selectInfra.bind(this)}
+              selectedInfra={this.state.selectedInfra}
+            />
+          </TabWrapper>
+
+          <ProvisionerLogs 
+            key={this.state.selectedInfra?.id} 
+            selectedInfra={this.state.selectedInfra} 
+          />
+        </StyledProvisioner>
+      )
+    }
+
+    return (
+      <StyledProvisioner>
+        You have not provisioned any resources for this project through Porter.
+      </StyledProvisioner>
+    );
+  }
+}
+
+Provisioner.contextType = Context;
+
+export default withRouter(Provisioner);
+
+const StyledProvisioner = styled.div`
+  width: 100%;
+  height: 350px;
+  background: #ffffff11;
+  color: #aaaabb;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  margin-top: 10px;
+`;
+
+const TabWrapper = styled.div`
+  width: 35%;
+  min-width: 250px;
+  height: 100%;
+  overflow-y: auto;
+`;

+ 247 - 0
dashboard/src/main/home/provisioner/ProvisionerLogs.tsx

@@ -0,0 +1,247 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import { Context } from 'shared/Context';
+import { InfraType } from 'shared/types';
+import posthog from 'posthog-js';
+import { RouteComponentProps, withRouter } from "react-router";
+
+import ansiparse from 'shared/ansiparser'
+import loading from 'assets/loading.gif';
+import warning from 'assets/warning.png';
+
+type PropsType = RouteComponentProps & {
+    selectedInfra: InfraType
+};
+
+type StateType = {
+  logs: string[],
+  ws: any,
+  scroll: boolean,
+  maxStep: number,
+  error: boolean,
+};
+
+class ProvisionerLogs extends Component<PropsType, StateType> {
+  
+  state = {
+    logs: [] as string[],
+    ws : null as any,
+    scroll: true,
+    maxStep: 0,
+    error: false,
+  }
+
+  ws = null as any;
+  parentRef = React.createRef<HTMLDivElement>()
+
+  scrollToBottom = () => {
+    this.parentRef.current.lastElementChild.scrollIntoView({ behavior: "auto" })
+  }
+
+  renderLogs = () => {
+    let { selectedInfra } = this.props;
+    let { logs, maxStep } = this.state;
+    if (!selectedInfra) {
+        return <Message>Please select a resource.</Message>
+    }
+
+    if (selectedInfra.status == 'destroyed') {
+        return (
+          <Message>
+              This resource has been auto-destroyed due to an error during provisioning.
+              <div>
+                Please check with your cloud provider to make sure all resources have been properly destroyed.
+              </div>
+          </Message>
+        )
+    }
+
+    if (logs.length == 0) {
+      switch (selectedInfra.status) {
+        case "creating":
+          return (
+            <Loading>
+              <LoadingGif src={loading} /> Provisioning resources...
+            </Loading>
+          )
+        case "destroying":
+          return (
+            <Message>
+              <LoadingGif src={loading} /> Destroying resources...
+            </Message>
+          )
+        case "error":
+          return <Message>Porter encountered an error while provisioning this resource.</Message>
+        default:
+          return <Message>{selectedInfra.status}</Message>
+      }
+    }
+
+    return logs.map((log, i) => {
+        return <Log key={i + 1}>{`[Step ${i + 1}/${maxStep}]` + log}</Log>
+    })
+  }
+
+  isJSON = (str: string) => {
+    try {
+      JSON.parse(str);
+    } catch (e) {
+      return false;
+    }
+    return true;
+  }
+
+  setupWebsocket = () => {
+    this.ws.onopen = () => {
+      console.log('connected to websocket')
+    }
+
+    this.ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let validEvents = [] as any[];
+      let err = null;
+
+      for (var i = 0; i < event.length; i++) {
+        let msg = event[i];
+        if (msg["Values"] && msg["Values"]["data"] && this.isJSON(msg["Values"]["data"])) { 
+          let d = JSON.parse(msg["Values"]["data"]);
+
+          if (d["kind"] == "error") {
+            err = d["log"];
+            break;
+          }
+
+          // add only valid events
+          if (d["log"] != null && d["created_resources"] != null && d["total_resources"] != null) {
+            validEvents.push(d);
+          }
+        }
+      }
+
+      if (err) {
+        posthog.capture('Provisioning Error', {error: err});
+
+        let e = ansiparse(err).map((el: any) => {
+          return el.text;
+        })
+
+        this.setState({ logs: [...this.state.logs, ...e], error: true });
+        return;
+      }
+
+      if (validEvents.length == 0) {
+        return;
+      }
+      
+      let logs = [] as any[]
+      validEvents.forEach((e: any) => {
+        logs.push(...ansiparse(e["log"]))
+      })
+
+      logs = logs.map((log: any) => {
+        return log.text
+      })
+
+      this.setState({ 
+        logs: [...this.state.logs, ...logs], 
+        maxStep: validEvents[validEvents.length - 1]["total_resources"]
+      }, () => {
+        this.scrollToBottom()
+      })
+    }
+
+    this.ws.onerror = (err: ErrorEvent) => {
+      console.log('websocket err', err)
+    }
+
+    this.ws.onclose = () => {
+      console.log('closing provisioner websocket')
+    }
+  }
+
+  componentDidMount() {
+    let { currentProject } = this.context;
+    let { selectedInfra } = this.props;
+
+    if (!selectedInfra) return;
+
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+    this.ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`)
+
+    this.setupWebsocket()
+    this.scrollToBottom();
+  }
+
+  componentWillUnmount() {
+    if (this.ws) {
+      this.ws.close()
+    }
+  }
+
+  render() {
+    return (
+      <LogStream>
+        <Wrapper ref={this.parentRef}>
+          {this.renderLogs()}
+        </Wrapper>
+      </LogStream>
+    );
+  }
+}
+
+ProvisionerLogs.contextType = Context;
+export default withRouter(ProvisionerLogs);
+
+const Loading = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  width: 100%;
+  color: #ffffff44;
+  font-size: 13px;
+`
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  padding: 25px 30px;
+`;
+
+const LogStream = styled.div`
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  float: right;
+  height: 100%;
+  background: #202227;
+  user-select: text;
+  max-width: 65%;
+  overflow-y: auto;
+  overflow-wrap: break-word; 
+`;
+
+const Message = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Log = styled.div`
+  font-family: monospace;
+  font-size: 12px;
+`;

+ 0 - 528
dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -1,528 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import posthog from "posthog-js";
-
-import api from "shared/api";
-import { Context } from "shared/Context";
-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 InfraStatuses from "./InfraStatuses";
-import { RouteComponentProps, withRouter } from "react-router";
-import { Link } from "react-router-dom";
-
-type PropsType = RouteComponentProps & {};
-
-type StateType = {
-  error: boolean;
-  logs: string[];
-  websockets: any[];
-  maxStep: Record<string, number>;
-  currentStep: Record<string, number>;
-  triggerEnd: boolean;
-  infras: InfraType[];
-};
-
-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 },
-];
-
-class ProvisionerStatus extends Component<PropsType, StateType> {
-  state = {
-    error: false,
-    logs: [] as string[],
-    websockets: [] as any[],
-    maxStep: {} as Record<string, any>,
-    currentStep: {} as Record<string, number>,
-    triggerEnd: false,
-    infras: [] as InfraType[],
-  };
-
-  parentRef = React.createRef<HTMLDivElement>();
-
-  scrollToBottom = (smooth: boolean) => {
-    if (smooth) {
-      this.parentRef.current.lastElementChild.scrollIntoView({
-        behavior: "smooth",
-      });
-    } else {
-      this.parentRef.current.lastElementChild.scrollIntoView({
-        behavior: "auto",
-      });
-    }
-  };
-
-  componentDidMount() {
-    console.log("mounting provisioner");
-    let { currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
-
-    // 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;
-
-        let maxStep = {} as Record<string, number>;
-
-        infras.forEach((infra: InfraType, i: number) => {
-          maxStep[infra.kind] = null;
-          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.id}/logs`
-          );
-          return this.setupWebsocket(ws, infra);
-        });
-
-        this.setState({
-          error,
-          infras,
-          websockets,
-          maxStep,
-          logs: ["Provisioning resources..."],
-        });
-      }
-    );
-  }
-
-  componentWillUnmount() {
-    if (this.state.websockets.length == 0) {
-      return;
-    }
-
-    this.state.websockets.forEach((ws: any) => {
-      ws.close();
-    });
-  }
-
-  isJSON = (str: string) => {
-    try {
-      JSON.parse(str);
-    } catch (e) {
-      return false;
-    }
-    return true;
-  };
-
-  setupWebsocket = (ws: WebSocket, infra: any) => {
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
-      let validEvents = [] as any[];
-      let err = null;
-
-      for (var i = 0; i < event.length; i++) {
-        let msg = event[i];
-        if (
-          msg["Values"] &&
-          msg["Values"]["data"] &&
-          this.isJSON(msg["Values"]["data"])
-        ) {
-          let d = JSON.parse(msg["Values"]["data"]);
-
-          if (d["kind"] == "error") {
-            err = d["log"];
-            break;
-          }
-
-          // add only valid events
-          if (
-            d["log"] != null &&
-            d["created_resources"] != null &&
-            d["total_resources"] != null
-          ) {
-            validEvents.push(d);
-          }
-        }
-      }
-
-      if (err) {
-        posthog.capture("Provisioning Error", { error: err });
-
-        let e = ansiparse(err).map((el: any) => {
-          return el.text;
-        });
-
-        let index = this.state.infras.findIndex((el) => el.kind === infra.kind);
-        infra.status = "error";
-        let infras = this.state.infras;
-        infras[index] = infra;
-        this.setState({
-          logs: [...this.state.logs, ...e],
-          error: true,
-          infras,
-        });
-        return;
-      }
-
-      if (validEvents.length == 0) {
-        return;
-      }
-
-      if (
-        !this.state.maxStep[infra.kind] ||
-        !this.state.maxStep[infra.kind]["total_resources"]
-      ) {
-        this.setState({
-          maxStep: {
-            ...this.state.maxStep,
-            [infra.kind]:
-              validEvents[validEvents.length - 1]["total_resources"],
-          },
-        });
-      }
-
-      let logs = [] as any[];
-      validEvents.forEach((e: any) => {
-        logs.push(...ansiparse(e["log"]));
-      });
-
-      logs = logs.map((log: any) => {
-        return log.text;
-      });
-
-      this.setState(
-        {
-          logs: [...this.state.logs, ...logs],
-          currentStep: {
-            ...this.state.currentStep,
-            [infra.kind]:
-              validEvents[validEvents.length - 1]["created_resources"],
-          },
-        },
-        () => {
-          this.scrollToBottom(false);
-        }
-      );
-    };
-
-    ws.onerror = (err: ErrorEvent) => {
-      console.log("websocket err", err);
-    };
-
-    ws.onclose = () => {
-      console.log("closing provisioner websocket");
-    };
-
-    return ws;
-  };
-
-  renderLogs = () => {
-    return this.state.logs.map((log, i) => {
-      return <Log key={i}>{log}</Log>;
-    });
-  };
-
-  onEnd = () => {
-    let myInterval = setInterval(() => {
-      api.getClusters(
-        "<token>",
-        {},
-        {
-          id: this.context.currentProject.id,
-        },
-        (err: any, res: any) => {
-          if (err) {
-            console.log(err);
-          } else if (res.data) {
-            let clusters = res.data;
-            if (clusters.length > 0) {
-              this.props.history.push("dashboard");
-              // console.log('provision end project: ', this.context.currentProject);
-              // console.log('provision end cluster: ', this.context.currentCluster);
-              clearInterval(myInterval);
-            } else {
-              // console.log('looped!')
-              // console.log('response :', res.data);
-              // console.log('provision end project: ', this.context.currentProject);
-              // console.log('provision end cluster: ', this.context.currentCluster);
-            }
-          }
-        }
-      );
-    }, 1000);
-  };
-
-  refreshLogs = () => {
-    if (this.state.websockets.length == 0) {
-      return;
-    }
-    let { currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
-
-    this.state.websockets.forEach((ws: any) => {
-      ws.close();
-    });
-
-    this.setState({
-      websockets: [],
-      logs: [],
-    });
-
-    let websockets = this.state.infras.map((infra: any) => {
-      let ws = new WebSocket(
-        `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`
-      );
-      return this.setupWebsocket(ws, infra);
-    });
-
-    this.setState({ websockets, logs: ["Provisioning resources..."] });
-  };
-
-  render() {
-    let { error, triggerEnd, infras } = this.state;
-
-    let maxStep = 0;
-    let currentStep = 0;
-    let skip = false;
-
-    for (let i = 0; i < infras.length; i++) {
-      if (!this.state.maxStep[infras[i].kind]) {
-        skip = true;
-      }
-    }
-
-    if (!skip) {
-      for (let key in this.state.maxStep) {
-        maxStep += this.state.maxStep[key];
-        currentStep += this.state.currentStep[key];
-      }
-    }
-
-    if (maxStep !== 0 && currentStep === maxStep && !triggerEnd) {
-      posthog.capture("Provisioning complete!");
-      this.onEnd();
-      this.setState({ triggerEnd: true });
-    }
-
-    return (
-      <StyledProvisioner>
-        {error ? (
-          <>
-            <TitleSection>
-              <Title>
-                <img src={warning} /> Provisioning Error
-              </Title>
-            </TitleSection>
-
-            <Helper>
-              Porter encountered an error while provisioning.
-              <Link to="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={
-              error
-                ? "0%"
-                : (
-                    (currentStep / (maxStep == 0 ? 1 : maxStep)) *
-                    100
-                  ).toString() + "%"
-            }
-          />
-        </LoadingBar>
-        <InfraStatuses infras={infras} />
-
-        <LogStream>
-          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
-        </LogStream>
-
-        <Helper>(Provisioning usually takes around 15 minutes)</Helper>
-      </StyledProvisioner>
-    );
-  }
-}
-
-ProvisionerStatus.contextType = Context;
-
-export default withRouter(ProvisionerStatus);
-
-const Options = styled.div`
-  width: 100%;
-  height: 25px;
-  background: #397ae3;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-`;
-
-const Refresh = styled.div`
-  display: flex;
-  align-items: center;
-  width: 87px;
-  user-select: none;
-  cursor: pointer;
-  height: 100%;
-
-  > i {
-    margin-left: 6px;
-    font-size: 17px;
-    margin-right: 6px;
-  }
-
-  :hover {
-    background: #2468d6;
-  }
-`;
-
-// const Link = styled.a`
-//   cursor: pointer;
-//   margin-left: 5px;
-//   margin-right: 5px;
-// `;
-
-const Warning = styled.span`
-  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
-    props.highlight ? "#f5cb42" : ""};
-  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
-    props.makeFlush ? "" : "5px"};
-  margin-right: 5px;
-`;
-
-const Wrapper = styled.div`
-  width: 100%;
-  height: 100%;
-  overflow: auto;
-  padding: 20px 25px;
-`;
-
-const Log = styled.div`
-  font-family: monospace;
-`;
-
-const LogStream = styled.div`
-  height: 300px;
-  margin-top: 20px;
-  font-size: 13px;
-  border: 2px solid #ffffff55;
-  border-radius: 10px;
-  width: 100%;
-  background: #00000022;
-  user-select: text;
-`;
-
-const Message = styled.div`
-  display: flex;
-  height: 100%;
-  width: 100%;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
-const Loaded = styled.div<{ progress: string }>`
-  width: ${(props) => props.progress};
-  height: 100%;
-  background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
-  background-size: 400% 400%;
-
-  animation: linkLoad 2s infinite;
-
-  @keyframes linkLoad {
-    0% {
-      background-position: 91% 100%;
-    }
-    100% {
-      background-position: 10% 0%;
-    }
-  }
-`;
-
-const LoadingBar = styled.div`
-  width: 100%;
-  margin-top: 24px;
-  overflow: hidden;
-  height: 20px;
-  background: #ffffff11;
-  border-radius: 30px;
-`;
-
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-
-  > img {
-    width: 20px;
-    margin-right: 10px;
-    margin-bottom: -2px;
-  }
-`;
-
-const TitleSection = styled.div`
-  margin-bottom: 20px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-
-  > a {
-    > i {
-      display: flex;
-      align-items: center;
-      margin-bottom: -2px;
-      font-size: 18px;
-      margin-left: 18px;
-      color: #858faaaa;
-      cursor: pointer;
-      :hover {
-        color: #aaaabb;
-      }
-    }
-  }
-`;
-
-const StyledProvisioner = styled.div`
-  width: calc(90% - 150px);
-  min-width: 300px;
-  height: 600px;
-  position: relative;
-  padding-top: 50px;
-  margin-top: calc(50vh - 350px);
-`;

+ 23 - 20
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -20,6 +20,24 @@ class ProjectSection extends Component<PropsType, StateType> {
     expanded: false,
   };
 
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    document.addEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  handleClickOutside = (e: any) => {
+    if (
+      this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(e.target)
+    ) {
+      this.setState({ expanded: false });
+    }
+  }
+
   renderOptionList = () => {
     let { setCurrentProject } = this.context;
 
@@ -28,7 +46,7 @@ class ProjectSection extends Component<PropsType, StateType> {
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => setCurrentProject(project)}
+          onClick={() => {this.setState({ expanded: false }); setCurrentProject(project)}}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -44,13 +62,12 @@ class ProjectSection extends Component<PropsType, StateType> {
     if (this.state.expanded) {
       return (
         <div>
-          <CloseOverlay onClick={() => this.setState({ expanded: false })} />
           <Dropdown>
             {this.renderOptionList()}
             <Option
               selected={false}
               lastItem={true}
-              onClick={() => this.props.history.push("new-project")}
+              onClick={() => { this.props.history.push('new-project') }}
             >
               <ProjectIconAlt>+</ProjectIconAlt>
               <ProjectLabel>Create a Project</ProjectLabel>
@@ -69,7 +86,9 @@ class ProjectSection extends Component<PropsType, StateType> {
     let { currentProject } = this.props;
     if (currentProject) {
       return (
-        <StyledProjectSection>
+        <StyledProjectSection
+          ref={this.wrapperRef}
+        >
           <MainSelector
             onClick={this.handleExpand}
             expanded={this.state.expanded}
@@ -103,13 +122,6 @@ const ProjectLabel = styled.div`
   text-overflow: ellipsis;
 `;
 
-const AddButton = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  padding: 12px 15px;
-`;
-
 const Plus = styled.div`
   margin-right: 10px;
   font-size: 15px;
@@ -165,15 +177,6 @@ const Option = styled.div`
   }
 `;
 
-const CloseOverlay = styled.div`
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 999;
-`;
-
 const Dropdown = styled.div`
   position: absolute;
   right: 10px;

+ 10 - 9
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -7,11 +7,10 @@ import settings from "assets/settings.svg";
 
 import { Context } from "shared/Context";
 
-import ClusterSection from "./ClusterSection";
-import ProjectSectionContainer from "./ProjectSectionContainer";
-import loading from "assets/loading.gif";
-import posthog from "posthog-js";
-import { RouteComponentProps, withRouter } from "react-router";
+import ClusterSection from './ClusterSection';
+import ProjectSectionContainer from './ProjectSectionContainer';
+import loading from 'assets/loading.gif';
+import { RouteComponentProps, withRouter } from 'react-router';
 
 type PropsType = RouteComponentProps & {
   forceSidebar: boolean;
@@ -120,10 +119,12 @@ class Sidebar extends Component<PropsType, StateType> {
             Templates
           </NavButton>
           <NavButton
-            selected={currentView === "integrations"}
-            //onClick={() => {
-            //  setCurrentView('integrations')
-            // }}
+            selected={currentView === 'integrations'}
+            /* 
+            onClick={() => {
+              setCurrentView('integrations')
+            }}
+            */
             onClick={() => {
               setCurrentModal("IntegrationsInstructionsModal", {});
             }}

+ 276 - 170
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -1,26 +1,22 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import randomWords from "random-words";
-import posthog from "posthog-js";
-import _ from "lodash";
-import { Context } from "shared/Context";
-import api from "shared/api";
-
-import {
-  PorterTemplate,
-  ChoiceType,
-  ClusterType,
-  StorageType,
-} from "shared/types";
-import Selector from "components/Selector";
-import ImageSelector from "components/image-selector/ImageSelector";
-import TabRegion from "components/TabRegion";
-import InputRow from "components/values-form/InputRow";
-import SaveButton from "components/SaveButton";
-import ValuesWrapper from "components/values-form/ValuesWrapper";
-import ValuesForm from "components/values-form/ValuesForm";
-import { isAlphanumeric } from "shared/common";
-import { safeDump } from "js-yaml";
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import randomWords from 'random-words';
+import posthog from 'posthog-js';
+import _ from 'lodash';
+import { Context } from 'shared/Context';
+import api from 'shared/api';
+
+import { ActionConfigType, ChoiceType, ClusterType, StorageType } from 'shared/types';
+import Selector from 'components/Selector';
+import ImageSelector from 'components/image-selector/ImageSelector';
+import TabRegion from 'components/TabRegion';
+import InputRow from 'components/values-form/InputRow';
+import SaveButton from 'components/SaveButton';
+import ActionConfEditor from 'components/repo-selector/ActionConfEditor';
+import ValuesWrapper from 'components/values-form/ValuesWrapper';
+import ValuesForm from 'components/values-form/ValuesForm';
+import { isAlphanumeric } from 'shared/common';
+import { safeDump } from 'js-yaml';
 
 type PropsType = {
   currentTemplate: any;
@@ -30,36 +26,75 @@ type PropsType = {
 };
 
 type StateType = {
-  currentView: string;
-  clusterOptions: { label: string; value: string }[];
-  saveValuesStatus: string | null;
-  selectedNamespace: string;
-  selectedCluster: string;
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  templateName: string;
-  tabOptions: ChoiceType[];
-  currentTab: string | null;
-  tabContents: any;
-  namespaceOptions: { label: string; value: string }[];
+  currentView: string,
+  clusterOptions: { label: string, value: string }[],
+  clusterMap: { [clusterId: string]: ClusterType },
+  saveValuesStatus: string | null
+  selectedNamespace: string,
+  selectedCluster: string,
+  selectedImageUrl: string | null,
+  sourceType: string,
+  selectedTag: string | null,
+  templateName: string,
+  tabOptions: ChoiceType[],
+  currentTab: string | null,
+  tabContents: any
+  namespaceOptions: { label: string, value: string }[],
+  actionConfig: ActionConfigType,
+  branch: string,
+  pathIsSet: boolean,
 };
 
 export default class LaunchTemplate extends Component<PropsType, StateType> {
   state = {
-    currentView: "repo",
-    clusterOptions: [] as { label: string; value: string }[],
-    saveValuesStatus: "No container image specified" as string | null,
+    currentView: 'repo',
+    clusterOptions: [] as { label: string, value: string }[],
+    clusterMap: {} as { [clusterId: string]: ClusterType },
+    saveValuesStatus: 'No container image specified' as (string | null),
     selectedCluster: this.context.currentCluster.name,
     selectedNamespace: "default",
-    selectedImageUrl: "" as string | null,
-    templateName: "",
-    selectedTag: "" as string | null,
+    selectedImageUrl: '' as string | null,
+    sourceType: 'registry',
+    templateName: '',
+    selectedTag: '' as string | null,
     tabOptions: [] as ChoiceType[],
     currentTab: null as string | null,
     tabContents: [] as any,
-    namespaceOptions: [] as { label: string; value: string }[],
+    namespaceOptions: [] as { label: string, value: string }[],
+    actionConfig: {
+      git_repo: '',
+      image_repo_uri: '',
+      git_repo_id: 0,
+      dockerfile_path: '',
+    } as ActionConfigType,
+    branch: '',
+    pathIsSet: false,
   };
 
+  createGHAction = (chartName: string, chartNamespace: string) => {
+    let { currentProject, currentCluster } = this.context;
+    let { actionConfig } = this.state;
+
+    api.createGHAction('<token>', {
+      git_repo: actionConfig.git_repo,
+      image_repo_uri: actionConfig.image_repo_uri,
+      dockerfile_path: actionConfig.dockerfile_path,
+      git_repo_id: actionConfig.git_repo_id,
+    }, {
+      project_id: currentProject.id,
+      CLUSTER_ID: currentCluster.id,
+      RELEASE_NAME: chartName,
+      RELEASE_NAMESPACE: chartNamespace,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        // Exit to initial settings tab
+        console.log(res.data);
+      }
+    });
+  }
+
   onSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject } = this.context;
     let name =
@@ -71,41 +106,41 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       _.set(values, key, wildcard[key]);
     }
 
-    api.deployTemplate(
-      "<token>",
-      {
-        templateName: this.props.currentTemplate.name,
-        storage: StorageType.Secret,
-        formValues: values,
-        namespace: this.state.selectedNamespace,
-        name,
-      },
-      {
-        id: currentProject.id,
-        cluster_id: currentCluster.id,
-        name: this.props.currentTemplate.name.toLowerCase().trim(),
-        version: "latest",
-      },
-      (err: any, res: any) => {
-        if (err) {
-          this.setState({ saveValuesStatus: "error" });
-          posthog.capture("Failed to deploy template", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            values: values,
-            error: err,
-          });
-        } else {
-          // this.props.setCurrentView('cluster-dashboard');
-          this.setState({ saveValuesStatus: "successful" });
-          posthog.capture("Deployed template", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            values: values,
-          });
+    api.deployTemplate('<token>', {
+      templateName: this.props.currentTemplate.name,
+      storage: StorageType.Secret,
+      formValues: values,
+      namespace: this.state.selectedNamespace,
+      name,
+    }, {
+      id: currentProject.id,
+      cluster_id: currentCluster.id,
+      name: this.props.currentTemplate.name.toLowerCase().trim(),
+      version: 'latest',
+    }, (err: any, res: any) => {
+      if (err) {
+        this.setState({ saveValuesStatus: 'error' });
+        posthog.capture('Failed to deploy template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+          error: err
+        })
+      } else {
+        if (this.state.sourceType === 'repo') {
+          this.createGHAction(name, this.state.selectedNamespace);
         }
+        // this.props.setCurrentView('cluster-dashboard');
+        this.setState({ saveValuesStatus: 'successful' }, () => {
+          // redirect to dashboard
+        });
+        posthog.capture('Deployed template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+        })
       }
-    );
+    });
   };
 
   onSubmit = (rawValues: any) => {
@@ -131,45 +166,60 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       tag = "latest";
     }
 
-    _.set(values, "image.repository", imageUrl);
-    _.set(values, "image.tag", tag);
-
-    api.deployTemplate(
-      "<token>",
-      {
-        templateName: this.props.currentTemplate.name,
-        imageURL: this.state.selectedImageUrl,
-        storage: StorageType.Secret,
-        formValues: values,
-        namespace: this.state.selectedNamespace,
-        name,
-      },
-      {
-        id: currentProject.id,
-        cluster_id: currentCluster.id,
-        name: this.props.currentTemplate.name.toLowerCase().trim(),
-        version: "latest",
-      },
-      (err: any, res: any) => {
-        if (err) {
-          this.setState({ saveValuesStatus: "error" });
-          posthog.capture("Failed to deploy template", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            values: values,
-            error: err,
-          });
-        } else {
-          // this.props.setCurrentView('cluster-dashboard');
-          this.setState({ saveValuesStatus: "successful" });
-          posthog.capture("Deployed template", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            values: values,
-          });
+    if (this.state.sourceType === 'repo') {
+      imageUrl = 'hello-world';
+      tag = 'latest';
+    }
+
+    _.set(values, "image.repository", imageUrl)
+    _.set(values, "image.tag", tag)
+
+    console.log(`
+      ${this.props.currentTemplate.name}\n
+      ${this.state.selectedImageUrl}\n
+      ${values}\n
+      ${this.state.selectedNamespace}\n
+      ${name}\n
+      ${currentProject.id}\n
+      ${currentCluster.id}\n}
+    `)
+
+    api.deployTemplate('<token>', {
+      templateName: this.props.currentTemplate.name,
+      imageURL: this.state.selectedImageUrl,
+      storage: StorageType.Secret,
+      formValues: values,
+      namespace: this.state.selectedNamespace,
+      name,
+    }, {
+      id: currentProject.id,
+      cluster_id: currentCluster.id,
+      name: this.props.currentTemplate.name.toLowerCase().trim(),
+      version: 'latest',
+    }, (err: any, res: any) => {
+      if (err) {
+        this.setState({ saveValuesStatus: 'error' });
+        posthog.capture('Failed to deploy template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+          error: err
+        })
+      } else {
+        if (this.state.sourceType === 'repo') {
+          this.createGHAction(name, this.state.selectedNamespace);
         }
+        // this.props.setCurrentView('cluster-dashboard');
+        this.setState({ saveValuesStatus: 'successful' }, () => {
+          // redirect to dashboard with namespace
+        });
+        posthog.capture('Deployed template', {
+          name: this.props.currentTemplate.name,
+          namespace: this.state.selectedNamespace,
+          values: values,
+        })
       }
-    );
+    });
   };
 
   renderTabContents = () => {
@@ -223,45 +273,41 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
     // TODO: query with selected filter once implemented
     let { currentProject, currentCluster } = this.context;
-    api.getClusters(
-      "<token>",
-      {},
-      { id: currentProject.id },
-      (err: any, res: any) => {
-        if (err) {
-          // console.log(err)
-        } else if (res.data) {
-          let clusterOptions = res.data.map((x: ClusterType) => {
-            return { label: x.name, value: x.name };
-          });
-          if (res.data.length > 0) {
-            this.setState({ clusterOptions });
-          }
+    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        // console.log(err)
+      } else if (res.data) {
+        let clusterOptions: { label: string, value: string }[] = [];
+        let clusterMap: { [clusterId: string]: ClusterType } = {};
+        res.data.forEach((cluster: ClusterType, i: number) => {
+          clusterOptions.push({ label: cluster.name, value: cluster.name });
+          clusterMap[cluster.name] = cluster;
+        })
+        if (res.data.length > 0) {
+          this.setState({ clusterOptions, clusterMap });
         }
       }
-    );
+    });
 
-    api.getNamespaces(
-      "<token>",
-      {
-        cluster_id: currentCluster.id,
-      },
-      { id: currentProject.id },
-      (err: any, res: any) => {
-        if (err) {
-          console.log(err);
-        } else if (res.data) {
-          let namespaceOptions = res.data.items.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
-            }
-          );
-          if (res.data.items.length > 0) {
-            this.setState({ namespaceOptions });
-          }
+    this.updateNamespaces(currentCluster.id);
+  }
+
+  updateNamespaces = (id: number) => {
+    let { currentProject } = this.context;
+    api.getNamespaces('<token>', {
+      cluster_id: id,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else if (res.data) {
+        let namespaceOptions = res.data.items.map((x: { metadata: {name: string}}) => { 
+          return { label: x.metadata.name, value: x.metadata.name } 
+        });
+        if (res.data.items.length > 0) {
+          this.setState({ namespaceOptions });
         }
       }
-    );
+    });
   }
 
   setSelectedImageUrl = (x: string) => {
@@ -327,25 +373,67 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelector = () => {
+    let { currentProject } = this.context;
+
     if (this.props.form?.hasSource) {
-      return (
-        <>
-          <Subtitle>
-            Select the container image you would like to connect to this
-            template.
-            <Required>*</Required>
-          </Subtitle>
-          <DarkMatter />
-          <ImageSelector
-            selectedTag={this.state.selectedTag}
-            selectedImageUrl={this.state.selectedImageUrl}
-            setSelectedImageUrl={this.setSelectedImageUrl}
-            setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
-            forceExpanded={true}
-          />
-          <br />
-        </>
-      );
+      if (this.state.sourceType === 'registry') {
+        return (
+          <>
+            <Subtitle>
+              Select the container image you would like to connect to this template or
+              <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
+                link a git repository
+              </Highlight>.
+              <Required>*</Required>
+            </Subtitle>
+            <DarkMatter />
+            <ImageSelector
+              selectedTag={this.state.selectedTag}
+              selectedImageUrl={this.state.selectedImageUrl}
+              setSelectedImageUrl={this.setSelectedImageUrl}
+              setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
+              forceExpanded={true}
+            />
+            <br />
+          </>
+        )
+      } else {
+        return (
+          <>
+            <Subtitle>
+              Select a repo to connect to. You can 
+              <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
+                log in with GitHub
+              </A> or
+              <Highlight
+                onClick={() => this.setState({
+                  sourceType: 'registry',
+                  actionConfig: {
+                    git_repo: '',
+                    image_repo_uri: '',
+                    git_repo_id: 0,
+                    dockerfile_path: '',
+                  } as ActionConfigType
+                })}
+              >
+                link an image registry
+              </Highlight>.
+              <Required>*</Required>
+            </Subtitle>
+            <ActionConfEditor
+              actionConfig={this.state.actionConfig}
+              branch={this.state.branch}
+              pathIsSet={this.state.pathIsSet}
+              setActionConfig={(actionConfig: ActionConfigType) => this.setState({ actionConfig }, () => {
+                this.setSelectedImageUrl(this.state.actionConfig.image_repo_uri);
+              })}
+              setBranch={(branch: string) => this.setState({ branch })}
+              setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+            />
+            <br />
+          </>
+        )
+      }
     }
   };
 
@@ -376,9 +464,12 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           </ClusterLabel>
           <Selector
             activeValue={this.state.selectedCluster}
-            setActiveValue={(cluster: string) =>
-              this.setState({ selectedCluster: cluster })
-            }
+            setActiveValue={(cluster: string) => {
+              this.context.setCurrentCluster(this.state.clusterMap[cluster]);
+              this.updateNamespaces(this.state.clusterMap[cluster].id);
+              console.log(this.state.clusterMap[cluster]);
+              this.setState({ selectedCluster: cluster });
+            }}
             options={this.state.clusterOptions}
             width="250px"
             dropdownWidth="335px"
@@ -408,10 +499,9 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             }
           >
             (lowercase letters, numbers, and "-" only)
-          </Warning>
-          . (Optional)
+          </Warning>. (Optional)
         </Subtitle>
-        <DarkMatter antiHeight="-27px" />
+        <DarkMatter antiHeight='-27px' />
         <InputRow
           type="text"
           value={this.state.templateName}
@@ -582,3 +672,19 @@ const StyledLaunchTemplate = styled.div`
   width: 100%;
   padding-bottom: 150px;
 `;
+
+const Highlight = styled.div`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+  padding-right: ${(props: { padRight?: boolean }) => props.padRight ? '5px' : ''};
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+  padding-right: ${(props: { padRight?: boolean }) => props.padRight ? '5px' : ''};
+`;

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

@@ -41,7 +41,11 @@ class ContextProvider extends Component {
     },
     currentProject: null as ProjectType | null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
-      localStorage.setItem("currentProject", currentProject.id.toString());
+      if (currentProject) {
+        localStorage.setItem('currentProject', currentProject.id.toString());
+      } else {
+        localStorage.removeItem('currentProject');
+      }
       this.setState({ currentProject }, () => {
         callback && callback();
       });

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

@@ -10,17 +10,29 @@ import { StorageType } from "./types";
  * @param {(err: Object, res: Object) => void} callback - Callback function.
  */
 
-const checkAuth = baseApi("GET", "/api/auth/check");
+const checkAuth = baseApi('GET', '/api/auth/check');
 
-const createAWSIntegration = baseApi<
-  {
-    aws_region: string;
-    aws_cluster_id?: string;
-    aws_access_key_id: string;
-    aws_secret_access_key: string;
-  },
-  { id: number }
->("POST", (pathParams) => {
+const connectECRRegistry = baseApi<{
+  name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
+
+const connectGCRRegistry = baseApi<{
+  name: string,
+  gcp_integration_id: string,
+  url: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
+
+const createAWSIntegration = baseApi<{
+  aws_region: string,
+  aws_cluster_id?: string,
+  aws_access_key_id: string,
+  aws_secret_access_key: string,
+}, { id: number }>('POST', pathParams => {
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
@@ -50,26 +62,13 @@ const createDOKS = baseApi<
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
-const createECR = baseApi<
-  {
-    name: string;
-    aws_integration_id: string;
-  },
-  { id: number }
->("POST", (pathParams) => {
-  return `/api/projects/${pathParams.id}/registries`;
-});
-
-const createGCPIntegration = baseApi<
-  {
-    gcp_region: string;
-    gcp_key_data: string;
-    gcp_project_id: string;
-  },
-  {
-    project_id: number;
-  }
->("POST", (pathParams) => {
+const createGCPIntegration = baseApi<{
+  gcp_region: string,
+  gcp_key_data: string,
+  gcp_project_id: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
@@ -553,10 +552,11 @@ const upgradeChartValues = baseApi<
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
+  connectECRRegistry,
+  connectGCRRegistry,
   createAWSIntegration,
   createDOCR,
   createDOKS,
-  createECR,
   createGCPIntegration,
   createGCR,
   createGHAction,

+ 19 - 79
dashboard/src/shared/common.tsx

@@ -1,7 +1,8 @@
-import aws from "assets/aws.png";
-import digitalOcean from "assets/do.png";
-import gcp from "assets/gcp.png";
-import { InfraType } from "shared/types";
+import aws from '../assets/aws.png';
+import digitalOcean from '../assets/do.png';
+import gcp from '../assets/gcp.png';
+import github from '../assets/github.png';
+import { InfraType } from '../shared/types';
 
 export const infraNames: any = {
   ecr: "Elastic Container Registry (ECR)",
@@ -19,11 +20,10 @@ export const integrationList: any = {
     label: "Kubernetes",
     buttonText: "Add a Cluster",
   },
-  repo: {
-    icon:
-      "https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png",
-    label: "Git Repository",
-    buttonText: "Add a Repository",
+  'repo': {
+    icon: 'https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png',
+    label: 'Git Repository',
+    buttonText: 'Link a Github Account',
   },
   registry: {
     icon:
@@ -69,8 +69,16 @@ export const integrationList: any = {
   },
   do: {
     icon: digitalOcean,
-    label: "DigitalOcean",
+    label: 'DigitalOcean',
+  },
+  'github': {
+    icon: github,
+    label: 'GitHub',
   },
+  'gitlab': {
+    icon: 'https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png',
+    label: 'Gitlab',
+  }
 };
 
 export const isAlphanumeric = (x: string | null) => {
@@ -85,72 +93,4 @@ export const getIgnoreCase = (object: any, key: string) => {
   return object[
     Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())
   ];
-};
-
-export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
-  // TODO: declare globally while avoidiing changes to the array on helper call
-  let infraSets = [
-    ["ecr", "eks"],
-    ["gcr", "gke"],
-    ["docr", "doks"],
-  ];
-  if (infras.length === 0) {
-    return false;
-  }
-
-  let completed = [] as string[];
-  infras.forEach((infra: InfraType, i: number) => {
-    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 infraSets = [
-    ["ecr", "eks"],
-    ["gcr", "gke"],
-    ["docr", "doks"],
-  ];
-  let newestInstances = {} as any;
-  let newestId = -1;
-  let whitelistedInfras = [] as string[];
-  infras.forEach((infra: InfraType, i: number) => {
-    // Determine the most recent set for which provisioning was attempted
-    if (infra.id > newestId) {
-      newestId = infra.id;
-      infraSets.forEach((infraSet: string[]) => {
-        infraSet.includes(infra.kind) ? (whitelistedInfras = infraSet) : null;
-      });
-    }
-
-    if (!newestInstances[infra.kind]) {
-      newestInstances[infra.kind] = infra;
-    } else {
-      let existingId = newestInstances[infra.kind].id;
-      if (infra.id > existingId) {
-        newestInstances[infra.kind] = infra;
-      }
-    }
-  });
-
-  let newestInfras = Object.values(newestInstances) as InfraType[];
-  let result = newestInfras.filter((x: InfraType) => {
-    return whitelistedInfras.includes(x.kind);
-  });
-  return result;
-};
+}

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

@@ -4,7 +4,6 @@ export const PorterUrls = [
   "integrations",
   "new-project",
   "cluster-dashboard",
-  "provisioner",
   "project-settings",
 ];
 

+ 1 - 1
internal/config/config.go

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

+ 11 - 0
staging.sh

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