jusrhee 5 лет назад
Родитель
Сommit
5afbea2034
95 измененных файлов с 2388 добавлено и 2351 удалено
  1. 8 9
      .github/ISSUE_TEMPLATE/bug.md
  2. 3 4
      .github/ISSUE_TEMPLATE/change.md
  3. 2 3
      .github/ISSUE_TEMPLATE/feature.md
  4. 8 5
      .github/PULL_REQUEST_TEMPLATE.md
  5. 46 0
      .github/workflows/dev.yaml
  6. 46 0
      .github/workflows/production.yaml
  7. 44 44
      .github/workflows/release.yaml
  8. 9 0
      .github/workflows/staging.yaml
  9. 16 7
      README.md
  10. 76 0
      cli/cmd/api/registry.go
  11. 3 0
      cli/cmd/auth.go
  12. 52 0
      cli/cmd/connect.go
  13. 76 0
      cli/cmd/connect/dockerhub.go
  14. 76 0
      cli/cmd/connect/registry.go
  15. 50 1
      cli/cmd/docker.go
  16. 1 1
      cli/cmd/version.go
  17. 1 1
      cmd/docker-credential-porter/main.go
  18. 132 32
      cmd/migrate/keyrotate/rotate.go
  19. 2 0
      cmd/migrate/main.go
  20. 0 1
      dashboard/src/App.tsx
  21. 11 11
      dashboard/src/assets/GithubIcon.tsx
  22. 12 2
      dashboard/src/components/image-selector/ImageList.tsx
  23. 79 103
      dashboard/src/components/image-selector/ImageSelector.tsx
  24. 82 43
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  25. 46 41
      dashboard/src/components/repo-selector/ActionDetails.tsx
  26. 1 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  27. 22 2
      dashboard/src/components/repo-selector/RepoList.tsx
  28. 35 36
      dashboard/src/components/repo-selector/RepoSelector.tsx
  29. 36 23
      dashboard/src/components/values-form/InputArray.tsx
  30. 6 11
      dashboard/src/components/values-form/RangeSlider.tsx
  31. 4 4
      dashboard/src/components/values-form/ValuesForm.tsx
  32. 4 1
      dashboard/src/components/values-form/ValuesWrapper.tsx
  33. 79 25
      dashboard/src/index.html
  34. 2 11
      dashboard/src/main/Main.tsx
  35. 34 30
      dashboard/src/main/home/Home.tsx
  36. 2 2
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  37. 16 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  38. 12 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  39. 3 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx
  40. 55 34
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  41. 24 20
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  42. 11 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  43. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  44. 89 74
      dashboard/src/main/home/integrations/IntegrationList.tsx
  45. 2 1
      dashboard/src/main/home/integrations/Integrations.tsx
  46. 78 15
      dashboard/src/main/home/launch/Launch.tsx
  47. 15 3
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  48. 117 93
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  49. 1 1
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  50. 0 0
      dashboard/src/main/home/launch/hardcodedNameDict.tsx
  51. 18 12
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  52. 91 73
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  53. 18 9
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  54. 8 10
      dashboard/src/main/home/sidebar/Sidebar.tsx
  55. 2 2
      dashboard/src/shared/Context.tsx
  56. 35 23
      dashboard/src/shared/api.tsx
  57. 18 17
      dashboard/src/shared/common.tsx
  58. 9 3
      dashboard/src/shared/routing.tsx
  59. 1 1
      dashboard/tsconfig.json
  60. 23 25
      dashboard/webpack.config.js
  61. 4 4
      docker-compose.dev.yaml
  62. 2 2
      docs/GCR.md
  63. 17 18
      docs/GETTING_STARTED.md
  64. 5 7
      helm/templates/service.yaml
  65. 9 5
      helm/values.yaml
  66. 1 1
      internal/config/config.go
  67. 7 0
      internal/forms/git_action.go
  68. 14 12
      internal/forms/registry.go
  69. 3 4
      internal/forms/release.go
  70. 9 11
      internal/helm/grapher/test_yaml/cassandra.yaml
  71. 41 41
      internal/helm/grapher/test_yaml/ingress.yaml
  72. 32 30
      internal/helm/grapher/test_yaml/kafka.yaml
  73. 1 1
      internal/helm/grapher/test_yaml/volumes.yaml
  74. 2 2
      internal/integrations/ci/actions/steps.go
  75. 0 2
      internal/kubernetes/agent.go
  76. 13 4
      internal/kubernetes/config.go
  77. 0 4
      internal/kubernetes/provisioner/global_stream.go
  78. 0 2
      internal/kubernetes/provisioner/resource_stream.go
  79. 2 0
      internal/models/cluster.go
  80. 13 11
      internal/models/integrations/integration.go
  81. 8 3
      internal/models/registry.go
  82. 252 0
      internal/registry/registry.go
  83. 24 12
      internal/repository/gorm/cluster.go
  84. 5 1213
      package-lock.json
  85. 0 3
      server/api/cluster_handler.go
  86. 19 0
      server/api/deploy_handler.go
  87. 30 20
      server/api/git_action_handler.go
  88. 26 2
      server/api/git_repo_handler.go
  89. 3 9
      server/api/integration_handler.go
  90. 0 5
      server/api/integration_handler_test.go
  91. 45 28
      server/api/registry_handler.go
  92. 0 3
      server/api/release_handler_test.go
  93. 19 0
      server/api/user_handler.go
  94. 1 3
      server/router/middleware/auth.go
  95. 24 0
      server/router/router.go

+ 8 - 9
.github/ISSUE_TEMPLATE/bug.md

@@ -1,23 +1,22 @@
 ---
 name: Bug Report
-about: 🐛 Found a bug? Let us know! 
-
+about: 🐛 Found a bug? Let us know!
 ---
 
 # Description
 
-<!-- Please provide a high-level description of what you were trying to accomplish and what went wrong. --> 
+<!-- Please provide a high-level description of what you were trying to accomplish and what went wrong. -->
 
 # Location
 
-- [ ] Browser 
-- [ ] CLI 
+- [ ] Browser
+- [ ] CLI
 - [ ] API
 
 # Steps to reproduce
 
-1. 
-2. 
-3. 
+1.
+2.
+3.
 
-# Additional Details
+# Additional Details

+ 3 - 4
.github/ISSUE_TEMPLATE/change.md

@@ -1,13 +1,12 @@
 ---
 name: Change
-about: 🛠️ Update functionality that already exists. 
-
+about: 🛠️ Update functionality that already exists.
 ---
 
 # Location
 
-- [ ] Browser 
-- [ ] CLI 
+- [ ] Browser
+- [ ] CLI
 - [ ] API
 
 # Motivation

+ 2 - 3
.github/ISSUE_TEMPLATE/feature.md

@@ -1,13 +1,12 @@
 ---
 name: Feature
 about: ✨ Add new functionality to the project.
-
 ---
 
 # Location
 
-- [ ] Browser 
-- [ ] CLI 
+- [ ] Browser
+- [ ] CLI
 - [ ] API
 
 # Requirements

+ 8 - 5
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,28 +1,31 @@
 ## Pull request type
 
-<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed. --> 
+<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed. -->
 
 Please check the type of change your PR introduces:
+
 - [ ] Bugfix
 - [ ] Feature
-- [ ] Other (please describe): 
+- [ ] Other (please describe):
 
 ## Pull request checklist
 
 Please check if your PR fulfills the following requirements:
-- [ ] If it's a backend change, tests for the changes have been added and `go test ./...` runs successfully from the root folder. 
+
+- [ ] If it's a backend change, tests for the changes have been added and `go test ./...` runs successfully from the root folder.
 - [ ] If it's a frontend change, Prettier has been run
 - [ ] Docs have been reviewed and added / updated if needed
 
 ## What is the current behavior?
-<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. 
+
+<!-- Please describe the current behavior that you are modifying, or link to a relevant issue.
 
 Issue Number: N/A
 
 -->
 
-
 ## What is the new behavior?
+
 <!-- Please describe the behavior or changes that are being added by this PR. -->
 
 <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. -->

+ 46 - 0
.github/workflows/dev.yaml

@@ -0,0 +1,46 @@
+name: Deploy to production
+on:
+  push:
+    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

+ 46 - 0
.github/workflows/production.yaml

@@ -0,0 +1,46 @@
+name: Deploy to production
+on:
+  push:
+    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

+ 44 - 44
.github/workflows/release.yaml

@@ -1,7 +1,7 @@
 on:
   push:
     tags:
-    - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+      - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
 
 name: Create release w/ binaries and docker image
 
@@ -9,38 +9,38 @@ jobs:
   docker-build-push:
     runs-on: ubuntu-latest
     steps:
-    - name: Get tag name
-      id: tag_name
-      run: |
-        tag=${GITHUB_TAG/refs\/tags\//}
-        echo ::set-output name=tag::$tag
-      env:
-        GITHUB_TAG: ${{ github.ref }}
-    - name: Checkout
-      uses: actions/checkout@v2.3.4
-    - name: Setup docker
-      uses: docker/login-action@v1
-      with:
-        username: ${{ secrets.DOCKERHUB_USERNAME }}
-        password: ${{ secrets.DOCKERHUB_TOKEN }}
-    - 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}}
-        EOL
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Setup docker
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - 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}}
+          EOL
 
-        cat ./dashboard/.env
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile
-    - name: Push
-      run: |
-        docker push porter1/porter:${{steps.tag_name.outputs.tag}}
+          cat ./dashboard/.env
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile
+      - name: Push
+        run: |
+          docker push porter1/porter:${{steps.tag_name.outputs.tag}}
   build:
     name: Build binaries
     runs-on: ubuntu-latest
@@ -93,7 +93,7 @@ jobs:
       # Note: we have to zip all binaries before uploading them as artifacts --
       # without this step, the binaries will be uploaded but the file metadata will
       # be listed as plaintext after downloading the artifact in a later step
-      # 
+      #
       # TODO: investigate
       - name: Zip Linux binaries
         run: |
@@ -151,7 +151,7 @@ jobs:
       - name: Install gon via HomeBrew for code signing and app notarization
         run: |
           brew tap mitchellh/gon
-          brew install mitchellh/gon/gon  
+          brew install mitchellh/gon/gon
       - name: Create a porter.gon.json file
         run: |
           echo "
@@ -246,7 +246,7 @@ jobs:
           draft: false
           prerelease: true
       - name: Upload Linux CLI Release Asset
-        id: upload-linux-cli-release-asset 
+        id: upload-linux-cli-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -257,7 +257,7 @@ jobs:
           asset_name: porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Linux Server Release Asset
-        id: upload-linux-server-release-asset 
+        id: upload-linux-server-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -268,7 +268,7 @@ jobs:
           asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Linux Docker Credential Release Asset
-        id: upload-linux-docker-cred-release-asset 
+        id: upload-linux-docker-cred-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -279,7 +279,7 @@ jobs:
           asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Darwin CLI Release Asset
-        id: upload-darwin-cli-release-asset 
+        id: upload-darwin-cli-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -290,7 +290,7 @@ jobs:
           asset_name: porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Darwin Server Release Asset
-        id: upload-darwin-server-release-asset 
+        id: upload-darwin-server-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -301,7 +301,7 @@ jobs:
           asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Darwin Docker Credential Release Asset
-        id: upload-darwin-docker-cred-release-asset 
+        id: upload-darwin-docker-cred-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -312,7 +312,7 @@ jobs:
           asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Windows CLI Release Asset
-        id: upload-windows-cli-release-asset 
+        id: upload-windows-cli-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -323,7 +323,7 @@ jobs:
           asset_name: porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Windows Server Release Asset
-        id: upload-windows-server-release-asset 
+        id: upload-windows-server-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -334,7 +334,7 @@ jobs:
           asset_name: portersvr_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Windows Docker Credential Release Asset
-        id: upload-windows-docker-cred-release-asset 
+        id: upload-windows-docker-cred-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -345,7 +345,7 @@ jobs:
           asset_name: docker-credential-porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip
           asset_content_type: application/zip
       - name: Upload Static Release Asset
-        id: upload-static-release-asset 
+        id: upload-static-release-asset
         uses: actions/upload-release-asset@v1
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 9 - 0
.github/workflows/staging.yaml

@@ -13,6 +13,9 @@ jobs:
         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
@@ -35,3 +38,9 @@ jobs:
     - 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

+ 16 - 7
README.md

@@ -1,4 +1,5 @@
-# Porter 
+# Porter
+
 [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/MhYNuWwqum)
 [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow)](https://twitter.com/getporterdev)
 
@@ -13,20 +14,23 @@ For help, questions, or if you just want a place to hang out, [join our Discord
 To keep updated on our progress, please watch the repo for new releases (**Watch > Custom > Releases**) and [follow us on Twitter](https://twitter.com/getporterdev)!
 
 ## Why Porter?
+
 ### A PaaS that grows with your applications
 
-A traditional PaaS like Heroku is great for minimizing unnecessary DevOps work but doesn't offer enough flexibility as your applications grow. Custom network rules, resource constraints, and cost are common reasons developers move their applications off Heroku beyond a certain scale. 
+A traditional PaaS like Heroku is great for minimizing unnecessary DevOps work but doesn't offer enough flexibility as your applications grow. Custom network rules, resource constraints, and cost are common reasons developers move their applications off Heroku beyond a certain scale.
 
 Porter brings the simplicity of a traditional PaaS to your own cloud provider while preserving the configurability of Kubernetes. Porter is built on top of a popular Kubernetes package manager `helm` and is compatible with standard Kubernetes management tools like `kubectl`, preparing your infra for mature DevOps work from day one.
 
 ![image](https://user-images.githubusercontent.com/65516095/103713478-71e75800-4f8a-11eb-915f-adee9d4f5bf7.png)
 
 ## Features
+
 ### Basics
+
 - One-click provisioning of a Kubernetes cluster in your own cloud console
-  - ✅   AWS
-  - ✅   GCP
-  - ✅   Digital Ocean
+  - ✅ AWS
+  - ✅ GCP
+  - ✅ Digital Ocean
 - Simple deploy of any public or private Docker image
 - Heroku-like GUI to monitor application status, logs, and history
 - Marketplace for one click add-ons (e.g. MongoDB, Redis, PostgreSQL)
@@ -35,6 +39,7 @@ Porter brings the simplicity of a traditional PaaS to your own cloud provider wh
 - Native CI/CD with buildpacks for non-Dockerized apps (🚧 Coming Soon)
 
 ### DevOps Mode
+
 For those who are familiar with Kubernetes and Helm:
 
 - Connect to existing Kubernetes clusters that are not provisioned by Porter
@@ -50,7 +55,9 @@ For those who are familiar with Kubernetes and Helm:
 Below are instructions for a quickstart. For full documentation, please visit our [official Docs.](https://docs.getporter.dev)
 
 ## CLI Installation
-### Mac 
+
+### Mac
+
 Run the following command to grab the latest binary:
 
 ```sh
@@ -73,6 +80,7 @@ sudo mv ./porter /usr/local/bin/porter
 For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/docs/cli-documentation#linux).
 
 ## Getting Started
+
 1. Sign up and log into [Porter Dashboard](https://dashboard.getporter.dev).
 
 2. Create a Project and select a cloud provider you want to provision a Kubernetes cluster in (AWS, GCP, DO). It is also possible to [link up your own Kubernetes cluster.](https://docs.getporter.dev/docs/cli-documentation#connecting-to-an-existing-cluster)
@@ -84,6 +92,7 @@ For Linux and Windows installation, see our [Docs](https://docs.getporter.dev/do
 5. From the Templates tab on the Dashboard, select the Docker template. Click on the image you have just pushed, configure the port, then hit deploy.
 
 ## Want to Help?
-We welcome all contributions. Submit an issue or a pull request to help us improve Porter! If you're interested in contributing, please [join our Discord community.](https://discord.gg/MhYNuWwqum) 
+
+We welcome all contributions. Submit an issue or a pull request to help us improve Porter! If you're interested in contributing, please [join our Discord community.](https://discord.gg/MhYNuWwqum)
 
 ![porter](https://user-images.githubusercontent.com/65516095/103712859-def9ee00-4f88-11eb-804c-4b775d697ec4.jpeg)

+ 76 - 0
cli/cmd/api/registry.go

@@ -59,6 +59,53 @@ func (c *Client) CreateECR(
 	return bodyResp, nil
 }
 
+// CreatePrivateRegistryRequest represents the accepted fields for creating
+// a private registry
+type CreatePrivateRegistryRequest struct {
+	Name               string `json:"name"`
+	URL                string `json:"url"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
+}
+
+// CreatePrivateRegistryResponse is the resulting registry after creation
+type CreatePrivateRegistryResponse models.RegistryExternal
+
+// CreatePrivateRegistry creates a private registry integration
+func (c *Client) CreatePrivateRegistry(
+	ctx context.Context,
+	projectID uint,
+	createPR *CreatePrivateRegistryRequest,
+) (*CreatePrivateRegistryResponse, error) {
+	data, err := json.Marshal(createPR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreatePrivateRegistryResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // CreateGCRRequest represents the accepted fields for creating
 // a GCR registry
 type CreateGCRRequest struct {
@@ -290,6 +337,35 @@ func (c *Client) GetGCRAuthorizationToken(
 	return bodyResp, nil
 }
 
+// GetDockerhubAuthorizationToken gets a Docker Hub authorization token
+func (c *Client) GetDockerhubAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+) (*GetTokenResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/dockerhub/token", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bodyResp := &GetTokenResponse{}
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 type GetDOCRTokenRequest struct {
 	ServerURL string `json:"server_url"`
 }

+ 3 - 0
cli/cmd/auth.go

@@ -200,6 +200,9 @@ func loginManual() error {
 		return err
 	}
 
+	// set the token to empty since this is manual (cookie-based) login
+	setToken("")
+
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
 	// get a list of projects, and set the current project

+ 52 - 0
cli/cmd/connect.go

@@ -43,6 +43,30 @@ var connectECRCmd = &cobra.Command{
 	},
 }
 
+var connectDockerhubCmd = &cobra.Command{
+	Use:   "dockerhub",
+	Short: "Adds a Docker Hub registry integration to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectDockerhub)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var connectRegistryCmd = &cobra.Command{
+	Use:   "registry",
+	Short: "Adds a custom image registry to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectRegistry)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectActionsCmd = &cobra.Command{
 	Use:   "actions",
 	Short: "Adds Github Actions to a project",
@@ -127,6 +151,8 @@ func init() {
 
 	connectCmd.AddCommand(connectActionsCmd)
 	connectCmd.AddCommand(connectECRCmd)
+	connectCmd.AddCommand(connectRegistryCmd)
+	connectCmd.AddCommand(connectDockerhubCmd)
 	connectCmd.AddCommand(connectGCRCmd)
 	connectCmd.AddCommand(connectDOCRCmd)
 	connectCmd.AddCommand(connectHRCmd)
@@ -193,6 +219,32 @@ func runConnectDOCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) er
 	return setRegistry(regID)
 }
 
+func runConnectDockerhub(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.Dockerhub(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
+func runConnectRegistry(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.Registry(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
 func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.Helm(
 		client,

+ 76 - 0
cli/cmd/connect/dockerhub.go

@@ -0,0 +1,76 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func Dockerhub(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for dockerhub name
+
+	repoName, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the Docker Hub image path, in the form of ${org_name}/${repo_name}. For example, porter1/porter.
+Image path: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	username, err := utils.PromptPlaintext(fmt.Sprintf(`Docker Hub username: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	password, err := utils.PromptPassword(`Provide the Docker Hub personal access token.
+Token:`)
+
+	if err != nil {
+		return 0, err
+	}
+
+	// create the basic auth integration
+	integration, err := client.CreateBasicAuthIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateBasicAuthIntegrationRequest{
+			Username: username,
+			Password: password,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created basic auth integration with id %d\n", integration.ID)
+
+	reg, err := client.CreatePrivateRegistry(
+		context.Background(),
+		projectID,
+		&api.CreatePrivateRegistryRequest{
+			URL:                fmt.Sprintf("index.docker.io/%s", repoName),
+			Name:               repoName,
+			BasicIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created private registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 76 - 0
cli/cmd/connect/registry.go

@@ -0,0 +1,76 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// Helm connects a Helm repository using HTTP basic authentication
+func Registry(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for helm repo name
+	repoURL, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the image registry URL (include the protocol). For example, https://my-custom-registry.getporter.dev.
+Image registry URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	username, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the username/password for authentication (press enter if no authenicaiton is required).
+Username: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	password, err := utils.PromptPasswordWithConfirmation()
+
+	if err != nil {
+		return 0, err
+	}
+
+	// create the basic auth integration
+	integration, err := client.CreateBasicAuthIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateBasicAuthIntegrationRequest{
+			Username: username,
+			Password: password,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created basic auth integration with id %d\n", integration.ID)
+
+	reg, err := client.CreatePrivateRegistry(
+		context.Background(),
+		projectID,
+		&api.CreatePrivateRegistryRequest{
+			URL:                repoURL,
+			Name:               repoURL,
+			BasicIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created private registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 50 - 1
cli/cmd/docker.go

@@ -2,7 +2,9 @@ package cmd
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"net/url"
 	"os"
@@ -16,6 +18,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/docker/cli/cli/config/configfile"
+	"github.com/docker/cli/cli/config/types"
 )
 
 var dockerCmd = &cobra.Command{
@@ -131,8 +134,54 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		return err
 	}
 
+	if config.CredentialHelpers == nil {
+		config.CredentialHelpers = make(map[string]string)
+	}
+
 	for _, regURL := range regToAdd {
-		config.CredentialHelpers[regURL] = "porter"
+		// if this is a dockerhub registry, see if an auth config has already been generated
+		// for index.docker.io
+		if strings.Contains(regURL, "index.docker.io") {
+			isAuthenticated := false
+
+			for key, _ := range config.AuthConfigs {
+				if key == "https://index.docker.io/v1/" {
+					isAuthenticated = true
+				}
+			}
+
+			if !isAuthenticated {
+				// get a dockerhub token from the Porter API
+				tokenResp, err := client.GetDockerhubAuthorizationToken(context.Background(), getProjectID())
+
+				if err != nil {
+					return err
+				}
+
+				decodedToken, err := base64.StdEncoding.DecodeString(tokenResp.Token)
+
+				if err != nil {
+					return fmt.Errorf("Invalid token: %v", err)
+				}
+
+				parts := strings.SplitN(string(decodedToken), ":", 2)
+
+				if len(parts) < 2 {
+					return fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
+				}
+
+				config.AuthConfigs["https://index.docker.io/v1/"] = types.AuthConfig{
+					Auth:     tokenResp.Token,
+					Username: parts[0],
+					Password: parts[1],
+				}
+
+				// since we're using token-based auth, unset the credstore
+				config.CredentialsStore = ""
+			}
+		} else {
+			config.CredentialHelpers[regURL] = "porter"
+		}
 	}
 
 	return config.Save()

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "v0.1.0-beta.3.4"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 1 - 1
cmd/docker-credential-porter/main.go

@@ -10,7 +10,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "v0.1.0-beta.3.4"
 
 func main() {
 	var versionFlag bool

+ 132 - 32
cmd/migrate/keyrotate/rotate.go

@@ -3,6 +3,8 @@ package keyrotate
 import (
 	"fmt"
 
+	"encoding/hex"
+
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	gorm "github.com/porter-dev/porter/internal/repository/gorm"
@@ -22,6 +24,10 @@ func Rotate(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 
 	fmt.Printf("beginning key rotation from %s to %s\n", string(oldKeyBytes), string(newKeyBytes))
 
+	for i, b := range oldKeyBytes {
+		fmt.Println(i, ":", string(b), string(newKeyBytes[i]))
+	}
+
 	err := rotateClusterModel(db, oldKey, newKey)
 
 	if err != nil {
@@ -127,7 +133,7 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		clusters := []*models.Cluster{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&clusters).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&clusters).Error; err != nil {
 			return err
 		}
 
@@ -138,6 +144,14 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			if err != nil {
 				return err
 			}
+			if err != nil {
+				fmt.Printf("error decrypting cluster %d\n", cluster.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				cluster.CertificateAuthorityData = []byte{}
+				cluster.TokenCache.Token = []byte{}
+			}
 		}
 
 		// encrypt with the new key and re-insert
@@ -145,6 +159,8 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptClusterData(cluster, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting cluster %d\n", cluster.ID)
+
 				return err
 			}
 
@@ -154,7 +170,7 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d clusters", count)
+	fmt.Printf("rotated %d clusters\n", count)
 
 	return nil
 }
@@ -174,7 +190,7 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		ccs := []*models.ClusterCandidate{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&ccs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&ccs).Error; err != nil {
 			return err
 		}
 
@@ -183,7 +199,12 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptClusterCandidateData(cc, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting cluster candidate %d\n", cc.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				cc.AWSClusterIDGuess = []byte{}
+				cc.Kubeconfig = []byte{}
 			}
 		}
 
@@ -192,6 +213,8 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptClusterCandidateData(cc, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting cluster candidate %d\n", cc.ID)
+
 				return err
 			}
 
@@ -201,7 +224,7 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d cluster candidates", count)
+	fmt.Printf("rotated %d cluster candidates\n", count)
 
 	return nil
 }
@@ -221,7 +244,7 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		regs := []*models.Registry{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&regs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&regs).Error; err != nil {
 			return err
 		}
 
@@ -230,7 +253,12 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptRegistryData(reg, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting registry %d\n", reg.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				reg.TokenCache.Token = []byte{}
+
 			}
 		}
 
@@ -239,6 +267,8 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptRegistryData(reg, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting registry %d\n", reg.ID)
+
 				return err
 			}
 
@@ -248,7 +278,7 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d registries", count)
+	fmt.Printf("rotated %d registries\n", count)
 
 	return nil
 }
@@ -268,7 +298,7 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		hrs := []*models.HelmRepo{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&hrs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&hrs).Error; err != nil {
 			return err
 		}
 
@@ -277,7 +307,11 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptHelmRepoData(hr, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting helm repo %d\n", hr.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				hr.TokenCache.Token = []byte{}
 			}
 		}
 
@@ -286,6 +320,8 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptHelmRepoData(hr, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting helm repo %d\n", hr.ID)
+
 				return err
 			}
 
@@ -295,7 +331,7 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d helm repos", count)
+	fmt.Printf("rotated %d helm repos\n", count)
 
 	return nil
 }
@@ -315,7 +351,7 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		infras := []*models.Infra{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&infras).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&infras).Error; err != nil {
 			return err
 		}
 
@@ -324,7 +360,17 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptInfraData(infra, oldKey)
 
 			if err != nil {
-				return err
+				oldKeyBytes := make([]byte, 32)
+				newKeyBytes := make([]byte, 32)
+
+				copy(oldKeyBytes[:], oldKey[:])
+				copy(newKeyBytes[:], newKey[:])
+
+				fmt.Printf("error decrypting infra %d, %s, %s, %s\n", infra.ID, hex.EncodeToString(infra.LastApplied), string(oldKeyBytes), string(newKeyBytes))
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				infra.LastApplied = []byte{}
 			}
 		}
 
@@ -333,6 +379,8 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptInfraData(infra, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting infra %d\n", infra.ID)
+
 				return err
 			}
 
@@ -342,7 +390,7 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d infras", count)
+	fmt.Printf("rotated %d infras\n", count)
 
 	return nil
 }
@@ -362,7 +410,7 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		kis := []*ints.KubeIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&kis).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&kis).Error; err != nil {
 			return err
 		}
 
@@ -371,7 +419,16 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptKubeIntegrationData(ki, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting kube integration %d\n", ki.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				ki.ClientCertificateData = []byte{}
+				ki.ClientKeyData = []byte{}
+				ki.Token = []byte{}
+				ki.Username = []byte{}
+				ki.Password = []byte{}
+				ki.Kubeconfig = []byte{}
 			}
 		}
 
@@ -380,6 +437,8 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptKubeIntegrationData(ki, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting kube integration %d\n", ki.ID)
+
 				return err
 			}
 
@@ -389,7 +448,7 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d kube integrations", count)
+	fmt.Printf("rotated %d kube integrations\n", count)
 
 	return nil
 }
@@ -409,7 +468,7 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		basics := []*ints.BasicIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&basics).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&basics).Error; err != nil {
 			return err
 		}
 
@@ -418,7 +477,12 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptBasicIntegrationData(basic, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting basic integration %d\n", basic.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				basic.Username = []byte{}
+				basic.Password = []byte{}
 			}
 		}
 
@@ -427,6 +491,8 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptBasicIntegrationData(basic, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting basic integration %d\n", basic.ID)
+
 				return err
 			}
 
@@ -436,7 +502,7 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d basic integrations", count)
+	fmt.Printf("rotated %d basic integrations\n", count)
 
 	return nil
 }
@@ -456,7 +522,7 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		oidcs := []*ints.OIDCIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&oidcs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&oidcs).Error; err != nil {
 			return err
 		}
 
@@ -465,7 +531,16 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptOIDCIntegrationData(oidc, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting oidc integration %d\n", oidc.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				oidc.IssuerURL = []byte{}
+				oidc.ClientID = []byte{}
+				oidc.ClientSecret = []byte{}
+				oidc.CertificateAuthorityData = []byte{}
+				oidc.IDToken = []byte{}
+				oidc.RefreshToken = []byte{}
 			}
 		}
 
@@ -474,6 +549,8 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptOIDCIntegrationData(oidc, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting oidc integration %d\n", oidc.ID)
+
 				return err
 			}
 
@@ -483,7 +560,7 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d oidc integrations", count)
+	fmt.Printf("rotated %d oidc integrations\n", count)
 
 	return nil
 }
@@ -503,7 +580,7 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		oauths := []*ints.OAuthIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&oauths).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&oauths).Error; err != nil {
 			return err
 		}
 
@@ -512,7 +589,13 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptOAuthIntegrationData(oauth, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting oauth integration %d\n", oauth.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				oauth.ClientID = []byte{}
+				oauth.AccessToken = []byte{}
+				oauth.RefreshToken = []byte{}
 			}
 		}
 
@@ -521,6 +604,8 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptOAuthIntegrationData(oauth, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting oauth integration %d\n", oauth.ID)
+
 				return err
 			}
 
@@ -530,7 +615,7 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d oauth integrations", count)
+	fmt.Printf("rotated %d oauth integrations\n", count)
 
 	return nil
 }
@@ -550,7 +635,7 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		gcps := []*ints.GCPIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&gcps).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&gcps).Error; err != nil {
 			return err
 		}
 
@@ -559,7 +644,11 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptGCPIntegrationData(gcp, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting gcp integration %d\n", gcp.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				gcp.GCPKeyData = []byte{}
 			}
 		}
 
@@ -568,6 +657,8 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptGCPIntegrationData(gcp, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting gcp integration %d\n", gcp.ID)
+
 				return err
 			}
 
@@ -577,7 +668,7 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d gcp integrations", count)
+	fmt.Printf("rotated %d gcp integrations\n", count)
 
 	return nil
 }
@@ -597,7 +688,7 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		awss := []*ints.AWSIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&awss).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&awss).Error; err != nil {
 			return err
 		}
 
@@ -606,7 +697,14 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptAWSIntegrationData(aws, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error encrypting aws integration %d\n", aws.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				aws.AWSAccessKeyID = []byte{}
+				aws.AWSClusterID = []byte{}
+				aws.AWSSecretAccessKey = []byte{}
+				aws.AWSSessionToken = []byte{}
 			}
 		}
 
@@ -615,6 +713,8 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptAWSIntegrationData(aws, newKey)
 
 			if err != nil {
+				fmt.Printf("error decrypting aws integration %d\n", aws.ID)
+
 				return err
 			}
 
@@ -624,7 +724,7 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d aws integrations", count)
+	fmt.Printf("rotated %d aws integrations\n", count)
 
 	return nil
 }

+ 2 - 0
cmd/migrate/main.go

@@ -77,6 +77,8 @@ func main() {
 }
 
 type RotateConf struct {
+	// we add a dummy field to avoid empty struct issue with envdecode
+	DummyField       string `env:"ASDF,default=asdf"`
 	OldEncryptionKey string `env:"OLD_ENCRYPTION_KEY"`
 	NewEncryptionKey string `env:"NEW_ENCRYPTION_KEY"`
 }

+ 0 - 1
dashboard/src/App.tsx

@@ -7,7 +7,6 @@ type PropsType = {};
 
 type StateType = {};
 
-
 export default class App extends Component<PropsType, StateType> {
   render() {
     return (

+ 11 - 11
dashboard/src/assets/GithubIcon.tsx

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

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

@@ -14,6 +14,7 @@ type PropsType = {
   selectedTag: string | null;
   clickedImage: ImageType | null;
   registry?: any;
+  noTagSelection?: boolean;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
@@ -36,6 +37,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     const { currentProject, setCurrentError } = this.context;
     let images = [] as ImageType[];
     let errors = [] as number[];
+
     if (!this.props.registry) {
       api
         .getProjectRegistries("<token>", {}, { id: currentProject.id })
@@ -91,13 +93,20 @@ export default class ImageSelector extends Component<PropsType, StateType> {
                         }) == registries.length
                           ? true
                           : false;
-
+                      
                       this.setState({
                         images,
                         loading: false,
                         error,
                       });
+                    } else {
+                      this.setState({
+                        images,
+                      })
                     }
+
+                   
+                    
                     resolveToNextController();
                   });
               }
@@ -216,7 +225,8 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
   renderExpanded = () => {
     let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
-    if (!this.props.clickedImage) {
+
+    if (!this.props.clickedImage || this.props.noTagSelection) {
       return (
         <div>
           <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>

+ 79 - 103
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   selectedTag: string | null;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
+  noTagSelection?: boolean;
 };
 
 type StateType = {
@@ -37,81 +38,81 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     clickedImage: null as ImageType | null,
   };
 
-  componentDidMount() {
-    const { currentProject, setCurrentError } = this.context;
-    let images = [] as ImageType[];
-    let errors = [] as number[];
-    api
-      .getProjectRegistries("<token>", {}, { id: currentProject.id })
-      .then(async (res) => {
-        let registries = res.data;
-        if (registries.length === 0) {
-          this.setState({ loading: false });
-        }
-
-        // Loop over connected image registries
-        registries.forEach(async (registry: any, i: number) => {
-          await new Promise((nextController: (res?: any) => void) => {
-            api
-              .getImageRepos(
-                "<token>",
-                {},
-                {
-                  project_id: currentProject.id,
-                  registry_id: registry.id,
-                }
-              )
-              .then((res) => {
-                res.data.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-                // Loop over found image repositories
-                let newImg = res.data.map((img: any) => {
-                  if (this.props.selectedImageUrl === img.uri) {
-                    this.setState({
-                      clickedImage: {
-                        kind: registry.service,
-                        source: img.uri,
-                        name: img.name,
-                        registryId: registry.id,
-                      },
-                    });
-                  }
-                  return {
-                    kind: registry.service,
-                    source: img.uri,
-                    name: img.name,
-                    registryId: registry.id,
-                  };
-                });
-                images.push(...newImg);
-                errors.push(0);
-              })
-              .catch(() => errors.push(1))
-              .finally(() => {
-                if (i == registries.length - 1) {
-                  let error =
-                    errors.reduce((a, b) => {
-                      return a + b;
-                    }) == registries.length
-                      ? true
-                      : false;
-
-                  this.setState({
-                    images,
-                    loading: false,
-                    error,
-                  });
-                }
-
-                nextController();
-              });
-          });
-        });
-      })
-      .catch((err) => {
-        console.log(err);
-        this.setState({ error: true });
-      });
-  }
+  // componentDidMount() {
+  //   const { currentProject, setCurrentError } = this.context;
+  //   let images = [] as ImageType[];
+  //   let errors = [] as number[];
+  //   api
+  //     .getProjectRegistries("<token>", {}, { id: currentProject.id })
+  //     .then(async (res) => {
+  //       let registries = res.data;
+  //       if (registries.length === 0) {
+  //         this.setState({ loading: false });
+  //       }
+
+  //       // Loop over connected image registries
+  //       registries.forEach(async (registry: any, i: number) => {
+  //         await new Promise((nextController: (res?: any) => void) => {
+  //           api
+  //             .getImageRepos(
+  //               "<token>",
+  //               {},
+  //               {
+  //                 project_id: currentProject.id,
+  //                 registry_id: registry.id,
+  //               }
+  //             )
+  //             .then((res) => {
+  //               res.data.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+  //               // Loop over found image repositories
+  //               let newImg = res.data.map((img: any) => {
+  //                 if (this.props.selectedImageUrl === img.uri) {
+  //                   this.setState({
+  //                     clickedImage: {
+  //                       kind: registry.service,
+  //                       source: img.uri,
+  //                       name: img.name,
+  //                       registryId: registry.id,
+  //                     },
+  //                   });
+  //                 }
+  //                 return {
+  //                   kind: registry.service,
+  //                   source: img.uri,
+  //                   name: img.name,
+  //                   registryId: registry.id,
+  //                 };
+  //               });
+  //               images.push(...newImg);
+  //               errors.push(0);
+  //             })
+  //             .catch(() => errors.push(1))
+  //             .finally(() => {
+  //               if (i == registries.length - 1) {
+  //                 let error =
+  //                   errors.reduce((a, b) => {
+  //                     return a + b;
+  //                   }) == registries.length
+  //                     ? true
+  //                     : false;
+
+  //                 this.setState({
+  //                   images,
+  //                   loading: false,
+  //                   error,
+  //                 });
+  //               }
+
+  //               nextController();
+  //             });
+  //         });
+  //       });
+  //     })
+  //     .catch((err) => {
+  //       console.log(err);
+  //       this.setState({ error: true });
+  //     });
+  // }
 
   /*
   <Highlight onClick={() => this.props.setCurrentView('integrations')}>
@@ -173,32 +174,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     }
   };
 
-  renderExpanded = () => {
-    let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
-    if (!this.state.clickedImage) {
-      return (
-        <div>
-          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
-          {this.renderBackButton()}
-        </div>
-      );
-    } else {
-      return (
-        <div>
-          <ExpandedWrapper>
-            <TagList
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedTag={setSelectedTag}
-              registryId={this.state.clickedImage.registryId}
-            />
-          </ExpandedWrapper>
-          {this.renderBackButton()}
-        </div>
-      );
-    }
-  };
-
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
@@ -257,6 +232,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
+            noTagSelection={this.props.noTagSelection}
             setSelectedImageUrl={this.props.setSelectedImageUrl}
             setSelectedTag={this.props.setSelectedTag}
             setClickedImage={(x: ImageType) =>
@@ -319,7 +295,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;
@@ -378,7 +354,7 @@ const Label = styled.div`
 
 const StyledImageSelector = styled.div`
   width: 100%;
-  margin-top: 22px;
+  margin-top: 10px;
   border: 1px solid #ffffff55;
   background: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
     props.isExpanded ? "#ffffff11" : ""};

+ 82 - 43
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -1,33 +1,34 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
+import React, { Component } from "react";
+import styled from "styled-components";
 
-import { ActionConfigType } from '../../shared/types';
-import { Context } from '../../shared/Context';
+import { ActionConfigType } from "shared/types";
+import { Context } from "shared/Context";
 
-import RepoList from './RepoList';
-import BranchList from './BranchList';
-import ContentsList from './ContentsList';
-import ActionDetails from './ActionDetails';
+import RepoList from "./RepoList";
+import BranchList from "./BranchList";
+import ContentsList from "./ContentsList";
+import ActionDetails from "./ActionDetails";
 
 type PropsType = {
-  actionConfig: ActionConfigType | null,
-  branch: string,
-  pathIsSet: boolean,
-  setActionConfig: (x: ActionConfigType) => void,
-  setBranch: (x: string) => void,
-  setPath: (x: boolean) => void,
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  pathIsSet: boolean;
+  setActionConfig: (x: ActionConfigType) => void;
+  setBranch: (x: string) => void;
+  setPath: (x: boolean) => void;
+  reset: () => void;
 };
 
 type StateType = {
-  loading: boolean,
-  error: boolean,
+  loading: boolean;
+  error: boolean;
 };
 
 export default class ActionConfEditor extends Component<PropsType, StateType> {
   state = {
     loading: true,
     error: false,
-  }
+  };
 
   renderExpanded = () => {
     let {
@@ -51,41 +52,55 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
       );
     } else if (!branch) {
       return (
-        <ExpandedWrapperAlt>
-          <BranchList
-            actionConfig={actionConfig}
-            setBranch={(branch: string) => setBranch(branch)}
-          />
-        </ExpandedWrapperAlt>
+        <>
+          <ExpandedWrapperAlt>
+            <BranchList
+              actionConfig={actionConfig}
+              setBranch={(branch: string) => setBranch(branch)}
+            />
+          </ExpandedWrapperAlt>
+          {this.renderResetButton()}
+        </>
       );
     } else if (!pathIsSet) {
       return (
+        <>
+          <ExpandedWrapperAlt>
+            <ContentsList
+              actionConfig={actionConfig}
+              branch={branch}
+              setActionConfig={setActionConfig}
+              setPath={() => setPath(true)}
+            />
+          </ExpandedWrapperAlt>
+          {this.renderResetButton()}
+        </>
+      );
+    }
+    return (
+      <>
         <ExpandedWrapperAlt>
-          <ContentsList
+          <ActionDetails
             actionConfig={actionConfig}
-            branch={branch}
             setActionConfig={setActionConfig}
-            setPath={() => setPath(true)}
           />
         </ExpandedWrapperAlt>
-      );
-    }
-    return (
-      <ExpandedWrapperAlt>
-        <ActionDetails
-          actionConfig={actionConfig}
-          setActionConfig={setActionConfig}
-        />
-      </ExpandedWrapperAlt>
-    )
-  }
+        {this.renderResetButton()}
+      </>
+    );
+  };
 
-  render() {
+  renderResetButton = () => {
     return (
-      <>
-        {this.renderExpanded()}
-      </>
+      <BackButton width="150px" onClick={this.props.reset}>
+        <i className="material-icons">keyboard_backspace</i>
+        Reset Selection
+      </BackButton>
     );
+  };
+
+  render() {
+    return <>{this.renderExpanded()}</>;
   }
 }
 
@@ -100,5 +115,29 @@ const ExpandedWrapper = styled.div`
   overflow-y: auto;
 `;
 
-const ExpandedWrapperAlt = styled(ExpandedWrapper)`
-`;
+const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 46 - 41
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -1,31 +1,32 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
+import ImageSelector from "components/image-selector/ImageSelector";
+import React, { Component } from "react";
+import styled from "styled-components";
 
-import { Context } from '../../shared/Context';
-import { ActionConfigType } from '../../shared/types';
-import InputRow from '../values-form/InputRow';
+import { Context } from "../../shared/Context";
+import { ActionConfigType } from "../../shared/types";
+import InputRow from "../values-form/InputRow";
 
 type PropsType = {
-  actionConfig: ActionConfigType | null,
-  setActionConfig: (x: ActionConfigType) => void,
+  actionConfig: ActionConfigType | null;
+  setActionConfig: (x: ActionConfigType) => void;
 };
 
 type StateType = {
-  dockerRepo: string,
-  error: boolean,
+  dockerRepo: string;
+  error: boolean;
 };
 
 export default class ActionDetails extends Component<PropsType, StateType> {
   state = {
-    dockerRepo: '',
+    dockerRepo: "",
     error: false,
-  }
+  };
 
   componentDidMount() {
     if (this.props.actionConfig.dockerfile_path) {
-      this.setPath('/Dockerfile');
+      this.setPath("/Dockerfile");
     } else {
-      this.setPath('Dockerfile');
+      this.setPath("Dockerfile");
     }
   }
 
@@ -34,58 +35,62 @@ export default class ActionDetails extends Component<PropsType, StateType> {
     let updatedConfig = actionConfig;
     updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.concat(x);
     setActionConfig(updatedConfig);
-  }
+  };
 
   setURL = (x: string) => {
     let { actionConfig, setActionConfig } = this.props;
     let updatedConfig = actionConfig;
     updatedConfig.image_repo_uri = x;
     setActionConfig(updatedConfig);
-  }
+  };
 
   renderConfirmation = () => {
-    let { actionConfig } = this.props;
     return (
       <Holder>
         <InputRow
           disabled={true}
-          label='Git Repository'
-          type='text'
-          width='100%'
-          value={actionConfig.git_repo}
+          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={actionConfig.dockerfile_path}
+          label="Dockerfile Path"
+          type="text"
+          width="100%"
+          value={this.props.actionConfig.dockerfile_path}
           setValue={(x: string) => console.log(x)}
         />
-        <InputRow
-          label='Docker Image Repository'
-          placeholder='Image Repo URI (ex. my-repo/image)'
-          type='text'
-          width='100%'
-          value={actionConfig.image_repo_uri}
-          setValue={(x: string) => this.setURL(x)}
-        />
+        <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>
-    )
-  }
+    );
+  };
 
   render() {
-    return (
-      <div>
-        {this.renderConfirmation()}
-      </div>
-    );
+    return <div>{this.renderConfirmation()}</div>;
   }
 }
 
+const Label = styled.div`
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
 ActionDetails.contextType = Context;
 
 const Holder = styled.div`
-  padding: 0px 12px;
-`;
+  padding: 0px 12px 24px 12px;
+`;

+ 1 - 1
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -160,7 +160,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
     return (
       <FileItem lastItem={false}>
         <img src={info} />
-        Select subfolder (Optional)
+        Select path to Dockerfile
       </FileItem>
     );
   };

+ 22 - 2
dashboard/src/components/repo-selector/RepoList.tsx

@@ -66,7 +66,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
             this.setState({ loading: false, error: false });
           }
         })
-        .catch((err) => this.setState({ loading: false, error: true }));
+        .catch((_) => this.setState({ loading: false, error: true }));
     } else {
       let grid = this.props.userId;
       api
@@ -107,7 +107,18 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
     } else if (error || !repos) {
       return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
     } else if (repos.length == 0) {
-      return <LoadingWrapper>No connected repos found.</LoadingWrapper>;
+      return (
+        <LoadingWrapper>
+          No connected Github repos found. You can
+          <A
+            padRight={true}
+            href={`/api/oauth/projects/${this.context.currentProject.id}/github?redirected=true`}
+          >
+            log in with GitHub
+          </A>{" "}
+          .
+        </LoadingWrapper>
+      );
     }
 
     return repos.map((repo: RepoType, i: number) => {
@@ -205,3 +216,12 @@ const ExpandedWrapperAlt = styled(ExpandedWrapper)`
   max-height: 275px;
   overflow-y: auto;
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+  padding-right: ${(props: { padRight?: boolean }) =>
+    props.padRight ? "5px" : ""};
+`;

+ 35 - 36
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -1,44 +1,41 @@
-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 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';
+import ButtonTray from "./ButtonTray";
+import ActionConfEditor from "./ActionConfEditor";
 
 type PropsType = {
-  chart: ChartType | null,
-  forceExpanded?: boolean,
-  actionConfig: ActionConfigType | null,
-  setActionConfig: (x: ActionConfigType) => void,
+  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,
+  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: '',
+    branch: "",
     pathIsSet: false,
     dockerfileSelected: false,
-  }
+  };
 
   renderExpanded = () => {
-    let {
-      actionConfig,
-      setActionConfig,
-      chart,
-    } = this.props;
-    
+    let { actionConfig, setActionConfig, chart } = this.props;
+
     return (
       <div>
         <ActionConfEditor
@@ -48,6 +45,14 @@ export default class RepoSelector extends Component<PropsType, StateType> {
           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}
@@ -66,13 +71,16 @@ export default class RepoSelector extends Component<PropsType, StateType> {
   renderSelected = () => {
     let { actionConfig } = this.props;
     if (actionConfig.git_repo) {
-      let subdir = actionConfig.dockerfile_path === '' ? '' : '/' + actionConfig.dockerfile_path;
+      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}
+            {!this.state.branch ? "(Select Branch)" : this.state.branch}
           </SelectedBranch>
         </RepoLabel>
       );
@@ -120,15 +128,6 @@ const SelectedBranch = styled.div`
   margin-left: 10px;
 `;
 
-const ExpandedWrapper = styled.div`
-  margin-top: 10px;
-  width: 100%;
-  border-radius: 3px;
-  border: 1px solid #ffffff44;
-  max-height: 275px;
-  overflow-y: auto;
-`;
-
 const RepoLabel = styled.div`
   display: flex;
   align-items: center;

+ 36 - 23
dashboard/src/components/values-form/InputArray.tsx

@@ -11,11 +11,18 @@ type PropsType = {
 type StateType = {};
 
 export default class InputArray extends Component<PropsType, StateType> {
+  dict2arr = (dict: Record<string, any>) => {
+    let arr = [];
+    for (let key in dict) {
+      arr.push(`${key}: ${dict[key]}`);
+    }
+    return arr;
+  };
 
-  renderInputList = () => {
+  renderInputList = (values: string[]) => {
     return (
       <>
-        {this.props.values.map((value: string, i: number) => {
+        {values.map((value: string, i: number) => {
           return (
             <InputWrapper>
               <Input
@@ -23,16 +30,18 @@ export default class InputArray extends Component<PropsType, StateType> {
                 width="270px"
                 value={value}
                 onChange={(e: any) => {
-                  let values = [...this.props.values];
-                  values[i] = e.target.value;
-                  this.props.setValues(values);
+                  let v = [...values];
+                  v[i] = e.target.value;
+                  this.props.setValues(v);
                 }}
               />
-              <DeleteButton onClick={() => {
-                let values = [...this.props.values];
-                values.splice(i, 1);
-                this.props.setValues(values);
-              }}>
+              <DeleteButton
+                onClick={() => {
+                  let v = [...values];
+                  v.splice(i, 1);
+                  this.props.setValues(v);
+                }}
+              >
                 <i className="material-icons">cancel</i>
               </DeleteButton>
             </InputWrapper>
@@ -40,22 +49,26 @@ export default class InputArray extends Component<PropsType, StateType> {
         })}
       </>
     );
-  }
+  };
 
   render() {
+    let { values } = this.props;
+
+    if (!Array.isArray(values)) {
+      values = this.dict2arr(values);
+    }
+
     return (
       <StyledInputArray>
         <Label>{this.props.label}</Label>
-        {
-          this.props.values.length === 0
-          ? <></>
-          : this.renderInputList()
-        }
-        <AddRowButton onClick={() => {
-          let values = [...this.props.values];
-          values.push("");
-          this.props.setValues(values);
-        }}>
+        {values.length === 0 ? <></> : this.renderInputList(values)}
+        <AddRowButton
+          onClick={() => {
+            let v = [...values];
+            v.push("");
+            this.props.setValues(v);
+          }}
+        >
           <i className="material-icons">add</i> Add Row
         </AddRowButton>
       </StyledInputArray>
@@ -97,7 +110,7 @@ const DeleteButton = styled.div`
   margin-left: 8px;
   margin-top: -3px;
   justify-content: center;
-  
+
   > i {
     font-size: 17px;
     color: #ffffff44;
@@ -140,4 +153,4 @@ const Label = styled.div`
 const StyledInputArray = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
-`;
+`;

+ 6 - 11
dashboard/src/components/values-form/RangeSlider.tsx

@@ -1,26 +1,21 @@
 import React, { ChangeEvent, Component } from "react";
-import Slider from '@material-ui/core/Slider';
+import Slider from "@material-ui/core/Slider";
 import styled from "styled-components";
 
-type PropsType = {
-};
+type PropsType = {};
 
-type StateType = {
-};
+type StateType = {};
 
 export default class RangeSelector extends Component<PropsType, StateType> {
-  state = {
-  };
+  state = {};
 
   render() {
     return (
       <StyledInputRow>
-        <Label>
-          asdfasdf
-        </Label>
+        <Label>asdfasdf</Label>
         <Slider
           value={12}
-          onChange={() => console.log('huh')}
+          onChange={() => console.log("huh")}
           valueLabelDisplay="auto"
           aria-labelledby="range-slider"
         />

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

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

+ 4 - 1
dashboard/src/components/values-form/ValuesWrapper.tsx

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

+ 79 - 25
dashboard/src/index.html

@@ -3,31 +3,85 @@
   <head>
     <title>Porter | Dashboard</title>
 
-    <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png">
-    <meta name="description" content="Fully-managed remote dev environments for any team." />
-    <meta property='og:title' content='Porter' />
-    <meta property='og:image' content='https://i.ibb.co/DL4695L/logo-wide.png' />
-    <meta property='og:description' content='Fully-managed remote dev environments for any team.' />
-    <meta property='og:url' content='https://getporter.dev' />
+    <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png" />
+    <meta
+      name="description"
+      content="Fully-managed remote dev environments for any team."
+    />
+    <meta property="og:title" content="Porter" />
+    <meta
+      property="og:image"
+      content="https://i.ibb.co/DL4695L/logo-wide.png"
+    />
+    <meta
+      property="og:description"
+      content="Fully-managed remote dev environments for any team."
+    />
+    <meta property="og:url" content="https://getporter.dev" />
 
-    <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png">
-    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
-    <link href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600" rel="stylesheet">
-  	<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
-  	<link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
-    <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
+    <link rel="icon" href="https://i.ibb.co/Xy0QK6P/dsquare.png" />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons"
+      rel="stylesheet"
+    />
+    <link
+      href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600"
+      rel="stylesheet"
+    />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons"
+      rel="stylesheet"
+    />
+    <link
+      href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css"
+      rel="stylesheet"
+    />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined"
+      rel="stylesheet"
+    />
   </head>
-<body>
-  <div id="output"></div>
-  <script>
-    window.intercomSettings = {
-      app_id: "gq56g49i"
-    };
-  </script>
+  <body>
+    <div id="output"></div>
+    <script>
+      window.intercomSettings = {
+        app_id: "gq56g49i",
+      };
+    </script>
 
-  <script>
-  // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-  (function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/gq56g49i';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
-  </script>
-</body>
-</html>
+    <script>
+      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+      (function () {
+        var w = window;
+        var ic = w.Intercom;
+        if (typeof ic === "function") {
+          ic("reattach_activator");
+          ic("update", w.intercomSettings);
+        } else {
+          var d = document;
+          var i = function () {
+            i.c(arguments);
+          };
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
+          };
+          w.Intercom = i;
+          var l = function () {
+            var s = d.createElement("script");
+            s.type = "text/javascript";
+            s.async = true;
+            s.src = "https://widget.intercom.io/widget/gq56g49i";
+            var x = d.getElementsByTagName("script")[0];
+            x.parentNode.insertBefore(s, x);
+          };
+          if (w.attachEvent) {
+            w.attachEvent("onload", l);
+          } else {
+            w.addEventListener("load", l, false);
+          }
+        }
+      })();
+    </script>
+  </body>
+</html>

+ 2 - 11
dashboard/src/main/Main.tsx

@@ -10,7 +10,7 @@ import Register from "./Register";
 import CurrentError from "./CurrentError";
 import Home from "./home/Home";
 import Loading from "components/Loading";
-import { PorterUrls } from "shared/routing";
+import { PorterUrl, PorterUrls } from "shared/routing";
 
 type PropsType = {};
 
@@ -71,15 +71,6 @@ export default class Main extends Component<PropsType, StateType> {
       return <Loading />;
     }
 
-    const authedUrls: PorterUrls[] = [
-      "dashboard",
-      "templates",
-      "integrations",
-      "new-project",
-      "cluster-dashboard",
-      "project-settings",
-    ];
-
     return (
       <Switch>
         <Route
@@ -116,7 +107,7 @@ export default class Main extends Component<PropsType, StateType> {
                   key="home"
                   currentProject={this.context.currentProject}
                   currentCluster={this.context.currentCluster}
-                  currentRoute={urlRoute as PorterUrls}
+                  currentRoute={urlRoute as PorterUrl}
                   logOut={this.handleLogOut}
                 />
               );

+ 34 - 30
dashboard/src/main/home/Home.tsx

@@ -1,35 +1,35 @@
 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 { Context } from "shared/Context";
 import api from "shared/api";
+import { Context } from "shared/Context";
+import { PorterUrl } from "shared/routing";
 import { ClusterType, ProjectType } from "shared/types";
 
-import Sidebar from "./sidebar/Sidebar";
-import Dashboard from "./dashboard/Dashboard";
-import ClusterDashboard from "./cluster-dashboard/ClusterDashboard";
+import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
-import Templates from "./templates/Templates";
+import ClusterDashboard from "./cluster-dashboard/ClusterDashboard";
+import Dashboard from "./dashboard/Dashboard";
 import Integrations from "./integrations/Integrations";
-import UpdateClusterModal from "./modals/UpdateClusterModal";
+import Templates from "./launch/Launch";
 import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
-import IntegrationsModal from "./modals/IntegrationsModal";
 import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
-import NewProject from "./new-project/NewProject";
+import IntegrationsModal from "./modals/IntegrationsModal";
+import Modal from "./modals/Modal";
+import UpdateClusterModal from "./modals/UpdateClusterModal";
 import Navbar from "./navbar/Navbar";
+import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
-import ConfirmOverlay from "components/ConfirmOverlay";
-import Modal from "./modals/Modal";
-import * as FullStory from "@fullstory/browser";
-import { Redirect, RouteComponentProps, withRouter } from "react-router";
-import { PorterUrls } from "shared/routing";
+import Sidebar from "./sidebar/Sidebar";
 
 type PropsType = RouteComponentProps & {
   logOut: () => void;
   currentProject: ProjectType;
   currentCluster: ClusterType;
-  currentRoute: PorterUrls;
+  currentRoute: PorterUrl;
 };
 
 type StateType = {
@@ -125,9 +125,13 @@ class Home extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
-  provisionDOCR = (integrationId: number, tier: string, callback?: any) => {
+  provisionDOCR = async (
+    integrationId: number,
+    tier: string,
+    callback?: any
+  ) => {
     console.log("Provisioning DOCR...");
-    return api.createDOCR(
+    await api.createDOCR(
       "<token>",
       {
         do_integration_id: integrationId,
@@ -138,23 +142,23 @@ class Home extends Component<PropsType, StateType> {
         project_id: this.props.currentProject.id,
       }
     );
+    return callback();
   };
 
-  provisionDOKS = (integrationId: number, region: string) => {
+  provisionDOKS = async (integrationId: number, region: string) => {
     console.log("Provisioning DOKS...");
-    return api
-      .createDOKS(
-        "<token>",
-        {
-          do_integration_id: integrationId,
-          doks_name: this.props.currentProject.name,
-          do_region: region,
-        },
-        {
-          project_id: this.props.currentProject.id,
-        }
-      )
-      .then(() => this.props.history.push("dashboard?tab=provisioner"));
+    await api.createDOKS(
+      "<token>",
+      {
+        do_integration_id: integrationId,
+        doks_name: this.props.currentProject.name,
+        do_region: region,
+      },
+      {
+        project_id: this.props.currentProject.id,
+      }
+    );
+    return this.props.history.push("dashboard?tab=provisioner");
   };
 
   checkDO = () => {

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

@@ -109,8 +109,8 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         <LineBreak />
 
         <ControlRow>
-          <Button onClick={() => this.props.history.push("templates")}>
-            <i className="material-icons">add</i> Deploy Template
+          <Button onClick={() => this.props.history.push("launch")}>
+            <i className="material-icons">add</i> Launch Template
           </Button>
           <SortFilterWrapper>
             <SortSelector

+ 16 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -223,8 +223,23 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
     // Convert dotted keys to nested objects
     let values = {};
+
     for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
+      if (key === "ingress.annotations") {
+        let annotations = {} as Record<string, any>;
+
+        if (Array.isArray(rawValues[key])) {
+          rawValues[key].forEach((v: string) => {
+            let splits = v.split(":");
+            annotations[splits[0].trim()] = splits[1].trim();
+          });
+        }
+
+        annotations["porter"] = "true";
+        _.set(values, key, annotations);
+      } else {
+        _.set(values, key, rawValues[key]);
+      }
     }
 
     // Weave in preexisting values and convert to yaml

+ 12 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -36,14 +36,17 @@ 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: {
-      git_repo: "",
-      image_repo_uri: "",
-      git_repo_id: 0,
-      dockerfile_path: "",
-    } as ActionConfigType,
+    actionConfig: defaultActionConfig,
     sourceType: "",
     selectedImageUrl: "",
     selectedTag: "",
@@ -219,6 +222,9 @@ export default class SettingsSection extends Component<PropsType, StateType> {
           setActionConfig={(actionConfig: ActionConfigType) =>
             this.setState({ actionConfig })
           }
+          resetActionConfig={() =>
+            this.setState({ actionConfig: defaultActionConfig })
+          }
         />
       </>
     );

+ 3 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx

@@ -38,11 +38,9 @@ export default class Node extends Component<PropsType, StateType> {
         h={Math.round(h)}
       >
         <Kind>
-          <StyledMark>
-            {this.props.showKindLabels ? kind : null}
-          </StyledMark>
+          <StyledMark>{this.props.showKindLabels ? kind : null}</StyledMark>
         </Kind>
-        <NodeBlock 
+        <NodeBlock
           onMouseDown={nodeMouseDown}
           onMouseUp={nodeMouseUp}
           onMouseEnter={() => this.props.setCurrentNode(this.props.node)}
@@ -53,9 +51,7 @@ export default class Node extends Component<PropsType, StateType> {
           <i className="material-icons">{icon}</i>
         </NodeBlock>
         <NodeLabel>
-          <StyledMark>
-            {name}
-          </StyledMark>
+          <StyledMark>{name}</StyledMark>
         </NodeLabel>
       </StyledNode>
     );

+ 55 - 34
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,15 +1,19 @@
-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 { GridRows, GridColumns } from '@visx/grid';
-import { scaleTime, scaleLinear } from '@visx/scale';
-import { withTooltip, Tooltip, TooltipWithBounds, defaultStyles } from '@visx/tooltip';
-import { WithTooltipProvidedProps } from '@visx/tooltip/lib/enhancers/withTooltip';
-import { localPoint } from '@visx/event';
-import { LinearGradient } from '@visx/gradient';
-import { max, extent, bisector } from 'd3-array';
-import { timeFormat } from 'd3-time-format';
+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 {
+  withTooltip,
+  Tooltip,
+  TooltipWithBounds,
+  defaultStyles,
+} from "@visx/tooltip";
+import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip";
+import { localPoint } from "@visx/event";
+import { LinearGradient } from "@visx/gradient";
+import { max, extent, bisector } from "d3-array";
+import { timeFormat } from "d3-time-format";
 
 /*
 export const accentColor = '#f5cb42';
@@ -19,15 +23,15 @@ export const accentColorDark = '#949eff';
 type TooltipData = AppleStock;
 
 const stock = appleStock.slice(800);
-export const background = '#3b697800';
-export const background2 = '#20405100';
-export const accentColor = '#949eff';
-export const accentColorDark = '#949eff';
+export const background = "#3b697800";
+export const background2 = "#20405100";
+export const accentColor = "#949eff";
+export const accentColorDark = "#949eff";
 const tooltipStyles = {
   ...defaultStyles,
   background,
-  border: '1px solid white',
-  color: 'white',
+  border: "1px solid white",
+  color: "white",
 };
 
 // util
@@ -36,7 +40,7 @@ const formatDate = timeFormat("%b %d, '%y");
 // 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 bisectDate = bisector<AppleStock, Date>((d) => new Date(d.date)).left;
 
 export type AreaProps = {
   width: number;
@@ -68,7 +72,7 @@ export default withTooltip<AreaProps, TooltipData>(
           range: [margin.left, innerWidth + margin.left],
           domain: extent(stock, getDate) as [Date, Date],
         }),
-      [innerWidth, margin.left],
+      [innerWidth, margin.left]
     );
     const stockValueScale = useMemo(
       () =>
@@ -77,12 +81,16 @@ export default withTooltip<AreaProps, TooltipData>(
           domain: [0, (max(stock, getStockValue) || 0) + innerHeight / 3],
           nice: true,
         }),
-      [margin.top, innerHeight],
+      [margin.top, innerHeight]
     );
 
     // tooltip handler
     const handleTooltip = useCallback(
-      (event: React.TouchEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
+      (
+        event:
+          | React.TouchEvent<SVGRectElement>
+          | React.MouseEvent<SVGRectElement>
+      ) => {
         const { x } = localPoint(event) || { x: 0 };
         const x0 = dateScale.invert(x);
         const index = bisectDate(stock, x0, 1);
@@ -90,7 +98,11 @@ export default withTooltip<AreaProps, TooltipData>(
         const d1 = stock[index];
         let d = d0;
         if (d1 && getDate(d1)) {
-          d = x0.valueOf() - getDate(d0).valueOf() > getDate(d1).valueOf() - x0.valueOf() ? d1 : d0;
+          d =
+            x0.valueOf() - getDate(d0).valueOf() >
+            getDate(d1).valueOf() - x0.valueOf()
+              ? d1
+              : d0;
         }
         showTooltip({
           tooltipData: d,
@@ -98,7 +110,7 @@ export default withTooltip<AreaProps, TooltipData>(
           tooltipTop: stockValueScale(getStockValue(d)),
         });
       },
-      [showTooltip, stockValueScale, dateScale],
+      [showTooltip, stockValueScale, dateScale]
     );
 
     return (
@@ -112,12 +124,21 @@ export default withTooltip<AreaProps, TooltipData>(
             fill="url(#area-background-gradient)"
             rx={14}
           />
-          <LinearGradient id="area-background-gradient" from={background} to={background2} />
-          <LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0} />
+          <LinearGradient
+            id="area-background-gradient"
+            from={background}
+            to={background2}
+          />
+          <LinearGradient
+            id="area-gradient"
+            from={accentColor}
+            to={accentColor}
+            toOpacity={0}
+          />
           <AreaClosed<AppleStock>
             data={stock}
-            x={d => dateScale(getDate(d)) ?? 0}
-            y={d => stockValueScale(getStockValue(d)) ?? 0}
+            x={(d) => dateScale(getDate(d)) ?? 0}
+            y={(d) => stockValueScale(getStockValue(d)) ?? 0}
             yScale={stockValueScale}
             strokeWidth={1}
             stroke="url(#area-gradient)"
@@ -184,12 +205,12 @@ export default withTooltip<AreaProps, TooltipData>(
               left={tooltipLeft}
               style={{
                 ...defaultStyles,
-                background: '#26272f',
-                color: '#aaaabb',
+                background: "#26272f",
+                color: "#aaaabb",
                 width: 100,
                 paddingTop: 35,
-                textAlign: 'center',
-                transform: 'translateX(-60px)',
+                textAlign: "center",
+                transform: "translateX(-60px)",
               }}
             >
               {formatDate(getDate(tooltipData))}
@@ -198,5 +219,5 @@ export default withTooltip<AreaProps, TooltipData>(
         )}
       </div>
     );
-  },
-);
+  }
+);

+ 24 - 20
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from "react";
 import styled from "styled-components";
-import ParentSize from '@visx/responsive/lib/components/ParentSize';
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
 
 import { Context } from "shared/Context";
 import { ChartType } from "shared/types";
@@ -13,26 +13,28 @@ type PropsType = {
 };
 
 type StateType = {
-  selectedRange: string,
-  selectedMetricLabel: string,
-  dropdownExpanded: boolean,
+  selectedRange: string;
+  selectedMetricLabel: string;
+  dropdownExpanded: boolean;
 };
 
 export default class ListSection extends Component<PropsType, StateType> {
   state = {
-    selectedRange: '1H',
-    selectedMetricLabel: 'CPU Utilization',
+    selectedRange: "1H",
+    selectedMetricLabel: "CPU Utilization",
     dropdownExpanded: false,
-  }
+  };
 
   renderDropdown = () => {
     if (this.state.dropdownExpanded) {
       return (
         <>
-          <DropdownOverlay onClick={() => this.setState({ dropdownExpanded: false })} />
+          <DropdownOverlay
+            onClick={() => this.setState({ dropdownExpanded: false })}
+          />
           <Dropdown
-            dropdownWidth='200px'
-            dropdownMaxHeight='200px'
+            dropdownWidth="200px"
+            dropdownMaxHeight="200px"
             onClick={() => this.setState({ dropdownExpanded: false })}
           >
             {this.renderOptionList()}
@@ -44,8 +46,8 @@ export default class ListSection extends Component<PropsType, StateType> {
 
   renderOptionList = () => {
     let metricOptions = [
-      { value: 'cpu', label: 'CPU Utilization' },
-      { value: 'ram', label: 'RAM Utilization' },
+      { value: "cpu", label: "CPU Utilization" },
+      { value: "ram", label: "RAM Utilization" },
     ];
     return metricOptions.map(
       (option: { value: string; label: string }, i: number) => {
@@ -69,8 +71,10 @@ export default class ListSection extends Component<PropsType, StateType> {
         <ParentSize>
           {({ width, height }) => <AreaChart width={width} height={height} />}
         </ParentSize>
-        <MetricSelector 
-          onClick={() => this.setState({ dropdownExpanded: !this.state.dropdownExpanded })}
+        <MetricSelector
+          onClick={() =>
+            this.setState({ dropdownExpanded: !this.state.dropdownExpanded })
+          }
         >
           {this.state.selectedMetricLabel}
           <i className="material-icons">arrow_drop_down</i>
@@ -79,12 +83,12 @@ export default class ListSection extends Component<PropsType, StateType> {
         <RangeWrapper>
           <TabSelector
             options={[
-              { value: '1H', label: '1H' }, 
-              { value: '1D', label: '1D' },
-              { value: '1M', label: '1M' }, 
-              { value: '3M', label: '3M' },
-              { value: '1Y', label: '1Y' }, 
-              { value: 'ALL', label: 'ALL' },
+              { value: "1H", label: "1H" },
+              { 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 })}

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

@@ -75,7 +75,9 @@ export default class ControllerTab extends Component<PropsType, StateType> {
         if (isFirst) {
           let pod = res.data[0];
           let status = this.getPodStatus(pod.status);
-          (status === "failed" && pod.status?.message) && this.props.setPodError(pod.status?.message);
+          status === "failed" &&
+            pod.status?.message &&
+            this.props.setPodError(pod.status?.message);
           selectPod(res.data[0]);
         }
       })
@@ -107,11 +109,14 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   };
 
   getPodStatus = (status: any) => {
-    if (status?.phase === "Pending" && status?.containerStatuses !== undefined) {
+    if (
+      status?.phase === "Pending" &&
+      status?.containerStatuses !== undefined
+    ) {
       return status.containerStatuses[0].state.waiting.reason;
       // return 'waiting'
     } else if (status?.phase === "Pending") {
-      return "Pending"
+      return "Pending";
     }
 
     if (status?.phase === "Failed") {
@@ -158,7 +163,9 @@ export default class ControllerTab extends Component<PropsType, StateType> {
               selected={selectedPod?.metadata?.name === pod?.metadata?.name}
               onClick={() => {
                 this.props.setPodError("");
-                (status === "failed" && pod.status?.message) && this.props.setPodError(pod.status?.message);
+                status === "failed" &&
+                  pod.status?.message &&
+                  this.props.setPodError(pod.status?.message);
                 selectPod(pod);
               }}
             >

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

@@ -41,7 +41,11 @@ export default class Logs extends Component<PropsType, StateType> {
       return <Message>Please select a pod to view its logs.</Message>;
     }
     if (this.state.logs.length == 0) {
-      return <Message>{this.props.podError || "No logs to display from this pod."}</Message>;
+      return (
+        <Message>
+          {this.props.podError || "No logs to display from this pod."}
+        </Message>
+      );
     }
     return this.state.logs.map((log, i) => {
       return <Log key={i}>{log}</Log>;

+ 89 - 74
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -1,31 +1,31 @@
 import React, { Component } 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 { ImageType, ActionConfigType } from "../../..//shared/types";
+import ImageList from "../../../components/image-selector/ImageList";
+import RepoList from "../../../components/repo-selector/RepoList";
 
 type PropsType = {
-  setCurrent: (x: any) => void,
-  currentCategory: string,
-  integrations: string[],
-  itemIdentifier?: any[],
-  titles?: string[],
-  isCategory?: boolean
+  setCurrent: (x: any) => void;
+  currentCategory: string;
+  integrations: string[];
+  itemIdentifier?: any[];
+  titles?: string[];
+  isCategory?: boolean;
 };
 
 type StateType = {
-  displayImages: boolean[],
-  allCollapsed: boolean,
+  displayImages: boolean[];
+  allCollapsed: boolean;
 };
 
 export default class IntegrationList extends Component<PropsType, StateType> {
   state = {
     displayImages: [] as boolean[],
     allCollapsed: false,
-  }
+  };
 
   componentDidMount() {
     let x: boolean[] = [];
@@ -54,7 +54,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
       x.push(false);
     }
     this.setState({ displayImages: x, allCollapsed: true });
-  }
+  };
 
   expandAll = () => {
     let x = [];
@@ -62,7 +62,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
       x.push(true);
     }
     this.setState({ displayImages: x, allCollapsed: false });
-  }
+  };
 
   toggleDisplay = (event: any, index: number) => {
     event.stopPropagation();
@@ -75,7 +75,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
       for (let i = 0; i < x.length; i++) {
         if (x[i]) {
           collapsed = false;
-          break
+          break;
         }
       }
       if (collapsed) {
@@ -85,14 +85,20 @@ export default class IntegrationList extends Component<PropsType, StateType> {
       }
     }
     this.setState({ displayImages: x });
-  }
+  };
 
   handleParent = (event: any, integration: string) => {
     this.props.setCurrent(integration);
-  }
+  };
 
   renderContents = () => {
-    let { integrations, titles, setCurrent, isCategory, currentCategory } = this.props;
+    let {
+      integrations,
+      titles,
+      setCurrent,
+      isCategory,
+      currentCategory,
+    } = this.props;
     if (titles && titles.length > 0) {
       return integrations.map((integration: string, i: number) => {
         let icon =
@@ -101,11 +107,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
           integrationList[integration] && integrationList[integration].label;
         let label = titles[i];
         return (
-          <Integration
-            key={i}
-            isCategory={isCategory}
-            disabled={false}
-          >
+          <Integration key={i} isCategory={isCategory} disabled={false}>
             <MainRow
               onClick={(e: any) => {
                 this.handleParent(e, integration);
@@ -120,10 +122,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
                   <Subtitle>{subtitle}</Subtitle>
                 </Description>
               </Flex>
-              <MaterialIconTray
-                isCategory={isCategory}
-                disabled={false}
-              >
+              <MaterialIconTray isCategory={isCategory} disabled={false}>
                 <i className="material-icons">more_vert</i>
                 <I
                   className="material-icons"
@@ -132,16 +131,13 @@ export default class IntegrationList extends Component<PropsType, StateType> {
                     this.toggleDisplay(e, i);
                   }}
                 >
-                  {isCategory ? 'launch' : 'expand_more'}
+                  {isCategory ? "launch" : "expand_more"}
                 </I>
               </MaterialIconTray>
             </MainRow>
-            {this.state.displayImages[i] &&
-              <ImageHodler
-                adjustMargin={currentCategory !== 'repo'}
-              >
-                {currentCategory !== 'repo'
-                  ?
+            {this.state.displayImages[i] && (
+              <ImageHodler adjustMargin={currentCategory !== "repo"}>
+                {currentCategory !== "repo" ? (
                   <ImageList
                     selectedImageUrl={null}
                     selectedTag={null}
@@ -151,29 +147,33 @@ export default class IntegrationList extends Component<PropsType, StateType> {
                     setSelectedTag={(x: string) => {}}
                     setClickedImage={(x: ImageType) => {}}
                   />
-                  :
+                ) : (
                   <RepoList
-                    actionConfig={{
-                      git_repo: '',
-                      image_repo_uri: '',
-                      git_repo_id: 0,
-                      dockerfile_path: '',
-                    } as ActionConfigType}
+                    actionConfig={
+                      {
+                        git_repo: "",
+                        image_repo_uri: "",
+                        git_repo_id: 0,
+                        dockerfile_path: "",
+                      } as ActionConfigType
+                    }
                     setActionConfig={(x: ActionConfigType) => {}}
                     readOnly={true}
                     userId={this.props.itemIdentifier[i]}
                   />
-                }
+                )}
               </ImageHodler>
-            }
+            )}
           </Integration>
         );
       });
     } else if (integrations && integrations.length > 0) {
       return integrations.map((integration: string, i: number) => {
-        let icon = integrationList[integration] && integrationList[integration].icon;
-        let label = integrationList[integration] && integrationList[integration].label;
-        let disabled = integration === 'kubernetes';
+        let icon =
+          integrationList[integration] && integrationList[integration].icon;
+        let label =
+          integrationList[integration] && integrationList[integration].label;
+        let disabled = integration === "kubernetes";
         return (
           <Integration
             key={i}
@@ -181,15 +181,14 @@ export default class IntegrationList extends Component<PropsType, StateType> {
             isCategory={isCategory}
             disabled={disabled}
           >
-            <MainRow
-              isCategory={isCategory}
-              disabled={disabled}
-            >
+            <MainRow isCategory={isCategory} disabled={disabled}>
               <Flex>
                 <Icon src={icon && icon} />
                 <Label>{label}</Label>
               </Flex>
-              <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
+              <i className="material-icons">
+                {isCategory ? "launch" : "more_vert"}
+              </i>
             </MainRow>
           </Integration>
         );
@@ -199,26 +198,31 @@ export default class IntegrationList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return ( 
+    return (
       <StyledIntegrationList>
-        {(this.props.titles && this.props.titles.length > 0) &&
+        {this.props.titles && this.props.titles.length > 0 && (
           <ControlRow>
             <Button
               onClick={() => {
                 if (this.state.allCollapsed) {
-                  this.expandAll()
+                  this.expandAll();
                 } else {
-                  this.collapseAll()
+                  this.collapseAll();
                 }
               }}
             >
-              {this.state.allCollapsed
-                ? <><i className="material-icons">expand_more</i> Expand All</>
-                : <><i className="material-icons">expand_less</i> Collapse All</>
-              }
+              {this.state.allCollapsed ? (
+                <>
+                  <i className="material-icons">expand_more</i> Expand All
+                </>
+              ) : (
+                <>
+                  <i className="material-icons">expand_less</i> Collapse All
+                </>
+              )}
             </Button>
           </ControlRow>
-        }
+        )}
         {this.renderContents()}
       </StyledIntegrationList>
     );
@@ -236,7 +240,8 @@ const Flex = styled.div`
 const ImageHodler = styled.div`
   width: 100%;
   padding: 12px;
-  margin-top: ${(props: {adjustMargin: boolean}) => props.adjustMargin ? '-10px' : '0px'};
+  margin-top: ${(props: { adjustMargin: boolean }) =>
+    props.adjustMargin ? "-10px" : "0px"};
 `;
 
 const MaterialIconTray = styled.div`
@@ -250,9 +255,11 @@ const MaterialIconTray = styled.div`
     border-radius: 20px;
     font-size: 18px;
     padding: 5px;
-    color: ${(props: { isCategory: boolean, disabled: boolean }) => props.isCategory ? '#616feecc' : '#ffffff44'};
+    color: ${(props: { isCategory: boolean; disabled: boolean }) =>
+      props.isCategory ? "#616feecc" : "#ffffff44"};
     :hover {
-      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+      background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
     }
   }
 `;
@@ -266,9 +273,11 @@ const MainRow = styled.div`
   padding: 25px;
   border-radius: 5px;
   :hover {
-    background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+    background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
     > i {
-      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11' };
+      background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
     }
   }
 
@@ -280,7 +289,8 @@ const MainRow = styled.div`
       props.isCategory ? "#616feecc" : "#ffffff44"};
     margin-right: -7px;
     :hover {
-      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+      background: ${(props: { isCategory: boolean; disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
     }
   }
 `;
@@ -290,7 +300,8 @@ const Integration = styled.div`
   display: flex;
   flex-direction: column;
   background: #26282f;
-  cursor: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+  cursor: ${(props: { isCategory: boolean; disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
   margin-bottom: 15px;
   border-radius: 5px;
   box-shadow: 0 5px 8px 0px #00000033;
@@ -341,7 +352,8 @@ const StyledIntegrationList = styled.div`
 `;
 
 const I = styled.i`
-  transform: ${(props: { showList: boolean }) => props.showList ? 'rotate(180deg)' : ''};
+  transform: ${(props: { showList: boolean }) =>
+    props.showList ? "rotate(180deg)" : ""};
 `;
 
 const ControlRow = styled.div`
@@ -368,7 +380,7 @@ const Button = styled.div`
   justify-content: space-between;
   font-size: 13px;
   cursor: pointer;
-  font-family: 'Work Sans', sans-serif;
+  font-family: "Work Sans", sans-serif;
   border-radius: 8px;
   color: white;
   height: 35px;
@@ -381,11 +393,14 @@ const Button = styled.div`
   white-space: nowrap;
   text-overflow: ellipsis;
   box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
 
-  background: ${(props: { disabled?: boolean }) => props.disabled ? '#aaaabbee' : '#616FEEcc'};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
-    background: ${(props: { disabled?: boolean }) => props.disabled ? '' : '#505edddd'};
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
   }
 
   > i {

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

@@ -128,7 +128,8 @@ export default class Integrations extends Component<PropsType, StateType> {
               return (
                 <Credential key={i}>
                   <i className="material-icons">admin_panel_settings</i>{" "}
-                  {item.name}
+                  {/* TODO: handle different types of items (ie. registry vs repo) */}
+                  {item.name || item.repo_entity}
                 </Credential>
               );
             })}

+ 78 - 15
dashboard/src/main/home/templates/Templates.tsx → dashboard/src/main/home/launch/Launch.tsx

@@ -10,8 +10,12 @@ import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
 
 import hardcodedNames from "./hardcodedNameDict";
+import { Link } from "react-router-dom";
 
-const tabOptions = [{ label: "Community Templates", value: "community" }];
+const tabOptions = [
+  { label: "Launch service", value: "docker" },
+  { label: "Community Templates", value: "community" },
+];
 
 type PropsType = {};
 
@@ -26,7 +30,7 @@ type StateType = {
 export default class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
-    currentTab: "community",
+    currentTab: "docker",
     porterTemplates: [] as PorterTemplate[],
     loading: true,
     error: false,
@@ -41,7 +45,11 @@ export default class Templates extends Component<PropsType, StateType> {
           this.state.porterTemplates.sort((a, b) =>
             a.name === "docker" ? -1 : b.name === "docker" ? 1 : 0
           );
-          this.setState({ loading: false });
+          // TODO: properly find "docker" template instead of relying on first entry
+          this.setState({
+            loading: false,
+            currentTemplate: this.state.porterTemplates[0],
+          });
         });
       })
       .catch(() => this.setState({ loading: false, error: true }));
@@ -82,8 +90,9 @@ export default class Templates extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.porterTemplates.map(
-      (template: PorterTemplate, i: number) => {
+    return this.state.porterTemplates
+      .filter((t) => t.name.toLowerCase() !== "docker")
+      .map((template: PorterTemplate, i: number) => {
         let { name, icon, description } = template;
         if (hardcodedNames[name]) {
           name = hardcodedNames[name];
@@ -98,11 +107,44 @@ export default class Templates extends Component<PropsType, StateType> {
             <TemplateDescription>{description}</TemplateDescription>
           </TemplateBlock>
         );
-      }
-    );
+      });
+  };
+
+  renderDefaultTemplate = () => {
+    if (!this.context.currentCluster) {
+      return (
+        <>
+          <Banner>
+            <i className="material-icons">error_outline</i>
+            <Link to="dashboard">Provision</Link> &nbsp;or&nbsp;
+            <Link
+              to="#"
+              onClick={() =>
+                this.context.setCurrentModal("ClusterInstructionsModal")
+              }
+            >
+              connect
+            </Link>
+            &nbsp;to a cluster
+          </Banner>
+        </>
+      );
+    }
+    if (this.state.currentTemplate) {
+      return (
+        <ExpandedTemplate
+          currentTemplate={this.state.porterTemplates[0]}
+          setCurrentTemplate={(currentTemplate: PorterTemplate) =>
+            this.setState({ currentTemplate })
+          }
+          skipDescription={true}
+        />
+      );
+    }
+    return null;
   };
 
-  renderContents = () => {
+  renderCommunityTemplates = () => {
     if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
@@ -113,11 +155,14 @@ export default class Templates extends Component<PropsType, StateType> {
         />
       );
     }
+    return <TemplateList>{this.renderTemplateList()}</TemplateList>;
+  };
 
+  render() {
     return (
       <TemplatesWrapper>
         <TitleSection>
-          <Title>Template Explorer</Title>
+          <Title>Launch</Title>
           <a
             href="https://docs.getporter.dev/docs/porter-templates"
             target="_blank"
@@ -129,16 +174,18 @@ export default class Templates extends Component<PropsType, StateType> {
           options={tabOptions}
           currentTab={this.state.currentTab}
           setCurrentTab={(value: string) =>
-            this.setState({ currentTab: value })
+            this.setState({
+              currentTab: value,
+              currentTemplate:
+                value === "docker" ? this.state.porterTemplates[0] : null,
+            })
           }
         />
-        <TemplateList>{this.renderTemplateList()}</TemplateList>
+        {this.state.currentTab === "docker"
+          ? this.renderDefaultTemplate()
+          : this.renderCommunityTemplates()}
       </TemplatesWrapper>
     );
-  };
-
-  render() {
-    return this.renderContents();
   }
 }
 
@@ -159,6 +206,22 @@ const Placeholder = styled.div`
   }
 `;
 
+const Banner = styled.div`
+  height: 40px;
+  width: 100%;
+  margin: 30px 0 30px;
+  font-size: 13px;
+  display: flex;
+  border-radius: 5px;
+  padding-left: 15px;
+  align-items: center;
+  background: #ffffff11;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding-top: 300px;
 `;

+ 15 - 3
dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx → dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -11,6 +11,7 @@ import Loading from "components/Loading";
 type PropsType = {
   currentTemplate: PorterTemplate;
   setCurrentTemplate: (x: PorterTemplate) => void;
+  skipDescription?: boolean;
 };
 
 type StateType = {
@@ -35,6 +36,10 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
+    this.fetchTemplateInfo();
+  }
+
+  fetchTemplateInfo = () => {
     this.setState({ loading: true });
     api
       .getTemplateInfo(
@@ -58,7 +63,13 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
         });
       })
       .catch((err) => this.setState({ loading: false, error: true }));
-  }
+  };
+
+  componentDidUpdate = (prevProps: PropsType) => {
+    if (prevProps.currentTemplate !== this.props.currentTemplate) {
+      this.fetchTemplateInfo();
+    }
+  };
 
   renderContents = () => {
     if (this.state.loading) {
@@ -68,11 +79,12 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
         </LoadingWrapper>
       );
     }
-    if (this.state.showLaunchTemplate) {
+    if (this.props.skipDescription || this.state.showLaunchTemplate) {
       return (
         <LaunchTemplate
           currentTemplate={this.props.currentTemplate}
           hideLaunch={() => this.setState({ showLaunchTemplate: false })}
+          hideBackButton={this.props.skipDescription}
           values={this.state.values}
           form={this.state.form}
         />
@@ -119,5 +131,5 @@ const LoadingWrapper = styled.div`
 const StyledExpandedTemplate = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  padding-top: 75px;
+  padding-top: 30px;
 `;

+ 117 - 93
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx → dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -27,6 +27,7 @@ type PropsType = {
   hideLaunch: () => void;
   values: any;
   form: any;
+  hideBackButton?: boolean;
 };
 
 type StateType = {
@@ -49,6 +50,13 @@ type StateType = {
   pathIsSet: boolean;
 };
 
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_repo_id: 0,
+  dockerfile_path: "",
+};
+
 export default class LaunchTemplate extends Component<PropsType, StateType> {
   state = {
     currentView: "repo",
@@ -65,12 +73,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     currentTab: null as string | null,
     tabContents: [] as any,
     namespaceOptions: [] as { label: string; value: string }[],
-    actionConfig: {
-      git_repo: "",
-      image_repo_uri: "",
-      git_repo_id: 0,
-      dockerfile_path: "",
-    } as ActionConfigType,
+    actionConfig: { ...defaultActionConfig },
     branch: "",
     pathIsSet: false,
   };
@@ -128,6 +131,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
+        console.log("ST");
+        console.log(this.state.sourceType);
         if (this.state.sourceType === "repo") {
           this.createGHAction(name, this.state.selectedNamespace);
         }
@@ -161,7 +166,19 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     // Convert dotted keys to nested objects
     let values = {};
     for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
+      if (key === "ingress.annotations") {
+        let annotations = {} as Record<string, any>;
+        if (Array.isArray(rawValues[key])) {
+          rawValues[key].forEach((v: string) => {
+            let splits = v.split(":");
+            annotations[splits[0].trim()] = splits[1].trim();
+          });
+        }
+        annotations["porter"] = "true";
+        _.set(values, key, annotations);  
+      } else {
+        _.set(values, key, rawValues[key]);
+      }
     }
 
     let imageUrl = this.state.selectedImageUrl;
@@ -180,8 +197,28 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       tag = "latest";
     }
 
-    _.set(values, "image.repository", imageUrl);
-    _.set(values, "image.tag", tag);
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      default:
+        provider = null;
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (imageUrl && tag) {
+      _.set(values, "image.repository", imageUrl);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
 
     console.log(`
       ${this.props.currentTemplate.name}\n
@@ -211,7 +248,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           version: "latest",
         }
       )
-      .then((res) => {
+      .then((_) => {
         if (this.state.sourceType === "repo") {
           this.createGHAction(name, this.state.selectedNamespace);
         }
@@ -219,20 +256,29 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard with namespace
         });
-        posthog.capture("Deployed template", {
-          name: this.props.currentTemplate.name,
-          namespace: this.state.selectedNamespace,
-          values: values,
-        });
+        try {
+          posthog.capture("Deployed template", {
+            name: this.props.currentTemplate.name,
+            namespace: this.state.selectedNamespace,
+            values: values,
+          });
+        } catch (error) {
+          console.log(error);
+        }
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
-        posthog.capture("Failed to deploy template", {
-          name: this.props.currentTemplate.name,
-          namespace: this.state.selectedNamespace,
-          values: values,
-          error: err,
-        });
+
+        try {
+          posthog.capture("Failed to deploy template", {
+            name: this.props.currentTemplate.name,
+            namespace: this.state.selectedNamespace,
+            values: values,
+            error: err,
+          });
+        } catch (error) {
+          console.log(error);
+        }
       });
   };
 
@@ -283,7 +329,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         tabOptions.push({ value: tab.name, label: tab.label });
       }
     });
-    console.log(tabOptions)
+    console.log(tabOptions);
     this.setState({ tabOptions, currentTab: tabOptions[0]["value"] });
 
     // TODO: query with selected filter once implemented
@@ -351,7 +397,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     );
   };
 
-  renderTabRegion = () => {
+  renderSettingsRegion = () => {
     if (this.state.tabOptions.length > 0) {
       return (
         <>
@@ -392,9 +438,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   // Display if current template uses source (image or repo)
-  renderSourceSelector = () => {
-    let { currentProject } = this.context;
-
+  renderSourceSelectorContent = () => {
     if (this.props.form?.hasSource) {
       if (this.state.sourceType === "registry") {
         return (
@@ -422,30 +466,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         return (
           <>
             <Subtitle>
-              Select a repo to connect to. You can
-              <A
-                padRight={true}
-                href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
-              >
-                log in with GitHub
-              </A>{" "}
-              or
-              <Highlight
-                onClick={() =>
-                  this.setState({
-                    sourceType: "registry",
-                    actionConfig: {
-                      git_repo: "",
-                      image_repo_uri: "",
-                      git_repo_id: 0,
-                      dockerfile_path: "",
-                    } as ActionConfigType,
-                  })
-                }
-              >
-                link an image registry
-              </Highlight>
-              .<Required>*</Required>
+              Select a repo to connect to, then a Dockerfile to build from.
+              <Required>*</Required>
             </Subtitle>
             <ActionConfEditor
               actionConfig={this.state.actionConfig}
@@ -460,6 +482,13 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
               }
               setBranch={(branch: string) => this.setState({ branch })}
               setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+              reset={() => {
+                this.setState({
+                  actionConfig: { ...defaultActionConfig },
+                  branch: "",
+                  pathIsSet: false,
+                });
+              }}
             />
             <br />
           </>
@@ -468,21 +497,39 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }
   };
 
+  renderSourceSelector = () => {
+    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>
+      </>
+    );
+  };
+
   render() {
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
 
     return (
       <StyledLaunchTemplate>
-        <TitleSection>
-          <Flex>
-            <i className="material-icons" onClick={this.props.hideLaunch}>
-              keyboard_backspace
-            </i>
-            <Title>Launch Template</Title>
-          </Flex>
-        </TitleSection>
         <ClusterSection>
+          {this.props.hideBackButton ? null : (
+            <Flex>
+              <i className="material-icons" onClick={this.props.hideLaunch}>
+                keyboard_backspace
+              </i>
+            </Flex>
+          )}
           <Template>
             {icon
               ? this.renderIcon(icon)
@@ -542,7 +589,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           width="100%"
         />
         {this.renderSourceSelector()}
-        {this.renderTabRegion()}
+        {this.renderSettingsRegion()}
       </StyledLaunchTemplate>
     );
   }
@@ -564,13 +611,6 @@ const Link = styled.a`
   margin-left: 5px;
 `;
 
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 35px 0px 35px;
-`;
-
 const Wrapper = styled.div`
   width: 100%;
   position: relative;
@@ -655,7 +695,6 @@ const ClusterSection = styled.div`
   font-family: "Work Sans", sans-serif;
   font-size: 14px;
   font-weight: 500;
-  margin-top: 20px;
   margin-bottom: 15px;
 
   > i {
@@ -681,25 +720,6 @@ const Flex = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 11px;
-  border-radius: 2px;
-  color: #ffffff;
-`;
-
-const TitleSection = styled.div`
-  display: flex;
-  margin-left: -42px;
-  height: 40px;
-  flex-direction: row;
-  justify-content: space-between;
-  width: calc(100% + 42px);
-  align-items: center;
-`;
-
 const StyledLaunchTemplate = styled.div`
   width: 100%;
   padding-bottom: 150px;
@@ -714,11 +734,15 @@ const Highlight = styled.div`
     props.padRight ? "5px" : ""};
 `;
 
-const A = styled.a`
-  color: #8590ff;
-  text-decoration: underline;
-  margin-left: 5px;
-  cursor: pointer;
-  padding-right: ${(props: { padRight?: boolean }) =>
-    props.padRight ? "5px" : ""};
+const StyledSourceBox = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 10px 35px 25px;
+  position: relative;
+  border-radius: 5px;
+  font-size: 13px;
+  overflow: auto;
+  margin-bottom: 25px;
 `;

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

@@ -297,7 +297,7 @@ const Title = styled.div`
 
 const TitleSection = styled.div`
   display: flex;
-  margin-left: -42px;
+  margin-left: 0px;
   flex-direction: row;
   height: 40px;
   justify-content: space-between;

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


+ 18 - 12
dashboard/src/main/home/provisioner/InfraStatuses.tsx

@@ -6,9 +6,9 @@ import { InfraType } from "shared/types";
 import { infraNames } from "shared/common";
 
 type PropsType = {
-  infras: InfraType[],
-  selectInfra: (infra: InfraType) => void,
-  selectedInfra: InfraType,
+  infras: InfraType[];
+  selectInfra: (infra: InfraType) => void;
+  selectedInfra: InfraType;
 };
 
 type StateType = {};
@@ -19,10 +19,14 @@ export default class InfraStatuses extends Component<PropsType, StateType> {
   renderStatusIcon = (status: string) => {
     if (status === "created") {
       return <StatusIcon>✓</StatusIcon>;
-    } else if (status === 'creating' || status === 'destroying') {
-      return <StatusIcon><img src={loadingDots} /></StatusIcon>
-    } else if (status === 'error' || status === 'destroyed') {
-      return <StatusIcon color='#e3366d'>✗</StatusIcon>
+    } else if (status === "creating" || status === "destroying") {
+      return (
+        <StatusIcon>
+          <img src={loadingDots} />
+        </StatusIcon>
+      );
+    } else if (status === "error" || status === "destroyed") {
+      return <StatusIcon color="#e3366d">✗</StatusIcon>;
     }
   };
 
@@ -31,9 +35,9 @@ export default class InfraStatuses extends Component<PropsType, StateType> {
       <StyledInfraStatuses>
         {this.props.infras.map((infra: InfraType, i: number) => {
           return (
-            <InfraRow 
+            <InfraRow
               key={infra.id}
-              selected={(infra.id === this.props.selectedInfra?.id)}
+              selected={infra.id === this.props.selectedInfra?.id}
               onClick={() => this.props.selectInfra(infra)}
             >
               {infraNames[infra.kind]}
@@ -52,7 +56,7 @@ const StatusIcon = styled.div<{ color?: string }>`
   justify-content: center;
   width: 20px;
   font-size: 16px;
-  color: ${props => props.color ? props.color : '#68c49c'};
+  color: ${(props) => (props.color ? props.color : "#68c49c")};
   margin-left: 10px;
 `;
 
@@ -75,8 +79,10 @@ const InfraRow = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
-  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : ''};
+  color: ${(props: { selected: boolean }) =>
+    props.selected ? "white" : "#ffffff66"};
+  background: ${(props: { selected: boolean }) =>
+    props.selected ? "#ffffff18" : ""};
   font-size: 13px;
   padding: 20px 19px 20px 42px;
   text-shadow: 0px 0px 8px none;

+ 91 - 73
dashboard/src/main/home/provisioner/ProvisionerLogs.tsx

@@ -1,59 +1,62 @@
-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 React, { Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { InfraType } from "shared/types";
+import posthog from "posthog-js";
 import { RouteComponentProps, withRouter } from "react-router";
 
-import ansiparse from 'shared/ansiparser'
-import loading from 'assets/loading.gif';
-import warning from 'assets/warning.png';
+import ansiparse from "shared/ansiparser";
+import loading from "assets/loading.gif";
+import warning from "assets/warning.png";
 
 type PropsType = RouteComponentProps & {
-    selectedInfra: InfraType
+  selectedInfra: InfraType;
 };
 
 type StateType = {
-  logs: string[],
-  ws: any,
-  scroll: boolean,
-  maxStep: number,
-  error: boolean,
+  logs: string[];
+  ws: any;
+  scroll: boolean;
+  maxStep: number;
+  error: boolean;
 };
 
 class ProvisionerLogs extends Component<PropsType, StateType> {
-  
   state = {
     logs: [] as string[],
-    ws : null as any,
+    ws: null as any,
     scroll: true,
     maxStep: 0,
     error: false,
-  }
+  };
 
   ws = null as any;
-  parentRef = React.createRef<HTMLDivElement>()
+  parentRef = React.createRef<HTMLDivElement>();
 
   scrollToBottom = () => {
-    this.parentRef.current.lastElementChild.scrollIntoView({ behavior: "auto" })
-  }
+    this.parentRef.current.lastElementChild.scrollIntoView({
+      behavior: "auto",
+    });
+  };
 
   renderLogs = () => {
     let { selectedInfra } = this.props;
     let { logs, maxStep } = this.state;
     if (!selectedInfra) {
-        return <Message>Please select a resource.</Message>
+      return <Message>Please select a resource.</Message>;
     }
 
-    if (selectedInfra.status == 'destroyed') {
-        return (
-          <Message>
-              This resource has been auto-destroyed due to an error during provisioning.
-              <div>
-                Please check with your cloud provider to make sure all resources have been properly destroyed.
-              </div>
-          </Message>
-        )
+    if (selectedInfra.status == "destroyed") {
+      return (
+        <Message>
+          This resource has been auto-destroyed due to an error during
+          provisioning.
+          <div>
+            Please check with your cloud provider to make sure all resources
+            have been properly destroyed.
+          </div>
+        </Message>
+      );
     }
 
     if (logs.length == 0) {
@@ -63,17 +66,21 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
             <Loading>
               <LoadingGif src={loading} /> Provisioning resources...
             </Loading>
-          )
+          );
         case "destroying":
           return (
             <Message>
               <LoadingGif src={loading} /> Destroying resources...
             </Message>
-          )
+          );
         case "error":
-          return <Message>Porter encountered an error while provisioning this resource.</Message>
+          return (
+            <Message>
+              Porter encountered an error while provisioning this resource.
+            </Message>
+          );
         default:
-          return <Message>{selectedInfra.status}</Message>
+          return <Message>{selectedInfra.status}</Message>;
       }
     }
 
@@ -81,10 +88,10 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
     return logs.map((log, i) => {
       if (log.trim().length != 0) {
         count += 1;
-        return <Log key={i + 1}>{`[Step ${count}/${maxStep}] ` + log}</Log>
+        return <Log key={i + 1}>{`[Step ${count}/${maxStep}] ` + log}</Log>;
       }
-    })
-  }
+    });
+  };
 
   isJSON = (str: string) => {
     try {
@@ -93,12 +100,12 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
       return false;
     }
     return true;
-  }
+  };
 
   setupWebsocket = () => {
     this.ws.onopen = () => {
-      console.log('connected to websocket')
-    }
+      console.log("connected to websocket");
+    };
 
     this.ws.onmessage = (evt: MessageEvent) => {
       let event = JSON.parse(evt.data);
@@ -107,7 +114,11 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
 
       for (var i = 0; i < event.length; i++) {
         let msg = event[i];
-        if (msg["Values"] && msg["Values"]["data"] && this.isJSON(msg["Values"]["data"])) { 
+        if (
+          msg["Values"] &&
+          msg["Values"]["data"] &&
+          this.isJSON(msg["Values"]["data"])
+        ) {
           let d = JSON.parse(msg["Values"]["data"]);
 
           if (d["kind"] == "error") {
@@ -116,18 +127,22 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
           }
 
           // add only valid events
-          if (d["log"] != null && d["created_resources"] != null && d["total_resources"] != null) {
+          if (
+            d["log"] != null &&
+            d["created_resources"] != null &&
+            d["total_resources"] != null
+          ) {
             validEvents.push(d);
           }
         }
       }
 
       if (err) {
-        posthog.capture('Provisioning Error', {error: err});
+        posthog.capture("Provisioning Error", { error: err });
 
         let e = ansiparse(err).map((el: any) => {
           return el.text;
-        })
+        });
 
         this.setState({ logs: [...this.state.logs, ...e], error: true });
         return;
@@ -136,32 +151,35 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
       if (validEvents.length == 0) {
         return;
       }
-      
-      let logs = [] as any[]
+
+      let logs = [] as any[];
       validEvents.forEach((e: any) => {
-        logs.push(...ansiparse(e["log"]))
-      })
+        logs.push(...ansiparse(e["log"]));
+      });
 
       logs = logs.map((log: any) => {
-        return log.text
-      })
-
-      this.setState({ 
-        logs: [...this.state.logs, ...logs], 
-        maxStep: validEvents[validEvents.length - 1]["total_resources"]
-      }, () => {
-        this.scrollToBottom()
-      })
-    }
+        return log.text;
+      });
+
+      this.setState(
+        {
+          logs: [...this.state.logs, ...logs],
+          maxStep: validEvents[validEvents.length - 1]["total_resources"],
+        },
+        () => {
+          this.scrollToBottom();
+        }
+      );
+    };
 
     this.ws.onerror = (err: ErrorEvent) => {
-      console.log('websocket err', err)
-    }
+      console.log("websocket err", err);
+    };
 
     this.ws.onclose = () => {
-      console.log('closing provisioner websocket')
-    }
-  }
+      console.log("closing provisioner websocket");
+    };
+  };
 
   componentDidMount() {
     let { currentProject } = this.context;
@@ -169,25 +187,25 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
 
     if (!selectedInfra) return;
 
-    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
-    this.ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`)
+    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    this.ws = new WebSocket(
+      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
+    );
 
-    this.setupWebsocket()
+    this.setupWebsocket();
     this.scrollToBottom();
   }
 
   componentWillUnmount() {
     if (this.ws) {
-      this.ws.close()
+      this.ws.close();
     }
   }
 
   render() {
     return (
       <LogStream>
-        <Wrapper ref={this.parentRef}>
-          {this.renderLogs()}
-        </Wrapper>
+        <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
       </LogStream>
     );
   }
@@ -205,7 +223,7 @@ const Loading = styled.div`
   width: 100%;
   color: #ffffff44;
   font-size: 13px;
-`
+`;
 
 const LoadingGif = styled.img`
   width: 15px;
@@ -231,7 +249,7 @@ const LogStream = styled.div`
   user-select: text;
   max-width: 65%;
   overflow-y: auto;
-  overflow-wrap: break-word; 
+  overflow-wrap: break-word;
 `;
 
 const Message = styled.div`
@@ -248,4 +266,4 @@ const Message = styled.div`
 const Log = styled.div`
   font-family: monospace;
   font-size: 12px;
-`;
+`;

+ 18 - 9
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -23,20 +23,25 @@ class ProjectSection extends Component<PropsType, StateType> {
   wrapperRef: any = React.createRef();
 
   componentDidMount() {
-    document.addEventListener('mousedown', this.handleClickOutside.bind(this));
+    document.addEventListener("mousedown", this.handleClickOutside.bind(this));
   }
 
   componentWillUnmount() {
-    document.removeEventListener('mousedown', this.handleClickOutside.bind(this));
+    document.removeEventListener(
+      "mousedown",
+      this.handleClickOutside.bind(this)
+    );
   }
 
   handleClickOutside = (e: any) => {
     if (
-      this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(e.target)
+      this.wrapperRef &&
+      this.wrapperRef.current &&
+      !this.wrapperRef.current.contains(e.target)
     ) {
       this.setState({ expanded: false });
     }
-  }
+  };
 
   renderOptionList = () => {
     let { setCurrentProject } = this.context;
@@ -46,7 +51,11 @@ class ProjectSection extends Component<PropsType, StateType> {
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => {this.setState({ expanded: false }); setCurrentProject(project)}}
+          onClick={() => {
+            this.setState({ expanded: false });
+            setCurrentProject(project);
+            this.props.history.push("dashboard");
+          }}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -67,7 +76,9 @@ class ProjectSection extends Component<PropsType, StateType> {
             <Option
               selected={false}
               lastItem={true}
-              onClick={() => { this.props.history.push('new-project') }}
+              onClick={() => {
+                this.props.history.push("new-project");
+              }}
             >
               <ProjectIconAlt>+</ProjectIconAlt>
               <ProjectLabel>Create a Project</ProjectLabel>
@@ -86,9 +97,7 @@ class ProjectSection extends Component<PropsType, StateType> {
     let { currentProject } = this.props;
     if (currentProject) {
       return (
-        <StyledProjectSection
-          ref={this.wrapperRef}
-        >
+        <StyledProjectSection ref={this.wrapperRef}>
           <MainSelector
             onClick={this.handleExpand}
             expanded={this.state.expanded}

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

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import category from "assets/category.svg";
 import integrations from "assets/integrations.svg";
-import filter from "assets/filter.svg";
+import rocket from "assets/rocket.png";
 import settings from "assets/settings.svg";
 
 import { Context } from "shared/Context";
@@ -112,19 +112,17 @@ class Sidebar extends Component<PropsType, StateType> {
             Dashboard
           </NavButton>
           <NavButton
-            onClick={() => this.props.history.push("templates")}
-            selected={currentView === "templates"}
+            onClick={() => this.props.history.push("launch")}
+            selected={currentView === "launch"}
           >
-            <Img src={filter} />
-            Templates
+            <Img src={rocket} />
+            Launch
           </NavButton>
           <NavButton
             selected={currentView === "integrations"}
-            /* 
-            onClick={() => {
-              setCurrentView('integrations')
-            }}
-            */
+            // onClick={() => {
+            //   this.props.history.push("integrations");
+            // }}
             onClick={() => {
               setCurrentModal("IntegrationsInstructionsModal", {});
             }}

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

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

+ 35 - 23
dashboard/src/shared/api.tsx

@@ -10,29 +10,38 @@ import { StorageType } from "./types";
  * @param {(err: Object, res: Object) => void} callback - Callback function.
  */
 
-const checkAuth = baseApi('GET', '/api/auth/check');
+const checkAuth = baseApi("GET", "/api/auth/check");
 
-const connectECRRegistry = baseApi<{
-  name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
+const connectECRRegistry = baseApi<
+  {
+    name: string;
+    aws_integration_id: string;
+  },
+  { id: number }
+>("POST", (pathParams) => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
-const connectGCRRegistry = baseApi<{
-  name: string,
-  gcp_integration_id: string,
-  url: string,
-}, { id: number }>('POST', pathParams => {
+const connectGCRRegistry = baseApi<
+  {
+    name: string;
+    gcp_integration_id: string;
+    url: string;
+  },
+  { id: number }
+>("POST", (pathParams) => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
-const createAWSIntegration = baseApi<{
-  aws_region: string,
-  aws_cluster_id?: string,
-  aws_access_key_id: string,
-  aws_secret_access_key: string,
-}, { id: number }>('POST', pathParams => {
+const createAWSIntegration = baseApi<
+  {
+    aws_region: string;
+    aws_cluster_id?: string;
+    aws_access_key_id: string;
+    aws_secret_access_key: string;
+  },
+  { id: number }
+>("POST", (pathParams) => {
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
@@ -62,13 +71,16 @@ const createDOKS = baseApi<
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
-const createGCPIntegration = baseApi<{
-  gcp_region: string,
-  gcp_key_data: string,
-  gcp_project_id: string,
-}, {
-  project_id: number,
-}>('POST', pathParams => {
+const createGCPIntegration = baseApi<
+  {
+    gcp_region: string;
+    gcp_key_data: string;
+    gcp_project_id: string;
+  },
+  {
+    project_id: number;
+  }
+>("POST", (pathParams) => {
   return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 

+ 18 - 17
dashboard/src/shared/common.tsx

@@ -1,8 +1,8 @@
-import aws from '../assets/aws.png';
-import digitalOcean from '../assets/do.png';
-import gcp from '../assets/gcp.png';
-import github from '../assets/github.png';
-import { InfraType } from '../shared/types';
+import aws from "../assets/aws.png";
+import digitalOcean from "../assets/do.png";
+import gcp from "../assets/gcp.png";
+import github from "../assets/github.png";
+import { InfraType } from "../shared/types";
 
 export const infraNames: any = {
   ecr: "Elastic Container Registry (ECR)",
@@ -20,10 +20,11 @@ export const integrationList: any = {
     label: "Kubernetes",
     buttonText: "Add a Cluster",
   },
-  'repo': {
-    icon: 'https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png',
-    label: 'Git Repository',
-    buttonText: 'Link a Github Account',
+  repo: {
+    icon:
+      "https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png",
+    label: "Git Repository",
+    buttonText: "Link a Github Account",
   },
   registry: {
     icon:
@@ -69,16 +70,16 @@ export const integrationList: any = {
   },
   do: {
     icon: digitalOcean,
-    label: 'DigitalOcean',
+    label: "DigitalOcean",
   },
-  'github': {
+  github: {
     icon: github,
-    label: 'GitHub',
+    label: "GitHub",
+  },
+  gitlab: {
+    icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
+    label: "Gitlab",
   },
-  'gitlab': {
-    icon: 'https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png',
-    label: 'Gitlab',
-  }
 };
 
 export const isAlphanumeric = (x: string | null) => {
@@ -93,4 +94,4 @@ export const getIgnoreCase = (object: any, key: string) => {
   return object[
     Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())
   ];
-}
+};

+ 9 - 3
dashboard/src/shared/routing.tsx

@@ -1,8 +1,16 @@
 import { Location } from "history";
 
+export type PorterUrl =
+  | "dashboard"
+  | "launch"
+  | "integrations"
+  | "new-project"
+  | "cluster-dashboard"
+  | "project-settings";
+
 export const PorterUrls = [
   "dashboard",
-  "templates",
+  "launch",
   "integrations",
   "new-project",
   "cluster-dashboard",
@@ -21,5 +29,3 @@ export const setSearchParam = (
     search: urlParams.toString(),
   };
 };
-
-export type PorterUrls = typeof PorterUrls[number];

+ 1 - 1
dashboard/tsconfig.json

@@ -12,4 +12,4 @@
     "removeComments": true,
     "moduleResolution": "node"
   }
-}
+}

+ 23 - 25
dashboard/webpack.config.js

@@ -1,7 +1,7 @@
-const path = require('path');
+const path = require("path");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
-const webpack = require('webpack');
-const dotenv = require('dotenv');
+const webpack = require("webpack");
+const dotenv = require("dotenv");
 
 module.exports = () => {
   const env = dotenv.config().parsed;
@@ -11,9 +11,9 @@ module.exports = () => {
   }, {});
 
   return {
-    entry: './src/index.tsx',
-    target: 'web',
-    mode: 'development',
+    entry: "./src/index.tsx",
+    target: "web",
+    mode: "development",
     module: {
       rules: [
         {
@@ -27,33 +27,31 @@ module.exports = () => {
         },
         {
           test: /\.(png|svg|jpg|gif|mp3)$/,
-          use: [
-            'file-loader'
-          ]
+          use: ["file-loader"],
         },
-        { test: /\.css$/, use: [ 'css-loader' ] },
+        { test: /\.css$/, use: ["css-loader"] },
         {
           test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
           use: [
             {
-              loader: 'file-loader',
+              loader: "file-loader",
               options: {
-                name: '[name].[ext]',
-                outputPath: 'fonts/'
-              }
-            }
-          ]
-        }
+                name: "[name].[ext]",
+                outputPath: "fonts/",
+              },
+            },
+          ],
+        },
       ],
     },
     resolve: {
       modules: [path.resolve(__dirname, "src"), "node_modules"],
-      extensions: ['*', '.tsx', '.ts', '.js', '.jsx', '.json'],
+      extensions: ["*", ".tsx", ".ts", ".js", ".jsx", ".json"],
     },
     output: {
-      filename: 'bundle.js',
-      path: path.resolve(__dirname, 'build'),
-      publicPath: '/'
+      filename: "bundle.js",
+      path: path.resolve(__dirname, "build"),
+      publicPath: "/",
     },
     devServer: {
       historyApiFallback: true,
@@ -62,7 +60,7 @@ module.exports = () => {
       new HtmlWebpackPlugin({
         template: path.resolve(__dirname, "src", "index.html"),
       }),
-      new webpack.DefinePlugin(envKeys)
-    ]
-  }
-};
+      new webpack.DefinePlugin(envKeys),
+    ],
+  };
+};

+ 4 - 4
docker-compose.dev.yaml

@@ -1,4 +1,4 @@
-version: '3'
+version: "3"
 services:
   webpack:
     build:
@@ -52,14 +52,14 @@ services:
     container_name: nginx
     restart: unless-stopped
     ports:
-      - '8080:8080'
+      - "8080:8080"
     volumes:
       - ./docker/nginx_local.conf:/etc/nginx/nginx.conf:ro
     depends_on:
       - porter
-      - webpack    
+      - webpack
 
 volumes:
   database:
   metabase:
-  chartmuseum:
+  chartmuseum:

+ 2 - 2
docs/GCR.md

@@ -8,13 +8,13 @@ Select **Create Service Account** and provide a name and brief description for t
 
 <img src="https://files.readme.io/aa8cda5-Screen_Shot_2020-06-24_at_4.03.33_PM.png" width="80%">
 
-After the service account has been created, you need to create a JSON key for your service account by going to **Actions** -> **Create key** and then selecting JSON as your key type. Once your JSON key file has downloaded, use the `porter connect gcr` command to add the registry to your project. 
+After the service account has been created, you need to create a JSON key for your service account by going to **Actions** -> **Create key** and then selecting JSON as your key type. Once your JSON key file has downloaded, use the `porter connect gcr` command to add the registry to your project.
 
 For example, for a key named `gcp-key-file.json` on Mac:
 
 ```diff
 $ cd ~/Downloads
-$ porter connect gcr 
+$ porter connect gcr
 Please provide the full path to a service account key file.
 Key file location: ./gcp-key-file.json
 + created gcp integration with id 3

+ 17 - 18
docs/GETTING_STARTED.md

@@ -2,17 +2,17 @@
 
 - [Prerequisites](#prerequisites)
 - [Installing](#installing)
-    - [Mac Installation](#mac-installation)
-    - [Linux Installation](#linux-installation)
-    - [Windows Installation](#windows-installation)
+  - [Mac Installation](#mac-installation)
+  - [Linux Installation](#linux-installation)
+  - [Windows Installation](#windows-installation)
 - [Local Setup](#local-setup)
-    - [Connecting to a Cluster](#connecting-to-a-cluster)
+  - [Connecting to a Cluster](#connecting-to-a-cluster)
 
 ## Prerequisites
 
-You must have access to a Kubernetes cluster with Helm charts installed and the Docker engine must be running on your machine. To quickly get a local Kubernetes cluster set up, following the instructions for [installing minikube](https://minikube.sigs.k8s.io/docs/start/), and make sure that minikube is set as the current context by ensuring the output of `kubectl config current-context` is `minikube`. 
+You must have access to a Kubernetes cluster with Helm charts installed and the Docker engine must be running on your machine. To quickly get a local Kubernetes cluster set up, following the instructions for [installing minikube](https://minikube.sigs.k8s.io/docs/start/), and make sure that minikube is set as the current context by ensuring the output of `kubectl config current-context` is `minikube`.
 
-## Installing 
+## Installing
 
 ### Mac Installation
 
@@ -58,12 +58,11 @@ sudo mv ./porter /usr/local/bin/porter
 
 ### Windows Installation
 
-Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0.1.0-beta.1_Windows_x86_64.zip
-) to download the Windows executable and add the binary to your `PATH`. 
+Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0.1.0-beta.1_Windows_x86_64.zip) to download the Windows executable and add the binary to your `PATH`.
 
 ## Local Setup
 
-> **Note:** the local setup process is tracked in [issue #60](https://github.com/porter-dev/porter/issues/60), while the overall onboarding flow is tracked in [issue #50](https://github.com/porter-dev/porter/issues/50). 
+> **Note:** the local setup process is tracked in [issue #60](https://github.com/porter-dev/porter/issues/60), while the overall onboarding flow is tracked in [issue #50](https://github.com/porter-dev/porter/issues/50).
 
 To view Porter locally, you must have access to a Kubernetes cluster with Helm charts installed. The simplest way to run Porter is via `porter server start`. After doing this, you can go to `http://localhost:8080` to register an account and create a project manually. Alternatively, you can run the following commands:
 
@@ -74,18 +73,18 @@ porter project create porter-test
 
 ### Connecting to a Cluster
 
-In the case of local setup, you will have to connect to a cluster using the CLI command `porter connect kubeconfig`. By default, this command will read the `current-context` that's set in your default `kubeconfig` (either by reading the `$KUBECONFIG` env variable or reading from `$HOME/.kube/config`). You can also pass a path to a kubeconfig file explicitly (see below). 
+In the case of local setup, you will have to connect to a cluster using the CLI command `porter connect kubeconfig`. By default, this command will read the `current-context` that's set in your default `kubeconfig` (either by reading the `$KUBECONFIG` env variable or reading from `$HOME/.kube/config`). You can also pass a path to a kubeconfig file explicitly (see below).
 
 The Porter CLI will attempt to generate a working kubeconfig for many types of cluster configurations and auth mechanisms, even though the necessary commands and/or certificates will not be present in the Porter container. The CLI will attempt the following resolutions:
 
-1. If a kubeconfig requires cluster CA data via the `certificate-authority` field, the CA data will be automatically populated. 
-2. If a kubeconfig requires client cert data via the `client-certificate` field, the certificate data will be automatically populated. 
-3. If a kubeconfig requires client key data via the `client-key` field, the key data will be automatically populated. 
-4. If a kubeconfig requires a custom `oidc` auth mechanism, and this mechanism requires OIDC issuer CA data via the `idp-certificate-authority` field, the CA data will be automatically populated. 
-5. If a kubeconfig requires a bearer token to be read from a `token-file` field, the token data will be automatically populated. 
-6. If a kubeconfig requires a custom `gcp` auth mechanism (for connecting with GKE clusters), the CLI will require a GCP `service-account` that has permissions to read from the GKE cluster. The CLI will ask the user if it can set this up automatically: if so, it will automatically detect the correct GCP project ID and will create a service account and download a key file. If the user does not wish the CLI to set this up automatically, the user will need to provide a file path to a service account key file that was downloaded from GCloud. 
+1. If a kubeconfig requires cluster CA data via the `certificate-authority` field, the CA data will be automatically populated.
+2. If a kubeconfig requires client cert data via the `client-certificate` field, the certificate data will be automatically populated.
+3. If a kubeconfig requires client key data via the `client-key` field, the key data will be automatically populated.
+4. If a kubeconfig requires a custom `oidc` auth mechanism, and this mechanism requires OIDC issuer CA data via the `idp-certificate-authority` field, the CA data will be automatically populated.
+5. If a kubeconfig requires a bearer token to be read from a `token-file` field, the token data will be automatically populated.
+6. If a kubeconfig requires a custom `gcp` auth mechanism (for connecting with GKE clusters), the CLI will require a GCP `service-account` that has permissions to read from the GKE cluster. The CLI will ask the user if it can set this up automatically: if so, it will automatically detect the correct GCP project ID and will create a service account and download a key file. If the user does not wish the CLI to set this up automatically, the user will need to provide a file path to a service account key file that was downloaded from GCloud.
 
-> **Note:** AWS EKS support coming soon. 
+> **Note:** AWS EKS support coming soon.
 
 #### Passing `kubeconfig` explicitly
 
@@ -101,4 +100,4 @@ You can initialize Porter with a set of contexts by passing a context list to st
 
 ```sh
 porter connect kubeconfig --contexts minikube --contexts staging
-```
+```

+ 5 - 7
helm/templates/service.yaml

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

+ 9 - 5
helm/values.yaml

@@ -25,10 +25,12 @@ serviceAccount:
 
 podAnnotations: {}
 
-podSecurityContext: {}
+podSecurityContext:
+  {}
   # fsGroup: 2000
 
-securityContext: {}
+securityContext:
+  {}
   # capabilities:
   #   drop:
   #   - ALL
@@ -42,18 +44,20 @@ service:
 
 ingress:
   enabled: true
-  annotations: {}
+  annotations:
+    {}
     # kubernetes.io/ingress.class: nginx
     # kubernetes.io/tls-acme: "true"
   hosts:
     - host: dashboard.getporter.dev
-      paths: ['/*']
+      paths: ["/*"]
   tls:
     - secretName: ingress-dashboard
       hosts:
         - dashboard.getporter.dev
 
-resources: {}
+resources:
+  {}
   # We usually recommend not to specify default resources and to leave this as a conscious
   # choice for the user. This also increases chances charts run on environments with little
   # resources, such as Minikube. If you do want to specify resources, uncomment the following

+ 1 - 1
internal/config/config.go

@@ -22,7 +22,7 @@ type ServerConf struct {
 	Port                 int           `env:"SERVER_PORT,default=8080"`
 	StaticFilePath       string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	CookieName           string        `env:"COOKIE_NAME,default=porter"`
-	CookieSecrets        []string      `env:"COOKIE_SECRETS,default=hashkey;blockkey"`
+	CookieSecrets        []string      `env:"COOKIE_SECRETS,default=random_hash_key_;random_block_key"`
 	TokenGeneratorSecret string        `env:"TOKEN_GENERATOR_SECRET,default=secret"`
 	TimeoutRead          time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
 	TimeoutWrite         time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`

+ 7 - 0
internal/forms/git_action.go

@@ -24,3 +24,10 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 		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"`
+}

+ 14 - 12
internal/forms/registry.go

@@ -9,23 +9,25 @@ import (
 // CreateRegistry represents the accepted values for creating a
 // registry
 type CreateRegistry struct {
-	Name             string `json:"name" form:"required"`
-	ProjectID        uint   `json:"project_id" form:"required"`
-	URL              string `json:"url"`
-	GCPIntegrationID uint   `json:"gcp_integration_id"`
-	AWSIntegrationID uint   `json:"aws_integration_id"`
-	DOIntegrationID  uint   `json:"do_integration_id"`
+	Name               string `json:"name" form:"required"`
+	ProjectID          uint   `json:"project_id" form:"required"`
+	URL                string `json:"url"`
+	GCPIntegrationID   uint   `json:"gcp_integration_id"`
+	AWSIntegrationID   uint   `json:"aws_integration_id"`
+	DOIntegrationID    uint   `json:"do_integration_id"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
 }
 
 // ToRegistry converts the form to a gorm registry model
 func (cr *CreateRegistry) ToRegistry(repo repository.Repository) (*models.Registry, error) {
 	registry := &models.Registry{
-		Name:             cr.Name,
-		ProjectID:        cr.ProjectID,
-		URL:              cr.URL,
-		GCPIntegrationID: cr.GCPIntegrationID,
-		AWSIntegrationID: cr.AWSIntegrationID,
-		DOIntegrationID:  cr.DOIntegrationID,
+		Name:               cr.Name,
+		ProjectID:          cr.ProjectID,
+		URL:                cr.URL,
+		GCPIntegrationID:   cr.GCPIntegrationID,
+		AWSIntegrationID:   cr.AWSIntegrationID,
+		DOIntegrationID:    cr.DOIntegrationID,
+		BasicIntegrationID: cr.BasicIntegrationID,
 	}
 
 	if registry.URL == "" && registry.AWSIntegrationID != 0 {

+ 3 - 4
internal/forms/release.go

@@ -1,7 +1,6 @@
 package forms
 
 import (
-	"fmt"
 	"net/url"
 	"strconv"
 
@@ -32,17 +31,14 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 		if err != nil {
 			return err
 		}
-		fmt.Println("setting cluster")
 		rf.Cluster = cluster
 	}
 
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
-		fmt.Println("setting namespace")
 		rf.Namespace = namespace[0]
 	}
 
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
-		fmt.Println("setting storage")
 		rf.Storage = storage[0]
 	}
 
@@ -129,4 +125,7 @@ type ChartTemplateForm struct {
 type InstallChartTemplateForm struct {
 	*ReleaseForm
 	*ChartTemplateForm
+
+	// optional git action config
+	GithubActionConfig *CreateGitActionOptional `json:"github_action,omitempty"`
 }

+ 9 - 11
internal/helm/grapher/test_yaml/cassandra.yaml

@@ -94,8 +94,8 @@ spec:
       app.kubernetes.io/name: cassandra
       app.kubernetes.io/instance: my-release
     matchExpressions:
-      - {key: tier, operator: In, values: [cache]}
-      - {key: environment, operator: NotIn, values: [dev]}
+      - { key: tier, operator: In, values: [cache] }
+      - { key: environment, operator: NotIn, values: [dev] }
   serviceName: my-release-cassandra-headless
   podManagementPolicy: OrderedReady
   replicas: 2
@@ -109,10 +109,9 @@ spec:
         app.kubernetes.io/instance: my-release
         app.kubernetes.io/managed-by: Helm
     spec:
-      
       affinity:
         podAffinity:
-          
+
         podAntiAffinity:
           preferredDuringSchedulingIgnoredDuringExecution:
             - podAffinityTerm:
@@ -125,7 +124,7 @@ spec:
                 topologyKey: kubernetes.io/hostname
               weight: 1
         nodeAffinity:
-          
+
       securityContext:
         fsGroup: 1001
       containers:
@@ -211,17 +210,17 @@ spec:
               containerPort: 9042
             - name: thrift
               containerPort: 9160
-          resources: 
+          resources:
             limits: {}
             requests: {}
           volumeMounts:
             - name: data
               mountPath: /bitnami/cassandra
-            
+
       volumes:
-      - name: config-volume
-        configMap:
-          name: config-example
+        - name: config-volume
+          configMap:
+            name: config-example
   volumeClaimTemplates:
     - metadata:
         name: data
@@ -234,4 +233,3 @@ spec:
         resources:
           requests:
             storage: "8Gi"
-

+ 41 - 41
internal/helm/grapher/test_yaml/ingress.yaml

@@ -7,15 +7,15 @@ metadata:
     nginx.ingress.kubernetes.io/rewrite-target: /
 spec:
   rules:
-  - http:
-      paths:
-      - path: /testpath
-        pathType: Prefix
-        backend:
-          service:
-            name: test
-            port:
-              number: 80
+    - http:
+        paths:
+          - path: /testpath
+            pathType: Prefix
+            backend:
+              service:
+                name: test
+                port:
+                  number: 80
 ---
 apiVersion: v1
 kind: Service
@@ -26,9 +26,9 @@ spec:
   selector:
     app: foo
   ports:
-  - protocol: TCP
-    port: 80
-    targetPort: 80
+    - protocol: TCP
+      port: 80
+      targetPort: 80
 ---
 apiVersion: v1
 kind: Service
@@ -39,9 +39,9 @@ spec:
   selector:
     app: foo
   ports:
-  - protocol: TCP
-    port: 80
-    targetPort: 80
+    - protocol: TCP
+      port: 80
+      targetPort: 80
 ---
 apiVersion: networking.k8s.io/v1
 kind: Ingress
@@ -51,16 +51,16 @@ metadata:
     nginx.ingress.kubernetes.io/rewrite-target: /
 spec:
   rules:
-  - http:
-      paths:
-      - path: /testpath
-        pathType: Prefix
-        backend:
-          resource:
-            name: resource-test
-            kind: StatefulSet
-            port:
-              number: 80
+    - http:
+        paths:
+          - path: /testpath
+            pathType: Prefix
+            backend:
+              resource:
+                name: resource-test
+                kind: StatefulSet
+                port:
+                  number: 80
 ---
 apiVersion: apps/v1
 kind: StatefulSet
@@ -85,22 +85,22 @@ spec:
               - key: log_level
                 path: log_level
       containers:
-      - name: nginx
-        image: k8s.gcr.io/nginx-slim:0.8
-        ports:
-        - containerPort: 80
-          name: web
-        volumeMounts:
-        - name: www
-          mountPath: /usr/share/nginx/html
+        - name: nginx
+          image: k8s.gcr.io/nginx-slim:0.8
+          ports:
+            - containerPort: 80
+              name: web
+          volumeMounts:
+            - name: www
+              mountPath: /usr/share/nginx/html
   volumeClaimTemplates:
-  - metadata:
-      name: www
-    spec:
-      accessModes: [ "ReadWriteOnce" ]
-      resources:
-        requests:
-          storage: 1Gi
+    - metadata:
+        name: www
+      spec:
+        accessModes: ["ReadWriteOnce"]
+        resources:
+          requests:
+            storage: 1Gi
 ---
 apiVersion: v1
 kind: ConfigMap
@@ -116,4 +116,4 @@ data:
     lives=3
     secret.code.lives=30
   ui.properties: |
-    color.good=purple
+    color.good=purple

+ 32 - 30
internal/helm/grapher/test_yaml/kafka.yaml

@@ -47,12 +47,10 @@ spec:
   clusterIP: None
   publishNotReadyAddresses: true
   ports:
-    
     - name: tcp-client
       port: 2181
       targetPort: client
-    
-    
+
     - name: follower
       port: 2888
       targetPort: follower
@@ -79,12 +77,10 @@ metadata:
 spec:
   type: ClusterIP
   ports:
-    
     - name: tcp-client
       port: 2181
       targetPort: client
-    
-    
+
     - name: follower
       port: 2888
       targetPort: follower
@@ -182,7 +178,6 @@ spec:
         app.kubernetes.io/managed-by: Helm
         app.kubernetes.io/component: zookeeper
     spec:
-      
       serviceAccountName: default
       securityContext:
         fsGroup: 1001
@@ -196,16 +191,16 @@ spec:
             - bash
             - -ec
             - |
-                # Execute entrypoint as usual after obtaining ZOO_SERVER_ID based on POD hostname
-                HOSTNAME=`hostname -s`
-                if [[ $HOSTNAME =~ (.*)-([0-9]+)$ ]]; then
-                  ORD=${BASH_REMATCH[2]}
-                  export ZOO_SERVER_ID=$((ORD+1))
-                else
-                  echo "Failed to get index from hostname $HOST"
-                  exit 1
-                fi
-                exec /entrypoint.sh /run.sh
+              # Execute entrypoint as usual after obtaining ZOO_SERVER_ID based on POD hostname
+              HOSTNAME=`hostname -s`
+              if [[ $HOSTNAME =~ (.*)-([0-9]+)$ ]]; then
+                ORD=${BASH_REMATCH[2]}
+                export ZOO_SERVER_ID=$((ORD+1))
+              else
+                echo "Failed to get index from hostname $HOST"
+                exit 1
+              fi
+              exec /entrypoint.sh /run.sh
           resources:
             requests:
               cpu: 250m
@@ -234,7 +229,7 @@ spec:
             - name: ZOO_MAX_SESSION_TIMEOUT
               value: "40000"
             - name: ZOO_SERVERS
-              value: my-release-zookeeper-0.my-release-zookeeper-headless.default.svc.cluster.local:2888:3888 
+              value: my-release-zookeeper-0.my-release-zookeeper-headless.default.svc.cluster.local:2888:3888
             - name: ZOO_ENABLE_AUTH
               value: "no"
             - name: ZOO_HEAP_SIZE
@@ -249,18 +244,21 @@ spec:
                   apiVersion: v1
                   fieldPath: metadata.name
           ports:
-            
             - name: client
               containerPort: 2181
-            
-            
+
             - name: follower
               containerPort: 2888
             - name: election
               containerPort: 3888
           livenessProbe:
             exec:
-              command: ['/bin/bash', '-c', 'echo "ruok" | timeout 2 nc -w 2 localhost 2181 | grep imok']
+              command:
+                [
+                  "/bin/bash",
+                  "-c",
+                  'echo "ruok" | timeout 2 nc -w 2 localhost 2181 | grep imok',
+                ]
             initialDelaySeconds: 30
             periodSeconds: 10
             timeoutSeconds: 5
@@ -268,7 +266,12 @@ spec:
             failureThreshold: 6
           readinessProbe:
             exec:
-              command: ['/bin/bash', '-c', 'echo "ruok" | timeout 2 nc -w 2 localhost 2181 | grep imok']
+              command:
+                [
+                  "/bin/bash",
+                  "-c",
+                  'echo "ruok" | timeout 2 nc -w 2 localhost 2181 | grep imok',
+                ]
             initialDelaySeconds: 5
             periodSeconds: 10
             timeoutSeconds: 5
@@ -319,7 +322,7 @@ spec:
         app.kubernetes.io/instance: my-release
         app.kubernetes.io/managed-by: Helm
         app.kubernetes.io/component: kafka
-    spec:      
+    spec:
       securityContext:
         fsGroup: 1001
         runAsUser: 1001
@@ -409,17 +412,17 @@ spec:
               port: kafka-client
             initialDelaySeconds: 10
             timeoutSeconds: 5
-            failureThreshold: 
-            periodSeconds: 
-            successThreshold: 
+            failureThreshold:
+            periodSeconds:
+            successThreshold:
           readinessProbe:
             tcpSocket:
               port: kafka-client
             initialDelaySeconds: 5
             timeoutSeconds: 5
             failureThreshold: 6
-            periodSeconds: 
-            successThreshold: 
+            periodSeconds:
+            successThreshold:
           resources:
             limits: {}
             requests: {}
@@ -443,4 +446,3 @@ spec:
         resources:
           requests:
             storage: "8Gi"
-

+ 1 - 1
internal/helm/grapher/test_yaml/volumes.yaml

@@ -32,4 +32,4 @@ data:
     lives=3
     secret.code.lives=30
   ui.properties: |
-    color.good=purple
+    color.good=purple

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

@@ -29,7 +29,7 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 
 const configure string = `
 porter auth login --token ${{secrets.%s}}
-porter docker configure
+sudo porter docker configure
 `
 
 func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
@@ -54,7 +54,7 @@ func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep
 }
 
 const deployPorter string = `
-curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)&repository=%s'
+curl -X POST "https://dashboard.getporter.dev/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)&repository=%s"
 `
 
 func deployPorterWebhookStep(webhookTokenSecretName, repoURL string) GithubActionYAMLStep {

+ 0 - 2
internal/kubernetes/agent.go

@@ -146,7 +146,6 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 			if _, _, err := conn.ReadMessage(); err != nil {
 				defer conn.Close()
 				errorchan <- nil
-				fmt.Println("Successfully closed log stream")
 				return
 			}
 		}
@@ -230,7 +229,6 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 			if _, _, err := conn.ReadMessage(); err != nil {
 				defer conn.Close()
 				defer close(stopper)
-				defer fmt.Println("Successfully closed controller status stream")
 				errorchan <- nil
 				return
 			}

+ 13 - 4
internal/kubernetes/config.go

@@ -2,6 +2,7 @@ package kubernetes
 
 import (
 	"errors"
+	"fmt"
 	"path/filepath"
 	"regexp"
 	"strings"
@@ -99,7 +100,13 @@ type OutOfClusterConfig struct {
 // the result of ToRawKubeConfigLoader, and also adds a custom http transport layer
 // if necessary (required for GCP auth)
 func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
-	restConf, err := conf.ToRawKubeConfigLoader().ClientConfig()
+	cmdConf, err := conf.GetClientConfigFromCluster()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
 
 	if err != nil {
 		return nil, err
@@ -157,11 +164,13 @@ func (conf *OutOfClusterConfig) ToRESTMapper() (meta.RESTMapper, error) {
 // GetClientConfigFromCluster will construct new clientcmd.ClientConfig using
 // the configuration saved within a Cluster model
 func (conf *OutOfClusterConfig) GetClientConfigFromCluster() (clientcmd.ClientConfig, error) {
-	cluster := conf.Cluster
+	if conf.Cluster == nil {
+		return nil, fmt.Errorf("cluster cannot be nil")
+	}
 
-	if cluster.AuthMechanism == models.Local {
+	if conf.Cluster.AuthMechanism == models.Local {
 		kubeAuth, err := conf.Repo.KubeIntegration.ReadKubeIntegration(
-			cluster.KubeIntegrationID,
+			conf.Cluster.KubeIntegrationID,
 		)
 
 		if err != nil {

+ 0 - 4
internal/kubernetes/provisioner/global_stream.go

@@ -85,8 +85,6 @@ func GlobalStreamListener(
 	repo repository.Repository,
 	errorChan chan error,
 ) {
-	fmt.Println("starting global stream listener")
-
 	for {
 		xstreams, err := client.XReadGroup(
 			context.Background(),
@@ -98,8 +96,6 @@ func GlobalStreamListener(
 			},
 		).Result()
 
-		fmt.Println(xstreams, err)
-
 		if err != nil {
 			errorChan <- err
 			return

+ 0 - 2
internal/kubernetes/provisioner/resource_stream.go

@@ -2,7 +2,6 @@ package provisioner
 
 import (
 	"context"
-	"fmt"
 
 	redis "github.com/go-redis/redis/v8"
 	"github.com/gorilla/websocket"
@@ -39,7 +38,6 @@ func ResourceStream(client *redis.Client, streamName string, conn *websocket.Con
 			).Result()
 
 			if err != nil {
-				fmt.Println("ERROR XREAD", err)
 				return
 			}
 

+ 2 - 0
internal/models/cluster.go

@@ -96,6 +96,8 @@ func (c *Cluster) Externalize() *ClusterExternal {
 		serv = integrations.EKS
 	} else if c.GCPIntegrationID != 0 {
 		serv = integrations.GKE
+	} else if c.DOIntegrationID != 0 {
+		serv = integrations.DOKS
 	}
 
 	return &ClusterExternal{

+ 13 - 11
internal/models/integrations/integration.go

@@ -5,17 +5,19 @@ type IntegrationService string
 
 // The list of supported third-party services
 const (
-	GKE      IntegrationService = "gke"
-	GCS      IntegrationService = "gcs"
-	S3       IntegrationService = "s3"
-	HelmRepo IntegrationService = "helm"
-	EKS      IntegrationService = "eks"
-	Kube     IntegrationService = "kube"
-	GCR      IntegrationService = "gcr"
-	ECR      IntegrationService = "ecr"
-	DOCR     IntegrationService = "docr"
-	Github   IntegrationService = "github"
-	Docker   IntegrationService = "docker"
+	GKE       IntegrationService = "gke"
+	DOKS      IntegrationService = "doks"
+	GCS       IntegrationService = "gcs"
+	S3        IntegrationService = "s3"
+	HelmRepo  IntegrationService = "helm"
+	EKS       IntegrationService = "eks"
+	Kube      IntegrationService = "kube"
+	GCR       IntegrationService = "gcr"
+	ECR       IntegrationService = "ecr"
+	DOCR      IntegrationService = "docr"
+	Github    IntegrationService = "github"
+	DockerHub IntegrationService = "dockerhub"
+	Docker    IntegrationService = "docker"
 )
 
 // PorterIntegration is a supported integration service, specifying an auth

+ 8 - 3
internal/models/registry.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"gorm.io/gorm"
 )
@@ -26,9 +28,10 @@ type Registry struct {
 	// All fields below this line are encrypted before storage
 	// ------------------------------------------------------------------
 
-	GCPIntegrationID uint
-	AWSIntegrationID uint
-	DOIntegrationID  uint
+	GCPIntegrationID   uint
+	AWSIntegrationID   uint
+	DOIntegrationID    uint
+	BasicIntegrationID uint
 
 	// A token cache that can be used by an auth mechanism (integration), if desired
 	TokenCache integrations.RegTokenCache
@@ -64,6 +67,8 @@ func (r *Registry) Externalize() *RegistryExternal {
 		serv = integrations.GCR
 	} else if r.DOIntegrationID != 0 {
 		serv = integrations.DOCR
+	} else if strings.Contains(r.URL, "index.docker.io") {
+		serv = integrations.DockerHub
 	}
 
 	return &RegistryExternal{

+ 252 - 0
internal/registry/registry.go

@@ -71,6 +71,10 @@ func (r *Registry) ListRepositories(
 		return r.listDOCRRepositories(repo, doAuth)
 	}
 
+	if r.BasicIntegrationID != 0 {
+		return r.listPrivateRegistryRepositories(repo)
+	}
+
 	return nil, fmt.Errorf("error listing repositories")
 }
 
@@ -252,6 +256,98 @@ func (r *Registry) listDOCRRepositories(
 	return res, nil
 }
 
+func (r *Registry) listPrivateRegistryRepositories(
+	repo repository.Repository,
+) ([]*Repository, error) {
+	// handle dockerhub different, as it doesn't implement the docker registry http api
+	if strings.Contains(r.URL, "docker.io") {
+		// in this case, we just return the single dockerhub repository that's linked
+		res := make([]*Repository, 0)
+
+		res = append(res, &Repository{
+			Name: strings.Split(r.URL, "docker.io/")[1],
+			URI:  r.URL,
+		})
+
+		return res, nil
+	}
+
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
+	client := &http.Client{}
+
+	// get the host and scheme to make the request
+	parsedURL, err := url.Parse(r.URL)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s://%s/v2/_catalog", parsedURL.Scheme, parsedURL.Host),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if the status code is 404, fallback to the Docker Hub implementation
+	if resp.StatusCode == 404 {
+		req, err := http.NewRequest(
+			"GET",
+			fmt.Sprintf("%s/", r.URL),
+			nil,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+		resp, err = client.Do(req)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	gcrResp := gcrRepositoryResp{}
+
+	if err := json.NewDecoder(resp.Body).Decode(&gcrResp); err != nil {
+		return nil, fmt.Errorf("Could not read private registry repositories: %v", err)
+	}
+
+	res := make([]*Repository, 0)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, repo := range gcrResp.Repositories {
+		res = append(res, &Repository{
+			Name: repo,
+			URI:  parsedURL.Host + "/" + repo,
+		})
+	}
+
+	return res, nil
+}
+
 func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
 	return &ints.TokenCache{
 		Token:  r.TokenCache.Token,
@@ -296,6 +392,10 @@ func (r *Registry) ListImages(
 		return r.listDOCRImages(repoName, repo, doAuth)
 	}
 
+	if r.BasicIntegrationID != 0 {
+		return r.listPrivateRegistryImages(repoName, repo)
+	}
+
 	return nil, fmt.Errorf("error listing images")
 }
 
@@ -444,6 +544,118 @@ func (r *Registry) listDOCRImages(
 	return res, nil
 }
 
+func (r *Registry) listPrivateRegistryImages(repoName string, repo repository.Repository) ([]*Image, error) {
+	// handle dockerhub different, as it doesn't implement the docker registry http api
+	if strings.Contains(r.URL, "docker.io") {
+		return r.listDockerHubImages(repoName, repo)
+	}
+
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
+	client := &http.Client{}
+
+	// get the host and scheme to make the request
+	parsedURL, err := url.Parse(r.URL)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s://%s/v2/%s/tags/list", parsedURL.Scheme, parsedURL.Host, repoName),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	gcrResp := gcrImageResp{}
+
+	if err := json.NewDecoder(resp.Body).Decode(&gcrResp); err != nil {
+		return nil, fmt.Errorf("Could not read private registry repositories: %v", err)
+	}
+
+	res := make([]*Image, 0)
+
+	for _, tag := range gcrResp.Tags {
+		res = append(res, &Image{
+			RepositoryName: repoName,
+			Tag:            tag,
+		})
+	}
+
+	return res, nil
+}
+
+type dockerHubImageResult struct {
+	Name string `json:"name"`
+}
+
+type dockerHubImageResp struct {
+	Results []dockerHubImageResult `json:"results"`
+}
+
+func (r *Registry) listDockerHubImages(repoName string, repo repository.Repository) ([]*Image, error) {
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := &http.Client{}
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("https://hub.docker.com/v2/repositories/%s/tags", strings.Split(r.URL, "docker.io/")[1]),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	imageResp := dockerHubImageResp{}
+
+	if err := json.NewDecoder(resp.Body).Decode(&imageResp); err != nil {
+		return nil, fmt.Errorf("Could not read private registry repositories: %v", err)
+	}
+
+	res := make([]*Image, 0)
+
+	for _, result := range imageResp.Results {
+		res = append(res, &Image{
+			RepositoryName: repoName,
+			Tag:            result.Name,
+		})
+	}
+
+	return res, nil
+}
+
 // GetDockerConfigJSON returns a dockerconfigjson file contents with "auths"
 // populated.
 func (r *Registry) GetDockerConfigJSON(
@@ -466,6 +678,10 @@ func (r *Registry) GetDockerConfigJSON(
 		conf, err = r.getDOCRDockerConfigFile(repo, doAuth)
 	}
 
+	if r.BasicIntegrationID != 0 {
+		conf, err = r.getPrivateRegistryDockerConfigFile(repo)
+	}
+
 	if err != nil {
 		return nil, err
 	}
@@ -596,6 +812,42 @@ func (r *Registry) getDOCRDockerConfigFile(
 	}, nil
 }
 
+func (r *Registry) getPrivateRegistryDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	authConfigKey := parsedURL.Host
+
+	if strings.Contains(r.URL, "index.docker.io") {
+		authConfigKey = "https://index.docker.io/v1/"
+	}
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			authConfigKey: types.AuthConfig{
+				Username: string(basic.Username),
+				Password: string(basic.Password),
+				Auth:     generateAuthToken(string(basic.Username), string(basic.Password)),
+			},
+		},
+	}, nil
+}
+
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
 }

+ 24 - 12
internal/repository/gorm/cluster.go

@@ -1,6 +1,8 @@
 package gorm
 
 import (
+	"context"
+
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
@@ -117,6 +119,8 @@ func (repo *ClusterRepository) UpdateClusterCandidateCreatedClusterID(
 func (repo *ClusterRepository) CreateCluster(
 	cluster *models.Cluster,
 ) (*models.Cluster, error) {
+	ctxDB := repo.db.WithContext(context.Background())
+
 	err := repo.EncryptClusterData(cluster, repo.key)
 
 	if err != nil {
@@ -125,11 +129,11 @@ func (repo *ClusterRepository) CreateCluster(
 
 	project := &models.Project{}
 
-	if err := repo.db.Where("id = ?", cluster.ProjectID).First(&project).Error; err != nil {
+	if err := ctxDB.Where("id = ?", cluster.ProjectID).First(&project).Error; err != nil {
 		return nil, err
 	}
 
-	assoc := repo.db.Model(&project).Association("Clusters")
+	assoc := ctxDB.Model(&project).Association("Clusters")
 
 	if assoc.Error != nil {
 		return nil, assoc.Error
@@ -140,7 +144,7 @@ func (repo *ClusterRepository) CreateCluster(
 	}
 
 	// create a token cache by default
-	assoc = repo.db.Model(cluster).Association("TokenCache")
+	assoc = ctxDB.Model(cluster).Association("TokenCache")
 
 	if assoc.Error != nil {
 		return nil, assoc.Error
@@ -163,10 +167,12 @@ func (repo *ClusterRepository) CreateCluster(
 func (repo *ClusterRepository) ReadCluster(
 	id uint,
 ) (*models.Cluster, error) {
+	ctxDB := repo.db.WithContext(context.Background())
+
 	cluster := &models.Cluster{}
 
 	// preload Clusters association
-	if err := repo.db.Preload("TokenCache").Where("id = ?", id).First(&cluster).Error; err != nil {
+	if err := ctxDB.Preload("TokenCache").Where("id = ?", id).First(&cluster).Error; err != nil {
 		return nil, err
 	}
 
@@ -184,9 +190,11 @@ func (repo *ClusterRepository) ReadCluster(
 func (repo *ClusterRepository) ListClustersByProjectID(
 	projectID uint,
 ) ([]*models.Cluster, error) {
+	ctxDB := repo.db.WithContext(context.Background())
+
 	clusters := []*models.Cluster{}
 
-	if err := repo.db.Where("project_id = ?", projectID).Find(&clusters).Error; err != nil {
+	if err := ctxDB.Where("project_id = ?", projectID).Find(&clusters).Error; err != nil {
 		return nil, err
 	}
 
@@ -201,13 +209,15 @@ func (repo *ClusterRepository) ListClustersByProjectID(
 func (repo *ClusterRepository) UpdateCluster(
 	cluster *models.Cluster,
 ) (*models.Cluster, error) {
+	ctxDB := repo.db.WithContext(context.Background())
+
 	err := repo.EncryptClusterData(cluster, repo.key)
 
 	if err != nil {
 		return nil, err
 	}
 
-	if err := repo.db.Save(cluster).Error; err != nil {
+	if err := ctxDB.Save(cluster).Error; err != nil {
 		return nil, err
 	}
 
@@ -224,6 +234,8 @@ func (repo *ClusterRepository) UpdateCluster(
 func (repo *ClusterRepository) UpdateClusterTokenCache(
 	tokenCache *ints.ClusterTokenCache,
 ) (*models.Cluster, error) {
+	ctxDB := repo.db.WithContext(context.Background())
+
 	if tok := tokenCache.Token; len(tok) > 0 {
 		cipherData, err := repository.Encrypt(tok, repo.key)
 
@@ -236,14 +248,14 @@ func (repo *ClusterRepository) UpdateClusterTokenCache(
 
 	cluster := &models.Cluster{}
 
-	if err := repo.db.Where("id = ?", tokenCache.ClusterID).First(&cluster).Error; err != nil {
+	if err := ctxDB.Where("id = ?", tokenCache.ClusterID).First(&cluster).Error; err != nil {
 		return nil, err
 	}
 
 	cluster.TokenCache.Token = tokenCache.Token
 	cluster.TokenCache.Expiry = tokenCache.Expiry
 
-	if err := repo.db.Save(cluster).Error; err != nil {
+	if err := ctxDB.Save(cluster).Error; err != nil {
 		return nil, err
 	}
 
@@ -347,14 +359,14 @@ func (repo *ClusterRepository) DecryptClusterData(
 	}
 
 	if tok := cluster.TokenCache.Token; len(tok) > 0 {
-
 		plaintext, err := repository.Decrypt(tok, key)
 
+		// in the case that the token cache is down, set empty token
 		if err != nil {
-			return err
+			cluster.TokenCache.Token = []byte{}
+		} else {
+			cluster.TokenCache.Token = plaintext
 		}
-
-		cluster.TokenCache.Token = plaintext
 	}
 
 	return nil

+ 5 - 1213
package-lock.json

@@ -2,1219 +2,11 @@
   "requires": true,
   "lockfileVersion": 1,
   "dependencies": {
-    "@types/classnames": {
-      "version": "2.2.11",
-      "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
-      "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
-    },
-    "@types/d3": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/@types/d3/-/d3-6.3.0.tgz",
-      "integrity": "sha512-YILdGsjNTbvkWZKsBasB4cVDwNPnni7ILMJg9keMErQHyuII2yO2jyFdUy5E+7k/HTNP/AucrPddQuu27udbeA==",
-      "requires": {
-        "@types/d3-array": "*",
-        "@types/d3-axis": "*",
-        "@types/d3-brush": "*",
-        "@types/d3-chord": "*",
-        "@types/d3-color": "*",
-        "@types/d3-contour": "*",
-        "@types/d3-delaunay": "*",
-        "@types/d3-dispatch": "*",
-        "@types/d3-drag": "*",
-        "@types/d3-dsv": "*",
-        "@types/d3-ease": "*",
-        "@types/d3-fetch": "*",
-        "@types/d3-force": "*",
-        "@types/d3-format": "*",
-        "@types/d3-geo": "*",
-        "@types/d3-hierarchy": "*",
-        "@types/d3-interpolate": "*",
-        "@types/d3-path": "*",
-        "@types/d3-polygon": "*",
-        "@types/d3-quadtree": "*",
-        "@types/d3-random": "*",
-        "@types/d3-scale": "*",
-        "@types/d3-scale-chromatic": "*",
-        "@types/d3-selection": "*",
-        "@types/d3-shape": "*",
-        "@types/d3-time": "*",
-        "@types/d3-time-format": "*",
-        "@types/d3-timer": "*",
-        "@types/d3-transition": "*",
-        "@types/d3-zoom": "*"
-      }
-    },
-    "@types/d3-array": {
-      "version": "2.9.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.9.0.tgz",
-      "integrity": "sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ=="
-    },
-    "@types/d3-axis": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-2.0.0.tgz",
-      "integrity": "sha512-gUdlEwGBLl3tXGiBnBNmNzph9W3bCfa4tBgWZD60Z1eDQKTY4zyCAcZ3LksignGfKawYatmDYcBdjJ5h/54sqA==",
-      "requires": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "@types/d3-brush": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-2.1.0.tgz",
-      "integrity": "sha512-rLQqxQeXWF4ArXi81GlV8HBNwJw9EDpz0jcWvvzv548EDE4tXrayBTOHYi/8Q4FZ/Df8PGXFzxpAVQmJMjOtvQ==",
-      "requires": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "@types/d3-chord": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-2.0.0.tgz",
-      "integrity": "sha512-3nHsLY7lImpZlM/hrPeDqqW2a+lRXXoHsG54QSurDGihZAIE/doQlohs0evoHrWOJqXyn4A4xbSVEtXnMEZZiw=="
-    },
-    "@types/d3-color": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.1.tgz",
-      "integrity": "sha512-u7LTCL7RnaavFSmob2rIAJLNwu50i6gFwY9cHFr80BrQURYQBRkJ+Yv47nA3Fm7FeRhdWTiVTeqvSeOuMAOzBQ=="
-    },
-    "@types/d3-contour": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-2.0.0.tgz",
-      "integrity": "sha512-PS9UO6zBQqwHXsocbpdzZFONgK1oRUgWtjjh/iz2vM06KaXLInLiKZ9e3OLBRerc1cU2uJYpO+8zOnb6frvCGQ==",
-      "requires": {
-        "@types/d3-array": "*",
-        "@types/geojson": "*"
-      }
-    },
-    "@types/d3-delaunay": {
-      "version": "5.3.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-5.3.0.tgz",
-      "integrity": "sha512-gJYcGxLu0xDZPccbUe32OUpeaNtd1Lz0NYJtko6ZLMyG2euF4pBzrsQXms67LHZCDFzzszw+dMhSL/QAML3bXw=="
-    },
-    "@types/d3-dispatch": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
-      "integrity": "sha512-Sh0KW6z/d7uxssD7K4s4uCSzlEG/+SP+U47q098NVdOfFvUKNTvKAIV4XqjxsUuhE/854ARAREHOxkr9gQOCyg=="
-    },
-    "@types/d3-drag": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-2.0.0.tgz",
-      "integrity": "sha512-VaUJPjbMnDn02tcRqsHLRAX5VjcRIzCjBfeXTLGe6QjMn5JccB5Cz4ztMRXMJfkbC45ovgJFWuj6DHvWMX1thA==",
-      "requires": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "@types/d3-dsv": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-2.0.1.tgz",
-      "integrity": "sha512-wovgiG9Mgkr/SZ/m/c0m+RwrIT4ozsuCWeLxJyoObDWsie2DeQT4wzMdHZPR9Ya5oZLQT3w3uSl0NehG0+0dCA=="
-    },
-    "@types/d3-ease": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-2.0.0.tgz",
-      "integrity": "sha512-6aZrTyX5LG+ptofVHf+gTsThLRY1nhLotJjgY4drYqk1OkJMu2UvuoZRlPw2fffjRHeYepue3/fxTufqKKmvsA=="
-    },
-    "@types/d3-fetch": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-2.0.0.tgz",
-      "integrity": "sha512-WnLepGtxepFfXRdPI8I5FTgNiHn9p4vMTTqaNCzJJfAswXx0rOY2jjeolzEU063em3iJmGZ+U79InnEeFOrCRw==",
-      "requires": {
-        "@types/d3-dsv": "*"
-      }
-    },
-    "@types/d3-force": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-2.1.0.tgz",
-      "integrity": "sha512-LGDtC2YADu8OBniq9EBx/MOsXsMcJbEkmfSpXuz6oVdRamB+3CLCiq5EKFPEILGZQckkilGFq1ZTJ7kc289k+Q=="
-    },
-    "@types/d3-format": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-2.0.0.tgz",
-      "integrity": "sha512-uagdkftxnGkO4pZw5jEYOM5ZnZOEsh7z8j11Qxk85UkB2RzfUUxRl7R9VvvJZHwKn8l+x+rpS77Nusq7FkFmIg=="
-    },
-    "@types/d3-geo": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.0.tgz",
-      "integrity": "sha512-DHHgYXW36lnAEQMYU2udKVOxxljHrn2EdOINeSC9jWCAXwOnGn7A19B8sNsHqgpu4F7O2bSD7//cqBXD3W0Deg==",
-      "requires": {
-        "@types/geojson": "*"
-      }
-    },
-    "@types/d3-hierarchy": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-2.0.0.tgz",
-      "integrity": "sha512-YxdskUvwzqggpnSnDQj4KVkicgjpkgXn/g/9M9iGsiToLS3nG6Ytjo1FoYhYVAAElV/fJBGVL3cQ9Hb7tcv+lw=="
-    },
-    "@types/d3-interpolate": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.0.tgz",
-      "integrity": "sha512-Wt1v2zTlEN8dSx8hhx6MoOhWQgTkz0Ukj7owAEIOF2QtI0e219paFX9rf/SLOr/UExWb1TcUzatU8zWwFby6gg==",
-      "requires": {
-        "@types/d3-color": "*"
-      }
-    },
-    "@types/d3-path": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.0.tgz",
-      "integrity": "sha512-tXcR/9OtDdeCIsyl6eTNHC3XOAOdyc6ceF3QGBXOd9jTcK+ex/ecr00p9L9362e/op3UEPpxrToi1FHrtTSj7Q=="
-    },
-    "@types/d3-polygon": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-2.0.0.tgz",
-      "integrity": "sha512-fISnMd8ePED1G4aa4V974Jmt+ajHSgPoxMa2D0ULxMybpx0Vw4WEzhQEaMIrL3hM8HVRcKTx669I+dTy/4PhAw=="
-    },
-    "@types/d3-quadtree": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-2.0.0.tgz",
-      "integrity": "sha512-YZuJuGBnijD0H+98xMJD4oZXgv/umPXy5deu3IimYTPGH3Kr8Th6iQUff0/6S80oNBD7KtOuIHwHUCymUiRoeQ=="
-    },
-    "@types/d3-random": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
-      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
-    },
-    "@types/d3-scale": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
-      "integrity": "sha512-qpQe8G02tzUwt9sdWX1h8A/W0Q1+N48wMnYXVOkrzeLUkCfvzJYV9Ee3aORCS4dN4ONRLFmMvaXdziQ29XGLjQ==",
-      "requires": {
-        "@types/d3-time": "*"
-      }
-    },
-    "@types/d3-scale-chromatic": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz",
-      "integrity": "sha512-Y62+2clOwZoKua84Ha0xU77w7lePiaBoTjXugT4l8Rd5LAk+Mn/ZDtrgs087a+B5uJ3jYUHHtKw5nuEzp0WBHw=="
-    },
-    "@types/d3-selection": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.0.tgz",
-      "integrity": "sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA=="
-    },
-    "@types/d3-shape": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.0.0.tgz",
-      "integrity": "sha512-NLzD02m5PiD1KLEDjLN+MtqEcFYn4ZL9+Rqc9ZwARK1cpKZXd91zBETbe6wpBB6Ia0D0VZbpmbW3+BsGPGnCpA==",
-      "requires": {
-        "@types/d3-path": "^1"
-      },
-      "dependencies": {
-        "@types/d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
-        }
-      }
-    },
-    "@types/d3-time": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.0.0.tgz",
-      "integrity": "sha512-Abz8bTzy8UWDeYs9pCa3D37i29EWDjNTjemdk0ei1ApYVNqulYlGUKip/jLOpogkPSsPz/GvZCYiC7MFlEk0iQ=="
-    },
-    "@types/d3-time-format": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.0.tgz",
-      "integrity": "sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew=="
-    },
-    "@types/d3-timer": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-2.0.0.tgz",
-      "integrity": "sha512-l6stHr1VD1BWlW6u3pxrjLtJfpPZq9I3XmKIQtq7zHM/s6fwEtI1Yn6Sr5/jQTrUDCC5jkS6gWqlFGCDArDqNg=="
-    },
-    "@types/d3-transition": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-2.0.0.tgz",
-      "integrity": "sha512-UJDzI98utcZQUJt3uIit/Ho0/eBIANzrWJrTmi4+TaKIyWL2iCu7ShP0o4QajCskhyjOA7C8+4CE3b1YirTzEQ==",
-      "requires": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "@types/d3-voronoi": {
-      "version": "1.1.9",
-      "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.9.tgz",
-      "integrity": "sha512-DExNQkaHd1F3dFPvGA/Aw2NGyjMln6E9QzsiqOcBgnE+VInYnFBHBBySbZQts6z6xD+5jTfKCP7M4OqMyVjdwQ=="
-    },
-    "@types/d3-zoom": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.0.tgz",
-      "integrity": "sha512-daL0PJm4yT0ISTGa7p2lHX0kvv9FO/IR1ooWbHR/7H4jpbaKiLux5FslyS/OvISPiJ5SXb4sOqYhO6fMB6hKRw==",
-      "requires": {
-        "@types/d3-interpolate": "*",
-        "@types/d3-selection": "*"
-      }
-    },
-    "@types/geojson": {
-      "version": "7946.0.7",
-      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.7.tgz",
-      "integrity": "sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ=="
-    },
-    "@types/lodash": {
-      "version": "4.14.168",
-      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
-      "integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q=="
-    },
-    "@types/prop-types": {
-      "version": "15.7.3",
-      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
-      "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
-    },
-    "@types/react": {
-      "version": "17.0.2",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz",
-      "integrity": "sha512-Xt40xQsrkdvjn1EyWe1Bc0dJLcil/9x2vAuW7ya+PuQip4UYUaXyhzWmAbwRsdMgwOFHpfp7/FFZebDU6Y8VHA==",
-      "requires": {
-        "@types/prop-types": "*",
-        "csstype": "^3.0.2"
-      }
-    },
-    "@types/react-dom": {
-      "version": "17.0.1",
-      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.1.tgz",
-      "integrity": "sha512-yIVyopxQb8IDZ7SOHeTovurFq+fXiPICa+GV3gp0Xedsl+MwQlMLKmvrnEjFbQxjliH5YVAEWFh975eVNmKj7Q==",
-      "requires": {
-        "@types/react": "*"
-      }
-    },
-    "@visx/annotation": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/annotation/-/annotation-1.4.0.tgz",
-      "integrity": "sha512-LxskgApI1/2cMR6F+CR2KmMFSVvQwSu6ihEzjn+hhdpS9v5NUEJiTe0YwkV53yCmol9k+h1asmqc4JfIJY14nw==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/drag": "1.3.0",
-        "@visx/group": "1.0.0",
-        "@visx/point": "1.0.0",
-        "@visx/shape": "1.4.0",
-        "@visx/text": "1.3.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.5.10",
-        "react-use-measure": "2.0.1"
-      }
-    },
-    "@visx/axis": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/axis/-/axis-1.4.0.tgz",
-      "integrity": "sha512-VBq170tBu+PJ1XJLk6xwzmNEXrqriaSiaP808peR3oArXd0yMOgmI+HKm18qzPS2hbdRgxZNQOnlDZvB43H95w==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "@visx/point": "1.0.0",
-        "@visx/scale": "1.4.0",
-        "@visx/shape": "1.4.0",
-        "@visx/text": "1.3.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.0"
-      }
-    },
-    "@visx/bounds": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-1.0.0.tgz",
-      "integrity": "sha512-QxD/OkZVkzpeP6L0YxUnIAsxlFemkDPfOumchVDRlrO4lZ3YXLmsnaEEiJpU5tSgNamZAUh+Tz3d2RbHp3qqxA==",
-      "requires": {
-        "@types/react": "*",
-        "@types/react-dom": "*",
-        "prop-types": "^15.5.10"
-      }
-    },
-    "@visx/brush": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/brush/-/brush-1.4.0.tgz",
-      "integrity": "sha512-Hg+J45rj3csXT98/DTyzigxPFndlJ65vdR7O+JdefQ5i3XJGK6vp/HDILthG5pdyCxqyYoCtfsy+u4bqzR1nrQ==",
-      "requires": {
-        "@visx/drag": "1.3.0",
-        "@visx/group": "1.0.0",
-        "@visx/shape": "1.4.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.1"
-      }
-    },
-    "@visx/clip-path": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/clip-path/-/clip-path-1.0.0.tgz",
-      "integrity": "sha512-s5Xdjlf/1RaOOS5kJO8d0xOY6BdEifP8dq0gIJwEfBMyQALOtTyiDNZ0v0yTT82qssLrtZ0fm3xVSRJC1cPB/Q==",
-      "requires": {
-        "@types/react": "*",
-        "prop-types": "^15.5.10"
-      }
-    },
-    "@visx/curve": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-1.0.0.tgz",
-      "integrity": "sha512-rN9TUf4uRmPuQ5Rd4kbvinSDsTbR61YB26+ucK6RNMHIr9aLmujpcPJhVwk22EWphRRGIxzK2OSp0d5dgpNppQ==",
-      "requires": {
-        "@types/d3-shape": "^1.3.1",
-        "d3-shape": "^1.0.6"
-      },
-      "dependencies": {
-        "@types/d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
-        },
-        "@types/d3-shape": {
-          "version": "1.3.5",
-          "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.5.tgz",
-          "integrity": "sha512-aPEax03owTAKynoK8ZkmkZEDZvvT4Y5pWgii4Jp4oQt0gH45j6siDl9gNDVC5kl64XHN2goN9jbYoHK88tFAcA==",
-          "requires": {
-            "@types/d3-path": "^1"
-          }
-        },
-        "d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
-        },
-        "d3-shape": {
-          "version": "1.3.7",
-          "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
-          "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
-          "requires": {
-            "d3-path": "1"
-          }
-        }
-      }
-    },
-    "@visx/drag": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@visx/drag/-/drag-1.3.0.tgz",
-      "integrity": "sha512-Hr5ceQ7R4pVUGQ1yFLwfiWd2Nd4Z74xqaxB2xNs/vpxqX6xhzS90agVoD1KiDtCN9IKOrS0J2bEPStJFFpu1OQ==",
-      "requires": {
-        "@types/react": "*",
-        "@visx/event": "1.3.0",
-        "prop-types": "^15.5.10"
-      }
-    },
-    "@visx/event": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@visx/event/-/event-1.3.0.tgz",
-      "integrity": "sha512-Nq0xz7c1eMc8j3CTt94hTZO+veQ1Ti7u22LZF4M2W36yKh5PtZRfM4O6tILSdO7cxigeNd1vbmZo9MSDmk5lbQ==",
-      "requires": {
-        "@types/react": "*",
-        "@visx/point": "1.0.0"
-      }
-    },
-    "@visx/geo": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/geo/-/geo-1.0.0.tgz",
-      "integrity": "sha512-lUTqKsZtYoz7+ei9PRkuSrqqDKLcS5T7w05TZZGng/jJKtXofUwmooKxoJEe6lu8szdsOgOPX0ijyIrLAvS1Ig==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/d3-geo": "^1.11.1",
-        "@types/geojson": "*",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "classnames": "^2.2.5",
-        "d3-geo": "^1.11.3",
-        "prop-types": "^15.5.10"
-      },
-      "dependencies": {
-        "@types/d3-geo": {
-          "version": "1.12.1",
-          "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-1.12.1.tgz",
-          "integrity": "sha512-8+gyGFyMCXIHtnMNKQDT++tZ4XYFXgiP5NK7mcv34aYXA16GQFiBBITjKzxghpO8QNVceOd9rUn1JY92WLNGQw==",
-          "requires": {
-            "@types/geojson": "*"
-          }
-        },
-        "d3-array": {
-          "version": "1.2.4",
-          "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
-          "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="
-        },
-        "d3-geo": {
-          "version": "1.12.1",
-          "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz",
-          "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==",
-          "requires": {
-            "d3-array": "1"
-          }
-        }
-      }
-    },
-    "@visx/glyph": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/glyph/-/glyph-1.0.0.tgz",
-      "integrity": "sha512-htDMWLoPApXt7pxxd6e0cebvB2VXoCvMrhkeRjgG0sYYWVe1LWUPs83DN2U8VEpLKp0uxmy28Pf/COBwj8qGIQ==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/d3-shape": "^1.3.1",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "classnames": "^2.2.5",
-        "d3-shape": "^1.2.0",
-        "prop-types": "^15.6.2"
-      },
-      "dependencies": {
-        "@types/d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
-        },
-        "@types/d3-shape": {
-          "version": "1.3.5",
-          "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.5.tgz",
-          "integrity": "sha512-aPEax03owTAKynoK8ZkmkZEDZvvT4Y5pWgii4Jp4oQt0gH45j6siDl9gNDVC5kl64XHN2goN9jbYoHK88tFAcA==",
-          "requires": {
-            "@types/d3-path": "^1"
-          }
-        },
-        "d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
-        },
-        "d3-shape": {
-          "version": "1.3.7",
-          "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
-          "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
-          "requires": {
-            "d3-path": "1"
-          }
-        }
-      }
-    },
-    "@visx/gradient": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/gradient/-/gradient-1.0.0.tgz",
-      "integrity": "sha512-Y+Xoz4dRF5+1Ru693Ik7v/Bb4d9kyVWz4iTBB2Oyfu7Ceo2VC7bh7McmLgmYmsnbbFWeJiAnow2Qzx0EHR5eCg==",
-      "requires": {
-        "@types/react": "*",
-        "prop-types": "^15.5.7"
-      }
-    },
-    "@visx/grid": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-1.4.0.tgz",
-      "integrity": "sha512-POwnGByfqYlZc5MOHU2I8RFXA5qO97LVx8ibSXLk2cV44Uoif7zHwQMZ+qup3JpYDKhtvjdO6GkBJ4UCE0apmg==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "@visx/point": "1.0.0",
-        "@visx/scale": "1.4.0",
-        "@visx/shape": "1.4.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.2"
-      }
-    },
-    "@visx/group": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/group/-/group-1.0.0.tgz",
-      "integrity": "sha512-2YlhGHTINUl7do046p/bkIYiD4xDv/sJ4JAaGrqFXwX68EJZ5Er/0gpZZ4nrADQlxB8/uyJvZzp1Q54ySfTMiA==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.2"
-      }
-    },
-    "@visx/heatmap": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/heatmap/-/heatmap-1.0.0.tgz",
-      "integrity": "sha512-sLobst48pdEVBG/LjPnLmylmZ0BrcC7M1m9ZwtYfwSpfh8u3si6l/E8w9hv1ohqILYCHp7tpvmC7soGUh5qk0g==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.1"
-      }
-    },
-    "@visx/hierarchy": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/hierarchy/-/hierarchy-1.0.0.tgz",
-      "integrity": "sha512-kT8RBebsbuN6zn+LaBhQEG+xVKd5IJki3kn0fki4pPHbJTaSMdNYUJicPtsKnWyRqxAuNQfKdKaKJfam4iNTtA==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/d3-hierarchy": "^1.1.6",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "classnames": "^2.2.5",
-        "d3-hierarchy": "^1.1.4",
-        "prop-types": "^15.6.1"
-      },
-      "dependencies": {
-        "@types/d3-hierarchy": {
-          "version": "1.1.7",
-          "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.7.tgz",
-          "integrity": "sha512-fvht6DOYKzqmXjMb/+xfgkmrWM4SD7rMA/ZbM+gGwr9ZTuIDfky95J8CARtaJo/ExeWyS0xGVdL2gqno2zrQ0Q=="
-        },
-        "d3-hierarchy": {
-          "version": "1.1.9",
-          "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
-          "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ=="
-        }
-      }
-    },
-    "@visx/legend": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/legend/-/legend-1.4.0.tgz",
-      "integrity": "sha512-NkYfk62D+uHOkcsury5OqnTNnzbSakuwbocqwn3DHdE/jdpJIJVx9gh3OBHWqdIIallLhnT7p2oBtDuN8lrsvw==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "@visx/scale": "1.4.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.5.10"
-      }
-    },
-    "@visx/marker": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/marker/-/marker-1.4.0.tgz",
-      "integrity": "sha512-UelBxlyzIjrj0YAWo80TL/tc2dBpqD4Fz0/+W7i1d6c2y4Y5lEp9SxRVK3nl4LmHd2dZAQe5XOmreKhiFvq3PQ==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "@visx/shape": "1.4.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.2"
-      }
-    },
-    "@visx/mock-data": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.0.0.tgz",
-      "integrity": "sha512-yd4lult1oEpmbj7pxzNb398VW+fRYaZWBFFKcqJEibAxlko4kmBfVqFR2gPZTp/K7I0/5mvD3hhNr1NpH2SIHA==",
-      "requires": {
-        "@types/d3-random": "^1.1.2",
-        "d3-random": "^1.0.3"
-      },
-      "dependencies": {
-        "@types/d3-random": {
-          "version": "1.1.3",
-          "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-1.1.3.tgz",
-          "integrity": "sha512-XXR+ZbFCoOd4peXSMYJzwk0/elP37WWAzS/DG+90eilzVbUSsgKhBcWqylGWe+lA2ubgr7afWAOBaBxRgMUrBQ=="
-        },
-        "d3-random": {
-          "version": "1.1.2",
-          "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz",
-          "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ=="
-        }
-      }
-    },
-    "@visx/network": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@visx/network/-/network-1.1.0.tgz",
-      "integrity": "sha512-TEsXvUIc1RAK2Jq+pHXWYNi/Gly/v8J/s9jBlFF4YHxNvq9Ra2cJ/8lJkiBVXRLZU9Pyxr48sUfnFoevkhMN6w==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/group": "1.0.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.6.2"
-      }
-    },
-    "@visx/pattern": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@visx/pattern/-/pattern-1.1.0.tgz",
-      "integrity": "sha512-mjuc/fvDb5rfKkvfDKyduwjbB2bidJtLj5q0GzhGeXImKxlqG255VwMSrkyPsPSmSnV39EHSrfKb/2oc/gSUGA==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.5.10"
-      }
-    },
-    "@visx/point": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
-      "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
-    },
-    "@visx/responsive": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.3.0.tgz",
-      "integrity": "sha512-RMfpjdnHKyhB/bb2i6x/vfQeYzfz+pJc3VUK+dP88lXXTkqv1O/NYIkXk2sWk6QhDw5muChHFmnZ1L8TnIOMXg==",
-      "requires": {
-        "@types/lodash": "^4.14.146",
-        "@types/react": "*",
-        "lodash": "^4.17.10",
-        "prop-types": "^15.6.1",
-        "resize-observer-polyfill": "1.5.1"
-      }
-    },
-    "@visx/scale": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
-      "integrity": "sha512-uNy/hsZCmCtL1hC7rTasMUtf9/faG/QcXNtQioe6VYwtbZxCMR53+yvz3W1oqAW8Y0bslGfKRMzT8T29OjAD/g==",
-      "requires": {
-        "@types/d3-interpolate": "^1.3.1",
-        "@types/d3-scale": "^3.2.1",
-        "@types/d3-time": "^1.0.10",
-        "d3-interpolate": "^1.4.0",
-        "d3-scale": "^3.2.3",
-        "d3-time": "^1.1.0"
-      },
-      "dependencies": {
-        "@types/d3-color": {
-          "version": "1.4.1",
-          "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
-          "integrity": "sha512-xkPLi+gbgUU9ED6QX4g6jqYL2KCB0/3AlM+ncMGqn49OgH0gFMY/ITGqPF8HwEiLzJaC+2L0I+gNwBgABv1Pvg=="
-        },
-        "@types/d3-interpolate": {
-          "version": "1.4.2",
-          "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-1.4.2.tgz",
-          "integrity": "sha512-ylycts6llFf8yAEs1tXzx2loxxzDZHseuhPokrqKprTQSTcD3JbJI1omZP1rphsELZO3Q+of3ff0ZS7+O6yVzg==",
-          "requires": {
-            "@types/d3-color": "^1"
-          }
-        },
-        "@types/d3-time": {
-          "version": "1.1.1",
-          "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
-          "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
-        },
-        "d3-color": {
-          "version": "1.4.1",
-          "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
-          "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
-        },
-        "d3-interpolate": {
-          "version": "1.4.0",
-          "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
-          "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
-          "requires": {
-            "d3-color": "1"
-          }
-        },
-        "d3-time": {
-          "version": "1.1.0",
-          "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
-          "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="
-        }
-      }
-    },
-    "@visx/shape": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-1.4.0.tgz",
-      "integrity": "sha512-iTeFGtsidHXoeEyfriwRj7vkgs3BYqXWuDVe/uW4kpn76u7M0LhRCy59ADlufYDn+19qdA/rVPv4oD6nrWMhCw==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/d3-path": "^1.0.8",
-        "@types/d3-shape": "^1.3.1",
-        "@types/lodash": "^4.14.146",
-        "@types/react": "*",
-        "@visx/curve": "1.0.0",
-        "@visx/group": "1.0.0",
-        "@visx/scale": "1.4.0",
-        "classnames": "^2.2.5",
-        "d3-path": "^1.0.5",
-        "d3-shape": "^1.2.0",
-        "lodash": "^4.17.15",
-        "prop-types": "^15.5.10"
-      },
-      "dependencies": {
-        "@types/d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
-        },
-        "@types/d3-shape": {
-          "version": "1.3.5",
-          "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.5.tgz",
-          "integrity": "sha512-aPEax03owTAKynoK8ZkmkZEDZvvT4Y5pWgii4Jp4oQt0gH45j6siDl9gNDVC5kl64XHN2goN9jbYoHK88tFAcA==",
-          "requires": {
-            "@types/d3-path": "^1"
-          }
-        },
-        "d3-path": {
-          "version": "1.0.9",
-          "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
-          "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
-        },
-        "d3-shape": {
-          "version": "1.3.7",
-          "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
-          "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
-          "requires": {
-            "d3-path": "1"
-          }
-        }
-      }
-    },
-    "@visx/text": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@visx/text/-/text-1.3.0.tgz",
-      "integrity": "sha512-fmFZ1S26DOH6u5ublcY33GmCBMyUufh9Mna8i30YZkS6kk7WO7ZCxMR9fVF71hrZ+rYWLylswXGwzL95LroE8g==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/lodash": "^4.14.160",
-        "@types/react": "*",
-        "classnames": "^2.2.5",
-        "lodash": "^4.17.20",
-        "prop-types": "^15.7.2",
-        "reduce-css-calc": "^1.3.0"
-      }
-    },
-    "@visx/tooltip": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-1.3.0.tgz",
-      "integrity": "sha512-8ZliQmcE3R2TvNHyCnViPlT9Msnx/prn6gfsa1QMgWySQzVhFlL4Man9hkmbbIvpwU1i1OarbANF7hUqz86jZQ==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/react": "*",
-        "@visx/bounds": "1.0.0",
-        "classnames": "^2.2.5",
-        "prop-types": "^15.5.10",
-        "react-use-measure": "2.0.1"
-      }
-    },
-    "@visx/visx": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/@visx/visx/-/visx-1.4.0.tgz",
-      "integrity": "sha512-5fv10x3JaEAShkZM73d/hTY4oA8nRSaIJ33B8utYci9jBBDHx5p7fpWBK1ocdlLwMzQnoU3oUxO5NcG0FK6Sow==",
-      "requires": {
-        "@visx/annotation": "1.4.0",
-        "@visx/axis": "1.4.0",
-        "@visx/bounds": "1.0.0",
-        "@visx/brush": "1.4.0",
-        "@visx/clip-path": "1.0.0",
-        "@visx/curve": "1.0.0",
-        "@visx/drag": "1.3.0",
-        "@visx/event": "1.3.0",
-        "@visx/geo": "1.0.0",
-        "@visx/glyph": "1.0.0",
-        "@visx/gradient": "1.0.0",
-        "@visx/grid": "1.4.0",
-        "@visx/group": "1.0.0",
-        "@visx/heatmap": "1.0.0",
-        "@visx/hierarchy": "1.0.0",
-        "@visx/legend": "1.4.0",
-        "@visx/marker": "1.4.0",
-        "@visx/mock-data": "1.0.0",
-        "@visx/network": "1.1.0",
-        "@visx/pattern": "1.1.0",
-        "@visx/point": "1.0.0",
-        "@visx/responsive": "1.3.0",
-        "@visx/scale": "1.4.0",
-        "@visx/shape": "1.4.0",
-        "@visx/text": "1.3.0",
-        "@visx/tooltip": "1.3.0",
-        "@visx/voronoi": "1.0.0",
-        "@visx/zoom": "1.3.0"
-      }
-    },
-    "@visx/voronoi": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/voronoi/-/voronoi-1.0.0.tgz",
-      "integrity": "sha512-eLjZKCJpGHjoPgX0t5a0C6jF612FT4rEuCOkQnWFx71ahpKF7dT2WXY00SrvufPGlmA5asjaVY1/Juss6SmgHA==",
-      "requires": {
-        "@types/classnames": "^2.2.9",
-        "@types/d3-voronoi": "^1.1.9",
-        "@types/react": "*",
-        "classnames": "^2.2.5",
-        "d3-voronoi": "^1.1.2",
-        "prop-types": "^15.6.1"
-      }
-    },
-    "@visx/zoom": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@visx/zoom/-/zoom-1.3.0.tgz",
-      "integrity": "sha512-CsxdV4srxWaZcbv2rsM0JXC30LnYOzWRrDkFgT7sKtV2h8uxlzNSeafrDIXyuu5YVWs6FYttapPms9jFGoS8SA==",
-      "requires": {
-        "@types/react": "*",
-        "@visx/event": "1.3.0",
-        "prop-types": "^15.6.2"
-      }
-    },
-    "balanced-match": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
-      "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
-    },
-    "classnames": {
-      "version": "2.2.6",
-      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
-      "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
-    },
-    "commander": {
-      "version": "2.20.3",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
-    },
-    "csstype": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
-      "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw=="
-    },
-    "d3": {
-      "version": "6.5.0",
-      "resolved": "https://registry.npmjs.org/d3/-/d3-6.5.0.tgz",
-      "integrity": "sha512-gr7FoRecKtBkBxelTeGVYERRTPgjPFLh2rOBisHdbXe3RIrVLjCo7COZYMSeFeiwVPOHDtxAGJlvN7XssNAIcg==",
-      "requires": {
-        "d3-array": "2",
-        "d3-axis": "2",
-        "d3-brush": "2",
-        "d3-chord": "2",
-        "d3-color": "2",
-        "d3-contour": "2",
-        "d3-delaunay": "5",
-        "d3-dispatch": "2",
-        "d3-drag": "2",
-        "d3-dsv": "2",
-        "d3-ease": "2",
-        "d3-fetch": "2",
-        "d3-force": "2",
-        "d3-format": "2",
-        "d3-geo": "2",
-        "d3-hierarchy": "2",
-        "d3-interpolate": "2",
-        "d3-path": "2",
-        "d3-polygon": "2",
-        "d3-quadtree": "2",
-        "d3-random": "2",
-        "d3-scale": "3",
-        "d3-scale-chromatic": "2",
-        "d3-selection": "2",
-        "d3-shape": "2",
-        "d3-time": "2",
-        "d3-time-format": "3",
-        "d3-timer": "2",
-        "d3-transition": "2",
-        "d3-zoom": "2"
-      }
-    },
-    "d3-array": {
-      "version": "2.11.0",
-      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.11.0.tgz",
-      "integrity": "sha512-26clcwmHQEdsLv34oNKq5Ia9tQ26Y/4HqS3dQzF42QBUqymZJ+9PORcN1G52bt37NsL2ABoX4lvyYZc+A9Y0zw==",
-      "requires": {
-        "internmap": "^1.0.0"
-      }
-    },
-    "d3-axis": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-2.0.0.tgz",
-      "integrity": "sha512-9nzB0uePtb+u9+dWir+HTuEAKJOEUYJoEwbJPsZ1B4K3iZUgzJcSENQ05Nj7S4CIfbZZ8/jQGoUzGKFznBhiiQ=="
-    },
-    "d3-brush": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-2.1.0.tgz",
-      "integrity": "sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==",
-      "requires": {
-        "d3-dispatch": "1 - 2",
-        "d3-drag": "2",
-        "d3-interpolate": "1 - 2",
-        "d3-selection": "2",
-        "d3-transition": "2"
-      }
-    },
-    "d3-chord": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-2.0.0.tgz",
-      "integrity": "sha512-D5PZb7EDsRNdGU4SsjQyKhja8Zgu+SHZfUSO5Ls8Wsn+jsAKUUGkcshLxMg9HDFxG3KqavGWaWkJ8EpU8ojuig==",
-      "requires": {
-        "d3-path": "1 - 2"
-      }
-    },
-    "d3-color": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
-      "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
-    },
-    "d3-contour": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-2.0.0.tgz",
-      "integrity": "sha512-9unAtvIaNk06UwqBmvsdHX7CZ+NPDZnn8TtNH1myW93pWJkhsV25JcgnYAu0Ck5Veb1DHiCv++Ic5uvJ+h50JA==",
-      "requires": {
-        "d3-array": "2"
-      }
-    },
-    "d3-delaunay": {
-      "version": "5.3.0",
-      "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-5.3.0.tgz",
-      "integrity": "sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==",
-      "requires": {
-        "delaunator": "4"
-      }
-    },
-    "d3-dispatch": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
-      "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA=="
-    },
-    "d3-drag": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
-      "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
-      "requires": {
-        "d3-dispatch": "1 - 2",
-        "d3-selection": "2"
-      }
-    },
-    "d3-dsv": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-2.0.0.tgz",
-      "integrity": "sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==",
-      "requires": {
-        "commander": "2",
-        "iconv-lite": "0.4",
-        "rw": "1"
-      }
-    },
-    "d3-ease": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
-      "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ=="
-    },
-    "d3-fetch": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-2.0.0.tgz",
-      "integrity": "sha512-TkYv/hjXgCryBeNKiclrwqZH7Nb+GaOwo3Neg24ZVWA3MKB+Rd+BY84Nh6tmNEMcjUik1CSUWjXYndmeO6F7sw==",
-      "requires": {
-        "d3-dsv": "1 - 2"
-      }
-    },
-    "d3-force": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz",
-      "integrity": "sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==",
-      "requires": {
-        "d3-dispatch": "1 - 2",
-        "d3-quadtree": "1 - 2",
-        "d3-timer": "1 - 2"
-      }
-    },
-    "d3-format": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
-      "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA=="
-    },
-    "d3-geo": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.1.tgz",
-      "integrity": "sha512-M6yzGbFRfxzNrVhxDJXzJqSLQ90q1cCyb3EWFZ1LF4eWOBYxFypw7I/NFVBNXKNqxv1bqLathhYvdJ6DC+th3A==",
-      "requires": {
-        "d3-array": ">=2.5"
-      }
-    },
-    "d3-hierarchy": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-2.0.0.tgz",
-      "integrity": "sha512-SwIdqM3HxQX2214EG9GTjgmCc/mbSx4mQBn+DuEETubhOw6/U3fmnji4uCVrmzOydMHSO1nZle5gh6HB/wdOzw=="
-    },
-    "d3-interpolate": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
-      "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
-      "requires": {
-        "d3-color": "1 - 2"
-      }
-    },
-    "d3-path": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
-      "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA=="
-    },
-    "d3-polygon": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz",
-      "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ=="
-    },
-    "d3-quadtree": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz",
-      "integrity": "sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw=="
-    },
-    "d3-random": {
-      "version": "2.2.2",
-      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
-      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
-    },
-    "d3-scale": {
-      "version": "3.2.3",
-      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
-      "integrity": "sha512-8E37oWEmEzj57bHcnjPVOBS3n4jqakOeuv1EDdQSiSrYnMCBdMd3nc4HtKk7uia8DUHcY/CGuJ42xxgtEYrX0g==",
-      "requires": {
-        "d3-array": "^2.3.0",
-        "d3-format": "1 - 2",
-        "d3-interpolate": "1.2.0 - 2",
-        "d3-time": "1 - 2",
-        "d3-time-format": "2 - 3"
-      }
-    },
-    "d3-scale-chromatic": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz",
-      "integrity": "sha512-LLqy7dJSL8yDy7NRmf6xSlsFZ6zYvJ4BcWFE4zBrOPnQERv9zj24ohnXKRbyi9YHnYV+HN1oEO3iFK971/gkzA==",
-      "requires": {
-        "d3-color": "1 - 2",
-        "d3-interpolate": "1 - 2"
-      }
-    },
-    "d3-selection": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
-      "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA=="
-    },
-    "d3-shape": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.0.0.tgz",
-      "integrity": "sha512-djpGlA779ua+rImicYyyjnOjeubyhql1Jyn1HK0bTyawuH76UQRWXd+pftr67H6Fa8hSwetkgb/0id3agKWykw==",
-      "requires": {
-        "d3-path": "1 - 2"
-      }
-    },
-    "d3-time": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.0.0.tgz",
-      "integrity": "sha512-2mvhstTFcMvwStWd9Tj3e6CEqtOivtD8AUiHT8ido/xmzrI9ijrUUihZ6nHuf/vsScRBonagOdj0Vv+SEL5G3Q=="
-    },
-    "d3-time-format": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
-      "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
-      "requires": {
-        "d3-time": "1 - 2"
-      }
-    },
-    "d3-timer": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
-      "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA=="
-    },
-    "d3-transition": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
-      "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
-      "requires": {
-        "d3-color": "1 - 2",
-        "d3-dispatch": "1 - 2",
-        "d3-ease": "1 - 2",
-        "d3-interpolate": "1 - 2",
-        "d3-timer": "1 - 2"
-      }
-    },
-    "d3-voronoi": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
-      "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg=="
-    },
-    "d3-zoom": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
-      "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
-      "requires": {
-        "d3-dispatch": "1 - 2",
-        "d3-drag": "2",
-        "d3-interpolate": "1 - 2",
-        "d3-selection": "2",
-        "d3-transition": "2"
-      }
-    },
-    "debounce": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz",
-      "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg=="
-    },
-    "delaunator": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz",
-      "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag=="
-    },
-    "iconv-lite": {
-      "version": "0.4.24",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
-      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
-      "requires": {
-        "safer-buffer": ">= 2.1.2 < 3"
-      }
-    },
-    "internmap": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.0.tgz",
-      "integrity": "sha512-SdoDWwNOTE2n4JWUsLn4KXZGuZPjPF9yyOGc8bnfWnBQh7BD/l80rzSznKc/r4Y0aQ7z3RTk9X+tV4tHBpu+dA=="
-    },
-    "js-tokens": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
-      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
-    },
-    "lodash": {
-      "version": "4.17.20",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
-      "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
-    },
-    "loose-envify": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
-      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
-      "requires": {
-        "js-tokens": "^3.0.0 || ^4.0.0"
-      }
-    },
-    "math-expression-evaluator": {
-      "version": "1.3.7",
-      "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.3.7.tgz",
-      "integrity": "sha512-nrbaifCl42w37hYd6oRLvoymFK42tWB+WQTMFtksDGQMi5GvlJwnz/CsS30FFAISFLtX+A0csJ0xLiuuyyec7w=="
-    },
-    "object-assign": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
-    },
-    "prop-types": {
-      "version": "15.7.2",
-      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
-      "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
-      "requires": {
-        "loose-envify": "^1.4.0",
-        "object-assign": "^4.1.1",
-        "react-is": "^16.8.1"
-      }
-    },
-    "react-is": {
-      "version": "16.13.1",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
-      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
-    },
-    "react-use-measure": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.0.1.tgz",
-      "integrity": "sha512-lFfHiqcXbJ2/6aUkZwt8g5YYM7EGqNVxJhMqMPqv1BVXRKp8D7jYLlmma0SvhRY4WYxxkZpCdbJvhDylb5gcEA==",
-      "requires": {
-        "debounce": "^1.2.0"
-      }
-    },
-    "reduce-css-calc": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
-      "integrity": "sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=",
-      "requires": {
-        "balanced-match": "^0.4.2",
-        "math-expression-evaluator": "^1.2.14",
-        "reduce-function-call": "^1.0.1"
-      }
-    },
-    "reduce-function-call": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
-      "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
-      "requires": {
-        "balanced-match": "^1.0.0"
-      },
-      "dependencies": {
-        "balanced-match": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
-          "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
-        }
-      }
-    },
-    "resize-observer-polyfill": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
-      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
-    },
-    "rw": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
-      "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q="
-    },
-    "safer-buffer": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
-      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+    "prettier": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
+      "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
+      "dev": true
     }
   }
 }

+ 0 - 3
server/api/cluster_handler.go

@@ -2,7 +2,6 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"strconv"
 
@@ -58,8 +57,6 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 
 	clusterExt := cluster.Externalize()
 
-	fmt.Println("CLUSTER EXTERNAL PROJECT ID", clusterExt.ProjectID, cluster.ProjectID)
-
 	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return

+ 19 - 0
server/api/deploy_handler.go

@@ -138,6 +138,25 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		}, w)
 	}
 
+	// if github action config is linked, call the github action config handler
+	if form.GithubActionConfig != nil {
+		gaForm := &forms.CreateGitAction{
+			ReleaseID:      release.ID,
+			GitRepo:        form.GithubActionConfig.GitRepo,
+			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
+			DockerfilePath: form.GithubActionConfig.DockerfilePath,
+			GitRepoID:      form.GithubActionConfig.GitRepoID,
+		}
+
+		// validate the form
+		if err := app.validator.Struct(form); err != nil {
+			app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+			return
+		}
+
+		app.createGitActionFromForm(projID, release, name, gaForm, w, r)
+	}
+
 	w.WriteHeader(http.StatusOK)
 }
 

+ 30 - 20
server/api/git_action_handler.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/models"
 )
 
 // HandleCreateGitAction creates a new Github action in a repository for a given
@@ -56,10 +57,28 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	gaExt := app.createGitActionFromForm(projID, release, name, form, w, r)
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(gaExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+func (app *App) createGitActionFromForm(
+	projID uint64,
+	release *models.Release,
+	name string,
+	form *forms.CreateGitAction,
+	w http.ResponseWriter,
+	r *http.Request,
+) *models.GitActionConfigExternal {
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
-		return
+		return nil
 	}
 
 	// convert the form to a git action config
@@ -67,7 +86,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
+		return nil
 	}
 
 	// read the git repo
@@ -75,21 +94,21 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
+		return nil
 	}
 
 	repoSplit := strings.Split(gitAction.GitRepo, "/")
 
 	if len(repoSplit) != 2 {
 		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
-		return
+		return nil
 	}
 
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+		return nil
 	}
 
 	userID, _ := session.Values["user_id"].(uint)
@@ -102,9 +121,8 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 	})
 
 	if err != nil {
-		fmt.Println("ERROR GENERATING TOKEN", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+		app.handleErrorInternal(err, w)
+		return nil
 	}
 
 	// create the commit in the git repo
@@ -125,9 +143,8 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 	_, err = gaRunner.Setup()
 
 	if err != nil {
-		fmt.Println("ERROR RUNNING SETUP", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+		app.handleErrorInternal(err, w)
+		return nil
 	}
 
 	// handle write to the database
@@ -135,17 +152,10 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
-		return
+		return nil
 	}
 
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 
-	w.WriteHeader(http.StatusCreated)
-
-	gaExt := ga.Externalize()
-
-	if err := json.NewEncoder(w).Encode(gaExt); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
+	return ga.Externalize()
 }

+ 26 - 2
server/api/git_repo_handler.go

@@ -91,6 +91,32 @@ func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(res)
 }
 
+// HandleDeleteProjectGitRepo handles the deletion of a Github Repo via the git repo ID
+func (app *App) HandleDeleteProjectGitRepo(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	repo, err := app.Repo.GitRepo.ReadGitRepo(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	err = app.Repo.GitRepo.DeleteGitRepo(repo)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleGetBranches retrieves a list of branch names for a specified repo
 func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 	tok, err := app.githubTokenFromRequest(r)
@@ -108,7 +134,6 @@ func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 	// List all branches for a specified repo
 	branches, _, err := client.Repositories.ListBranches(context.Background(), owner, name, nil)
 	if err != nil {
-		fmt.Println(err)
 		return
 	}
 
@@ -159,7 +184,6 @@ func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request)
 
 	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
 	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
-	// fmt.Println(res)
 	json.NewEncoder(w).Encode(res)
 }
 

+ 3 - 9
server/api/integration_handler.go

@@ -66,15 +66,13 @@ func (app *App) HandleListRepoIntegrations(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateGCPIntegration creates a new GCP integration in the DB
 func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	userID, _ := session.Values["user_id"].(uint)
-
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -129,15 +127,13 @@ func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateAWSIntegration creates a new AWS integration in the DB
 func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	userID, _ := session.Values["user_id"].(uint)
-
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -192,15 +188,13 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
 func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	userID, _ := session.Values["user_id"].(uint)
-
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {

+ 0 - 5
server/api/integration_handler_test.go

@@ -2,7 +2,6 @@ package api_test
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"strings"
 	"testing"
@@ -269,8 +268,6 @@ func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 
 	bytes := tester.rr.Body.Bytes()
 
-	fmt.Println(string(bytes))
-
 	json.Unmarshal(bytes, &gotBody)
 	json.Unmarshal([]byte(c.expBody), &expBody)
 
@@ -312,8 +309,6 @@ func basicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 
 	bytes := tester.rr.Body.Bytes()
 
-	fmt.Println(string(bytes))
-
 	json.Unmarshal(bytes, &gotBody)
 	json.Unmarshal([]byte(c.expBody), &expBody)
 

+ 45 - 28
server/api/registry_handler.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"net/http"
 	"strconv"
@@ -174,6 +175,50 @@ func (app *App) HandleGetProjectRegistryECRToken(w http.ResponseWriter, r *http.
 	}
 }
 
+// HandleGetProjectRegistryDockerhubToken gets a Dockerhub token for a registry
+func (app *App) HandleGetProjectRegistryDockerhubToken(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// list registries and find one that matches the region
+	regs, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
+	var token string
+	var expiresAt *time.Time
+
+	for _, reg := range regs {
+		if reg.BasicIntegrationID != 0 && strings.Contains(reg.URL, "index.docker.io") {
+			basic, err := app.Repo.BasicIntegration.ReadBasicIntegration(reg.BasicIntegrationID)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			token = base64.StdEncoding.EncodeToString([]byte(string(basic.Username) + ":" + string(basic.Password)))
+
+			// we'll just set an arbitrary 30-day expiry time (this is not enforced)
+			timeExpires := time.Now().Add(30 * 24 * 3600 * time.Second)
+			expiresAt = &timeExpires
+		}
+	}
+
+	resp := &RegTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 type GCRTokenRequestBody struct {
 	ServerURL string `json:"server_url"`
 }
@@ -441,32 +486,4 @@ func (app *App) HandleListImages(w http.ResponseWriter, r *http.Request) {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
-
-	// ref, err := name.ParseReference("gcr.io/google-containers/pause")
-	// if err != nil {
-	// 	fmt.Println(err)
-	// 	return
-	// }
-
-	// img, err := remote.Image(ref)
-	// if err != nil {
-	// 	fmt.Println(err)
-	// 	return
-	// }
-	// fmt.Println(img.Size())
-
-	// ctx := r.Context()
-	// reg, err := name.NewRegistry("index.docker.io")
-	// if err != nil {
-	// 	fmt.Println("fuk")
-	// 	fmt.Println(err)
-	// 	return
-	// }
-
-	// stuff, err := remote.Catalog(ctx, reg, remote.WithAuthFromKeychain(authn.DefaultKeychain))
-	// if err != nil {
-	// 	fmt.Println(err)
-	// 	return
-	// }
-	// fmt.Println(stuff[0])
 }

+ 0 - 3
server/api/release_handler_test.go

@@ -2,7 +2,6 @@ package api_test
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -362,8 +361,6 @@ var rollbackReleaseTests = []*releaseTest{
 
 				expBodyJSON := releaseStubToReleaseJSON(releaseStub{"wordpress", "default", 3, "1.0.1", release.StatusDeployed})
 
-				fmt.Println(rr2.Body.String())
-
 				json.Unmarshal(rr2.Body.Bytes(), gotBody)
 				json.Unmarshal([]byte(expBodyJSON), expBody)
 

+ 19 - 0
server/api/user_handler.go

@@ -480,3 +480,22 @@ func (app *App) sendUser(w http.ResponseWriter, userID uint, email, redirect str
 	}
 	return nil
 }
+
+func (app *App) getUserIDFromRequest(r *http.Request) (uint, error) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		return 0, err
+	}
+
+	// first, check for token
+	tok := app.getTokenFromRequest(r)
+
+	if tok != nil {
+		return tok.IBy, nil
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	return userID, nil
+}

+ 1 - 3
server/router/middleware/auth.go

@@ -670,8 +670,6 @@ func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
 
 	tok := auth.getTokenFromRequest(r)
 
-	fmt.Println("CHECKED TOKEN FROM REQUEST", tok)
-
 	if tok != nil {
 		return true
 	}
@@ -680,7 +678,7 @@ func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
 	if err != nil {
 		session.Values["authenticated"] = false
 		if err := session.Save(r, w); err != nil {
-			fmt.Println("error while saving session in isLoggedIn", err)
+			return false
 		}
 		return false
 	}

+ 24 - 0
server/router/router.go

@@ -753,6 +753,16 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/registries/dockerhub/token",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetProjectRegistryDockerhubToken, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/registries/docr/token",
@@ -939,6 +949,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/gitrepos/{git_repo_id}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveGitRepoAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectGitRepo, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/gitrepos/{git_repo_id}/repos",