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

Merge pull request #406 from porter-dev/beta.3.domain-generation

Test domain generation on dev
abelanger5 5 лет назад
Родитель
Сommit
1ec74f1c28
100 измененных файлов с 15167 добавлено и 1548 удалено
  1. 39 39
      .github/workflows/dev.yaml
  2. 41 39
      .github/workflows/production.yaml
  3. 41 39
      .github/workflows/staging.yaml
  4. 2 0
      .prettierignore
  5. 10378 1
      dashboard/package-lock.json
  6. 1 2
      dashboard/package.json
  7. 45 0
      dashboard/src/components/InfoTooltip.tsx
  8. 66 0
      dashboard/src/components/RadioSelector.tsx
  9. 2 0
      dashboard/src/components/ResourceTab.tsx
  10. 12 0
      dashboard/src/components/StatusIndicator.tsx
  11. 6 2
      dashboard/src/components/image-selector/ImageList.tsx
  12. 7 2
      dashboard/src/components/image-selector/ImageSelector.tsx
  13. 2 0
      dashboard/src/components/image-selector/TagList.tsx
  14. 71 34
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  15. 298 42
      dashboard/src/components/repo-selector/ActionDetails.tsx
  16. 18 1
      dashboard/src/components/repo-selector/BranchList.tsx
  17. 0 158
      dashboard/src/components/repo-selector/ButtonTray.tsx
  18. 260 26
      dashboard/src/components/repo-selector/ContentsList.tsx
  19. 33 1
      dashboard/src/components/repo-selector/RepoList.tsx
  20. 0 177
      dashboard/src/components/repo-selector/RepoSelector.tsx
  21. 7 0
      dashboard/src/components/values-form/CheckboxRow.tsx
  22. 0 2
      dashboard/src/components/values-form/Helper.tsx
  23. 1 1
      dashboard/src/components/values-form/InputRow.tsx
  24. 11 1
      dashboard/src/components/values-form/ValuesForm.tsx
  25. 65 1
      dashboard/src/index.html
  26. 5 5
      dashboard/src/index.tsx
  27. 1 1
      dashboard/src/main/CurrentError.tsx
  28. 23 0
      dashboard/src/main/Login.tsx
  29. 15 14
      dashboard/src/main/Main.tsx
  30. 22 0
      dashboard/src/main/Register.tsx
  31. 7 15
      dashboard/src/main/home/Home.tsx
  32. 24 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  33. 87 37
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  34. 16 115
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  35. 103 25
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  36. 421 21
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  37. 22 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  38. 9 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  39. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  40. 290 0
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  41. 61 209
      dashboard/src/main/home/integrations/IntegrationList.tsx
  42. 220 0
      dashboard/src/main/home/integrations/IntegrationRow.tsx
  43. 74 328
      dashboard/src/main/home/integrations/Integrations.tsx
  44. 11 9
      dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx
  45. 0 0
      dashboard/src/main/home/integrations/create-integration/DockerHubForm.tsx
  46. 0 0
      dashboard/src/main/home/integrations/create-integration/ECRForm.tsx
  47. 0 0
      dashboard/src/main/home/integrations/create-integration/EKSForm.tsx
  48. 0 0
      dashboard/src/main/home/integrations/create-integration/GCRForm.tsx
  49. 0 0
      dashboard/src/main/home/integrations/create-integration/GKEForm.tsx
  50. 105 0
      dashboard/src/main/home/integrations/edit-integration/DockerHubForm.tsx
  51. 139 0
      dashboard/src/main/home/integrations/edit-integration/ECRForm.tsx
  52. 124 0
      dashboard/src/main/home/integrations/edit-integration/EKSForm.tsx
  53. 38 0
      dashboard/src/main/home/integrations/edit-integration/EditIntegrationForm.tsx
  54. 165 0
      dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx
  55. 111 0
      dashboard/src/main/home/integrations/edit-integration/GKEForm.tsx
  56. 87 27
      dashboard/src/main/home/launch/Launch.tsx
  57. 12 8
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  58. 357 94
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  59. 2 1
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  60. 3 0
      dashboard/src/main/home/launch/hardcodedNameDict.tsx
  61. 1 0
      dashboard/src/main/home/new-project/NewProject.tsx
  62. 62 3
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  63. 50 1
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  64. 61 2
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  65. 3 3
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  66. 2 0
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  67. 8 8
      dashboard/src/main/home/sidebar/Sidebar.tsx
  68. 70 10
      dashboard/src/shared/api.tsx
  69. 0 1
      dashboard/src/shared/types.tsx
  70. 1 0
      dashboard/webpack.config.js
  71. 1 0
      go.mod
  72. 3 0
      go.sum
  73. 1 0
      internal/config/config.go
  74. 1 1
      internal/forms/chart.go
  75. 8 0
      internal/forms/domain.go
  76. 16 9
      internal/forms/git_action.go
  77. 12 0
      internal/forms/metrics.go
  78. 43 7
      internal/integrations/ci/actions/actions.go
  79. 23 5
      internal/integrations/ci/actions/steps.go
  80. 9 0
      internal/kubernetes/agent.go
  81. 210 0
      internal/kubernetes/domain/domain.go
  82. 151 0
      internal/kubernetes/prometheus/metrics.go
  83. 40 0
      internal/models/dns_record.go
  84. 9 2
      internal/models/gitrepo.go
  85. 8 1
      internal/models/registry.go
  86. 42 0
      internal/registry/registry.go
  87. 11 0
      internal/repository/dns_record.go
  88. 6 0
      internal/repository/gorm/cluster.go
  89. 27 0
      internal/repository/gorm/dns_record.go
  90. 1 0
      internal/repository/gorm/repository.go
  91. 36 0
      internal/repository/memory/dns_record.go
  92. 1 0
      internal/repository/memory/repository.go
  93. 1 0
      internal/repository/repository.go
  94. 0 12
      package-lock.json
  95. 2 0
      server/api/deploy_handler.go
  96. 99 0
      server/api/dns_record_handler.go
  97. 28 0
      server/api/git_action_handler.go
  98. 117 0
      server/api/k8s_handler.go
  99. 24 2
      server/api/release_handler.go
  100. 16 2
      server/api/template_handler.go

+ 39 - 39
.github/workflows/dev.yaml

@@ -1,46 +1,46 @@
 name: Deploy to production
 on:
   push:
-    branches: 
-    - dev
+    branches:
+      - dev
 jobs:
   deploy:
     runs-on: ubuntu-latest
     steps:
-    - name: Set up Cloud SDK
-      uses: google-github-actions/setup-gcloud@master
-      with:
-        project_id: ${{ secrets.GCP_PROJECT_ID }}
-        service_account_key: ${{ secrets.GCP_SA_KEY }}
-        export_default_credentials: true
-    - name: Install kubectl
-      run: |
-        sudo apt-get install kubectl
-    - name: Log in to gcloud CLI
-      run: gcloud auth configure-docker
-    - name: Checkout
-      uses: actions/checkout@v2.3.4
-    - name: Write Dashboard Environment Variables
-      run: |
-        cat >./dashboard/.env <<EOL
-        NODE_ENV=production
-        API_SERVER=dashboard.dev.getporter.dev
-        FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
-        DISCORD_KEY=${{secrets.DISCORD_KEY}}
-        DISCORD_CID=${{secrets.DISCORD_CID}}
-        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-        POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
-        POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
-        EOL
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./docker/Dockerfile
-    - name: Push
-      run: |
-        docker push gcr.io/porter-dev-273614/porter:dev
-    - name: Deploy to cluster
-      run: |
-        gcloud container clusters get-credentials \
-          dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
-          
-        kubectl rollout restart deployment/porter
+      - name: Set up Cloud SDK
+        uses: google-github-actions/setup-gcloud@master
+        with:
+          project_id: ${{ secrets.GCP_PROJECT_ID }}
+          service_account_key: ${{ secrets.GCP_SA_KEY }}
+          export_default_credentials: true
+      - name: Install kubectl
+        run: |
+          sudo apt-get install kubectl
+      - name: Log in to gcloud CLI
+        run: gcloud auth configure-docker
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=production
+          API_SERVER=dashboard.dev.getporter.dev
+          FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
+          DISCORD_KEY=${{secrets.DISCORD_KEY}}
+          DISCORD_CID=${{secrets.DISCORD_CID}}
+          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+          EOL
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./docker/Dockerfile
+      - name: Push
+        run: |
+          docker push gcr.io/porter-dev-273614/porter:dev
+      - name: Deploy to cluster
+        run: |
+          gcloud container clusters get-credentials \
+            dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+            
+          kubectl rollout restart deployment/porter

+ 41 - 39
.github/workflows/production.yaml

@@ -1,46 +1,48 @@
 name: Deploy to production
 on:
   push:
-    branches: 
-    - production
+    branches:
+      - production
 jobs:
   deploy:
     runs-on: ubuntu-latest
     steps:
-    - name: Set up Cloud SDK
-      uses: google-github-actions/setup-gcloud@master
-      with:
-        project_id: ${{ secrets.GCP_PROJECT_ID }}
-        service_account_key: ${{ secrets.GCP_SA_KEY }}
-        export_default_credentials: true
-    - name: Install kubectl
-      run: |
-        sudo apt-get install kubectl
-    - name: Log in to gcloud CLI
-      run: gcloud auth configure-docker
-    - name: Checkout
-      uses: actions/checkout@v2.3.4
-    - name: Write Dashboard Environment Variables
-      run: |
-        cat >./dashboard/.env <<EOL
-        NODE_ENV=production
-        API_SERVER=dashboard.getporter.dev
-        FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
-        DISCORD_KEY=${{secrets.DISCORD_KEY}}
-        DISCORD_CID=${{secrets.DISCORD_CID}}
-        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-        POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
-        POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
-        EOL
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile
-    - name: Push
-      run: |
-        docker push gcr.io/porter-dev-273614/porter:latest
-    - name: Deploy to cluster
-      run: |
-        gcloud container clusters get-credentials \
-          production-2 --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
-          
-        kubectl rollout restart deployment/porter
+      - name: Set up Cloud SDK
+        uses: google-github-actions/setup-gcloud@master
+        with:
+          project_id: ${{ secrets.GCP_PROJECT_ID }}
+          service_account_key: ${{ secrets.GCP_SA_KEY }}
+          export_default_credentials: true
+      - name: Install kubectl
+        run: |
+          sudo apt-get install kubectl
+      - name: Log in to gcloud CLI
+        run: gcloud auth configure-docker
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=production
+          API_SERVER=dashboard.getporter.dev
+          FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
+          DISCORD_KEY=${{secrets.DISCORD_KEY}}
+          DISCORD_CID=${{secrets.DISCORD_CID}}
+          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+          SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
+          APPLICATION_CHART_REPO_URL=${{secrets.APPLICATION_CHART_REPO_URL}}
+          EOL
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile
+      - name: Push
+        run: |
+          docker push gcr.io/porter-dev-273614/porter:latest
+      - name: Deploy to cluster
+        run: |
+          gcloud container clusters get-credentials \
+            production-2 --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+            
+          kubectl rollout restart deployment/porter

+ 41 - 39
.github/workflows/staging.yaml

@@ -1,46 +1,48 @@
 name: Build, Push to GCR.
 on:
   push:
-    branches: 
-    - staging
+    branches:
+      - staging
 jobs:
   login-build-push:
     runs-on: ubuntu-latest
     steps:
-    - name: Set up Cloud SDK
-      uses: google-github-actions/setup-gcloud@master
-      with:
-        project_id: ${{ secrets.GCP_PROJECT_ID }}
-        service_account_key: ${{ secrets.GCP_SA_KEY }}
-        export_default_credentials: true
-    - name: Install kubectl
-      run: |
-        sudo apt-get install kubectl
-    - name: Log in to gcloud CLI
-      run: gcloud auth configure-docker
-    - name: Checkout
-      uses: actions/checkout@v2.3.4
-    - name: Write Dashboard Environment Variables
-      run: |
-        cat >./dashboard/.env <<EOL
-        NODE_ENV=production
-        API_SERVER=dashboard.staging.getporter.dev
-        FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
-        DISCORD_KEY=${{secrets.DISCORD_KEY}}
-        DISCORD_CID=${{secrets.DISCORD_CID}}
-        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-        POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
-        POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
-        EOL
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./docker/Dockerfile
-    - name: Push
-      run: |
-        docker push gcr.io/porter-dev-273614/porter:staging
-    - name: Deploy to cluster
-      run: |
-        gcloud container clusters get-credentials \
-          staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
-          
-        kubectl rollout restart deployment/porter
+      - name: Set up Cloud SDK
+        uses: google-github-actions/setup-gcloud@master
+        with:
+          project_id: ${{ secrets.GCP_PROJECT_ID }}
+          service_account_key: ${{ secrets.GCP_SA_KEY }}
+          export_default_credentials: true
+      - name: Install kubectl
+        run: |
+          sudo apt-get install kubectl
+      - name: Log in to gcloud CLI
+        run: gcloud auth configure-docker
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=production
+          API_SERVER=dashboard.staging.getporter.dev
+          FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
+          DISCORD_KEY=${{secrets.DISCORD_KEY}}
+          DISCORD_CID=${{secrets.DISCORD_CID}}
+          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+          SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
+          APPLICATION_CHART_REPO_URL=${{secrets.APPLICATION_CHART_REPO_URL}}
+          EOL
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./docker/Dockerfile
+      - name: Push
+        run: |
+          docker push gcr.io/porter-dev-273614/porter:staging
+      - name: Deploy to cluster
+        run: |
+          gcloud container clusters get-credentials \
+            staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+            
+          kubectl rollout restart deployment/porter

+ 2 - 0
.prettierignore

@@ -0,0 +1,2 @@
+build
+node_modules

Разница между файлами не показана из-за своего большого размера
+ 10378 - 1
dashboard/package-lock.json


+ 1 - 2
dashboard/package.json

@@ -3,7 +3,6 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@fullstory/browser": "^1.4.5",
     "@material-ui/core": "^4.11.3",
     "@types/d3-array": "^2.9.0",
     "@types/d3-time-format": "^3.0.0",
@@ -13,6 +12,7 @@
     "@types/material-ui": "^0.21.8",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
+    "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
     "@visx/gradient": "^1.0.0",
@@ -32,7 +32,6 @@
     "js-yaml": "^3.14.0",
     "lodash": "^4.17.20",
     "markdown-to-jsx": "^7.0.1",
-    "posthog-js": "^1.8.5",
     "qs": "^6.9.4",
     "random-words": "^1.1.1",
     "react": "^16.13.1",

+ 45 - 0
dashboard/src/components/InfoTooltip.tsx

@@ -0,0 +1,45 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  text: string;
+};
+
+type StateType = {
+  showTooltip: boolean;
+};
+
+export default class InfoTooltip extends Component<PropsType, StateType> {
+  state = {
+    showTooltip: false,
+  };
+
+  render() {
+    return (
+      <StyledInfoTooltip>
+        <i className="material-icons">help_outline</i>
+      </StyledInfoTooltip>
+    );
+  }
+}
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  width: 26px;
+  margin-right: 2px;
+
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -14px;
+    font-size: 18px;
+    right: -1px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 66 - 0
dashboard/src/components/RadioSelector.tsx

@@ -0,0 +1,66 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  selected: string;
+  setSelected: (x: string) => void;
+  options: { value: string; label: string }[];
+};
+
+type StateType = {};
+
+export default class RadioSelector extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <StyledRadioSelector>
+        {this.props.options.map(
+          (option: { label: string; value: string }, i: number) => {
+            let selected = option.value === this.props.selected;
+            return (
+              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+                <Indicator selected={selected}>
+                  {selected && <Circle />}
+                </Indicator>
+                {option.label}
+              </RadioRow>
+            );
+          }
+        )}
+      </StyledRadioSelector>
+    );
+  }
+}
+
+const RadioRow = styled.div`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  margin-bottom: 12px;
+  :hover {
+    > div {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  margin-right: 4px;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Circle = styled.div`
+  width: 8px;
+  height: 8px;
+  background: #ffffff55;
+  border-radius: 15px;
+`;
+
+const StyledRadioSelector = styled.div``;

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

@@ -253,6 +253,8 @@ const StatusColor = styled.div`
       ? "#4797ff"
       : props.status === "failed" || props.status === "FailedValidation"
       ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
       : "#f5cb42"};
   border-radius: 20px;
 `;

+ 12 - 0
dashboard/src/components/StatusIndicator.tsx

@@ -33,6 +33,14 @@ export default class StatusIndicator extends Component<PropsType, StateType> {
       for (var uid in this.props.controllers) {
         let value = this.props.controllers[uid];
         let available = this.getAvailability(value.metadata.kind, value);
+
+        if (
+          value.metadata.kind?.toLowerCase() === "job" &&
+          !value.status?.active
+        ) {
+          return "completed";
+        }
+
         let progressing = true;
 
         this.props.controllers[uid]?.status?.conditions?.forEach(
@@ -67,6 +75,8 @@ export default class StatusIndicator extends Component<PropsType, StateType> {
         return c.status.readyReplicas == c.status.replicas;
       case "daemonset":
         return c.status.numberAvailable == c.status.desiredNumberScheduled;
+      case "job":
+        return c.status.active;
     }
   };
 
@@ -97,6 +107,8 @@ const StatusColor = styled.div`
       ? "#4797ff"
       : props.status === "failed"
       ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
       : "#f5cb42"};
   border-radius: 20px;
   margin-right: 16px;

+ 6 - 2
dashboard/src/components/image-selector/ImageList.tsx

@@ -33,6 +33,7 @@ export default class ImageList extends Component<PropsType, StateType> {
     images: [] as ImageType[],
   };
 
+  // TODO: Try to unhook before unmount
   componentDidMount() {
     const { currentProject, setCurrentError } = this.context;
     let images = [] as ImageType[];
@@ -168,6 +169,7 @@ export default class ImageList extends Component<PropsType, StateType> {
   */
   renderImageList = () => {
     let { images, loading, error } = this.state;
+
     if (loading) {
       return (
         <LoadingWrapper>
@@ -259,7 +261,9 @@ const BackButton = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-top: 10px;
+  margin-top: 28px;
+  margin-bottom: -6px;
+  height: 35px;
   cursor: pointer;
   font-size: 13px;
   padding: 5px 13px;
@@ -286,7 +290,7 @@ const ImageItem = styled.div`
   font-size: 13px;
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected: boolean }) =>
-    props.lastItem ? "#00000000" : "#606166"};
+      props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;

+ 7 - 2
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -119,6 +119,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
   */
   renderImageList = () => {
     let { images, loading, error } = this.state;
+
     if (loading) {
       return (
         <LoadingWrapper>
@@ -195,7 +196,11 @@ export default class ImageSelector extends Component<PropsType, StateType> {
           value={selectedImageUrl}
           onChange={(e: any) => {
             setSelectedImageUrl(e.target.value);
-            this.setState({ clickedImage: null });
+            this.setState({ clickedImage: null, isExpanded: false });
+
+            if (e.target.value == "") {
+              this.setState({ isExpanded: true });
+            }
           }}
           placeholder="Enter or select your container image URL"
         />
@@ -293,7 +298,7 @@ const ImageItem = styled.div`
   font-size: 13px;
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected: boolean }) =>
-    props.lastItem ? "#00000000" : "#606166"};
+      props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;

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

@@ -72,6 +72,8 @@ export default class TagList extends Component<PropsType, StateType> {
       );
     } else if (error || !tags) {
       return <LoadingWrapper>Error loading tags</LoadingWrapper>;
+    } else if (tags.length === 0) {
+      return <LoadingWrapper>This image repository is empty.</LoadingWrapper>;
     }
 
     return tags.map((tag: string, i: number) => {

+ 71 - 34
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -12,11 +12,15 @@ 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;
   reset: any;
+  dockerfilePath: string;
+  setDockerfilePath: (x: string) => void;
+  folderPath: string;
+  setFolderPath: (x: string) => void;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
 };
 
 type StateType = {
@@ -24,6 +28,12 @@ type StateType = {
   error: boolean;
 };
 
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_repo_id: 0,
+};
+
 export default class ActionConfEditor extends Component<PropsType, StateType> {
   state = {
     loading: true,
@@ -31,14 +41,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
   };
 
   renderExpanded = () => {
-    let {
-      actionConfig,
-      branch,
-      pathIsSet,
-      setActionConfig,
-      setBranch,
-      setPath,
-    } = this.props;
+    let { actionConfig, branch, setActionConfig, setBranch } = this.props;
 
     if (!actionConfig.git_repo) {
       return (
@@ -50,7 +53,8 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           />
         </ExpandedWrapper>
       );
-    } else if (!branch) {
+    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
+      /* else if (!branch) {
       return (
         <>
           <ExpandedWrapperAlt>
@@ -59,10 +63,14 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
               setBranch={(branch: string) => setBranch(branch)}
             />
           </ExpandedWrapperAlt>
-          {this.renderResetButton()}
+          <Br />
+          <BackButton width="135px" onClick={() => setActionConfig({ ...defaultActionConfig })}>
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
         </>
       );
-    } else if (!pathIsSet) {
+    } */
       return (
         <>
           <ExpandedWrapperAlt>
@@ -70,32 +78,33 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
               actionConfig={actionConfig}
               branch={branch}
               setActionConfig={setActionConfig}
-              setPath={() => setPath(true)}
+              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
+              setFolderPath={(x: string) => this.props.setFolderPath(x)}
             />
           </ExpandedWrapperAlt>
-          {this.renderResetButton()}
+          <Br />
+          <BackButton
+            width="135px"
+            onClick={() => setActionConfig({ ...defaultActionConfig })}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
         </>
       );
     }
     return (
-      <>
-        <ExpandedWrapperAlt>
-          <ActionDetails
-            actionConfig={actionConfig}
-            setActionConfig={setActionConfig}
-          />
-        </ExpandedWrapperAlt>
-        {this.renderResetButton()}
-      </>
-    );
-  };
-
-  renderResetButton = () => {
-    return (
-      <BackButton width="150px" onClick={this.props.reset}>
-        <i className="material-icons">keyboard_backspace</i>
-        Reset Selection
-      </BackButton>
+      <ActionDetails
+        branch={branch}
+        setDockerfilePath={this.props.setDockerfilePath}
+        setFolderPath={this.props.setFolderPath}
+        actionConfig={actionConfig}
+        setActionConfig={setActionConfig}
+        dockerfilePath={this.props.dockerfilePath}
+        folderPath={this.props.folderPath}
+        setSelectedRegistry={this.props.setSelectedRegistry}
+        selectedRegistry={this.props.selectedRegistry}
+      />
     );
   };
 
@@ -106,6 +115,31 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
 
 ActionConfEditor.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 8px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderButton = styled.div`
+  margin-bottom: 5px;
+  padding: 5px 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  margin-right: 10px;
+`;
+
+const RepoHeader = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const ExpandedWrapper = styled.div`
   margin-top: 10px;
   width: 100%;
@@ -121,10 +155,13 @@ const BackButton = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-top: 10px;
+  margin-top: 22px;
   cursor: pointer;
   font-size: 13px;
+  height: 35px;
   padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
   border: 1px solid #ffffff55;
   border-radius: 3px;
   width: ${(props: { width: string }) => props.width};

+ 298 - 42
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -2,94 +2,350 @@ import ImageSelector from "components/image-selector/ImageSelector";
 import React, { Component } from "react";
 import styled from "styled-components";
 
+import { integrationList } from "shared/common";
 import { Context } from "../../shared/Context";
+import api from "../../shared/api";
+import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../values-form/InputRow";
+import InfoTooltip from "components/InfoTooltip";
 
 type PropsType = {
   actionConfig: ActionConfigType | null;
   setActionConfig: (x: ActionConfigType) => void;
+  branch: string;
+  dockerfilePath: string;
+  folderPath: string;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
 };
 
 type StateType = {
   dockerRepo: string;
   error: boolean;
+  registries: any[] | null;
+  loading: boolean;
 };
 
+const dummyRegistries = [
+  { id: 1, service: "ecr", url: "https://idfkasdfasdf" },
+  { id: 12, service: "ecr", url: "https://dfasdfidfkasdfasdf" },
+  { id: 11, service: "gcr", url: "https://idfkasdfasdf" },
+] as any[];
+
 export default class ActionDetails extends Component<PropsType, StateType> {
   state = {
     dockerRepo: "",
     error: false,
+    registries: null as any[] | null,
+    loading: true,
   };
 
   componentDidMount() {
-    if (this.props.actionConfig.dockerfile_path) {
-      this.setPath("/Dockerfile");
-    } else {
-      this.setPath("Dockerfile");
-    }
+    // TODO: Handle custom registry case (unroll repos?)
+    api
+      .getProjectRegistries(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      )
+      .then((res: any) => {
+        this.setState({ registries: res.data, loading: false });
+        if (res.data.length === 1) {
+          this.props.setSelectedRegistry(res.data[0]);
+        }
+      })
+      .catch((err: any) => console.log(err));
   }
 
-  setPath = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.concat(x);
-    setActionConfig(updatedConfig);
+  renderIntegrationList = () => {
+    let { loading, registries } = this.state;
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    }
+
+    return registries.map((registry: any, i: number) => {
+      let icon =
+        integrationList[registry.service] &&
+        integrationList[registry.service].icon;
+      if (!icon) {
+        icon = integrationList["docker"].icon;
+      }
+      return (
+        <RegistryItem
+          key={i}
+          isSelected={
+            this.props.selectedRegistry &&
+            registry.id === this.props.selectedRegistry.id
+          }
+          lastItem={i === registries.length - 1}
+          onClick={() => this.props.setSelectedRegistry(registry)}
+        >
+          <img src={icon && icon} />
+          {registry.url}
+        </RegistryItem>
+      );
+    });
   };
 
-  setURL = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.image_repo_uri = x;
-    setActionConfig(updatedConfig);
+  renderRegistrySection = () => {
+    let { registries } = this.state;
+    if (!registries || registries.length === 0 || registries.length === 1) {
+      return;
+    } else {
+      return (
+        <>
+          <Subtitle>
+            Select an Image Destination
+            <Required>*</Required>
+          </Subtitle>
+          <ExpandedWrapper>{this.renderIntegrationList()}</ExpandedWrapper>
+        </>
+      );
+    }
   };
 
-  renderConfirmation = () => {
+  render() {
     return (
-      <Holder>
+      <>
+        <DarkMatter />
         <InputRow
           disabled={true}
           label="Git Repository"
           type="text"
           width="100%"
           value={this.props.actionConfig.git_repo}
-          setValue={(x: string) => console.log(x)}
-        />
-        <InputRow
-          disabled={true}
-          label="Dockerfile Path"
-          type="text"
-          width="100%"
-          value={this.props.actionConfig.dockerfile_path}
-          setValue={(x: string) => console.log(x)}
         />
-        <Label>Target Image URL</Label>
-        <ImageSelector
-          selectedTag="latest"
-          selectedImageUrl={this.props.actionConfig.image_repo_uri}
-          setSelectedImageUrl={this.setURL}
-          setSelectedTag={() => null}
-          forceExpanded={true}
-          noTagSelection={true}
-        />
-      </Holder>
-    );
-  };
+        {this.props.dockerfilePath ? (
+          <InputRow
+            disabled={true}
+            label="Dockerfile Path"
+            type="text"
+            width="100%"
+            value={this.props.dockerfilePath}
+          />
+        ) : (
+          <InputRow
+            disabled={true}
+            label="Folder Path"
+            type="text"
+            width="100%"
+            value={this.props.folderPath}
+          />
+        )}
+        {this.renderRegistrySection()}
+        <SubtitleAlt>
+          <Bold>Note:</Bold> To auto-deploy each time you push changes, Porter
+          will write Github Secrets and a GitHub Actions file to your repo.
+          <Highlight
+            href="https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions"
+            target="_blank"
+          >
+            Learn More
+          </Highlight>
+        </SubtitleAlt>
+        <Br />
 
-  render() {
-    return <div>{this.renderConfirmation()}</div>;
+        <Flex>
+          <BackButton
+            width="140px"
+            onClick={() => {
+              this.props.setDockerfilePath(null);
+              this.props.setFolderPath(null);
+            }}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Folder
+          </BackButton>
+          {this.props.selectedRegistry ? (
+            <StatusWrapper successful={true}>
+              <i className="material-icons">done</i> Source selected
+            </StatusWrapper>
+          ) : (
+            <StatusWrapper>
+              <i className="material-icons">error_outline</i>A connected
+              container registry is required
+            </StatusWrapper>
+          )}
+        </Flex>
+      </>
+    );
   }
 }
 
-const Label = styled.div`
+ActionDetails.contextType = Context;
+
+const Highlight = styled.a`
+  color: #949eff;
+  text-decoration: none;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const Bold = styled.div`
+  font-weight: 800;
   color: #ffffff;
+  margin-right: 5px;
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 21px;
+`;
+
+const SubtitleAlt = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+  margin-top: -3px;
+  margin-bottom: -7px;
+  font-weight: 400;
+`;
+
+const RegistryItem = 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;
+  margin-bottom: 15px;
+`;
+
+const StatusWrapper = styled.div<{ successful?: boolean }>`
+  display: flex;
+  align-items: center;
   font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+  margin-left: 20px;
+  margin-top: 26px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
+  }
+
+  animation: statusFloatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes statusFloatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;
 
-ActionDetails.contextType = Context;
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  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 AdvancedHeader = styled.div`
+  margin-top: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+  margin-bottom: -8px;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-bottom: -18px;
+`;
 
 const Holder = styled.div`
   padding: 0px 12px 24px 12px;

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

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import branch_icon from "assets/branch.png";
+import info from "assets/info.svg";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
@@ -79,7 +80,15 @@ export default class BranchList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return <div>{this.renderBranchList()}</div>;
+    return (
+      <>
+        <InfoRow lastItem={false}>
+          <img src={info} />
+          Select Branch
+        </InfoRow>
+        {this.renderBranchList()}
+      </>
+    );
   }
 }
 
@@ -114,6 +123,14 @@ const BranchName = styled.div`
   }
 `;
 
+const InfoRow = styled(BranchName)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;

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

@@ -1,158 +0,0 @@
-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,
-        }
-      )
-      .then((res) => console.log(res.data))
-      .catch(console.log);
-  };
-
-  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;
-`;

+ 260 - 26
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import file from "assets/file.svg";
 import folder from "assets/folder.svg";
 import info from "assets/info.svg";
+import close from "assets/close.png";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
@@ -14,39 +15,46 @@ type PropsType = {
   actionConfig: ActionConfigType | null;
   branch: string;
   setActionConfig: (x: ActionConfigType) => void;
-  setPath: () => void;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
 };
 
 type StateType = {
   loading: boolean;
   error: boolean;
   contents: FileType[];
+  currentDir: string;
+  dockerfiles: string[];
 };
 
+const dummyDockerfiles = ["dev.Dockerfile", "prod.Dockerfile", "Dockerfile"];
+
 export default class ContentsList extends Component<PropsType, StateType> {
   state = {
     loading: true,
     error: false,
     contents: [] as FileType[],
+    currentDir: "",
+    dockerfiles: [] as string[],
   };
 
-  setSubdirectory = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.dockerfile_path = x;
-    setActionConfig(updatedConfig);
+  componentDidMount() {
     this.updateContents();
+  }
+
+  setSubdirectory = (x: string) => {
+    this.setState({ currentDir: x }, () => this.updateContents());
   };
 
   updateContents = () => {
-    let { actionConfig, branch } = this.props;
     let { currentProject } = this.context;
-
+    let { actionConfig, branch } = this.props;
+    console.log(this.state.currentDir);
     // Get branch contents
     api
       .getBranchContents(
         "<token>",
-        { dir: actionConfig.dockerfile_path },
+        { dir: this.state.currentDir },
         {
           project_id: currentProject.id,
           git_repo_id: actionConfig.git_repo_id,
@@ -80,10 +88,6 @@ export default class ContentsList extends Component<PropsType, StateType> {
       });
   };
 
-  componentDidMount() {
-    this.updateContents();
-  }
-
   renderContentList = () => {
     let { contents, loading, error } = this.state;
     if (loading) {
@@ -103,7 +107,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
         return (
           <Item
             key={i}
-            isSelected={item.Path === this.props.actionConfig.dockerfile_path}
+            isSelected={item.Path === this.state.currentDir}
             lastItem={i === contents.length - 1}
             onClick={() => this.setSubdirectory(item.Path)}
           >
@@ -113,13 +117,13 @@ export default class ContentsList extends Component<PropsType, StateType> {
         );
       }
 
-      if (fileName === "Dockerfile") {
+      if (fileName.includes("Dockerfile")) {
         return (
           <FileItem
             key={i}
             lastItem={i === contents.length - 1}
             isADocker
-            onClick={() => this.props.setPath()}
+            onClick={() => this.props.setDockerfilePath(item.Path)}
           >
             <img src={file} />
             {fileName}
@@ -136,15 +140,11 @@ export default class ContentsList extends Component<PropsType, StateType> {
   };
 
   renderJumpToParent = () => {
-    let { actionConfig } = this.props;
-    if (actionConfig.dockerfile_path !== "") {
-      let splits = actionConfig.dockerfile_path.split("/");
+    if (this.state.currentDir !== "") {
+      let splits = this.state.currentDir.split("/");
       let subdir = "";
       if (splits.length !== 1) {
-        subdir = actionConfig.dockerfile_path.replace(
-          splits[splits.length - 1],
-          ""
-        );
+        subdir = this.state.currentDir.replace(splits[splits.length - 1], "");
         if (subdir.charAt(subdir.length - 1) === "/") {
           subdir = subdir.slice(0, subdir.length - 1);
         }
@@ -160,23 +160,257 @@ export default class ContentsList extends Component<PropsType, StateType> {
     return (
       <FileItem lastItem={false}>
         <img src={info} />
-        Select path to Dockerfile
+        Select Application Folder
       </FileItem>
     );
   };
 
+  handleContinue = () => {
+    let dockerfiles = [] as string[];
+    this.state.contents.forEach((item: FileType, i: number) => {
+      let splits = item.Path.split("/");
+      let fileName = splits[splits.length - 1];
+      if (fileName.includes("Dockerfile")) {
+        dockerfiles.push(fileName);
+      }
+    });
+    if (dockerfiles.length > 0) {
+      this.setState({ dockerfiles });
+    } else {
+      if (this.state.currentDir !== "") {
+        this.props.setFolderPath(this.state.currentDir);
+      } else {
+        this.props.setFolderPath("./");
+      }
+    }
+  };
+
+  renderOverlay = () => {
+    if (this.state.dockerfiles.length > 0) {
+      return (
+        <Overlay>
+          <BgOverlay onClick={() => this.setState({ dockerfiles: [] })} />
+          <CloseButton onClick={() => this.setState({ dockerfiles: [] })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Label>
+            Porter has detected at least one Dockerfile in this folder. Would
+            you like to use an existing Dockerfile?
+          </Label>
+          <DockerfileList>
+            {this.state.dockerfiles.map((dockerfile: string, i: number) => {
+              return (
+                <Row
+                  key={i}
+                  onClick={() =>
+                    this.props.setDockerfilePath(
+                      `${this.state.currentDir || "."}/${dockerfile}`
+                    )
+                  }
+                  isLast={this.state.dockerfiles.length - 1 === i}
+                >
+                  <Indicator selected={false}></Indicator>
+                  {dockerfile}
+                </Row>
+              );
+            })}
+          </DockerfileList>
+          <ConfirmButton
+            onClick={() =>
+              this.props.setFolderPath(this.state.currentDir || "./")
+            }
+          >
+            No, I don't want to use a Dockerfile
+          </ConfirmButton>
+        </Overlay>
+      );
+    }
+  };
+
   render() {
     return (
-      <div>
+      <>
         {this.renderJumpToParent()}
         {this.renderContentList()}
-      </div>
+        <FlexWrapper>
+          <UseButton onClick={this.handleContinue}>Continue</UseButton>
+          <StatusWrapper
+            href="https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+            target="_blank"
+          >
+            <i className="material-icons">help_outline</i>
+            <div>Auto build requirements</div>
+          </StatusWrapper>
+        </FlexWrapper>
+        {this.renderOverlay()}
+      </>
     );
   }
 }
 
 ContentsList.contextType = Context;
 
+const FlexWrapper = styled.div`
+  position: absolute;
+  bottom: 28px;
+  left: 185px;
+  display: flex;
+  align-items: center;
+`;
+
+const StatusWrapper = styled.a<{ successful?: boolean }>`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #949eff;
+  margin-right: 25px;
+  margin-left: 20px;
+  cursor: pointer;
+  text-decoration: none;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const BgOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  background-color: rgba(0, 0, 0, 0.8);
+  z-index: -1;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  margin-right: 13px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Label = styled.div`
+  max-width: 420px;
+  line-height: 1.5em;
+  text-align: center;
+  font-size: 14px;
+`;
+
+const DockerfileList = styled.div`
+  border-radius: 3px;
+  margin-top: 20px;
+  border: 1px solid #aaaabb;
+  background: #ffffff22;
+  width: 100%;
+  max-width: 500px;
+  max-height: 140px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLast: boolean }>`
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  align-items: center;
+  border-bottom: ${(props) => !props.isLast && "1px solid #aaaabb"};
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const ConfirmButton = styled.div`
+  font-size: 18px;
+  padding: 7px 12px;
+  outline: none;
+  border: 1px solid white;
+  margin-top: 25px;
+  border-radius: 10px;
+  text-align: center;
+  cursor: pointer;
+  opacity: 0;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  font-weight: 500;
+  animation: linEnter 0.3s 0.1s;
+  animation-fill-mode: forwards;
+  @keyframes linEnter {
+    from {
+      transform: translateY(20px);
+      opacity: 0;
+    }
+    to {
+      transform: translateY(0px);
+      opacity: 1;
+    }
+  }
+  :hover {
+    background: white;
+    color: #232323;
+  }
+`;
+
+const Overlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding: 0 90px;
+`;
+
+const UseButton = styled.div`
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #616feecc;
+  font-weight: 500;
+  padding: 10px 15px;
+  border-radius: 3px;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: pointer;
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
 const BackLabel = styled.div`
   font-size: 16px;
   padding-left: 16px;

+ 33 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import github from "assets/github.png";
+import info from "assets/info.svg";
 
 import api from "shared/api";
 import { RepoType, ActionConfigType } from "shared/types";
@@ -28,6 +29,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
     error: false,
   };
 
+  // TODO: Try to unhook before unmount
   componentDidMount() {
     let { currentProject } = this.context;
 
@@ -51,6 +53,15 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
                   repo.GHRepoID = grid;
                 });
                 allRepos = allRepos.concat(res.data);
+                allRepos.sort((a: any, b: any) => {
+                  if (a.FullName < b.FullName) {
+                    return -1;
+                  } else if (a.FullName > b.FullName) {
+                    return 1;
+                  } else {
+                    return 0;
+                  }
+                });
                 this.setState({
                   repos: allRepos,
                   loading: false,
@@ -79,6 +90,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           res.data.forEach((repo: any, id: number) => {
             repo.GHRepoID = grid;
           });
+          // TODO: sort repos alphabetically
           this.setState({ repos: res.data, loading: false, error: false });
         })
         .catch((err) => {
@@ -141,7 +153,19 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
     if (this.props.readOnly) {
       return <ExpandedWrapperAlt>{this.renderRepoList()}</ExpandedWrapperAlt>;
     } else {
-      return <ExpandedWrapper>{this.renderRepoList()}</ExpandedWrapper>;
+      return (
+        <ExpandedWrapper>
+          <InfoRow
+            isSelected={false}
+            lastItem={false}
+            readOnly={this.props.readOnly}
+          >
+            <img src={info} />
+            Select Repo
+          </InfoRow>
+          {this.renderRepoList()}
+        </ExpandedWrapper>
+      );
     }
   };
 
@@ -194,6 +218,14 @@ const RepoName = styled.div`
   }
 `;
 
+const InfoRow = styled(RepoName)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;

+ 0 - 177
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -1,177 +0,0 @@
-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 ButtonTray from "./ButtonTray";
-import ActionConfEditor from "./ActionConfEditor";
-
-type PropsType = {
-  chart: ChartType | null;
-  forceExpanded?: boolean;
-  actionConfig: ActionConfigType | null;
-  setActionConfig: (x: ActionConfigType) => void;
-  resetActionConfig: () => void;
-};
-
-type StateType = {
-  isExpanded: boolean;
-  repos: RepoType[];
-  branch: string;
-  pathIsSet: boolean;
-  dockerfileSelected: boolean;
-};
-
-export default class RepoSelector extends Component<PropsType, StateType> {
-  state = {
-    isExpanded: this.props.forceExpanded,
-    repos: [] as RepoType[],
-    branch: "",
-    pathIsSet: false,
-    dockerfileSelected: false,
-  };
-
-  renderExpanded = () => {
-    let { actionConfig, setActionConfig, chart } = this.props;
-
-    return (
-      <div>
-        <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 })}
-          reset={() => {
-            this.setState({
-              branch: "",
-              pathIsSet: false,
-              dockerfileSelected: false,
-            });
-            this.props.resetActionConfig();
-          }}
-        />
-        <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 { actionConfig } = this.props;
-    if (actionConfig.git_repo) {
-      let subdir =
-        actionConfig.dockerfile_path === ""
-          ? ""
-          : "/" + actionConfig.dockerfile_path;
-      return (
-        <RepoLabel>
-          <img src={github} />
-          {actionConfig.git_repo + subdir}
-          <SelectedBranch>
-            {!this.state.branch ? "(Select Branch)" : this.state.branch}
-          </SelectedBranch>
-        </RepoLabel>
-      );
-    }
-    return (
-      <RepoLabel>
-        <img src={info} />
-        No source selected
-      </RepoLabel>
-    );
-  };
-
-  handleClick = () => {
-    if (!this.props.forceExpanded) {
-      this.setState({ isExpanded: !this.state.isExpanded });
-    }
-  };
-
-  render() {
-    return (
-      <>
-        <StyledRepoSelector
-          onClick={this.handleClick}
-          isExpanded={this.state.isExpanded}
-          forceExpanded={this.props.forceExpanded}
-        >
-          {this.renderSelected()}
-          {this.props.forceExpanded ? null : (
-            <i className="material-icons">
-              {this.state.isExpanded ? "close" : "build"}
-            </i>
-          )}
-        </StyledRepoSelector>
-
-        {this.state.isExpanded ? this.renderExpanded() : null}
-      </>
-    );
-  }
-}
-
-RepoSelector.contextType = Context;
-
-const SelectedBranch = styled.div`
-  color: #ffffff55;
-  margin-left: 10px;
-`;
-
-const RepoLabel = styled.div`
-  display: flex;
-  align-items: center;
-
-  > img {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-  }
-`;
-
-const StyledRepoSelector = styled.div`
-  width: 100%;
-  margin-top: 22px;
-  border: 1px solid #ffffff55;
-  background: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
-    props.isExpanded ? "#ffffff11" : ""};
-  border-radius: 3px;
-  user-select: none;
-  height: 40px;
-  font-size: 13px;
-  color: #ffffff;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  cursor: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
-    props.forceExpanded ? "" : "pointer"};
-  :hover {
-    background: #ffffff11;
-
-    > i {
-      background: #ffffff22;
-    }
-  }
-
-  > i {
-    font-size: 16px;
-    color: #ffffff66;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    border-radius: 20px;
-    padding: 4px;
-  }
-`;

+ 7 - 0
dashboard/src/components/values-form/CheckboxRow.tsx

@@ -5,6 +5,7 @@ type PropsType = {
   label: string;
   checked: boolean;
   toggle: () => void;
+  required?: boolean;
 };
 
 type StateType = {};
@@ -18,12 +19,18 @@ export default class CheckboxRow extends Component<PropsType, StateType> {
             <i className="material-icons">done</i>
           </Checkbox>
           {this.props.label}
+          {this.props.required && <Required>*</Required>}
         </CheckboxWrapper>
       </StyledCheckboxRow>
     );
   }
 }
 
+const Required = styled.section`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
 const CheckboxWrapper = styled.div`
   display: flex;
   align-items: center;

+ 0 - 2
dashboard/src/components/values-form/Helper.tsx

@@ -11,6 +11,4 @@ const StyledHelper = styled.div`
   font-size: 13px;
   margin-bottom: 15px;
   margin-top: 20px;
-  display: flex;
-  align-items: center;
 `;

+ 1 - 1
dashboard/src/components/values-form/InputRow.tsx

@@ -5,7 +5,7 @@ type PropsType = {
   label?: string;
   type: string;
   value: string | number;
-  setValue: (x: string | number) => void;
+  setValue?: (x: string | number) => void;
   unit?: string;
   placeholder?: string;
   width?: string;

+ 11 - 1
dashboard/src/components/values-form/ValuesForm.tsx

@@ -19,6 +19,7 @@ type PropsType = {
   sections?: Section[];
   metaState?: any;
   setMetaState?: any;
+  handleEnvChange?: (x: any) => void;
 };
 
 type StateType = any;
@@ -47,7 +48,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "resource-list":
           if (Array.isArray(item.value)) {
             return (
-              <ResourceList>
+              <ResourceList key={i}>
                 {item.value.map((resource: any, i: number) => {
                   return (
                     <ExpandableResource
@@ -75,9 +76,18 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "key-value-array":
           return (
             <KeyValueArray
+              key={i}
               values={this.props.metaState[key]}
               setValues={(x: any) => {
                 this.props.setMetaState({ [key]: x });
+
+                // Need to pull env vars out of form.yaml for createGHA build env vars
+                if (
+                  this.props.handleEnvChange &&
+                  key === "container.env.normal"
+                ) {
+                  this.props.handleEnvChange(x);
+                }
               }}
               label={item.label}
             />

+ 65 - 1
dashboard/src/index.html

@@ -3,10 +3,74 @@
   <head>
     <title>Porter | Dashboard</title>
 
+    <script>
+      !(function () {
+        var analytics = (window.analytics = window.analytics || []);
+        if (!analytics.initialize)
+          if (analytics.invoked)
+            window.console &&
+              console.error &&
+              console.error("Segment snippet included twice.");
+          else {
+            analytics.invoked = !0;
+            analytics.methods = [
+              "trackSubmit",
+              "trackClick",
+              "trackLink",
+              "trackForm",
+              "pageview",
+              "identify",
+              "reset",
+              "group",
+              "track",
+              "ready",
+              "alias",
+              "debug",
+              "page",
+              "once",
+              "off",
+              "on",
+              "addSourceMiddleware",
+              "addIntegrationMiddleware",
+              "setAnonymousId",
+              "addDestinationMiddleware",
+            ];
+            analytics.factory = function (e) {
+              return function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(e);
+                analytics.push(t);
+                return analytics;
+              };
+            };
+            for (var e = 0; e < analytics.methods.length; e++) {
+              var key = analytics.methods[e];
+              analytics[key] = analytics.factory(key);
+            }
+            analytics.load = function (key, e) {
+              var t = document.createElement("script");
+              t.type = "text/javascript";
+              t.async = !0;
+              t.src =
+                "https://cdn.segment.com/analytics.js/v1/" +
+                key +
+                "/analytics.min.js";
+              var n = document.getElementsByTagName("script")[0];
+              n.parentNode.insertBefore(t, n);
+              analytics._loadOptions = e;
+            };
+            analytics._writeKey = "ZKKaKBrAw9BGE8aF8XDoupd7Fi6ZyN5b";
+            analytics.SNIPPET_VERSION = "4.13.2";
+            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
+            analytics.page();
+          }
+      })();
+    </script>
+
     <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png" />
     <meta
       name="description"
-      content="Fully-managed remote dev environments for any team."
+      content="Kubernetes powered PaaS that runs in your own cloud."
     />
     <meta property="og:title" content="Porter" />
     <meta

+ 5 - 5
dashboard/src/index.tsx

@@ -1,11 +1,11 @@
 import * as React from "react";
 import * as ReactDOM from "react-dom";
 import App from "./App";
-import * as FullStory from "@fullstory/browser";
 
-FullStory.init({
-  orgId: process.env.FULLSTORY_ORG_ID,
-  devMode: process.env.NODE_ENV == "development",
-});
+declare global {
+  interface Window {
+    analytics: any;
+  }
+}
 
 ReactDOM.render(<App />, document.getElementById("output"));

+ 1 - 1
dashboard/src/main/CurrentError.tsx

@@ -94,7 +94,7 @@ const StyledCurrentError = styled.div`
   position: fixed;
   bottom: 22px;
   width: 300px;
-  left: 100px;
+  left: 20px;
   padding: 15px;
   padding-right: 0px;
   font-family: "Work Sans", sans-serif;

+ 23 - 0
dashboard/src/main/Login.tsx

@@ -167,6 +167,16 @@ export default class Login extends Component<PropsType, StateType> {
             </Helper>
           </FormWrapper>
         </LoginPanel>
+
+        <Footer>
+          © 2021 Porter Technologies Inc. •
+          <Link
+            href="https://docs.getporter.dev/docs/terms-of-service"
+            target="_blank"
+          >
+            Terms & Privacy
+          </Link>
+        </Footer>
       </StyledLogin>
     );
   }
@@ -174,6 +184,19 @@ export default class Login extends Component<PropsType, StateType> {
 
 Login.contextType = Context;
 
+const Footer = styled.div`
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  margin-bottom: 30px;
+  width: 100vw;
+  text-align: center;
+  color: #aaaabb;
+  font-size: 13px;
+  padding-right: 8px;
+  font: Work Sans, sans-serif;
+`;
+
 const DarkMatter = styled.div`
   margin-top: -10px;
 `;

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

@@ -94,20 +94,31 @@ export default class Main extends Component<PropsType, StateType> {
           }}
         />
         <Route
-          path={`/:subroute`}
+          exact
+          path="/"
+          render={() => {
+            if (this.state.isLoggedIn) {
+              return <Redirect to="/dashboard" />;
+            } else {
+              return <Redirect to="/login" />;
+            }
+          }}
+        />
+        <Route
+          path={`/:baseRoute`}
           render={(routeProps) => {
-            const urlRoute = routeProps.location.pathname.slice(1);
+            const baseRoute = routeProps.match.params.baseRoute;
             if (
               this.state.isLoggedIn &&
               this.state.initialized &&
-              PorterUrls.includes(urlRoute)
+              PorterUrls.includes(baseRoute)
             ) {
               return (
                 <Home
                   key="home"
                   currentProject={this.context.currentProject}
                   currentCluster={this.context.currentCluster}
-                  currentRoute={urlRoute as PorterUrl}
+                  currentRoute={baseRoute as PorterUrl}
                   logOut={this.handleLogOut}
                 />
               );
@@ -116,16 +127,6 @@ export default class Main extends Component<PropsType, StateType> {
             }
           }}
         />
-        <Route
-          path="/"
-          render={() => {
-            if (this.state.isLoggedIn) {
-              return <Redirect to="/dashboard" />;
-            } else {
-              return <Redirect to="/login" />;
-            }
-          }}
-        />
       </Switch>
     );
   };

+ 22 - 0
dashboard/src/main/Register.tsx

@@ -182,6 +182,15 @@ export default class Register extends Component<PropsType, StateType> {
             </Helper>
           </FormWrapper>
         </LoginPanel>
+        <Footer>
+          © 2021 Porter Technologies Inc. •
+          <Link
+            href="https://docs.getporter.dev/docs/terms-of-service"
+            target="_blank"
+          >
+            Terms & Privacy
+          </Link>
+        </Footer>
       </StyledRegister>
     );
   }
@@ -189,6 +198,19 @@ export default class Register extends Component<PropsType, StateType> {
 
 Register.contextType = Context;
 
+const Footer = styled.div`
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  margin-bottom: 30px;
+  width: 100vw;
+  text-align: center;
+  color: #aaaabb;
+  font-size: 13px;
+  padding-right: 8px;
+  font: Work Sans, sans-serif;
+`;
+
 const DarkMatter = styled.div`
   margin-top: -10px;
 `;

+ 7 - 15
dashboard/src/main/home/Home.tsx

@@ -1,8 +1,6 @@
 import React, { Component } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
-import posthog from "posthog-js";
 import styled from "styled-components";
-import * as FullStory from "@fullstory/browser";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -199,13 +197,18 @@ class Home extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    let { user } = this.context;
-    FullStory.identify(user.email);
+    let { user, projects } = this.context;
 
     // Handle redirect from DO
     let queryString = window.location.search;
     let urlParams = new URLSearchParams(queryString);
 
+    window.analytics.identify(user.userId, {
+      email: user.email,
+      createdAt: Date.now(),
+      projects,
+    });
+
     let err = urlParams.get("error");
     if (err) {
       this.context.setCurrentError(err);
@@ -219,19 +222,8 @@ class Home extends Component<PropsType, StateType> {
       this.checkDO();
     }
 
-    // initialize posthog on non-localhosts. Gracefully fail when env vars are not set.
     this.setState({ ghRedirect: urlParams.get("gh_oauth") !== null });
     urlParams.delete("gh_oauth");
-
-    window.location.href.indexOf("localhost") === -1 &&
-      posthog.init(process.env.POSTHOG_API_KEY || "placeholder", {
-        api_host: process.env.POSTHOG_HOST || "placeholder",
-        loaded: function (posthog: any) {
-          posthog.identify(user.userId);
-          posthog.people.set({ email: user.email });
-        },
-      });
-
     this.getProjects(defaultProjectId);
   }
 

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

@@ -11,6 +11,8 @@ import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
 import { RouteComponentProps, withRouter } from "react-router";
 
+import api from "shared/api";
+
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   setSidebar: (x: boolean) => void;
@@ -20,6 +22,7 @@ type StateType = {
   namespace: string;
   sortType: string;
   currentChart: ChartType | null;
+  isMetricsInstalled: boolean;
 };
 
 class ClusterDashboard extends Component<PropsType, StateType> {
@@ -29,8 +32,28 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       ? localStorage.getItem("SortType")
       : "Newest",
     currentChart: null as ChartType | null,
+    isMetricsInstalled: false,
   };
 
+  componentDidMount() {
+    api
+      .getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: this.context.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ isMetricsInstalled: true });
+      })
+      .catch(() => {
+        this.setState({ isMetricsInstalled: false });
+      });
+  }
+
   componentDidUpdate(prevProps: PropsType) {
     localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
@@ -77,6 +100,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           setCurrentChart={(x: ChartType | null) =>
             this.setState({ currentChart: x })
           }
+          isMetricsInstalled={this.state.isMetricsInstalled}
           setSidebar={setSidebar}
         />
       );

+ 87 - 37
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -33,6 +33,7 @@ type PropsType = {
   currentCluster: ClusterType;
   setCurrentChart: (x: ChartType | null) => void;
   setSidebar: (x: boolean) => void;
+  isMetricsInstalled: boolean;
 };
 
 type StateType = {
@@ -255,10 +256,19 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           saveValuesStatus: "successful",
           forceRefreshRevisions: true,
         });
+
+        window.analytics.track("Chart Upgraded", {
+          chart: this.props.currentChart.name,
+          values: valuesYaml,
+        });
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
-        console.log(err);
+        window.analytics.track("Failed to Upgrade Chart", {
+          chart: this.props.currentChart.name,
+          values: valuesYaml,
+          error: err,
+        });
       });
   };
 
@@ -278,7 +288,18 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "status":
-        return <StatusSection currentChart={chart} selectors={podSelectors} />;
+        let controller_uid = Object.keys(this.state.controllers)[0];
+        if (chart.chart.metadata.name == "job") {
+          return (
+            <StatusSection
+              currentChart={chart}
+              selectors={[
+                `job-name=${chart.name}-job,controller-uid=${controller_uid}`,
+              ]}
+            />
+          );
+        }
+        return <StatusSection currentChart={chart} />;
       case "settings":
         return (
           <SettingsSection
@@ -360,11 +381,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     }
 
     // Append universal tabs
-    tabOptions.push(
-      { label: "Status", value: "status" },
-      //{ label: "Metrics", value: "metrics" },
-      { label: "Chart Overview", value: "graph" }
-    );
+    tabOptions.push({ label: "Status", value: "status" });
+
+    if (this.props.isMetricsInstalled) {
+      tabOptions.push({ label: "Metrics", value: "metrics" });
+    }
+
+    tabOptions.push({ label: "Chart Overview", value: "graph" });
 
     if (this.state.devOpsMode) {
       tabOptions.push(
@@ -435,6 +458,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       for (var uid in this.state.controllers) {
         let value = this.state.controllers[uid];
         let available = this.getAvailability(value.metadata.kind, value);
+
+        if (
+          value.metadata.kind?.toLowerCase() == "job" &&
+          !value.status?.active
+        ) {
+          return "completed";
+        }
+
         let progressing = true;
 
         this.state.controllers[uid]?.status?.conditions?.forEach(
@@ -469,6 +500,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         return c.status.readyReplicas == c.status.replicas;
       case "daemonset":
         return c.status.numberAvailable == c.status.desiredNumberScheduled;
+      case "job":
+        return c.status.active;
     }
   };
 
@@ -476,11 +509,15 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
     let { currentChart } = this.props;
 
-    this.getChartData(this.props.currentChart);
-    this.getControllers(this.props.currentChart);
+    window.analytics.track("Opened Chart", {
+      chart: currentChart.name,
+    });
+
+    this.getChartData(currentChart);
+    this.getControllers(currentChart);
     this.setControllerWebsockets(
       ["deployment", "statefulset", "daemonset", "replicaset"],
-      this.props.currentChart
+      currentChart
     );
 
     api
@@ -497,34 +534,45 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           revision: currentChart.version,
         }
       )
-      .then((res) => this.setState({ components: res.data.Objects }))
-      .catch(console.log);
+      .then((res) =>
+        this.setState({ components: res.data.Objects }, () => {
+          let ingressName = null;
+          for (var i = 0; i < this.state.components.length; i++) {
+            if (this.state.components[i].Kind === "Ingress") {
+              ingressName = this.state.components[i].Name;
+            }
+          }
 
-    api
-      .getIngress(
-        "<token>",
-        {
-          cluster_id: currentCluster.id,
-        },
-        {
-          id: currentProject.id,
-          name: `${this.props.currentChart.name}-docker`,
-          namespace: `${this.props.currentChart.namespace}`,
-        }
-      )
-      .then((res) => {
-        if (res.data?.spec?.rules && res.data?.spec?.rules[0]?.host) {
-          this.setState({ url: `https://${res.data?.spec?.rules[0]?.host}` });
-          return;
-        }
+          api
+            .getIngress(
+              "<token>",
+              {
+                cluster_id: currentCluster.id,
+              },
+              {
+                id: currentProject.id,
+                name: ingressName,
+                namespace: `${this.props.currentChart.namespace}`,
+              }
+            )
+            .then((res) => {
+              if (res.data?.spec?.rules && res.data?.spec?.rules[0]?.host) {
+                this.setState({
+                  url: `https://${res.data?.spec?.rules[0]?.host}`,
+                });
+                return;
+              }
 
-        if (res.data?.status?.loadBalancer?.ingress) {
-          this.setState({
-            url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}`,
-          });
-          return;
-        }
-      })
+              if (res.data?.status?.loadBalancer?.ingress) {
+                this.setState({
+                  url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}`,
+                });
+                return;
+              }
+            })
+            .catch(console.log);
+        })
+      )
       .catch(console.log);
 
     this.updateTabs();
@@ -629,7 +677,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
                 <IconWrapper>{this.renderIcon()}</IconWrapper>
                 {chart.name}
               </Title>
-              {this.renderUrl()}
+              {chart.chart.metadata.name != "worker" &&
+                chart.chart.metadata.name != "job" &&
+                this.renderUrl()}
               <InfoWrapper>
                 <StatusIndicator
                   controllers={this.state.controllers}

+ 16 - 115
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -12,11 +12,11 @@ import {
 import { Context } from "shared/Context";
 
 import ImageSelector from "components/image-selector/ImageSelector";
-import RepoSelector from "components/repo-selector/RepoSelector";
 import SaveButton from "components/SaveButton";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
 import InputRow from "components/values-form/InputRow";
+import _ from "lodash";
 
 type PropsType = {
   currentChart: ChartType;
@@ -25,7 +25,6 @@ type PropsType = {
 };
 
 type StateType = {
-  actionConfig: ActionConfigType;
   sourceType: string;
   selectedImageUrl: string | null;
   selectedTag: string | null;
@@ -36,17 +35,8 @@ type StateType = {
   action: ActionConfigType;
 };
 
-// TODO: put in shared, duped from LaunchTemplate.tsx
-const defaultActionConfig: ActionConfigType = {
-  git_repo: "",
-  image_repo_uri: "",
-  git_repo_id: 0,
-  dockerfile_path: "",
-};
-
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    actionConfig: defaultActionConfig,
     sourceType: "",
     selectedImageUrl: "",
     selectedTag: "",
@@ -58,7 +48,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       git_repo: "",
       image_repo_uri: "",
       git_repo_id: 0,
-      dockerfile_path: "",
     } as ActionConfigType,
   };
 
@@ -111,14 +100,25 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       },
     };
 
-    let values = yaml.dump(image);
+    let values = {};
+    let rawValues = this.props.currentChart.config;
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    // Weave in preexisting values and convert to yaml
+    let valuesYaml = yaml.dump({
+      ...values,
+      ...image,
+    });
+
     api
       .upgradeChartValues(
         "<token>",
         {
           namespace: this.props.currentChart.namespace,
           storage: StorageType.Secret,
-          values,
+          values: valuesYaml,
         },
         {
           id: currentProject.id,
@@ -136,111 +136,13 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       });
   };
 
-  /*
-    <Helper>
-      Specify a container image and tag or
-      <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
-        link a repo
-      </Highlight>.
-    </Helper>
-  */
-  renderSourceSection = () => {
-    if (!this.props.currentChart.form.hasSource) {
-      return;
-    }
-
-    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>
-          <Helper>Specify a container image and tag.</Helper>
-          <ImageSelector
-            selectedImageUrl={this.state.selectedImageUrl}
-            selectedTag={this.state.selectedTag}
-            setSelectedImageUrl={(x: string) =>
-              this.setState({ selectedImageUrl: x })
-            }
-            setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
-            forceExpanded={true}
-          />
-        </>
-      );
-    }
-
-    let { currentProject } = this.context;
-    return (
-      <>
-        <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 })
-          }
-          resetActionConfig={() =>
-            this.setState({ actionConfig: defaultActionConfig })
-          }
-        />
-      </>
-    );
-  };
-
   renderWebhookSection = () => {
-    if (!this.props.currentChart.form.hasSource) {
+    if (!this.props.currentChart?.form?.hasSource) {
       return;
     }
 
     if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=???&repository=???'`;
+      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH&repository=IMAGE_REPOSITORY_URL'`;
       return (
         <>
           <Heading>Redeploy Webhook</Heading>
@@ -269,7 +171,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     return (
       <Wrapper>
         <StyledSettingsSection>
-          {this.renderSourceSection()}
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
           <Button

+ 103 - 25
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,14 +1,18 @@
 import React, { useMemo, useCallback } from "react";
 import { AreaClosed, Line, Bar } from "@visx/shape";
-import appleStock, { AppleStock } from "@visx/mock-data/lib/mocks/appleStock";
 import { curveMonotoneX } from "@visx/curve";
 import { scaleTime, scaleLinear } from "@visx/scale";
+import { AxisLeft, AxisBottom } from "@visx/axis";
+
 import {
   withTooltip,
   Tooltip,
   TooltipWithBounds,
   defaultStyles,
 } from "@visx/tooltip";
+
+import { GridRows, GridColumns } from "@visx/grid";
+
 import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip";
 import { localPoint } from "@visx/event";
 import { LinearGradient } from "@visx/gradient";
@@ -20,9 +24,15 @@ export const accentColor = '#f5cb42';
 export const accentColorDark = '#949eff';
 */
 
-type TooltipData = AppleStock;
+export type MetricsData = {
+  date: number; // unix timestamp
+  value: number; // value
+};
+
+type TooltipData = MetricsData;
+
+var globalData: MetricsData[];
 
-const stock = appleStock.slice(800);
 export const background = "#3b697800";
 export const background2 = "#20405100";
 export const accentColor = "#949eff";
@@ -35,14 +45,29 @@ const tooltipStyles = {
 };
 
 // util
-const formatDate = timeFormat("%b %d, '%y");
+const formatDate = timeFormat("%H:%M:%S %b %d, '%y");
+
+const hourFormat = timeFormat("%H:%M");
+const dayFormat = timeFormat("%b %d");
+
+// map resolutions to formats
+const formats: { [range: string]: (date: Date) => string } = {
+  "1H": hourFormat,
+  "6H": hourFormat,
+  "1D": hourFormat,
+  "1M": dayFormat,
+};
 
 // accessors
-const getDate = (d: AppleStock) => new Date(d.date);
-const getStockValue = (d: AppleStock) => d.close;
-const bisectDate = bisector<AppleStock, Date>((d) => new Date(d.date)).left;
+const getDate = (d: MetricsData) => new Date(d.date * 1000);
+const getValue = (d: MetricsData) => d.value;
+
+const bisectDate = bisector<MetricsData, Date>((d) => new Date(d.date * 1000))
+  .left;
 
 export type AreaProps = {
+  data: MetricsData[];
+  resolution: string;
   width: number;
   height: number;
   margin?: { top: number; right: number; bottom: number; left: number };
@@ -50,6 +75,8 @@ export type AreaProps = {
 
 export default withTooltip<AreaProps, TooltipData>(
   ({
+    data,
+    resolution,
     width,
     height,
     margin = { top: 0, right: 0, bottom: 0, left: 0 },
@@ -59,10 +86,14 @@ export default withTooltip<AreaProps, TooltipData>(
     tooltipTop = 0,
     tooltipLeft = 0,
   }: AreaProps & WithTooltipProvidedProps<TooltipData>) => {
-    if (width < 10) return null;
+    globalData = data;
+
+    if (width == 0 || height == 0 || width < 10) {
+      return null;
+    }
 
     // bounds
-    const innerWidth = width - margin.left - margin.right;
+    const innerWidth = width - margin.left - margin.right - 40;
     const innerHeight = height - margin.top - margin.bottom - 20;
 
     // scales
@@ -70,18 +101,18 @@ export default withTooltip<AreaProps, TooltipData>(
       () =>
         scaleTime({
           range: [margin.left, innerWidth + margin.left],
-          domain: extent(stock, getDate) as [Date, Date],
+          domain: extent(globalData, getDate) as [Date, Date],
         }),
-      [innerWidth, margin.left]
+      [innerWidth, margin.left, width, height, data]
     );
-    const stockValueScale = useMemo(
+    const valueScale = useMemo(
       () =>
         scaleLinear({
           range: [innerHeight + margin.top, margin.top],
-          domain: [0, (max(stock, getStockValue) || 0) + innerHeight / 3],
+          domain: [0, 1.25 * max(globalData, getValue)],
           nice: true,
         }),
-      [margin.top, innerHeight]
+      [margin.top, innerHeight, width, height, data]
     );
 
     // tooltip handler
@@ -93,10 +124,11 @@ export default withTooltip<AreaProps, TooltipData>(
       ) => {
         const { x } = localPoint(event) || { x: 0 };
         const x0 = dateScale.invert(x);
-        const index = bisectDate(stock, x0, 1);
-        const d0 = stock[index - 1];
-        const d1 = stock[index];
+        const index = bisectDate(globalData, x0, 1);
+        const d0 = globalData[index - 1];
+        const d1 = globalData[index];
         let d = d0;
+
         if (d1 && getDate(d1)) {
           d =
             x0.valueOf() - getDate(d0).valueOf() >
@@ -104,13 +136,14 @@ export default withTooltip<AreaProps, TooltipData>(
               ? d1
               : d0;
         }
+
         showTooltip({
           tooltipData: d,
-          tooltipLeft: x,
-          tooltipTop: stockValueScale(getStockValue(d)),
+          tooltipLeft: x || 0,
+          tooltipTop: valueScale(getValue(d)) || 0,
         });
       },
-      [showTooltip, stockValueScale, dateScale]
+      [showTooltip, valueScale, dateScale, width, height, data]
     );
 
     return (
@@ -135,16 +168,61 @@ export default withTooltip<AreaProps, TooltipData>(
             to={accentColor}
             toOpacity={0}
           />
-          <AreaClosed<AppleStock>
-            data={stock}
+          <GridRows
+            left={margin.left}
+            scale={valueScale}
+            width={innerWidth}
+            strokeDasharray="1,3"
+            stroke="white"
+            strokeOpacity={0.2}
+            pointerEvents="none"
+          />
+          <GridColumns
+            top={margin.top}
+            scale={dateScale}
+            height={innerHeight}
+            strokeDasharray="1,3"
+            stroke="white"
+            strokeOpacity={0.2}
+            pointerEvents="none"
+          />
+          <AreaClosed<MetricsData>
+            data={data}
             x={(d) => dateScale(getDate(d)) ?? 0}
-            y={(d) => stockValueScale(getStockValue(d)) ?? 0}
-            yScale={stockValueScale}
+            y={(d) => valueScale(getValue(d)) ?? 0}
+            height={innerHeight}
+            yScale={valueScale}
             strokeWidth={1}
             stroke="url(#area-gradient)"
             fill="url(#area-gradient)"
             curve={curveMonotoneX}
           />
+          <AxisLeft
+            left={10}
+            scale={valueScale}
+            hideAxisLine={true}
+            hideTicks={true}
+            tickLabelProps={() => ({
+              fill: "white",
+              fontSize: 11,
+              textAnchor: "start",
+              fillOpacity: 0.4,
+              dy: 0,
+            })}
+          />
+          <AxisBottom
+            top={height - 20}
+            scale={dateScale}
+            tickFormat={formats[resolution]}
+            hideAxisLine={true}
+            hideTicks={true}
+            tickLabelProps={() => ({
+              fill: "white",
+              fontSize: 11,
+              textAnchor: "middle",
+              fillOpacity: 0.4,
+            })}
+          />
           <Bar
             x={margin.left}
             y={margin.top}
@@ -198,7 +276,7 @@ export default withTooltip<AreaProps, TooltipData>(
               left={tooltipLeft + 12}
               style={tooltipStyles}
             >
-              {`$${getStockValue(tooltipData)}`}
+              {getValue(tooltipData)}
             </TooltipWithBounds>
             <Tooltip
               top={-10}

+ 421 - 21
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -2,27 +2,292 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 
+import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
+import { ChartType, StorageType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
-import AreaChart from "./AreaChart";
+import AreaChart, { MetricsData } from "./AreaChart";
 
 type PropsType = {
   currentChart: ChartType;
 };
 
 type StateType = {
+  controllers: any[];
+  selectedController: any;
+  pods: string[];
+  selectedPod: string;
   selectedRange: string;
+  selectedMetric: string;
   selectedMetricLabel: string;
+  controllerDropdownExpanded: boolean;
+  podDropdownExpanded: boolean;
   dropdownExpanded: boolean;
+  data: MetricsData[];
 };
 
-export default class ListSection extends Component<PropsType, StateType> {
+type MetricsCPUDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    cpu: string;
+  }[];
+}[];
+
+type MetricsMemoryDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    memory: string;
+  }[];
+}[];
+
+type MetricsNetworkDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    bytes: string;
+  }[];
+}[];
+
+const resolutions: { [range: string]: string } = {
+  "1H": "15s",
+  "6H": "15s",
+  "1D": "15s",
+  "1M": "5h",
+};
+
+const secondsBeforeNow: { [range: string]: number } = {
+  "1H": 60 * 60,
+  "6H": 60 * 60 * 6,
+  "1D": 60 * 60 * 24,
+  "1M": 60 * 60 * 24 * 30,
+};
+
+export default class MetricsSection extends Component<PropsType, StateType> {
   state = {
+    pods: [] as string[],
+    selectedPod: "",
+    controllers: [] as any[],
+    selectedController: null as any,
     selectedRange: "1H",
-    selectedMetricLabel: "CPU Utilization",
+    selectedMetric: "cpu",
+    selectedMetricLabel: "CPU Utilization (vCPUs)",
     dropdownExpanded: false,
+    podDropdownExpanded: false,
+    controllerDropdownExpanded: false,
+    data: [] as MetricsData[],
+  };
+
+  componentDidMount() {
+    // get all controllers and read in a list of pods
+    let { currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api
+      .getChartControllers(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        // TODO -- check at least one controller returned
+
+        // iterate through the controllers to get the list of pods
+        this.setState({
+          controllers: res.data,
+          selectedController: res.data[0],
+        });
+
+        this.getPods();
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        this.setState({ controllers: [] });
+      });
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    // if resolution, data kind, controllers, or pods have changed, update data
+    if (this.state.selectedMetric != prevState.selectedMetric) {
+      this.getMetrics();
+    }
+
+    if (this.state.selectedRange != prevState.selectedRange) {
+      this.getMetrics();
+    }
+
+    if (this.state.selectedPod != prevState.selectedPod) {
+      this.getMetrics();
+    }
+
+    if (
+      this.state.selectedController?.metadata?.name !=
+      prevState.selectedController?.metadata?.name
+    ) {
+      this.getMetrics();
+    }
+  }
+
+  getMetrics = () => {
+    if (this.state.pods.length == 0) {
+      return;
+    }
+
+    let { currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let kind = this.state.selectedMetric;
+    let shouldsum = true;
+
+    // calculate start and end range
+    var d = new Date();
+    var end = Math.round(d.getTime() / 1000);
+    var start = end - secondsBeforeNow[this.state.selectedRange];
+
+    let pods = this.state.pods;
+
+    if (this.state.selectedPod != "All") {
+      pods = [this.state.selectedPod];
+    }
+
+    api
+      .getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: kind,
+          shouldsum: shouldsum,
+          pods: pods,
+          namespace: currentChart.namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[this.state.selectedRange],
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        // transform the metrics to expected form
+        if (kind == "cpu") {
+          let data = res.data as MetricsCPUDataResponse;
+
+          // if summed, just look at the first data
+          let tData = data[0].results.map(
+            (
+              d: {
+                date: number;
+                cpu: string;
+              },
+              i: number
+            ) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.cpu),
+              };
+            }
+          );
+
+          this.setState({ data: tData });
+        } else if (kind == "memory") {
+          let data = res.data as MetricsMemoryDataResponse;
+
+          let tData = data[0].results.map(
+            (
+              d: {
+                date: number;
+                memory: string;
+              },
+              i: number
+            ) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.memory) / (1024 * 1024), // put units in Mi
+              };
+            }
+          );
+
+          this.setState({ data: tData });
+        } else if (kind == "network") {
+          let data = res.data as MetricsNetworkDataResponse;
+
+          let tData = data[0].results.map(
+            (
+              d: {
+                date: number;
+                bytes: string;
+              },
+              i: number
+            ) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.bytes) / 1024, // put units in Ki
+              };
+            }
+          );
+
+          this.setState({ data: tData });
+        }
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        // this.setState({ controllers: [], loading: false });
+      });
+  };
+
+  getPods = () => {
+    let { selectedController } = this.state;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    let selectors = [] as string[];
+    let ml =
+      selectedController?.spec?.selector?.matchLabels ||
+      selectedController?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    selectors.push(selector);
+
+    api
+      .getMatchingPods(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          selectors,
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        let pods = res?.data?.map((pod: any) => {
+          return pod?.metadata?.name;
+        });
+
+        this.setState({ pods, selectedPod: "All" });
+
+        this.getMetrics();
+      })
+      .catch((err) => {
+        console.log(err);
+        setCurrentError(JSON.stringify(err));
+        return;
+      });
   };
 
   renderDropdown = () => {
@@ -33,7 +298,7 @@ export default class ListSection extends Component<PropsType, StateType> {
             onClick={() => this.setState({ dropdownExpanded: false })}
           />
           <Dropdown
-            dropdownWidth="200px"
+            dropdownWidth="230px"
             dropdownMaxHeight="200px"
             onClick={() => this.setState({ dropdownExpanded: false })}
           >
@@ -44,18 +309,107 @@ export default class ListSection extends Component<PropsType, StateType> {
     }
   };
 
+  renderPodDropdown = () => {
+    if (this.state.podDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay
+            onClick={() => this.setState({ podDropdownExpanded: false })}
+          />
+          <Dropdown
+            dropdownWidth="400px"
+            dropdownMaxHeight="200px"
+            onClick={() => this.setState({ podDropdownExpanded: false })}
+          >
+            {this.renderPodOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderPodOptionList = () => {
+    let allPod = [
+      <Option
+        key={0}
+        selected={"All" === this.state.selectedPod}
+        onClick={() => this.setState({ selectedPod: "All" })}
+        lastItem={false}
+      >
+        All (summed)
+      </Option>,
+    ];
+
+    let podOptions = this.state.pods.map((option: string, i: number) => {
+      return (
+        <Option
+          key={i + 1}
+          selected={option === this.state.selectedPod}
+          onClick={() => this.setState({ selectedPod: option })}
+          lastItem={i === this.state.pods.length - 1}
+        >
+          {option}
+        </Option>
+      );
+    });
+
+    return allPod.concat(podOptions);
+  };
+
+  renderControllerDropdown = () => {
+    if (this.state.controllerDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay
+            onClick={() => this.setState({ controllerDropdownExpanded: false })}
+          />
+          <Dropdown
+            dropdownWidth="300px"
+            dropdownMaxHeight="200px"
+            onClick={() => this.setState({ controllerDropdownExpanded: false })}
+          >
+            {this.renderControllerOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderControllerOptionList = () => {
+    return this.state.controllers.map((controller: any, i: number) => {
+      let name = controller?.metadata?.name;
+
+      return (
+        <Option
+          key={i}
+          selected={name === this.state.selectedController?.metadata?.name}
+          onClick={() => this.setState({ selectedController: controller })}
+          lastItem={i === this.state.controllers.length - 1}
+        >
+          {name}
+        </Option>
+      );
+    });
+  };
+
   renderOptionList = () => {
     let metricOptions = [
-      { value: "cpu", label: "CPU Utilization" },
-      { value: "ram", label: "RAM Utilization" },
+      { value: "cpu", label: "CPU Utilization (vCPUs)" },
+      { value: "memory", label: "RAM Utilization (Mi)" },
+      { value: "network", label: "Network Received Bytes (Ki)" },
     ];
     return metricOptions.map(
       (option: { value: string; label: string }, i: number) => {
         return (
           <Option
             key={i}
-            selected={option.label === this.state.selectedMetricLabel}
-            onClick={() => this.setState({ selectedMetricLabel: option.label })}
+            selected={option.value === this.state.selectedMetric}
+            onClick={() =>
+              this.setState({
+                selectedMetric: option.value,
+                selectedMetricLabel: option.label,
+              })
+            }
             lastItem={i === metricOptions.length - 1}
           >
             {option.label}
@@ -69,26 +423,57 @@ export default class ListSection extends Component<PropsType, StateType> {
     return (
       <StyledMetricsSection>
         <ParentSize>
-          {({ width, height }) => <AreaChart width={width} height={height} />}
+          {({ width, height }) => (
+            <AreaChart
+              data={this.state.data}
+              width={width}
+              height={height}
+              resolution={this.state.selectedRange}
+              margin={{ top: 60, right: -40, bottom: 0, left: 50 }}
+            />
+          )}
         </ParentSize>
         <MetricSelector
           onClick={() =>
             this.setState({ dropdownExpanded: !this.state.dropdownExpanded })
           }
         >
-          {this.state.selectedMetricLabel}
+          <MetricsLabel>{this.state.selectedMetricLabel}</MetricsLabel>
           <i className="material-icons">arrow_drop_down</i>
           {this.renderDropdown()}
         </MetricSelector>
+        <ControllerSelector
+          onClick={() =>
+            this.setState({
+              controllerDropdownExpanded: !this.state
+                .controllerDropdownExpanded,
+            })
+          }
+        >
+          <MetricsLabel>
+            {this.state.selectedController?.metadata?.name}
+          </MetricsLabel>
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderControllerDropdown()}
+        </ControllerSelector>
+        <PodSelector
+          onClick={() =>
+            this.setState({
+              podDropdownExpanded: !this.state.podDropdownExpanded,
+            })
+          }
+        >
+          <MetricsLabel>{this.state.selectedPod}</MetricsLabel>
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderPodDropdown()}
+        </PodSelector>
         <RangeWrapper>
           <TabSelector
             options={[
               { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
               { value: "1D", label: "1D" },
               { value: "1M", label: "1M" },
-              { value: "3M", label: "3M" },
-              { value: "1Y", label: "1Y" },
-              { value: "ALL", label: "ALL" },
             ]}
             currentTab={this.state.selectedRange}
             setCurrentTab={(x: string) => this.setState({ selectedRange: x })}
@@ -99,7 +484,7 @@ export default class ListSection extends Component<PropsType, StateType> {
   }
 }
 
-ListSection.contextType = Context;
+MetricsSection.contextType = Context;
 
 const DropdownOverlay = styled.div`
   position: fixed;
@@ -153,19 +538,19 @@ const Dropdown = styled.div`
 
 const RangeWrapper = styled.div`
   position: absolute;
-  bottom: 10px;
+  top: 0;
+  right: 0;
   font-weight: bold;
-  left: 0;
-  width: 100%;
+  width: 156px;
 `;
 
 const MetricSelector = styled.div`
-  font-size: 16px;
+  font-size: 13px;
   font-weight: 500;
   color: #ffffff;
   position: absolute;
-  top: 0;
-  left: 5px;
+  top: 10px;
+  left: 0;
   display: flex;
   align-items: center;
   cursor: pointer;
@@ -183,6 +568,21 @@ const MetricSelector = styled.div`
   }
 `;
 
+const MetricsLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const ControllerSelector = styled(MetricSelector)`
+  left: 230px;
+`;
+
+const PodSelector = styled(MetricSelector)`
+  left: 490px;
+`;
+
 const StyledMetricsSection = styled.div`
   width: 100%;
   height: 100%;

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

@@ -9,6 +9,7 @@ type PropsType = {
   controller: any;
   selectedPod: any;
   selectPod: Function;
+  selectors: any;
   isLast?: boolean;
   isFirst?: boolean;
   setPodError: (x: string) => void;
@@ -46,6 +47,10 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     }
     selectors.push(selector);
 
+    if (selectors.length == 0 && this.props.selectors) {
+      selectors = this.props.selectors;
+    }
+
     api
       .getMatchingPods(
         "<token>",
@@ -105,6 +110,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           c.status?.numberAvailable || 0,
           c.status?.desiredNumberScheduled || 0,
         ];
+      case "job":
+        return [1, 1];
     }
   };
 
@@ -147,6 +154,13 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
     let [available, total] = this.getAvailability(controller.kind, controller);
     let status = available == total ? "running" : "waiting";
+
+    if (controller.kind?.toLowerCase() == "job" && !controller.status.active) {
+      status = "completed";
+    }
+
+    console.log("STATUS", status);
+
     return (
       <ResourceTab
         label={controller.kind}
@@ -157,6 +171,12 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       >
         {this.state.raw.map((pod, i) => {
           let status = this.getPodStatus(pod.status);
+          if (
+            controller.kind?.toLowerCase() == "job" &&
+            !controller.status.active
+          ) {
+            status = "completed";
+          }
           return (
             <Tab
               key={pod.metadata?.name}
@@ -260,6 +280,8 @@ const StatusColor = styled.div`
       ? "#4797ff"
       : props.status === "failed"
       ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
       : "#f5cb42"};
   border-radius: 20px;
 `;

+ 9 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -40,6 +40,15 @@ export default class Logs extends Component<PropsType, StateType> {
     if (!selectedPod?.metadata?.name) {
       return <Message>Please select a pod to view its logs.</Message>;
     }
+
+    if (selectedPod?.status.phase === "Succeeded") {
+      return (
+        <Message>
+          ⌛ This job has been completed. You can now delete this job.
+        </Message>
+      );
+    }
+
     if (this.state.logs.length == 0) {
       return (
         <Message>

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -10,7 +10,7 @@ import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
 
 type PropsType = {
-  selectors: string[];
+  selectors?: string[];
   currentChart: ChartType;
 };
 
@@ -56,6 +56,7 @@ export default class StatusSection extends Component<PropsType, StateType> {
           key={c.metadata.uid}
           selectedPod={this.state.selectedPod}
           selectPod={this.selectPod.bind(this)}
+          selectors={this.props.selectors}
           controller={c}
           isLast={i === this.state.controllers.length - 1}
           isFirst={i === 0}
@@ -92,7 +93,7 @@ export default class StatusSection extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    const { selectors, currentChart } = this.props;
+    const { currentChart } = this.props;
     let { currentCluster, currentProject, setCurrentError } = this.context;
 
     api

+ 290 - 0
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -0,0 +1,290 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import GHIcon from "assets/GithubIcon";
+
+import { Context } from "shared/Context";
+import { integrationList } from "shared/common";
+import { RouteComponentProps, withRouter } from "react-router";
+import IntegrationList from "./IntegrationList";
+import api from "shared/api";
+
+type PropsType = RouteComponentProps & {
+  category: string;
+};
+
+type StateType = {
+  // currentIntegration: string | null;
+  currentOptions: any[];
+  currentTitles: any[];
+  currentIds: any[];
+  currentIntegrationData: any[];
+};
+
+class IntegrationCategories extends Component<PropsType, StateType> {
+  state = {
+    currentOptions: [] as any[],
+    currentTitles: [] as any[],
+    currentIds: [] as any[],
+    currentIntegrationData: [] as any[],
+  };
+
+  componentDidMount() {
+    this.getIntegrationsForCategory(this.props.category);
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.props.category != prevProps.category) {
+      this.getIntegrationsForCategory(this.props.category);
+    }
+  }
+
+  getIntegrationsForCategory = (categoryType: string) => {
+    const { currentProject } = this.context;
+    this.setState({
+      currentOptions: [],
+      currentTitles: [],
+      currentIntegrationData: [],
+    });
+    switch (categoryType) {
+      case "kubernetes":
+        api
+          .getProjectClusters("<token>", {}, { id: currentProject.id })
+          .then()
+          .catch(console.log);
+        break;
+      case "registry":
+        api
+          .getProjectRegistries("<token>", {}, { id: currentProject.id })
+          .then((res) => {
+            // Sort res.data into service type and sort each service's registry alphabetically
+            let grouped: any = {};
+            let final: any = [];
+            for (let i = 0; i < res.data.length; i++) {
+              let p = res.data[i].service;
+              if (!grouped[p]) {
+                grouped[p] = [];
+              }
+              grouped[p].push(res.data[i]);
+            }
+            Object.values(grouped).forEach((val: any) => {
+              final = final.concat(
+                val.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
+              );
+            });
+
+            let currentOptions = [] as string[];
+            let currentTitles = [] as string[];
+            final.forEach((integration: any, i: number) => {
+              currentOptions.push(integration.service);
+              currentTitles.push(integration.name);
+            });
+            this.setState({
+              currentOptions,
+              currentTitles,
+              currentIntegrationData: final,
+            });
+          })
+          .catch(console.log);
+        break;
+      case "repo":
+        api
+          .getGitRepos("<token>", {}, { project_id: currentProject.id })
+          .then((res) => {
+            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,
+            });
+          })
+          .catch(console.log);
+        break;
+      default:
+        console.log("Unknown integration category.");
+    }
+  };
+
+  render = () => {
+    const { category: currentCategory } = this.props;
+    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.props.history.push("/integrations")}
+              >
+                keyboard_backspace
+              </i>
+              <Icon src={icon && icon} />
+              <Title>{label}</Title>
+            </Flex>
+            <Button
+              onClick={() =>
+                this.context.setCurrentModal("IntegrationsModal", {
+                  category: currentCategory,
+                  setCurrentIntegration: (x: string) =>
+                    this.props.history.push(
+                      `/integrations/${this.props.category}/create/${x}`
+                    ),
+                })
+              }
+            >
+              <i className="material-icons">add</i>
+              {buttonText}
+            </Button>
+          </TitleSectionAlt>
+
+          <LineBreak />
+
+          <IntegrationList
+            currentCategory={currentCategory}
+            integrations={this.state.currentOptions}
+            titles={this.state.currentTitles}
+            itemIdentifier={this.state.currentIntegrationData}
+          />
+        </div>
+      );
+    } else {
+      return (
+        <div>
+          <TitleSectionAlt>
+            <Flex>
+              <i
+                className="material-icons"
+                onClick={() => this.props.history.push("/integrations")}
+              >
+                keyboard_backspace
+              </i>
+              <Icon src={icon && icon} />
+              <Title>{label}</Title>
+            </Flex>
+            <Button
+              onClick={() =>
+                window.open(
+                  `/api/oauth/projects/${this.context.currentProject.id}/github`
+                )
+              }
+            >
+              <GHIcon />
+              {buttonText}
+            </Button>
+          </TitleSectionAlt>
+
+          <LineBreak />
+
+          <IntegrationList
+            currentCategory={currentCategory}
+            integrations={this.state.currentOptions}
+            titles={this.state.currentTitles}
+            itemIdentifier={this.state.currentIds}
+          />
+        </div>
+      );
+    }
+  };
+}
+
+IntegrationCategories.contextType = Context;
+
+export default withRouter(IntegrationCategories);
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    margin-right: 11px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Button = styled.div`
+  height: 100%;
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: pointer;
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > img,
+  i {
+    width: 20px;
+    height: 20px;
+    font-size: 16px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    justify-content: center;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  height: 40px;
+`;
+
+const TitleSectionAlt = styled(TitleSection)`
+  margin-left: -42px;
+  width: calc(100% + 42px);
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 32px 0px 24px;
+`;

+ 61 - 209
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -1,14 +1,12 @@
-import React, { Component } from "react";
+import React, { Component, MouseEvent } from "react";
 import styled from "styled-components";
 
-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";
+import { Context } from "shared/Context";
+import { integrationList } from "shared/common";
+import IntegrationRow from "./IntegrationRow";
 
 type PropsType = {
-  setCurrent: (x: any) => void;
+  setCurrent?: (x: string) => void;
   currentCategory: string;
   integrations: string[];
   itemIdentifier?: any[];
@@ -17,154 +15,60 @@ type PropsType = {
 };
 
 type StateType = {
-  displayImages: boolean[];
-  allCollapsed: boolean;
+  displayExpanded: boolean[];
 };
 
 export default class IntegrationList extends Component<PropsType, StateType> {
   state = {
-    displayImages: [] as boolean[],
-    allCollapsed: false,
+    displayExpanded: this.props.integrations.map(() => 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);
-  }
+  allCollapsed = () =>
+    this.state.displayExpanded.reduce((prev, cur) => prev && !cur, true);
 
   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 });
+      this.collapseAll();
     }
   }
 
   collapseAll = () => {
-    let x = [];
-    for (let i = 0; i < this.state.displayImages.length; i++) {
-      x.push(false);
-    }
-    this.setState({ displayImages: x, allCollapsed: true });
+    this.setState({
+      displayExpanded: this.props.integrations.map(() => false),
+    });
   };
 
   expandAll = () => {
-    let x = [];
-    for (let i = 0; i < this.state.displayImages.length; i++) {
-      x.push(true);
-    }
-    this.setState({ displayImages: x, allCollapsed: false });
+    this.setState({ displayExpanded: this.props.integrations.map(() => true) });
   };
 
-  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 });
-      }
+  toggleDisplay = (event: MouseEvent, index: number) => {
+    if (event) {
+      event.stopPropagation();
     }
-    this.setState({ displayImages: x });
+    let x = this.state.displayExpanded;
+    x[index] = !x[index];
+    this.setState({ displayExpanded: x });
   };
 
-  handleParent = (event: any, integration: string) => {
-    this.props.setCurrent(integration);
-  };
+  handleParent = (event: any, integration: string) =>
+    this.props.setCurrent && this.props.setCurrent(integration);
 
   renderContents = () => {
-    let {
-      integrations,
-      titles,
-      setCurrent,
-      isCategory,
-      currentCategory,
-    } = this.props;
+    let { integrations, titles, setCurrent, isCategory } = this.props;
     if (titles && titles.length > 0) {
       return integrations.map((integration: string, i: number) => {
-        let icon =
-          integrationList[integration] && integrationList[integration].icon;
-        let subtitle =
-          integrationList[integration] && integrationList[integration].label;
         let label = titles[i];
         return (
-          <Integration key={i} isCategory={isCategory} disabled={false}>
-            <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>
+          <IntegrationRow
+            category={this.props.currentCategory}
+            integration={integration}
+            expanded={this.state.displayExpanded[i]}
+            key={i}
+            itemId={this.props.itemIdentifier[i]}
+            label={label}
+            toggleCollapse={(e: MouseEvent) => this.toggleDisplay(e, i)}
+          ></IntegrationRow>
         );
       });
     } else if (integrations && integrations.length > 0) {
@@ -177,11 +81,12 @@ export default class IntegrationList extends Component<PropsType, StateType> {
         return (
           <Integration
             key={i}
-            onClick={() => (disabled ? null : setCurrent(integration))}
-            isCategory={isCategory}
+            onClick={() =>
+              disabled ? null : setCurrent && setCurrent(integration)
+            }
             disabled={disabled}
           >
-            <MainRow isCategory={isCategory} disabled={disabled}>
+            <MainRow disabled={disabled}>
               <Flex>
                 <Icon src={icon && icon} />
                 <Label>{label}</Label>
@@ -197,31 +102,29 @@ export default class IntegrationList extends Component<PropsType, StateType> {
     return <Placeholder>No integrations set up yet.</Placeholder>;
   };
 
+  collapseAllButton = () => (
+    <Button
+      onClick={() =>
+        this.allCollapsed() ? this.expandAll() : this.collapseAll()
+      }
+    >
+      {this.allCollapsed() ? (
+        <>
+          <i className="material-icons">expand_more</i> Expand All
+        </>
+      ) : (
+        <>
+          <i className="material-icons">expand_less</i> Collapse All
+        </>
+      )}
+    </Button>
+  );
+
   render() {
     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>
+          <ControlRow>{this.collapseAllButton()}</ControlRow>
         )}
         {this.renderContents()}
       </StyledIntegrationList>
@@ -237,33 +140,6 @@ const Flex = styled.div`
   justify-content: center;
 `;
 
-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: 100%;
@@ -273,10 +149,10 @@ const MainRow = styled.div`
   padding: 25px;
   border-radius: 5px;
   :hover {
-    background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+    background: ${(props: { disabled: boolean }) =>
       props.disabled ? "" : "#ffffff11"};
     > i {
-      background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+      background: ${(props: { disabled: boolean }) =>
         props.disabled ? "" : "#ffffff11"};
     }
   }
@@ -285,11 +161,10 @@ const MainRow = styled.div`
     border-radius: 20px;
     font-size: 18px;
     padding: 5px;
-    color: ${(props: { isCategory: boolean; disabled: boolean }) =>
-      props.isCategory ? "#616feecc" : "#ffffff44"};
+    color: #ffffff44;
     margin-right: -7px;
     :hover {
-      background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+      background: ${(props: { disabled: boolean }) =>
         props.disabled ? "" : "#ffffff11"};
     }
   }
@@ -300,34 +175,19 @@ const Integration = styled.div`
   display: flex;
   flex-direction: column;
   background: #26282f;
-  cursor: ${(props: { isCategory: boolean; disabled: boolean }) =>
+  cursor: ${(props: { 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;
-  margin: 0;
-  padding: 0;
-`;
-
 const Label = styled.div`
   color: #ffffff;
   font-size: 14px;
   font-weight: 500;
 `;
 
-const Subtitle = styled.div`
-  color: #aaaabb;
-  font-size: 13px;
-  display: flex;
-  align-items: center;
-  padding-top: 5px;
-`;
-
 const Icon = styled.img`
   width: 30px;
   margin-right: 18px;
@@ -349,6 +209,7 @@ const Placeholder = styled.div`
 
 const StyledIntegrationList = styled.div`
   margin-top: 20px;
+  margin-bottom: 80px;
 `;
 
 const I = styled.i`
@@ -364,15 +225,6 @@ const ControlRow = styled.div`
   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;

+ 220 - 0
dashboard/src/main/home/integrations/IntegrationRow.tsx

@@ -0,0 +1,220 @@
+import styled from "styled-components";
+import React, { Component, MouseEvent, MouseEventHandler } from "react";
+
+import ImageList from "components/image-selector/ImageList";
+import RepoList from "components/repo-selector/RepoList";
+import { ActionConfigType } from "shared/types";
+import { integrationList } from "shared/common";
+
+import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
+
+type PropsType = {
+  toggleCollapse: MouseEventHandler;
+  label: string;
+  integration: string;
+  expanded: boolean;
+  category: string; // "repo" | "registry"; see Integrations.tsx
+  itemId: number;
+};
+
+type StateType = {
+  editMode: boolean;
+};
+
+export default class IntegrationRow extends Component<PropsType, StateType> {
+  state = {
+    editMode: false,
+  };
+
+  editButtonOnClick = (e: MouseEvent) => {
+    e.stopPropagation();
+    if (!this.props.expanded) {
+      this.setState({
+        editMode: true,
+      });
+      this.props.toggleCollapse(null);
+    } else {
+      this.setState({
+        editMode: !this.state.editMode,
+      });
+      if (this.state.editMode) {
+        this.props.toggleCollapse(null);
+      }
+    }
+  };
+
+  render = () => {
+    const icon =
+      integrationList[this.props.integration] &&
+      integrationList[this.props.integration].icon;
+    const subtitle =
+      integrationList[this.props.integration] &&
+      integrationList[this.props.integration].label;
+    return (
+      <Integration disabled={false}>
+        <MainRow onClick={this.props.toggleCollapse} disabled={false}>
+          <Flex>
+            <Icon src={icon && icon} />
+            <Description>
+              <Label>{this.props.label}</Label>
+              <Subtitle>{subtitle}</Subtitle>
+            </Description>
+          </Flex>
+          <MaterialIconTray disabled={false}>
+            {/* <i className="material-icons"
+            onClick={this.editButtonOnClick}>mode_edit</i> */}
+            <I
+              className="material-icons"
+              showList={this.props.expanded}
+              onClick={this.props.toggleCollapse}
+            >
+              expand_more
+            </I>
+          </MaterialIconTray>
+        </MainRow>
+        {this.props.expanded && !this.state.editMode && (
+          <ImageHodler adjustMargin={this.props.category !== "repo"}>
+            {this.props.category !== "repo" ? (
+              <ImageList
+                selectedImageUrl={null}
+                selectedTag={null}
+                clickedImage={null}
+                registry={this.props.itemId}
+                setSelectedImageUrl={() => {}}
+                setSelectedTag={() => {}}
+                setClickedImage={() => {}}
+              />
+            ) : (
+              <RepoList
+                actionConfig={
+                  {
+                    git_repo: "",
+                    image_repo_uri: "",
+                    git_repo_id: 0,
+                    dockerfile_path: "",
+                  } as ActionConfigType
+                }
+                setActionConfig={() => {}}
+                readOnly={true}
+                userId={this.props.itemId}
+              />
+            )}
+          </ImageHodler>
+        )}
+        {this.props.expanded && this.state.editMode && (
+          <CreateIntegrationForm
+            integrationName={this.props.integration}
+            closeForm={() => {
+              this.setState({ editMode: false });
+            }}
+          />
+        )}
+      </Integration>
+    );
+  };
+}
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  margin-bottom: 15px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+`;
+
+const Icon = styled.img`
+  width: 30px;
+  margin-right: 18px;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const MaterialIconTray = styled.div`
+  width: 32px;
+  margin-right: -7px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Description = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Subtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding-top: 5px;
+`;
+
+const I = styled.i`
+  transform: ${(props: { showList: boolean }) =>
+    props.showList ? "rotate(180deg)" : ""};
+`;
+
+const ImageHodler = styled.div`
+  width: 100%;
+  padding: 12px;
+  margin-top: ${(props: { adjustMargin: boolean }) =>
+    props.adjustMargin ? "-10px" : "0px"};
+`;

+ 74 - 328
dashboard/src/main/home/integrations/Integrations.tsx

@@ -1,317 +1,99 @@
 import React, { Component } from "react";
-import styled from "styled-components";
+import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 
-import { Context } from "shared/Context";
-import api from "shared/api";
 import { integrationList } from "shared/common";
+import styled from "styled-components";
 
+import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
+import IntegrationCategories from "./IntegrationCategories";
 import IntegrationList from "./IntegrationList";
-import IntegrationForm from "./integration-form/IntegrationForm";
 
-import GHIcon from "assets/GithubIcon";
-
-type PropsType = {};
+type PropsType = RouteComponentProps;
 
 type StateType = {
-  currentCategory: string | null;
-  currentIntegration: string | null;
-  currentOptions: any[];
-  currentTitles: any[];
-  currentIds: any[];
   currentIntegrationData: any[];
 };
 
-export default class Integrations extends Component<PropsType, StateType> {
+const IntegrationCategoryStrings = ["registry", "repo"]; /*"kubernetes",*/
+
+class Integrations extends Component<PropsType, StateType> {
   state = {
-    currentCategory: null as string | null,
-    currentIntegration: null as string | null,
-    currentOptions: [] as any[],
-    currentTitles: [] as any[],
-    currentIds: [] as any[],
     currentIntegrationData: [] as any[],
   };
 
-  // TODO: implement once backend is restructured
-  getIntegrations = (categoryType: string) => {
-    let { currentProject } = this.context;
-    this.setState({
-      currentOptions: [],
-      currentTitles: [],
-      currentIntegrationData: [],
-    });
-    switch (categoryType) {
-      case "kubernetes":
-        api
-          .getProjectClusters("<token>", {}, { id: currentProject.id })
-          .then()
-          .catch(console.log);
-        break;
-      case "registry":
-        api
-          .getProjectRegistries("<token>", {}, { id: currentProject.id })
-          .then((res) => {
-            // Sort res.data into service type and sort each service's registry alphabetically
-            let grouped: any = {};
-            let final: any = [];
-            for (let i = 0; i < res.data.length; i++) {
-              let p = res.data[i].service;
-              if (!grouped[p]) {
-                grouped[p] = [];
-              }
-              grouped[p].push(res.data[i]);
+  render = () => (
+    <StyledIntegrations>
+      <Switch>
+        <Route
+          path="/integrations/:category/create/:integration"
+          render={(rp) => {
+            const { integration, category } = rp.match.params;
+            if (!IntegrationCategoryStrings.includes(category)) {
+              this.props.history.push("/integrations");
             }
-            Object.values(grouped).forEach((val: any) => {
-              final = final.concat(
-                val.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
-              );
-            });
-
-            let currentOptions = [] as string[];
-            let currentTitles = [] as string[];
-            final.forEach((integration: any, i: number) => {
-              currentOptions.push(integration.service);
-              currentTitles.push(integration.name);
-            });
-            this.setState({
-              currentOptions,
-              currentTitles,
-              currentIntegrationData: res.data,
-            });
-          })
-          .catch(console.log);
-        break;
-      case "repo":
-        api
-          .getGitRepos("<token>", {}, { project_id: currentProject.id })
-          .then((res) => {
-            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,
-            });
-          })
-          .catch(console.log);
-        break;
-      default:
-        console.log("Unknown integration category.");
-    }
-  };
-
-  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
-    if (
-      this.state.currentCategory &&
-      this.state.currentCategory !== prevState.currentCategory
-    ) {
-      this.getIntegrations(this.state.currentCategory);
-    }
-  }
-
-  renderIntegrationContents = () => {
-    if (this.state.currentIntegrationData) {
-      let items = this.state.currentIntegrationData.filter(
-        (item) => item.service === this.state.currentIntegration
-      );
-      if (items.length > 0) {
-        return (
-          <div>
-            <Label>Existing Credentials</Label>
-            {items.map((item: any, i: number) => {
-              return (
-                <Credential key={i}>
-                  <i className="material-icons">admin_panel_settings</i>{" "}
-                  {/* TODO: handle different types of items (ie. registry vs repo) */}
-                  {item.name || item.repo_entity}
-                </Credential>
-              );
-            })}
-            <br />
-          </div>
-        );
-      }
-    }
-  };
-
-  renderContents = () => {
-    let { currentProject } = this.context;
-    let { currentCategory, currentIntegration } = this.state;
-
-    // TODO: Split integration page into separate component
-    if (currentIntegration) {
-      let icon =
-        integrationList[currentIntegration] &&
-        integrationList[currentIntegration].icon;
-      return (
-        <div>
-          <TitleSectionAlt>
-            <Flex>
-              <i
-                className="material-icons"
-                onClick={() => this.setState({ currentIntegration: null })}
-              >
-                keyboard_backspace
-              </i>
-              <Icon src={icon && icon} />
-              <Title>{integrationList[currentIntegration].label}</Title>
-            </Flex>
-          </TitleSectionAlt>
-          {this.renderIntegrationContents()}
-          <IntegrationForm
-            integrationName={currentIntegration}
-            closeForm={() => {
-              this.setState({ currentIntegration: null });
-              this.getIntegrations(this.state.currentCategory);
-            }}
-          />
-          <Br />
-        </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;
-      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 />
-
-            <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 (
+            let icon =
+              integrationList[integration] && integrationList[integration].icon;
+            return (
+              <div>
+                <TitleSectionAlt>
+                  <Flex>
+                    <i
+                      className="material-icons"
+                      onClick={() =>
+                        this.props.history.push(`/integrations/${category}`)
+                      }
+                    >
+                      keyboard_backspace
+                    </i>
+                    <Icon src={icon && icon} />
+                    <Title>{integrationList[integration].label}</Title>
+                  </Flex>
+                </TitleSectionAlt>
+                <CreateIntegrationForm
+                  integrationName={integration}
+                  closeForm={() => {
+                    this.props.history.push(`/integrations/${category}`);
+                  }}
+                />
+                <Br />
+              </div>
+            );
+          }}
+        />
+        <Route
+          path="/integrations/:category"
+          render={(rp) => {
+            const currentCategory = rp.match.params.category;
+            if (!IntegrationCategoryStrings.includes(currentCategory)) {
+              this.props.history.push("/integrations");
+            }
+            return (
+              <IntegrationCategories
+                category={currentCategory}
+              ></IntegrationCategories>
+            );
+          }}
+        />
+        <Route>
           <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 />
+            <TitleSection>
+              <Title>Integrations</Title>
+            </TitleSection>
 
             <IntegrationList
-              currentCategory={currentCategory}
-              integrations={this.state.currentOptions}
-              titles={this.state.currentTitles}
-              setCurrent={(x: string) =>
-                this.setState({ currentIntegration: x })
-              }
-              itemIdentifier={this.state.currentIds}
+              currentCategory={""}
+              integrations={["kubernetes", "registry", "repo"]}
+              setCurrent={(x) => this.props.history.push(`/integrations/${x}`)}
+              isCategory={true}
             />
           </div>
-        );
-      }
-    }
-    return (
-      <div>
-        <TitleSection>
-          <Title>Integrations</Title>
-        </TitleSection>
-
-        <IntegrationList
-          currentCategory={""}
-          integrations={["kubernetes", "registry", "repo"]}
-          setCurrent={(x: any) => this.setState({ currentCategory: x })}
-          isCategory={true}
-        />
-      </div>
-    );
-  };
-
-  render() {
-    return <StyledIntegrations>{this.renderContents()}</StyledIntegrations>;
-  }
+        </Route>
+      </Switch>
+    </StyledIntegrations>
+  );
 }
 
-Integrations.contextType = Context;
-
-const Label = styled.div`
-  font-size: 14px;
-  font-weight: 500;
-  margin-bottom: 20px;
-`;
-
-const Credential = styled.div`
-  width: 100%;
-  height: 30px;
-  font-size: 13px;
-  display: flex;
-  align-items: center;
-  padding: 20px;
-  padding-left: 13px;
-  width: 100%;
-  border-radius: 5px;
-  background: #ffffff11;
-  margin-bottom: 5px;
-
-  > i {
-    font-size: 22px;
-    color: #ffffff44;
-    margin-right: 10px;
-  }
-`;
+export default withRouter(Integrations);
 
 const Br = styled.div`
   width: 100%;
@@ -341,35 +123,6 @@ const Flex = styled.div`
   }
 `;
 
-const Button = styled.div`
-  height: 100%;
-  background: #616feecc;
-  :hover {
-    background: #505edddd;
-  }
-  color: white;
-  font-weight: 500;
-  font-size: 13px;
-  padding: 10px 15px;
-  border-radius: 3px;
-  cursor: pointer;
-  box-shadow: 0 5px 8px 0px #00000010;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-
-  > img,
-  i {
-    width: 20px;
-    height: 20px;
-    font-size: 16px;
-    display: flex;
-    align-items: center;
-    margin-right: 10px;
-    justify-content: center;
-  }
-`;
-
 const Title = styled.div`
   font-size: 24px;
   font-weight: 600;
@@ -397,12 +150,5 @@ const TitleSectionAlt = styled(TitleSection)`
 const StyledIntegrations = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  padding-top: 45px;
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 32px 0px 24px;
+  padding-top: 75px;
 `;

+ 11 - 9
dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx → dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx

@@ -13,24 +13,26 @@ type PropsType = {
 
 type StateType = {};
 
-export default class IntegrationForm extends Component<PropsType, StateType> {
+export default class CreateIntegrationForm extends Component<
+  PropsType,
+  StateType
+> {
   state = {};
 
-  render() {
-    let { closeForm } = this.props;
+  render = () => {
     switch (this.props.integrationName) {
       case "docker-hub":
-        return <DockerHubForm closeForm={closeForm} />;
+        return <DockerHubForm closeForm={this.props.closeForm} />;
       case "gke":
-        return <GKEForm closeForm={closeForm} />;
+        return <GKEForm closeForm={this.props.closeForm} />;
       case "eks":
-        return <EKSForm closeForm={closeForm} />;
+        return <EKSForm closeForm={this.props.closeForm} />;
       case "ecr":
-        return <ECRForm closeForm={closeForm} />;
+        return <ECRForm closeForm={this.props.closeForm} />;
       case "gcr":
-        return <GCRForm closeForm={closeForm} />;
+        return <GCRForm closeForm={this.props.closeForm} />;
       default:
         return null;
     }
-  }
+  };
 }

+ 0 - 0
dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx → dashboard/src/main/home/integrations/create-integration/DockerHubForm.tsx


+ 0 - 0
dashboard/src/main/home/integrations/integration-form/ECRForm.tsx → dashboard/src/main/home/integrations/create-integration/ECRForm.tsx


+ 0 - 0
dashboard/src/main/home/integrations/integration-form/EKSForm.tsx → dashboard/src/main/home/integrations/create-integration/EKSForm.tsx


+ 0 - 0
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx → dashboard/src/main/home/integrations/create-integration/GCRForm.tsx


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


+ 105 - 0
dashboard/src/main/home/integrations/edit-integration/DockerHubForm.tsx

@@ -0,0 +1,105 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import InputRow from "components/values-form/InputRow";
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  registryURL: string;
+  dockerEmail: string;
+  dockerUsername: string;
+  dockerPassword: string;
+};
+
+export default class DockerHubForm extends Component<PropsType, StateType> {
+  state = {
+    registryURL: "",
+    dockerEmail: "",
+    dockerUsername: "",
+    dockerPassword: "",
+  };
+
+  isDisabled = (): boolean => {
+    let {
+      registryURL,
+      dockerEmail,
+      dockerUsername,
+      dockerPassword,
+    } = this.state;
+    if (
+      registryURL === "" ||
+      dockerEmail === "" ||
+      dockerUsername === "" ||
+      dockerPassword === ""
+    ) {
+      return true;
+    }
+    return false;
+  };
+
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  };
+
+  render() {
+    return (
+      <StyledForm>
+        <CredentialWrapper>
+          <InputRow
+            type="text"
+            value={this.state.registryURL}
+            setValue={(x: string) => this.setState({ registryURL: x })}
+            label="📦 Registry URL"
+            placeholder="ex: index.docker.io"
+            width="100%"
+          />
+          <InputRow
+            type="text"
+            value={this.state.dockerEmail}
+            setValue={(x: string) => this.setState({ dockerEmail: x })}
+            label="✉️ Docker Email"
+            placeholder="ex: captain@ahab.com"
+            width="100%"
+          />
+          <InputRow
+            type="text"
+            value={this.state.dockerUsername}
+            setValue={(x: string) => this.setState({ dockerUsername: x })}
+            label="👤 Docker Username"
+            placeholder="ex: whale_watcher_2000"
+            width="100%"
+          />
+          <InputRow
+            type="password"
+            value={this.state.dockerPassword}
+            setValue={(x: string) => this.setState({ dockerPassword: x })}
+            label="🔒 Docker Password"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            width="100%"
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text="Save Settings"
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 139 - 0
dashboard/src/main/home/integrations/edit-integration/ECRForm.tsx

@@ -0,0 +1,139 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import InputRow from "components/values-form/InputRow";
+import SaveButton from "components/SaveButton";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+type PropsType = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  credentialsName: string;
+  awsRegion: string;
+  awsAccessId: string;
+  awsSecretKey: string;
+};
+
+export default class ECRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: "",
+    awsRegion: "",
+    awsAccessId: "",
+    awsSecretKey: "",
+  };
+
+  isDisabled = (): boolean => {
+    let { awsRegion, awsAccessId, awsSecretKey, credentialsName } = this.state;
+    if (
+      awsRegion === "" ||
+      awsAccessId === "" ||
+      awsSecretKey === "" ||
+      credentialsName === ""
+    ) {
+      return true;
+    }
+    return false;
+  };
+
+  catchErr = (err: any) => console.log(err);
+
+  handleSubmit = () => {
+    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 }
+      )
+      .then((res) =>
+        api.connectECRRegistry(
+          "<token>",
+          {
+            name: credentialsName,
+            aws_integration_id: res.data.id,
+          },
+          { id: currentProject.id }
+        )
+      )
+      .then(() => this.props.closeForm())
+      .catch(this.catchErr);
+  };
+
+  render() {
+    return (
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>
+            Give a name to this set of registry credentials (just for Porter).
+          </Helper>
+          <InputRow
+            type="text"
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label="🏷️ Registry Name"
+            placeholder="ex: paper-straw"
+            width="100%"
+          />
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type="text"
+            value={this.state.awsRegion}
+            setValue={(x: string) => this.setState({ awsRegion: x })}
+            label="📍 AWS Region"
+            placeholder="ex: mars-north-12"
+            width="100%"
+          />
+          <InputRow
+            type="text"
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label="👤 AWS Access ID"
+            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+            width="100%"
+          />
+          <InputRow
+            type="password"
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label="🔒 AWS Secret Key"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            width="100%"
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text="Save Settings"
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+ECRForm.contextType = Context;
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 124 - 0
dashboard/src/main/home/integrations/edit-integration/EKSForm.tsx

@@ -0,0 +1,124 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import InputRow from "components/values-form/InputRow";
+import TextArea from "components/values-form/TextArea";
+import SaveButton from "components/SaveButton";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+type PropsType = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  clusterName: string;
+  clusterEndpoint: string;
+  clusterCA: string;
+  awsAccessId: string;
+  awsSecretKey: string;
+};
+
+export default class EKSForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: "",
+    clusterEndpoint: "",
+    clusterCA: "",
+    awsAccessId: "",
+    awsSecretKey: "",
+  };
+
+  isDisabled = (): boolean => {
+    let {
+      clusterName,
+      clusterEndpoint,
+      clusterCA,
+      awsAccessId,
+      awsSecretKey,
+    } = this.state;
+    if (
+      clusterName === "" ||
+      clusterEndpoint === "" ||
+      clusterCA === "" ||
+      awsAccessId === "" ||
+      awsSecretKey === ""
+    ) {
+      return true;
+    }
+    return false;
+  };
+
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  };
+
+  render() {
+    return (
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type="text"
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label="🏷️ Cluster Name"
+            placeholder="ex: briny-pagelet"
+            width="100%"
+          />
+          <InputRow
+            type="text"
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label="🌐 Cluster Endpoint"
+            placeholder="ex: 00.00.000.00"
+            width="100%"
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label="🔏 Cluster Certificate"
+            placeholder="(Paste your certificate here)"
+            width="100%"
+          />
+
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type="text"
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label="👤 AWS Access ID"
+            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+            width="100%"
+          />
+          <InputRow
+            type="password"
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label="🔒 AWS Secret Key"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            width="100%"
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text="Save Settings"
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 38 - 0
dashboard/src/main/home/integrations/edit-integration/EditIntegrationForm.tsx

@@ -0,0 +1,38 @@
+import React, { Component } from "react";
+
+import DockerHubForm from "./DockerHubForm";
+import GKEForm from "./GKEForm";
+import EKSForm from "./EKSForm";
+import GCRForm from "./GCRForm";
+import ECRForm from "./ECRForm";
+
+type PropsType = {
+  integrationName: string;
+  closeForm: () => void;
+};
+
+type StateType = {};
+
+export default class CreateIntegrationForm extends Component<
+  PropsType,
+  StateType
+> {
+  state = {};
+
+  render = () => {
+    switch (this.props.integrationName) {
+      case "docker-hub":
+        return <DockerHubForm closeForm={this.props.closeForm} />;
+      case "gke":
+        return <GKEForm closeForm={this.props.closeForm} />;
+      case "eks":
+        return <EKSForm closeForm={this.props.closeForm} />;
+      case "ecr":
+        return <ECRForm closeForm={this.props.closeForm} />;
+      case "gcr":
+        return <GCRForm closeForm={this.props.closeForm} />;
+      default:
+        return null;
+    }
+  };
+}

+ 165 - 0
dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx

@@ -0,0 +1,165 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import InputRow from "components/values-form/InputRow";
+import TextArea from "components/values-form/TextArea";
+import SaveButton from "components/SaveButton";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+type PropsType = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  credentialsName: string;
+  gcpRegion: string;
+  serviceAccountKey: string;
+  gcpProjectID: string;
+  url: string;
+};
+
+export default class GCRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: "",
+    gcpRegion: "",
+    serviceAccountKey: "",
+    gcpProjectID: "",
+    url: "",
+  };
+
+  isDisabled = (): boolean => {
+    let {
+      credentialsName,
+      gcpRegion,
+      gcpProjectID,
+      serviceAccountKey,
+    } = this.state;
+    if (
+      credentialsName === "" ||
+      gcpRegion === "" ||
+      serviceAccountKey === "" ||
+      gcpProjectID === ""
+    ) {
+      return true;
+    }
+    return false;
+  };
+
+  catchError = (err: any) => console.log(err);
+
+  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,
+        }
+      )
+      .then((res) =>
+        api.connectGCRRegistry(
+          "<token>",
+          {
+            name: this.state.credentialsName,
+            gcp_integration_id: res.data.id,
+            url: this.state.url,
+          },
+          {
+            id: currentProject.id,
+          }
+        )
+      )
+      .then((res) => {
+        console.log(res.data);
+        this.props.closeForm();
+      })
+      .catch(this.catchError);
+  };
+
+  render() {
+    return (
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>
+            Give a name to this set of registry credentials (just for Porter).
+          </Helper>
+          <InputRow
+            type="text"
+            value={this.state.credentialsName}
+            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={(gcpRegion: string) => this.setState({ gcpRegion })}
+            label="📍 GCP Region"
+            placeholder="ex: uranus-north3"
+            width="100%"
+          />
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(serviceAccountKey: string) =>
+              this.setState({ serviceAccountKey })
+            }
+            label="🔑 Service Account Key (JSON)"
+            placeholder="(Paste your JSON service account key here)"
+            width="100%"
+          />
+          <InputRow
+            type="text"
+            value={this.state.gcpProjectID}
+            setValue={(gcpProjectID: string) => this.setState({ gcpProjectID })}
+            label="📝 GCP Project ID"
+            placeholder="ex: skynet-dev-172969"
+            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
+          text="Save Settings"
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+GCRForm.contextType = Context;
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 111 - 0
dashboard/src/main/home/integrations/edit-integration/GKEForm.tsx

@@ -0,0 +1,111 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import InputRow from "components/values-form/InputRow";
+import TextArea from "components/values-form/TextArea";
+import SaveButton from "components/SaveButton";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+type PropsType = {
+  closeForm: () => void;
+};
+
+type StateType = {
+  clusterName: string;
+  clusterEndpoint: string;
+  clusterCA: string;
+  serviceAccountKey: string;
+};
+
+export default class GKEForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: "",
+    clusterEndpoint: "",
+    clusterCA: "",
+    serviceAccountKey: "",
+  };
+
+  isDisabled = (): boolean => {
+    let {
+      clusterName,
+      clusterEndpoint,
+      clusterCA,
+      serviceAccountKey,
+    } = this.state;
+    if (
+      clusterName === "" ||
+      clusterEndpoint === "" ||
+      clusterCA === "" ||
+      serviceAccountKey === ""
+    ) {
+      return true;
+    }
+    return false;
+  };
+
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  };
+
+  render() {
+    return (
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type="text"
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label="🏷️ Cluster Name"
+            placeholder="ex: briny-pagelet"
+            width="100%"
+          />
+          <InputRow
+            type="text"
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label="🌐 Cluster Endpoint"
+            placeholder="ex: 00.00.000.00"
+            width="100%"
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label="🔏 Cluster Certificate"
+            placeholder="(Paste your certificate here)"
+            width="100%"
+          />
+
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <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%"
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text="Save Settings"
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 87 - 27
dashboard/src/main/home/launch/Launch.tsx

@@ -22,7 +22,8 @@ type PropsType = {};
 type StateType = {
   currentTemplate: PorterTemplate | null;
   currentTab: string;
-  porterTemplates: PorterTemplate[];
+  addonTemplates: PorterTemplate[];
+  applicationTemplates: PorterTemplate[];
   loading: boolean;
   error: boolean;
 };
@@ -31,24 +32,40 @@ export default class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
     currentTab: "docker",
-    porterTemplates: [] as PorterTemplate[],
+    addonTemplates: [] as PorterTemplate[],
+    applicationTemplates: [] as PorterTemplate[],
     loading: true,
     error: false,
   };
 
   componentDidMount() {
     api
-      .getTemplates("<token>", {}, {})
+      .getAddonTemplates("<token>", {}, {})
       .then((res) => {
-        this.setState({ porterTemplates: res.data, error: false }, () => {
-          this.state.porterTemplates.sort((a, b) => (a.name > b.name ? 1 : -1));
-          this.state.porterTemplates.sort((a, b) =>
-            a.name === "docker" ? -1 : b.name === "docker" ? 1 : 0
+        this.setState({ addonTemplates: res.data, error: false }, () => {
+          this.state.addonTemplates.sort((a, b) => (a.name > b.name ? 1 : -1));
+          this.setState({
+            loading: false,
+          });
+        });
+      })
+      .catch(() => this.setState({ loading: false, error: true }));
+
+    api
+      .getApplicationTemplates(
+        "<token>",
+        {
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        },
+        {}
+      )
+      .then((res) => {
+        this.setState({ applicationTemplates: res.data, error: false }, () => {
+          this.state.applicationTemplates.sort((a, b) =>
+            a.version > b.version ? 1 : -1
           );
-          // TODO: properly find "docker" template instead of relying on first entry
           this.setState({
             loading: false,
-            currentTemplate: this.state.porterTemplates[0],
           });
         });
       })
@@ -67,8 +84,51 @@ export default class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderTemplateList = () => {
-    let { loading, error, porterTemplates } = this.state;
+  renderApplicationList = () => {
+    let { loading, error, applicationTemplates } = this.state;
+
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (error) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error retrieving templates.
+        </Placeholder>
+      );
+    } else if (applicationTemplates.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No templates found.
+        </Placeholder>
+      );
+    }
+
+    return this.state.applicationTemplates.map(
+      (template: PorterTemplate, i: number) => {
+        let { name, icon, description } = template;
+        if (hardcodedNames[name]) {
+          name = hardcodedNames[name];
+        }
+        return (
+          <TemplateBlock
+            key={i}
+            onClick={() => this.setState({ currentTemplate: template })}
+          >
+            {this.renderIcon(icon)}
+            <TemplateTitle>{name}</TemplateTitle>
+            <TemplateDescription>{description}</TemplateDescription>
+          </TemplateBlock>
+        );
+      }
+    );
+  };
+
+  renderAddonList = () => {
+    let { loading, error, addonTemplates } = this.state;
 
     if (loading) {
       return (
@@ -82,7 +142,7 @@ export default class Templates extends Component<PropsType, StateType> {
           <i className="material-icons">error</i> Error retrieving templates.
         </Placeholder>
       );
-    } else if (porterTemplates.length === 0) {
+    } else if (addonTemplates.length === 0) {
       return (
         <Placeholder>
           <i className="material-icons">category</i> No templates found.
@@ -90,9 +150,8 @@ export default class Templates extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.porterTemplates
-      .filter((t) => t.name.toLowerCase() !== "docker")
-      .map((template: PorterTemplate, i: number) => {
+    return this.state.addonTemplates.map(
+      (template: PorterTemplate, i: number) => {
         let { name, icon, description } = template;
         if (hardcodedNames[name]) {
           name = hardcodedNames[name];
@@ -107,10 +166,11 @@ export default class Templates extends Component<PropsType, StateType> {
             <TemplateDescription>{description}</TemplateDescription>
           </TemplateBlock>
         );
-      });
+      }
+    );
   };
 
-  renderDefaultTemplate = () => {
+  renderApplicationTemplates = () => {
     if (!this.context.currentCluster) {
       return (
         <>
@@ -133,21 +193,23 @@ export default class Templates extends Component<PropsType, StateType> {
     if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
-          currentTemplate={this.state.porterTemplates[0]}
+          currentTab={this.state.currentTab}
+          currentTemplate={this.state.currentTemplate}
           setCurrentTemplate={(currentTemplate: PorterTemplate) =>
             this.setState({ currentTemplate })
           }
-          skipDescription={true}
+          skipDescription={false}
         />
       );
     }
-    return null;
+    return <TemplateList>{this.renderApplicationList()}</TemplateList>;
   };
 
-  renderCommunityTemplates = () => {
+  renderAddonTemplates = () => {
     if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
+          currentTab={this.state.currentTab}
           currentTemplate={this.state.currentTemplate}
           setCurrentTemplate={(currentTemplate: PorterTemplate) =>
             this.setState({ currentTemplate })
@@ -155,7 +217,7 @@ export default class Templates extends Component<PropsType, StateType> {
         />
       );
     }
-    return <TemplateList>{this.renderTemplateList()}</TemplateList>;
+    return <TemplateList>{this.renderAddonList()}</TemplateList>;
   };
 
   render() {
@@ -176,14 +238,13 @@ export default class Templates extends Component<PropsType, StateType> {
           setCurrentTab={(value: string) =>
             this.setState({
               currentTab: value,
-              currentTemplate:
-                value === "docker" ? this.state.porterTemplates[0] : null,
+              currentTemplate: null,
             })
           }
         />
         {this.state.currentTab === "docker"
-          ? this.renderDefaultTemplate()
-          : this.renderCommunityTemplates()}
+          ? this.renderApplicationTemplates()
+          : this.renderAddonTemplates()}
       </TemplatesWrapper>
     );
   }
@@ -331,7 +392,6 @@ const TitleSection = styled.div`
       font-size: 18px;
       margin-left: 18px;
       color: #858faaaa;
-      cursor: pointer;
       :hover {
         color: #aaaabb;
       }

+ 12 - 8
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -10,6 +10,7 @@ import Loading from "components/Loading";
 
 type PropsType = {
   currentTemplate: PorterTemplate;
+  currentTab: string;
   setCurrentTemplate: (x: PorterTemplate) => void;
   skipDescription?: boolean;
 };
@@ -41,15 +42,16 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
 
   fetchTemplateInfo = () => {
     this.setState({ loading: true });
+    let params =
+      this.props.currentTab == "docker"
+        ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
+        : {};
+
     api
-      .getTemplateInfo(
-        "<token>",
-        {},
-        {
-          name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: "latest",
-        }
-      )
+      .getTemplateInfo("<token>", params, {
+        name: this.props.currentTemplate.name.toLowerCase().trim(),
+        version: "latest",
+      })
       .then((res) => {
         let { form, values, markdown, metadata } = res.data;
         let keywords = metadata.keywords;
@@ -82,6 +84,7 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
     if (this.props.skipDescription || this.state.showLaunchTemplate) {
       return (
         <LaunchTemplate
+          currentTab={this.props.currentTab}
           currentTemplate={this.props.currentTemplate}
           hideLaunch={() => this.setState({ showLaunchTemplate: false })}
           hideBackButton={this.props.skipDescription}
@@ -94,6 +97,7 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
     return (
       <FadeWrapper>
         <TemplateInfo
+          currentTab={this.props.currentTab}
           currentTemplate={this.props.currentTemplate}
           setCurrentTemplate={this.props.setCurrentTemplate}
           launchTemplate={() => this.setState({ showLaunchTemplate: true })}

+ 357 - 94
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -1,10 +1,11 @@
 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 close from "assets/close.png";
+import { RouteComponentProps, withRouter } from "react-router";
 
 import {
   ActionConfigType,
@@ -20,10 +21,12 @@ 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 RadioSelector from "components/RadioSelector";
 import { isAlphanumeric } from "shared/common";
 
-type PropsType = {
+type PropsType = RouteComponentProps & {
   currentTemplate: any;
+  currentTab: string;
   hideLaunch: () => void;
   values: any;
   form: any;
@@ -47,26 +50,29 @@ type StateType = {
   namespaceOptions: { label: string; value: string }[];
   actionConfig: ActionConfigType;
   branch: string;
-  pathIsSet: boolean;
+  repoType: string;
+  dockerfilePath: string | null;
+  folderPath: string | null;
+  selectedRegistry: any | null;
+  env: any;
 };
 
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
   git_repo_id: 0,
-  dockerfile_path: "",
 };
 
-export default class LaunchTemplate extends Component<PropsType, StateType> {
+class LaunchTemplate extends Component<PropsType, StateType> {
   state = {
     currentView: "repo",
     clusterOptions: [] as { label: string; value: string }[],
     clusterMap: {} as { [clusterId: string]: ClusterType },
-    saveValuesStatus: "No container image specified" as string | null,
+    saveValuesStatus: "" as string | null,
     selectedCluster: this.context.currentCluster.name,
     selectedNamespace: "default",
     selectedImageUrl: "" as string | null,
-    sourceType: "registry",
+    sourceType: "",
     templateName: "",
     selectedTag: "" as string | null,
     tabOptions: [] as ChoiceType[],
@@ -75,21 +81,34 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     namespaceOptions: [] as { label: string; value: string }[],
     actionConfig: { ...defaultActionConfig },
     branch: "",
-    pathIsSet: false,
+    repoType: "",
+    dockerfilePath: null as string | null,
+    folderPath: null as string | null,
+    selectedRegistry: null as any | null,
+    env: {},
   };
 
   createGHAction = (chartName: string, chartNamespace: string) => {
     let { currentProject, currentCluster } = this.context;
     let { actionConfig } = this.state;
+    let imageRepoUri = `${this.state.selectedRegistry.url}/${chartName}-${chartNamespace}`;
+
+    // DockerHub registry integration is per repo
+    if (this.state.selectedRegistry.service === "dockerhub") {
+      imageRepoUri = this.state.selectedRegistry.url;
+    }
 
     api
       .createGHAction(
         "<token>",
         {
           git_repo: actionConfig.git_repo,
-          image_repo_uri: actionConfig.image_repo_uri,
-          dockerfile_path: actionConfig.dockerfile_path,
+          registry_id: this.state.selectedRegistry.id,
+          dockerfile_path: this.state.dockerfilePath,
+          folder_path: this.state.folderPath,
+          image_repo_uri: imageRepoUri,
           git_repo_id: actionConfig.git_repo_id,
+          env: this.state.env,
         },
         {
           project_id: currentProject.id,
@@ -131,23 +150,23 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
-        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,
+          setTimeout(() => {
+            this.props.history.push("cluster-dashboard");
+          }, 500);
+          window.analytics.track("Deployed Add-on", {
+            name: this.props.currentTemplate.name,
+            namespace: this.state.selectedNamespace,
+            values: values,
+          });
         });
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
         setCurrentError(err.response.data.errors[0]);
-        posthog.capture("Failed to deploy template", {
+        window.analytics.track("Failed to Deploy Add-on", {
           name: this.props.currentTemplate.name,
           namespace: this.state.selectedNamespace,
           values: values,
@@ -156,14 +175,14 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       });
   };
 
-  onSubmit = (rawValues: any) => {
+  onSubmit = async (rawValues: any) => {
     let { currentCluster, currentProject } = this.context;
     let name =
       this.state.templateName || randomWords({ exactly: 3, join: "-" });
     this.setState({ saveValuesStatus: "loading" });
 
     // Convert dotted keys to nested objects
-    let values = {};
+    let values : any = {};
     for (let key in rawValues) {
       _.set(values, key, rawValues[key]);
     }
@@ -206,16 +225,35 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }
 
     _.set(values, "ingress.provider", provider);
+    var url : string
+
+    // check if template is docker and create external domain if necessary
+    if (this.props.currentTemplate.name == "web") {
+      if (values?.ingress?.enabled && values?.ingress?.hosts?.length == 0) {
+        url = await new Promise((resolve, reject) => {
+          api.createSubdomain(
+            "<token>",
+            {
+              release_name: this.props.currentTemplate.name.toLowerCase().trim(),
+            },
+            {
+              id: currentProject.id,
+              cluster_id: currentCluster.id,
+            }
+          ).then((res) => {
+            resolve(res.data?.external_url)
+          })
+          .catch((err) => {
+            this.setState({ saveValuesStatus: "error" });
+          });
+        })
+
+        values.ingress.hosts = [url]
+        values.ingress.custom_domain = true
+      }
+    }
 
-    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}
-    `);
+    console.log("VALUES ARE", values)
 
     api
       .deployTemplate(
@@ -233,57 +271,121 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           cluster_id: currentCluster.id,
           name: this.props.currentTemplate.name.toLowerCase().trim(),
           version: "latest",
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
         }
       )
       .then((_) => {
+        console.log("Deployed template.");
         if (this.state.sourceType === "repo") {
+          console.log("Creating GHA");
           this.createGHAction(name, this.state.selectedNamespace);
         }
         // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard with namespace
+          setTimeout(() => {
+            this.props.history.push("cluster-dashboard");
+          }, 1000);
         });
+        /*
         try {
-          posthog.capture("Deployed template", {
+          window.analytics.track("Deployed Application", {
             name: this.props.currentTemplate.name,
             namespace: this.state.selectedNamespace,
+            sourceType: this.state.sourceType,
             values: values,
           });
         } catch (error) {
           console.log(error);
         }
+        */
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
-
+        /*
         try {
-          posthog.capture("Failed to deploy template", {
+          window.analytics.track("Failed to Deploy Application", {
             name: this.props.currentTemplate.name,
             namespace: this.state.selectedNamespace,
+            sourceType: this.state.sourceType,
             values: values,
             error: err,
           });
         } catch (error) {
           console.log(error);
         }
+        */
       });
   };
 
+  submitIsDisabled = () => {
+    let {
+      templateName,
+      sourceType,
+      selectedImageUrl,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+
+    // Allow if name is invalid
+    if (templateName.length > 0 && !isAlphanumeric(templateName)) {
+      return true;
+    }
+
+    if (this.props.form?.hasSource) {
+      // Allow if source type is registry and image URL is specified
+      if (sourceType === "registry" && selectedImageUrl) {
+        return false;
+      }
+
+      // Allow if source type is repo and dockerfile or folder path is set
+      if (sourceType === "repo" && (dockerfilePath || folderPath)) {
+        return !this.state.selectedRegistry;
+      }
+
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  getStatus = () => {
+    let {
+      selectedRegistry,
+      sourceType,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+
+    if (this.submitIsDisabled()) {
+      if (
+        sourceType === "repo" &&
+        (dockerfilePath || folderPath) &&
+        !selectedRegistry
+      ) {
+        return "A connected container registry is required";
+      }
+      let { templateName } = this.state;
+      if (templateName.length > 0 && !isAlphanumeric(templateName)) {
+        return "Template name contains illegal characters";
+      }
+      return "No application source specified";
+    } else {
+      return this.state.saveValuesStatus;
+    }
+  };
+
   renderTabContents = () => {
     return (
       <ValuesWrapper
         formTabs={this.props.form?.tabs}
         onSubmit={
-          this.props.currentTemplate.name === "docker"
+          this.props.currentTab === "docker"
             ? this.onSubmit
             : this.onSubmitAddon
         }
-        saveValuesStatus={this.state.saveValuesStatus}
-        disabled={
-          (this.state.templateName.length > 0 &&
-            !isAlphanumeric(this.state.templateName)) ||
-          (this.props.form?.hasSource ? !this.state.selectedImageUrl : false)
-        }
+        saveValuesStatus={this.getStatus()}
+        disabled={this.submitIsDisabled()}
       >
         {(metaState: any, setMetaState: any) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {
@@ -292,6 +394,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
               return (
                 <ValuesForm
                   metaState={metaState}
+                  handleEnvChange={(x: any) => this.setState({ env: x })}
                   setMetaState={setMetaState}
                   key={tab.name}
                   sections={tab.sections}
@@ -366,11 +469,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   setSelectedImageUrl = (x: string) => {
-    if (x === "") {
-      this.setState({ saveValuesStatus: "No container image specified" });
-    } else {
-      this.setState({ saveValuesStatus: "" });
-    }
     this.setState({ selectedImageUrl: x });
   };
 
@@ -390,6 +488,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     if (this.state.tabOptions.length > 0) {
       return (
         <>
+          <Heading>Additional Settings</Heading>
           <Subtitle>
             Configure additional settings for this template. (Optional)
           </Subtitle>
@@ -428,18 +527,45 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelectorContent = () => {
-    if (this.state.sourceType === "registry") {
+    if (this.state.sourceType === "") {
       return (
-        <>
+        <BlockList>
+          <Block
+            onClick={() => {
+              this.setState({ sourceType: "repo" });
+            }}
+          >
+            <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
+            <BlockTitle>Git Repository</BlockTitle>
+            <BlockDescription>
+              Deploy using source from a Git repo.
+            </BlockDescription>
+          </Block>
+          <Block
+            onClick={() => {
+              this.setState({ sourceType: "registry" });
+            }}
+          >
+            <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
+            <BlockTitle>Docker Registry</BlockTitle>
+            <BlockDescription>
+              Deploy a container from an image registry.
+            </BlockDescription>
+          </Block>
+        </BlockList>
+      );
+    } else if (this.state.sourceType === "registry") {
+      return (
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
           <Subtitle>
-            Select the container image you would like to connect to this
-            template
-            {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
-              link a git repository
-            </Highlight> */}
-            .<Required>*</Required>
+            Specify the container image you would like to connect to this
+            template.
+            <Required>*</Required>
           </Subtitle>
-          <DarkMatter />
+          <DarkMatter antiHeight="-4px" />
           <ImageSelector
             selectedTag={this.state.selectedTag}
             selectedImageUrl={this.state.selectedImageUrl}
@@ -448,19 +574,48 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             forceExpanded={true}
           />
           <br />
-        </>
+        </StyledSourceBox>
+      );
+    } else if (this.state.repoType === "" && false) {
+      return (
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Subtitle>
+            Are you using an existing Dockerfile from your repo?
+            <Required>*</Required>
+          </Subtitle>
+          <RadioSelector
+            options={[
+              {
+                value: "dockerfile",
+                label: "Yes, I am using an existing Dockerfile",
+              },
+              {
+                value: "buildpack",
+                label: "No, I am not using an existing Dockerfile",
+              },
+            ]}
+            selected={this.state.repoType}
+            setSelected={(x: string) => this.setState({ repoType: x })}
+          />
+        </StyledSourceBox>
       );
     } else {
       return (
-        <>
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
           <Subtitle>
-            Select a repo to connect to, then a Dockerfile to build from.
+            Provide a repo folder to use as source.
             <Required>*</Required>
           </Subtitle>
+          <DarkMatter antiHeight="-4px" />
           <ActionConfEditor
             actionConfig={this.state.actionConfig}
             branch={this.state.branch}
-            pathIsSet={this.state.pathIsSet}
             setActionConfig={(actionConfig: ActionConfigType) =>
               this.setState({ actionConfig }, () => {
                 this.setSelectedImageUrl(
@@ -469,40 +624,41 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
               })
             }
             setBranch={(branch: string) => this.setState({ branch })}
-            setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+            setDockerfilePath={(x: string) =>
+              this.setState({ dockerfilePath: x })
+            }
+            dockerfilePath={this.state.dockerfilePath}
+            folderPath={this.state.folderPath}
+            setFolderPath={(x: string) => this.setState({ folderPath: x })}
             reset={() => {
               this.setState({
                 actionConfig: { ...defaultActionConfig },
                 branch: "",
-                pathIsSet: false,
+                dockerfilePath: null,
+                folderPath: null,
               });
             }}
+            setSelectedRegistry={(x: any) => {
+              this.setState({ selectedRegistry: x });
+            }}
+            selectedRegistry={this.state.selectedRegistry}
           />
           <br />
-        </>
+        </StyledSourceBox>
       );
     }
   };
 
   renderSourceSelector = () => {
-    if (!this.props.form?.hasSource) {
-      return;
-    }
-
     return (
       <>
-        <TabRegion
-          options={[
-            { label: "Registry", value: "registry" },
-            { label: "Github", value: "repo" },
-          ]}
-          currentTab={this.state.sourceType}
-          setCurrentTab={(x) => this.setState({ sourceType: x })}
-        >
-          <StyledSourceBox>
-            {this.renderSourceSelectorContent()}
-          </StyledSourceBox>
-        </TabRegion>
+        <Heading>Deployment Method</Heading>
+        <Subtitle>
+          Choose the deployment method you would like to use for this
+          application.
+          <Required>*</Required>
+        </Subtitle>
+        {this.renderSourceSelectorContent()}
       </>
     );
   };
@@ -513,19 +669,15 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
     return (
       <StyledLaunchTemplate>
-        {name !== "docker" && (
-          <HeaderSection>
-            <i className="material-icons" onClick={this.props.hideLaunch}>
-              keyboard_backspace
-            </i>
-            {icon
-              ? this.renderIcon(icon)
-              : this.renderIcon(currentTemplate.icon)}
-            <Title>{name}</Title>
-          </HeaderSection>
-        )}
+        <HeaderSection>
+          <i className="material-icons" onClick={this.props.hideLaunch}>
+            keyboard_backspace
+          </i>
+          {icon ? this.renderIcon(icon) : this.renderIcon(currentTemplate.icon)}
+          <Title>{name}</Title>
+        </HeaderSection>
         <DarkMatter antiHeight="-13px" />
-        <Heading isAtTop={name !== "docker"}>Name</Heading>
+        <Heading isAtTop={true}>Name</Heading>
         <Subtitle>
           Randomly generated if left blank.
           <Warning
@@ -545,6 +697,9 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           placeholder="ex: doctor-scientist"
           width="100%"
         />
+
+        {this.props.form?.hasSource && this.renderSourceSelector()}
+
         <Heading>Destination</Heading>
         <Subtitle>
           Specify the cluster and namespace you would like to deploy your
@@ -582,7 +737,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
-        {this.renderSourceSelector()}
         {this.renderSettingsRegion()}
       </StyledLaunchTemplate>
     );
@@ -590,6 +744,111 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 }
 
 LaunchTemplate.contextType = Context;
+export default withRouter(LaunchTemplate);
+
+const Bold = styled.div`
+  font-weight: bold;
+  color: white;
+  margin-right: 5px;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const BlockIcon = styled.img<{ bw?: boolean }>`
+  height: 38px;
+  padding: 2px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${(props) => (props.disabled ? "" : "#ffffff11")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
 
 const Title = styled.div`
   font-size: 24px;
@@ -603,6 +862,7 @@ const Title = styled.div`
 const HeaderSection = styled.div`
   display: flex;
   align-items: center;
+  margin-bottom: 30px;
 
   > i {
     cursor: pointer;
@@ -621,7 +881,7 @@ const Heading = styled.div<{ isAtTop?: boolean }>`
   font-weight: 500;
   font-size: 16px;
   margin-bottom: 5px;
-  margin-top: ${(props) => (props.isAtTop ? "30px" : "10px")};
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
   display: flex;
   align-items: center;
 `;
@@ -634,6 +894,7 @@ const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Link = styled.a`
@@ -698,7 +959,8 @@ const NamespaceLabel = styled.div`
 
 const Icon = styled.img`
   width: 21px;
-  margin-right: 10px;
+  margin-right: 6px;
+  margin-left: 10px;
 `;
 
 const Polymer = styled.div`
@@ -725,7 +987,7 @@ const ClusterSection = styled.div`
   font-size: 14px;
   margin-top: 2px;
   font-weight: 500;
-  margin-bottom: 22px;
+  margin-bottom: 32px;
 
   > i {
     font-size: 25px;
@@ -769,10 +1031,11 @@ const StyledSourceBox = styled.div`
   height: 100%;
   background: #ffffff11;
   color: #ffffff;
-  padding: 10px 35px 25px;
+  padding: 14px 35px 20px;
   position: relative;
   border-radius: 5px;
   font-size: 13px;
+  margin-top: 6px;
   overflow: auto;
   margin-bottom: 25px;
 `;

+ 2 - 1
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -12,6 +12,7 @@ import hardcodedNames from "../hardcodedNameDict";
 
 type PropsType = {
   currentTemplate: any;
+  currentTab: string;
   setCurrentTemplate: (x: PorterTemplate) => void;
   launchTemplate: () => void;
   markdown: string | null;
@@ -73,7 +74,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
           </Banner>
         </>
       );
-    } else if (this.props.currentTemplate.name.toLowerCase() === "docker") {
+    } else if (this.props.currentTab == "docker") {
       return (
         <>
           <Br />

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

@@ -7,6 +7,9 @@ const hardcodedNames: { [key: string]: string } = {
   postgresql: "PostgreSQL",
   redis: "Redis",
   ubuntu: "Ubuntu",
+  web: "Web Service",
+  worker: "Worker",
+  job: "Job",
 };
 
 export default hardcodedNames;

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

@@ -182,6 +182,7 @@ const Placeholder = styled.div`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Highlight = styled.div`

+ 62 - 3
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -9,6 +9,7 @@ import { InfraType } from "shared/types";
 
 import SelectRow from "components/values-form/SelectRow";
 import InputRow from "components/values-form/InputRow";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import SaveButton from "components/SaveButton";
@@ -28,6 +29,7 @@ type StateType = {
   awsSecretKey: string;
   selectedInfras: { value: string; label: string }[];
   buttonStatus: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -41,7 +43,7 @@ const regionOptions = [
   { value: "us-west-1", label: "US West (N. California) us-west-1" },
   { value: "us-west-2", label: "US West (Oregon) us-west-2" },
   { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
-  { value: "ap-east-1", label: "Asia Pacific (Hong Kong)ap-east-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
   { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
   { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
   { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
@@ -66,6 +68,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
     awsSecretKey: "",
     selectedInfras: [...provisionOptions],
     buttonStatus: "",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -88,6 +91,10 @@ class AWSFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { awsRegion, awsAccessId, awsSecretKey, selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -202,6 +209,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
 
   // TODO: handle generically (with > 2 steps)
   onCreateAWS = () => {
+    this.setState({ buttonStatus: "loading" });
     let { projectName } = this.props;
     let { selectedInfras } = this.state;
 
@@ -236,6 +244,23 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (
+      !this.state.awsAccessId ||
+      !this.state.awsSecretKey ||
+      !this.state.provisionConfirmed ||
+      this.props.projectName === ""
+    ) {
+      return "Required fields missing";
+    }
+    return this.state.buttonStatus;
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { awsRegion, awsAccessId, awsSecretKey, selectedInfras } = this.state;
@@ -284,7 +309,9 @@ class AWSFormSection extends Component<PropsType, StateType> {
           />
           <Br />
           <Heading>AWS Resources</Heading>
-          <Helper>Porter will provision the following AWS resources</Helper>
+          <Helper>
+            Porter will provision the following AWS resources in your own cloud.
+          </Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}
@@ -292,13 +319,38 @@ class AWSFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three t2.medium instances
+            (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
+            resources. Learn more about EKS pricing
+            <Highlight
+              href="https://aws.amazon.com/eks/pricing/"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text="Submit"
-          disabled={this.checkFormDisabled()}
+          disabled={
+            this.checkFormDisabled() || this.state.buttonStatus === "loading"
+          }
           onClick={this.onCreateAWS}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledAWSFormSection>
@@ -310,6 +362,13 @@ AWSFormSection.contextType = Context;
 
 export default withRouter(AWSFormSection);
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

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

@@ -7,6 +7,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
+import CheckboxRow from "components/values-form/CheckboxRow";
 import SelectRow from "components/values-form/SelectRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
@@ -24,6 +25,7 @@ type StateType = {
   selectedInfras: { value: string; label: string }[];
   subscriptionTier: string;
   doRegion: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -56,6 +58,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     selectedInfras: [...provisionOptions],
     subscriptionTier: "starter",
     doRegion: "nyc1",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -78,6 +81,10 @@ export default class DOFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -143,6 +150,17 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (!this.state.provisionConfirmed || this.props.projectName === "") {
+      return "Required fields missing";
+    }
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { selectedInfras, subscriptionTier, doRegion } = this.state;
@@ -174,7 +192,8 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           <Br />
           <Heading>DigitalOcean Resources</Heading>
           <Helper>
-            Porter will provision the following DigitalOcean resources
+            Porter will provision the following DigitalOcean resources in your
+            own cloud.
           </Helper>
           <CheckboxList
             options={provisionOptions}
@@ -183,6 +202,28 @@ export default class DOFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three Standard (2vCPUs /
+            2GB RAM) droplets. DigitalOcean will bill you for any provisioned
+            resources. Learn more about DOKS pricing
+            <Highlight
+              href="https://www.digitalocean.com/products/kubernetes/"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
@@ -190,6 +231,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           disabled={this.checkFormDisabled()}
           onClick={this.onCreateDO}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledAWSFormSection>
@@ -199,6 +241,13 @@ export default class DOFormSection extends Component<PropsType, StateType> {
 
 DOFormSection.contextType = Context;
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 61 - 2
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -8,6 +8,7 @@ import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
 import SelectRow from "components/values-form/SelectRow";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
@@ -28,6 +29,7 @@ type StateType = {
   gcpKeyData: string;
   selectedInfras: { value: string; label: string }[];
   buttonStatus: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -69,6 +71,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
     gcpKeyData: "",
     selectedInfras: [...provisionOptions],
     buttonStatus: "",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -91,6 +94,10 @@ class GCPFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -216,6 +223,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
   // TODO: handle generically (with > 2 steps)
   onCreateGCP = () => {
+    this.setState({ buttonStatus: "loading" });
     let { projectName } = this.props;
 
     if (!projectName) {
@@ -225,6 +233,23 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (
+      !this.state.gcpProjectId ||
+      !this.state.gcpKeyData ||
+      !this.state.provisionConfirmed ||
+      this.props.projectName === ""
+    ) {
+      return "Required fields missing";
+    }
+    return this.state.buttonStatus;
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
@@ -273,7 +298,9 @@ class GCPFormSection extends Component<PropsType, StateType> {
           />
           <Br />
           <Heading>GCP Resources</Heading>
-          <Helper>Porter will provision the following GCP resources</Helper>
+          <Helper>
+            Porter will provision the following GCP resources in your own cloud.
+          </Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}
@@ -281,13 +308,38 @@ class GCPFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three e2-medium instances
+            (2vCPUs and 4GB RAM each). Google Cloud will bill you for any
+            provisioned resources. Learn more about GKE pricing
+            <Highlight
+              href="https://cloud.google.com/kubernetes-engine/pricing"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text="Submit"
-          disabled={this.checkFormDisabled()}
+          disabled={
+            this.checkFormDisabled() || this.state.buttonStatus === "loading"
+          }
           onClick={this.onCreateGCP}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledGCPFormSection>
@@ -299,6 +351,13 @@ GCPFormSection.contextType = Context;
 
 export default withRouter(GCPFormSection);
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

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

@@ -2,7 +2,6 @@ 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";
@@ -138,8 +137,9 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
       }
 
       if (err) {
-        posthog.capture("Provisioning Error", { error: err });
-
+        window.analytics.track("Provisioning Error", {
+          error: err,
+        });
         let e = ansiparse(err).map((el: any) => {
           return el.text;
         });

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

@@ -203,6 +203,7 @@ const PositionWrapper = styled.div<{ selectedProvider: string | null }>``;
 const Highlight = styled.div`
   margin-left: 5px;
   color: #8590ff;
+  display: inline-block;
   cursor: pointer;
 `;
 
@@ -219,6 +220,7 @@ const BlockList = styled.div`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Icon = styled.img<{ bw?: boolean }>`

+ 8 - 8
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -103,7 +103,7 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
             onClick={() =>
               currentView !== "provisioner" &&
-              this.props.history.push("dashboard?tab=overview")
+              this.props.history.push("/dashboard?tab=overview")
             }
             selected={
               currentView === "dashboard" || currentView === "provisioner"
@@ -113,7 +113,7 @@ class Sidebar extends Component<PropsType, StateType> {
             Dashboard
           </NavButton>
           <NavButton
-            onClick={() => this.props.history.push("launch")}
+            onClick={() => this.props.history.push("/launch")}
             selected={currentView === "launch"}
           >
             <Img src={rocket} />
@@ -121,12 +121,12 @@ class Sidebar extends Component<PropsType, StateType> {
           </NavButton>
           <NavButton
             selected={currentView === "integrations"}
-            // onClick={() => {
-            //   this.props.history.push("integrations");
-            // }}
             onClick={() => {
-              setCurrentModal("IntegrationsInstructionsModal", {});
+              this.props.history.push("/integrations");
             }}
+            // onClick={() => {
+            //   setCurrentModal("IntegrationsInstructionsModal", {});
+            // }}
           >
             <Img src={integrations} />
             Integrations
@@ -135,7 +135,7 @@ class Sidebar extends Component<PropsType, StateType> {
             return obj.user_id === this.context.user.userId;
           })[0].kind === "admin" && (
             <NavButton
-              onClick={() => this.props.history.push("project-settings")}
+              onClick={() => this.props.history.push("/project-settings")}
               selected={this.props.currentView === "project-settings"}
             >
               <Img enlarge={true} src={settings} />
@@ -284,7 +284,7 @@ const BottomSection = styled.div`
 const DiscordButton = styled.a`
   position: absolute;
   text-decoration: none;
-  bottom: 15px;
+  bottom: 17px;
   display: flex;
   align-items: center;
   width: calc(100% - 30px);

+ 70 - 10
dashboard/src/shared/api.tsx

@@ -98,9 +98,12 @@ const createGCR = baseApi<
 const createGHAction = baseApi<
   {
     git_repo: string;
+    registry_id: number;
     image_repo_uri: string;
     dockerfile_path: string;
+    folder_path: string;
     git_repo_id: number;
+    env: any;
   },
   {
     project_id: number;
@@ -140,6 +143,20 @@ const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
   return `/api/projects`;
 });
 
+const createSubdomain = baseApi<
+  {
+    release_name: string;
+  },
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  let { cluster_id, id } = pathParams;
+
+  return `/api/projects/${id}/k8s/subdomain?cluster_id=${cluster_id}`;
+});
+
 const deleteCluster = baseApi<
   {},
   {
@@ -175,9 +192,14 @@ const deployTemplate = baseApi<
     cluster_id: number;
     name: string;
     version: string;
+    repo_url?: string;
   }
 >("POST", (pathParams) => {
-  let { cluster_id, id, name, version } = pathParams;
+  let { cluster_id, id, name, version, repo_url } = pathParams;
+
+  if (repo_url) {
+    return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}&repo_url=${repo_url}`;
+  }
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
 
@@ -348,6 +370,22 @@ const getMatchingPods = baseApi<
   return `/api/projects/${pathParams.id}/k8s/pods`;
 });
 
+const getMetrics = baseApi<
+  {
+    cluster_id: number;
+    metric: string;
+    shouldsum: boolean;
+    pods: string[];
+    namespace: string;
+    startrange: number;
+    endrange: number;
+    resolution: string;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/metrics`;
+});
+
 const getNamespaces = baseApi<
   {
     cluster_id: number;
@@ -385,6 +423,15 @@ const getProjects = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/users/${pathParams.id}/projects`;
 });
 
+const getPrometheusIsInstalled = baseApi<
+  {
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/prometheus/detect`;
+});
+
 const getRegistryIntegrations = baseApi("GET", "/api/integrations/registry");
 
 const getReleaseToken = baseApi<
@@ -451,14 +498,23 @@ const getRevisions = baseApi<
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
 });
 
-const getTemplateInfo = baseApi<{}, { name: string; version: string }>(
-  "GET",
-  (pathParams) => {
-    return `/api/templates/${pathParams.name}/${pathParams.version}`;
-  }
-);
+const getTemplateInfo = baseApi<
+  {
+    repo_url?: string;
+  },
+  { name: string; version: string }
+>("GET", (pathParams) => {
+  return `/api/templates/${pathParams.name}/${pathParams.version}`;
+});
+
+const getAddonTemplates = baseApi("GET", "/api/templates");
 
-const getTemplates = baseApi("GET", "/api/templates");
+const getApplicationTemplates = baseApi<
+  {
+    repo_url?: string;
+  },
+  {}
+>("GET", "/api/templates");
 
 const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/users/${pathParams.id}`;
@@ -532,7 +588,7 @@ const uninstallTemplate = baseApi<
   }
 >("POST", (pathParams) => {
   let { id, name, cluster_id, storage, namespace } = pathParams;
-  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
+  return `/api/projects/${id}/delete/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
 
 const updateUser = baseApi<
@@ -578,6 +634,7 @@ export default {
   deleteCluster,
   deleteInvite,
   deleteProject,
+  createSubdomain,
   deployTemplate,
   destroyEKS,
   destroyGKE,
@@ -598,19 +655,22 @@ export default {
   getIngress,
   getInvites,
   getMatchingPods,
+  getMetrics,
   getNamespaces,
   getOAuthIds,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,
   getProjects,
+  getPrometheusIsInstalled,
   getRegistryIntegrations,
   getReleaseToken,
   getRepoIntegrations,
   getRepos,
   getRevisions,
   getTemplateInfo,
-  getTemplates,
+  getAddonTemplates,
+  getApplicationTemplates,
   getUser,
   linkGithubProject,
   logInUser,

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

@@ -165,5 +165,4 @@ export interface ActionConfigType {
   git_repo: string;
   image_repo_uri: string;
   git_repo_id: number;
-  dockerfile_path: string;
 }

+ 1 - 0
dashboard/webpack.config.js

@@ -59,6 +59,7 @@ module.exports = () => {
     plugins: [
       new HtmlWebpackPlugin({
         template: path.resolve(__dirname, "src", "index.html"),
+        segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
       }),
       new webpack.DefinePlugin(envKeys),
     ],

+ 1 - 0
go.mod

@@ -36,6 +36,7 @@ require (
 	github.com/google/go-github/v33 v33.0.0
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/googleapis/gnostic v0.2.2 // indirect
+	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.4.2

+ 3 - 0
go.sum

@@ -513,6 +513,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
 github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
 github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
 github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
+github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
 github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
 github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
 github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
@@ -1498,6 +1500,7 @@ k8s.io/gengo v0.0.0-20190822140433-26a664648505/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8
 k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/helm v2.16.12+incompatible h1:K2zhF8+B85Ya1n7n3eH34xwwp5qNUM42TBFENDZJT7w=
 k8s.io/helm v2.16.12+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI=
+k8s.io/helm v2.17.0+incompatible h1:Bpn6o1wKLYqKM3+Osh8e+1/K2g/GsQJ4F4yNF2+deao=
 k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=

+ 1 - 0
internal/config/config.go

@@ -29,6 +29,7 @@ type ServerConf struct {
 	TimeoutIdle          time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 	IsLocal              bool          `env:"IS_LOCAL,default=false"`
 	IsTesting            bool          `env:"IS_TESTING,default=false"`
+	AppRootDomain        string        `env:"APP_ROOT_DOMAIN,default=porter.run"`
 
 	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
 

+ 1 - 1
internal/forms/chart.go

@@ -4,7 +4,7 @@ import "net/url"
 
 // ChartForm is the base type for CRUD operations on charts
 type ChartForm struct {
-	RepoURL string
+	RepoURL string 
 	Name    string `json:"name"`
 	Version string `json:"version"`
 }

+ 8 - 0
internal/forms/domain.go

@@ -0,0 +1,8 @@
+package forms
+
+// CreateDomainForm represents the accepted values for creating a DNS record
+type CreateDomainForm struct {
+	*K8sForm
+
+	ReleaseName string `json:"release_name" form:"required"`
+}

+ 16 - 9
internal/forms/git_action.go

@@ -7,11 +7,14 @@ import (
 // CreateGitAction represents the accepted values for creating a
 // github action integration
 type CreateGitAction struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
-	GitRepo        string `json:"git_repo" form:"required"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
-	DockerfilePath string `json:"dockerfile_path" form:"required"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
+	ReleaseID      uint              `json:"release_id" form:"required"`
+	GitRepo        string            `json:"git_repo" form:"required"`
+	ImageRepoURI   string            `json:"image_repo_uri" form:"required"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id" form:"required"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }
 
 // ToGitActionConfig converts the form to a gorm git action config model
@@ -21,13 +24,17 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 		GitRepo:        ca.GitRepo,
 		ImageRepoURI:   ca.ImageRepoURI,
 		DockerfilePath: ca.DockerfilePath,
+		FolderPath:     ca.FolderPath,
 		GitRepoID:      ca.GitRepoID,
 	}, nil
 }
 
 type CreateGitActionOptional struct {
-	GitRepo        string `json:"git_repo"`
-	ImageRepoURI   string `json:"image_repo_uri"`
-	DockerfilePath string `json:"dockerfile_path"`
-	GitRepoID      uint   `json:"git_repo_id"`
+	GitRepo        string            `json:"git_repo"`
+	ImageRepoURI   string            `json:"image_repo_uri"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }

+ 12 - 0
internal/forms/metrics.go

@@ -0,0 +1,12 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+)
+
+// MetricsQueryForm is the form for querying pod usage metrics (cpu, memory)
+type MetricsQueryForm struct {
+	*K8sForm
+
+	*prometheus.QueryOpts
+}

+ 43 - 7
internal/integrations/ci/actions/actions.go

@@ -26,10 +26,12 @@ type GithubActions struct {
 
 	WebhookToken string
 	PorterToken  string
+	BuildEnv     map[string]string
 	ProjectID    uint
 	ReleaseName  string
 
 	DockerFilePath string
+	FolderPath     string
 	ImageRepoURL   string
 
 	defaultBranch string
@@ -69,6 +71,13 @@ func (g *GithubActions) Setup() (string, error) {
 		return "", err
 	}
 
+	// create a new secret with the build variables
+	err = g.createEnvSecret(client)
+
+	if err != nil {
+		return "", err
+	}
+
 	fileBytes, err := g.GetGithubActionYAML()
 
 	if err != nil {
@@ -107,6 +116,20 @@ type GithubActionYAML struct {
 }
 
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
+	gaSteps := []GithubActionYAMLStep{
+		getCheckoutCodeStep(),
+		getDownloadPorterStep(),
+		getConfigurePorterStep(g.getPorterTokenSecretName()),
+	}
+
+	if g.DockerFilePath == "" {
+		gaSteps = append(gaSteps, getBuildPackPushStep(g.getBuildEnvSecretName(), g.FolderPath, g.ImageRepoURL))
+	} else {
+		gaSteps = append(gaSteps, getDockerBuildPushStep(g.getBuildEnvSecretName(), g.DockerFilePath, g.ImageRepoURL))
+	}
+
+	gaSteps = append(gaSteps, deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL))
+
 	actionYAML := &GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
@@ -119,13 +142,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-deploy": {
 				RunsOn: "ubuntu-latest",
-				Steps: []GithubActionYAMLStep{
-					getCheckoutCodeStep(),
-					getDownloadPorterStep(),
-					getConfigurePorterStep(g.getPorterTokenSecretName()),
-					getDockerBuildPushStep(g.DockerFilePath, g.ImageRepoURL),
-					deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL),
-				},
+				Steps:  gaSteps,
 			},
 		},
 	}
@@ -195,12 +212,31 @@ func (g *GithubActions) createGithubSecret(
 	return nil
 }
 
+func (g *GithubActions) createEnvSecret(client *github.Client) error {
+	// convert the env object to a string
+	lines := make([]string, 0)
+
+	for key, val := range g.BuildEnv {
+		lines = append(lines, fmt.Sprintf(`%s=%s`, key, val))
+	}
+
+	secretName := g.getBuildEnvSecretName()
+
+	return g.createGithubSecret(client, secretName, strings.Join(lines, "\n"))
+}
+
 func (g *GithubActions) getWebhookSecretName() string {
 	return fmt.Sprintf("WEBHOOK_%s", strings.Replace(
 		strings.ToUpper(g.ReleaseName), "-", "_", -1),
 	)
 }
 
+func (g *GithubActions) getBuildEnvSecretName() string {
+	return fmt.Sprintf("ENV_%s", strings.Replace(
+		strings.ToUpper(g.ReleaseName), "-", "_", -1),
+	)
+}
+
 func (g *GithubActions) getPorterYMLFileName() string {
 	return fmt.Sprintf("porter_%s.yml", strings.Replace(
 		strings.ToLower(g.ReleaseName), "-", "_", -1),

+ 23 - 5
internal/integrations/ci/actions/steps.go

@@ -31,7 +31,7 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 }
 
 const configure string = `
-porter auth login --token ${{secrets.%s}}
+sudo porter auth login --token ${{secrets.%s}}
 sudo porter docker configure
 `
 
@@ -44,15 +44,33 @@ func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
 }
 
 const dockerBuildPush string = `
-docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
-docker push %s:$(git rev-parse --short HEAD)
+export $(echo "${{secrets.%s}}" | xargs)
+sudo docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
+sudo docker push %s:$(git rev-parse --short HEAD)
 `
 
-func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep {
+func getDockerBuildPushStep(envSecretName, dockerFilePath, repoURL string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Docker build, push",
 		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(dockerBuildPush, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
+		Run:  fmt.Sprintf(dockerBuildPush, envSecretName, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
+	}
+}
+
+const buildPackPush string = `
+export $(echo "${{secrets.%s}}" | xargs)
+sudo add-apt-repository ppa:cncf-buildpacks/pack-cli
+sudo apt-get update
+sudo apt-get install pack-cli
+sudo pack build %s:$(git rev-parse --short HEAD) --path %s --builder heroku/buildpacks:18
+sudo docker push %s:$(git rev-parse --short HEAD)
+`
+
+func getBuildPackPushStep(envSecretName, folderPath, repoURL string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Docker build, push",
+		ID:   "docker_build_push",
+		Run:  fmt.Sprintf(buildPackPush, envSecretName, repoURL, folderPath, repoURL),
 	}
 }
 

+ 9 - 0
internal/kubernetes/agent.go

@@ -110,6 +110,15 @@ func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
 	)
 }
 
+// GetJob gets the job by name and namespace
+func (a *Agent) GetJob(c grapher.Object) (*batchv1.Job, error) {
+	return a.Clientset.BatchV1().Jobs(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
 // GetPodsByLabel retrieves pods with matching labels
 func (a *Agent) GetPodsByLabel(selector string) (*v1.PodList, error) {
 	// Search in all namespaces for matching pods

+ 210 - 0
internal/kubernetes/domain/domain.go

@@ -0,0 +1,210 @@
+package domain
+
+import (
+	"context"
+	"fmt"
+	"math/rand"
+	"net"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/api/extensions/v1beta1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+)
+
+// GetNGINXIngressServiceIP retrieves the external address of the nginx-ingress service
+func GetNGINXIngressServiceIP(clientset kubernetes.Interface) (string, bool, error) {
+	svcList, err := clientset.CoreV1().Services("").List(context.TODO(), metav1.ListOptions{
+		LabelSelector: "app.kubernetes.io/component=controller,app.kubernetes.io/managed-by=Helm",
+	})
+
+	if err != nil {
+		return "", false, err
+	}
+
+	var nginxSvc *v1.Service
+	exists := false
+
+	for _, svc := range svcList.Items {
+		// check that helm chart annotation is correct exists
+		if chartAnn, found := svc.Annotations["helm.sh/chart"]; found {
+			if strings.Contains(chartAnn, "ingress-nginx") && svc.Spec.Type == v1.ServiceTypeLoadBalancer {
+				nginxSvc = &svc
+				exists = true
+			}
+		}
+	}
+
+	return nginxSvc.Spec.LoadBalancerIP, exists, nil
+}
+
+// DNSRecord wraps the gorm DNSRecord model
+type DNSRecord models.DNSRecord
+
+type CreateDNSRecordConfig struct {
+	ReleaseName string
+	RootDomain  string
+	Endpoint    string
+}
+
+// NewDNSRecordForEndpoint generates a random subdomain and returns a DNSRecord
+// model
+func (c *CreateDNSRecordConfig) NewDNSRecordForEndpoint() *models.DNSRecord {
+	const allowed = "123456789abcdefghijklmnopqrstuvwxyz"
+	suffix := make([]byte, 8)
+	for i := range suffix {
+		suffix[i] = allowed[rand.Intn(len(allowed))]
+	}
+
+	subdomain := fmt.Sprintf("%s-%s", c.ReleaseName, string(suffix))
+
+	return &models.DNSRecord{
+		SubdomainPrefix: subdomain,
+		RootDomain:      c.RootDomain,
+		Endpoint:        c.Endpoint,
+		Hostname:        fmt.Sprintf("%s.%s", subdomain, c.RootDomain),
+	}
+}
+
+func (e *DNSRecord) CreateDomain(clientset kubernetes.Interface) error {
+	// determine if IP address or domain
+	err := e.createIngress(clientset)
+
+	if err != nil {
+		return err
+	}
+
+	return e.createServiceWithEndpoint(clientset)
+}
+
+func (e *DNSRecord) createIngress(clientset kubernetes.Interface) error {
+	_, err := clientset.ExtensionsV1beta1().Ingresses("default").Create(
+		context.TODO(),
+		&v1beta1.Ingress{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"kubernetes.io/ingress.class":                  "nginx",
+					"nginx.ingress.kubernetes.io/ssl-redirect":     "true",
+					"nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
+					"nginx.ingress.kubernetes.io/upstream-vhost":   e.Hostname,
+				},
+				Name:      e.SubdomainPrefix,
+				Namespace: "default",
+			},
+			Spec: v1beta1.IngressSpec{
+				Rules: []v1beta1.IngressRule{
+					{
+						Host: fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain),
+						IngressRuleValue: v1beta1.IngressRuleValue{
+							HTTP: &v1beta1.HTTPIngressRuleValue{
+								Paths: []v1beta1.HTTPIngressPath{
+									{
+										Backend: v1beta1.IngressBackend{
+											ServiceName: e.SubdomainPrefix,
+											ServicePort: intstr.IntOrString{
+												Type:   intstr.Int,
+												IntVal: 443,
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		metav1.CreateOptions{},
+	)
+
+	return err
+}
+
+func (e *DNSRecord) createServiceWithEndpoint(clientset kubernetes.Interface) error {
+	// determine if endpoint needs to be created or external name is ok
+	isIPv4 := net.ParseIP(e.Endpoint) != nil
+
+	svcSpec := v1.ServiceSpec{
+		Ports: []v1.ServicePort{
+			{
+				Port: 80,
+				TargetPort: intstr.IntOrString{
+					Type:   intstr.Int,
+					IntVal: 80,
+				},
+				Name: "http",
+			},
+			{
+				Port: 443,
+				TargetPort: intstr.IntOrString{
+					Type:   intstr.Int,
+					IntVal: 443,
+				},
+				Name: "https",
+			},
+		},
+	}
+
+	// case service spec on ipv4
+	if isIPv4 {
+		svcSpec.ClusterIP = "None"
+	} else {
+		svcSpec.Type = "ExternalName"
+		svcSpec.ExternalName = e.Endpoint
+	}
+
+	// create service
+	_, err := clientset.CoreV1().Services("default").Create(
+		context.TODO(),
+		&v1.Service{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      e.SubdomainPrefix,
+				Namespace: "default",
+			},
+			Spec: svcSpec,
+		},
+		metav1.CreateOptions{},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if isIPv4 {
+		_, err = clientset.CoreV1().Endpoints("default").Create(
+			context.TODO(),
+			&v1.Endpoints{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      e.SubdomainPrefix,
+					Namespace: "default",
+				},
+				Subsets: []v1.EndpointSubset{
+					{
+						Addresses: []v1.EndpointAddress{
+							{
+								IP: e.Endpoint,
+							},
+						},
+						Ports: []v1.EndpointPort{
+							{
+								Name: "http",
+								Port: 80,
+							},
+							{
+								Name: "https",
+								Port: 443,
+							},
+						},
+					},
+				},
+			},
+			metav1.CreateOptions{},
+		)
+	}
+
+	return err
+}

+ 151 - 0
internal/kubernetes/prometheus/metrics.go

@@ -0,0 +1,151 @@
+package prometheus
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// returns the prometheus service name
+func GetPrometheusService(clientset kubernetes.Interface) (*v1.Service, bool, error) {
+	services, err := clientset.CoreV1().Services("").List(context.TODO(), metav1.ListOptions{
+		LabelSelector: "app=prometheus,component=server,heritage=Helm",
+	})
+
+	if err != nil {
+		return nil, false, err
+	}
+
+	if len(services.Items) == 0 {
+		return nil, false, nil
+	}
+
+	return &services.Items[0], true, nil
+}
+
+type QueryOpts struct {
+	Metric     string   `schema:"metric"`
+	ShouldSum  bool     `schema:"shouldsum"`
+	PodList    []string `schema:"pods"`
+	Namespace  string   `schema:"namespace"`
+	StartRange uint     `schema:"startrange"`
+	EndRange   uint     `schema:"endrange"`
+	Resolution string   `schema:"resolution"`
+}
+
+func QueryPrometheus(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *QueryOpts,
+) ([]byte, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("prometheus service has no exposed ports to query")
+	}
+
+	podSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
+	query := ""
+
+	if opts.Metric == "cpu" {
+		query = fmt.Sprintf("rate(container_cpu_usage_seconds_total{%s}[5m])", podSelector)
+	} else if opts.Metric == "memory" {
+		query = fmt.Sprintf("container_memory_usage_bytes{%s}", podSelector)
+	} else if opts.Metric == "network" {
+		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, strings.Join(opts.PodList, "|"))
+		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
+	}
+
+	if opts.ShouldSum {
+		query = fmt.Sprintf("sum(%s)", query)
+	}
+
+	queryParams := map[string]string{
+		"query": query,
+		"start": fmt.Sprintf("%d", opts.StartRange),
+		"end":   fmt.Sprintf("%d", opts.EndRange),
+		"step":  opts.Resolution,
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/query_range",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseQuery(rawQuery, opts.Metric)
+}
+
+type promRawQuery struct {
+	Data struct {
+		Result []struct {
+			Metric struct {
+				Pod string `json:"pod,omitempty"`
+			} `json:"metric,omitempty"`
+
+			Values [][]interface{} `json:"values"`
+		} `json:"result"`
+	} `json:"data"`
+}
+
+type promParsedSingletonQueryResult struct {
+	Date   interface{} `json:"date,omitempty"`
+	CPU    interface{} `json:"cpu,omitempty"`
+	Memory interface{} `json:"memory,omitempty"`
+	Bytes  interface{} `json:"bytes,omitempty"`
+}
+
+type promParsedSingletonQuery struct {
+	Pod     string                           `json:"pod,omitempty"`
+	Results []promParsedSingletonQueryResult `json:"results"`
+}
+
+func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
+	rawQueryObj := &promRawQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	res := make([]*promParsedSingletonQuery, 0)
+
+	for _, result := range rawQueryObj.Data.Result {
+		singleton := &promParsedSingletonQuery{
+			Pod: result.Metric.Pod,
+		}
+
+		singletonResults := make([]promParsedSingletonQueryResult, 0)
+
+		for _, values := range result.Values {
+			singletonResult := &promParsedSingletonQueryResult{
+				Date: values[0],
+			}
+
+			if metric == "cpu" {
+				singletonResult.CPU = values[1]
+			} else if metric == "memory" {
+				singletonResult.Memory = values[1]
+			} else if metric == "network" {
+				singletonResult.Bytes = values[1]
+			}
+
+			singletonResults = append(singletonResults, *singletonResult)
+		}
+
+		singleton.Results = singletonResults
+
+		res = append(res, singleton)
+	}
+
+	return json.Marshal(res)
+}

+ 40 - 0
internal/models/dns_record.go

@@ -0,0 +1,40 @@
+package models
+
+import (
+	"fmt"
+
+	"gorm.io/gorm"
+)
+
+// DNSRecord type that extends gorm.Model
+type DNSRecord struct {
+	gorm.Model
+
+	SubdomainPrefix string `json:"subdomain_prefix" gorm:"unique"`
+	RootDomain      string `json:"root_domain"`
+
+	Endpoint string `json:"endpoint"`
+	Hostname string `json:"hostname"`
+
+	ClusterID uint `json:"cluster_id"`
+}
+
+// DNSRecordExternal represents the DNSRecord type that is sent over REST
+type DNSRecordExternal struct {
+	ExternalURL string `json:"external_url"`
+
+	Endpoint string `json:"endpoint"`
+	Hostname string `json:"hostname"`
+
+	ClusterID uint `json:"cluster_id"`
+}
+
+// Externalize generates an external Project to be shared over REST
+func (p *DNSRecord) Externalize() *DNSRecordExternal {
+	return &DNSRecordExternal{
+		ExternalURL: fmt.Sprintf("%s.%s", p.SubdomainPrefix, p.RootDomain),
+		Endpoint:    p.Endpoint,
+		Hostname:    p.Hostname,
+		ClusterID:   p.ClusterID,
+	}
+}

+ 9 - 2
internal/models/gitrepo.go

@@ -14,7 +14,7 @@ type GitRepo struct {
 	ProjectID uint `json:"project_id"`
 
 	// The username/organization that this repo integration is linked to
-	RepoEntity string `json:"repo_entity"`
+	RepoEntity string `json:"repo_entity" gorm:"unique"`
 
 	// The various auth mechanisms available to the integration
 	OAuthIntegrationID uint
@@ -62,7 +62,10 @@ type GitActionConfig struct {
 	GitRepoID uint `json:"git_repo_id"`
 
 	// The path to the dockerfile in the git repo
-	DockerfilePath string `json:"dockerfile_path" form:"required"`
+	DockerfilePath string `json:"dockerfile_path"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
 }
 
 // GitActionConfigExternal is an external GitActionConfig to be shared over REST
@@ -78,6 +81,9 @@ type GitActionConfigExternal struct {
 
 	// The path to the dockerfile in the git repo
 	DockerfilePath string `json:"dockerfile_path" form:"required"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
 }
 
 // Externalize generates an external GitActionConfig to be shared over REST
@@ -87,5 +93,6 @@ func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
 		ImageRepoURI:   r.ImageRepoURI,
 		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,
+		FolderPath:     r.FolderPath,
 	}
 }

+ 8 - 1
internal/models/registry.go

@@ -71,11 +71,18 @@ func (r *Registry) Externalize() *RegistryExternal {
 		serv = integrations.DockerHub
 	}
 
+	uri := r.URL
+
+	// remove the protocol
+	if splStr := strings.Split(uri, "://"); len(splStr) > 1 {
+		uri = splStr[1]
+	}
+
 	return &RegistryExternal{
 		ID:        r.ID,
 		ProjectID: r.ProjectID,
 		Name:      r.Name,
-		URL:       r.URL,
+		URL:       uri,
 		Service:   serv,
 		InfraID:   r.InfraID,
 	}

+ 42 - 0
internal/registry/registry.go

@@ -373,6 +373,48 @@ func (r *Registry) setTokenCacheFunc(
 	}
 }
 
+// CreateRepository creates a repository for a registry, if needed
+// (currently only required for ECR)
+func (r *Registry) CreateRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	// if aws, create repository
+	if r.AWSIntegrationID != 0 {
+		return r.createECRRepository(repo, name)
+	}
+
+	// otherwise, no-op
+	return nil
+}
+
+func (r *Registry) createECRRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	aws, err := repo.AWSIntegration.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return err
+	}
+
+	svc := ecr.New(sess)
+
+	_, err = svc.CreateRepository(&ecr.CreateRepositoryInput{
+		RepositoryName: &name,
+	})
+
+	return err
+}
+
 // ListImages lists the images for an image repository
 func (r *Registry) ListImages(
 	repoName string,

+ 11 - 0
internal/repository/dns_record.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// DNSRecordRepository represents the set of queries on the
+// DNSRecord model
+type DNSRecordRepository interface {
+	CreateDNSRecord(record *models.DNSRecord) (*models.DNSRecord, error)
+}

+ 6 - 0
internal/repository/gorm/cluster.go

@@ -252,6 +252,12 @@ func (repo *ClusterRepository) UpdateClusterTokenCache(
 		return nil, err
 	}
 
+	// delete the existing token cache first
+	if err := ctxDB.Where("id = ?", tokenCache.ID).Unscoped().Delete(&cluster.TokenCache).Error; err != nil {
+		return nil, err
+	}
+
+	// set the new token cache
 	cluster.TokenCache.Token = tokenCache.Token
 	cluster.TokenCache.Expiry = tokenCache.Expiry
 

+ 27 - 0
internal/repository/gorm/dns_record.go

@@ -0,0 +1,27 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// DNSRecordRepository uses gorm.DB for querying the database
+type DNSRecordRepository struct {
+	db *gorm.DB
+}
+
+// NewDNSRecordRepository returns a DNSRecordRepository which uses
+// gorm.DB for querying the database
+func NewDNSRecordRepository(db *gorm.DB) repository.DNSRecordRepository {
+	return &DNSRecordRepository{db}
+}
+
+// CreateDNSRecord creates a new helm repo
+func (repo *DNSRecordRepository) CreateDNSRecord(record *models.DNSRecord) (*models.DNSRecord, error) {
+	if err := repo.db.Create(record).Error; err != nil {
+		return nil, err
+	}
+
+	return record, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -21,6 +21,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GitActionConfig:  NewGitActionConfigRepository(db),
 		Invite:           NewInviteRepository(db),
 		AuthCode:         NewAuthCodeRepository(db),
+		DNSRecord:        NewDNSRecordRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 36 - 0
internal/repository/memory/dns_record.go

@@ -0,0 +1,36 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// DNSRecordRepository implements repository.DNSRecordRepository
+type DNSRecordRepository struct {
+	canQuery   bool
+	dnsRecords []*models.DNSRecord
+}
+
+// NewDNSRecordRepository will return errors if canQuery is false
+func NewDNSRecordRepository(canQuery bool) repository.DNSRecordRepository {
+	return &DNSRecordRepository{
+		canQuery,
+		[]*models.DNSRecord{},
+	}
+}
+
+// CreateDNSRecord creates a new repoistry
+func (repo *DNSRecordRepository) CreateDNSRecord(
+	record *models.DNSRecord,
+) (*models.DNSRecord, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.dnsRecords = append(repo.dnsRecords, record)
+	record.ID = uint(len(repo.dnsRecords))
+
+	return record, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -17,6 +17,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		GitRepo:          NewGitRepoRepository(canQuery),
 		Invite:           NewInviteRepository(canQuery),
 		AuthCode:         NewAuthCodeRepository(canQuery),
+		DNSRecord:        NewDNSRecordRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 1 - 0
internal/repository/repository.go

@@ -14,6 +14,7 @@ type Repository struct {
 	GitActionConfig  GitActionConfigRepository
 	Invite           InviteRepository
 	AuthCode         AuthCodeRepository
+	DNSRecord        DNSRecordRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 0 - 12
package-lock.json

@@ -1,12 +0,0 @@
-{
-  "requires": true,
-  "lockfileVersion": 1,
-  "dependencies": {
-    "prettier": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
-      "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
-      "dev": true
-    }
-  }
-}

+ 2 - 0
server/api/deploy_handler.go

@@ -146,6 +146,8 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
+			BuildEnv:       form.GithubActionConfig.BuildEnv,
+			RegistryID:     form.GithubActionConfig.RegistryID,
 		}
 
 		// validate the form

+ 99 - 0
server/api/dns_record_handler.go

@@ -0,0 +1,99 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
+)
+
+// HandleCreateProjectCluster creates a new cluster
+func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.CreateDomainForm{
+		K8sForm: &forms.K8sForm{
+			OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	endpoint, found, err := domain.GetNGINXIngressServiceIP(agent.Clientset)
+
+	if !found {
+		app.handleErrorInternal(fmt.Errorf("target cluster does not have nginx ingress"), w)
+		return
+	}
+
+	createDomain := domain.CreateDNSRecordConfig{
+		ReleaseName: form.ReleaseName,
+		RootDomain:  app.ServerConf.AppRootDomain,
+		Endpoint:    endpoint,
+	}
+
+	record := createDomain.NewDNSRecordForEndpoint()
+
+	record, err = app.Repo.DNSRecord.CreateDNSRecord(record)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// launch provisioning destruction pod
+	inClusterAgent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_record := domain.DNSRecord(*record)
+
+	err = _record.CreateDomain(inClusterAgent.Clientset)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(record.Externalize()); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}

+ 28 - 0
server/api/git_action_handler.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/registry"
 )
 
 // HandleCreateGitAction creates a new Github action in a repository for a given
@@ -81,6 +82,31 @@ func (app *App) createGitActionFromForm(
 		return nil
 	}
 
+	// if the registry was provisioned through Porter, create a repository if necessary
+	if form.RegistryID != 0 {
+		// read the registry
+		reg, err := app.Repo.Registry.ReadRegistry(form.RegistryID)
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return nil
+		}
+
+		_reg := registry.Registry(*reg)
+		regAPI := &_reg
+
+		// parse the name from the registry
+		nameSpl := strings.Split(form.ImageRepoURI, "/")
+		repoName := nameSpl[len(nameSpl)-1]
+
+		err = regAPI.CreateRepository(*app.Repo, repoName)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return nil
+		}
+	}
+
 	// convert the form to a git action config
 	gitAction, err := form.ToGitActionConfig()
 
@@ -136,8 +162,10 @@ func (app *App) createGitActionFromForm(
 		ProjectID:      uint(projID),
 		ReleaseName:    name,
 		DockerFilePath: gitAction.DockerfilePath,
+		FolderPath:     gitAction.FolderPath,
 		ImageRepoURL:   gitAction.ImageRepoURI,
 		PorterToken:    encoded,
+		BuildEnv:       form.BuildEnv,
 	}
 
 	_, err = gaRunner.Setup()

+ 117 - 0
server/api/k8s_handler.go

@@ -2,13 +2,16 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/url"
 
 	"github.com/go-chi/chi"
+	"github.com/gorilla/schema"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -321,3 +324,117 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 		return
 	}
 }
+
+// HandleDetectPrometheusInstalled detects a prometheus installation in the target cluster
+func (app *App) HandleDetectPrometheusInstalled(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	// detect prometheus service
+	_, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if !found {
+		http.NotFound(w, r)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+func (app *App) HandleGetPodMetrics(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.MetricsQueryForm{
+		K8sForm: &forms.K8sForm{
+			OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+		QueryOpts: &prometheus.QueryOpts{},
+	}
+
+	form.K8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// decode from JSON to form value
+	decoder := schema.NewDecoder()
+	decoder.IgnoreUnknownKeys(true)
+
+	if err := decoder.Decode(form.QueryOpts, vals); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	// get prometheus service
+	promSvc, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if !found {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	rawQuery, err := prometheus.QueryPrometheus(agent.Clientset, promSvc, form.QueryOpts)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	fmt.Fprint(w, string(rawQuery))
+}

+ 24 - 2
server/api/release_handler.go

@@ -8,6 +8,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"helm.sh/helm/v3/pkg/release"
@@ -70,7 +71,8 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 // PorterRelease is a helm release with a form attached
 type PorterRelease struct {
 	*release.Release
-	Form *models.FormYAML `json:"form"`
+	Form       *models.FormYAML `json:"form"`
+	HasMetrics bool             `json:"has_metrics"`
 }
 
 // HandleGetRelease retrieves a single release based on a name and revision
@@ -149,7 +151,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		HelmRelease:   release,
 	}
 
-	res := &PorterRelease{release, nil}
+	res := &PorterRelease{release, nil, false}
 
 	for _, file := range release.Chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
@@ -176,6 +178,16 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	// get prometheus service
+	_, found, err := prometheus.GetPrometheusService(agent.K8sAgent.Clientset)
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	res.HasMetrics = found
+
 	if err := json.NewEncoder(w).Encode(res); err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
@@ -357,6 +369,16 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 				return
 			}
 
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "Job":
+			rc, err := k8sAgent.GetJob(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
 			rc.Kind = c.Kind
 			retrievedControllers = append(retrievedControllers, rc)
 		}

+ 16 - 2
server/api/template_handler.go

@@ -17,8 +17,22 @@ import (
 // HandleListTemplates retrieves a list of Porter templates
 // TODO: test and reduce fragility (handle untar/parse error for individual charts)
 // TODO: separate markdown retrieval into its own query if necessary
-func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
-	repoIndex, err := loader.LoadRepoIndexPublic(app.ServerConf.DefaultHelmRepoURL)
+func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {		
+	
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	repoURL := app.ServerConf.DefaultHelmRepoURL
+
+	if inputRepoURL, ok := vals["repo_url"]; ok && len(inputRepoURL) == 1 {
+		repoURL = inputRepoURL[0]
+	}
+
+	repoIndex, err := loader.LoadRepoIndexPublic(repoURL)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)

Некоторые файлы не были показаны из-за большого количества измененных файлов