Explorar o código

Merge branch 'beta.3.metrics-visualization-frontend' into beta.3.metrics-visualization

mergin
Alexander Belanger %!s(int64=5) %!d(string=hai) anos
pai
achega
44f618d938
Modificáronse 100 ficheiros con 2757 adicións e 12628 borrados
  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. 3 0
      dashboard/.prettierignore
  21. 1 0
      dashboard/.prettierrc.json
  22. 1 10251
      dashboard/package-lock.json
  23. 1 0
      dashboard/package.json
  24. 0 1
      dashboard/src/App.tsx
  25. BIN=BIN
      dashboard/src/assets/DaGsIs8VwAAGHM1.jpg
  26. 11 11
      dashboard/src/assets/GithubIcon.tsx
  27. 1 0
      dashboard/src/assets/discord.svg
  28. 1 0
      dashboard/src/components/ResourceTab.tsx
  29. 1 1
      dashboard/src/components/TabRegion.tsx
  30. 2 2
      dashboard/src/components/TabSelector.tsx
  31. 12 4
      dashboard/src/components/image-selector/ImageList.tsx
  32. 79 105
      dashboard/src/components/image-selector/ImageSelector.tsx
  33. 82 43
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  34. 45 40
      dashboard/src/components/repo-selector/ActionDetails.tsx
  35. 1 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  36. 22 2
      dashboard/src/components/repo-selector/RepoList.tsx
  37. 35 36
      dashboard/src/components/repo-selector/RepoSelector.tsx
  38. 156 0
      dashboard/src/components/values-form/InputArray.tsx
  39. 1 1
      dashboard/src/components/values-form/InputRow.tsx
  40. 185 0
      dashboard/src/components/values-form/KeyValueArray.tsx
  41. 6 11
      dashboard/src/components/values-form/RangeSlider.tsx
  42. 35 7
      dashboard/src/components/values-form/ValuesForm.tsx
  43. 9 1
      dashboard/src/components/values-form/ValuesWrapper.tsx
  44. 40 26
      dashboard/src/index.html
  45. 2 11
      dashboard/src/main/Main.tsx
  46. 34 30
      dashboard/src/main/home/Home.tsx
  47. 2 2
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  48. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  49. 20 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  50. 3 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx
  51. 75 48
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  52. 65 20
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  53. 13 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  54. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  55. 1 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  56. 89 74
      dashboard/src/main/home/integrations/IntegrationList.tsx
  57. 2 1
      dashboard/src/main/home/integrations/Integrations.tsx
  58. 78 15
      dashboard/src/main/home/launch/Launch.tsx
  59. 16 4
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  60. 219 164
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  61. 2 2
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  62. 0 0
      dashboard/src/main/home/launch/hardcodedNameDict.tsx
  63. 4 17
      dashboard/src/main/home/modals/Modal.tsx
  64. 1 0
      dashboard/src/main/home/project-settings/InviteList.tsx
  65. 18 12
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  66. 91 73
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  67. 18 9
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  68. 47 10
      dashboard/src/main/home/sidebar/Sidebar.tsx
  69. 2 2
      dashboard/src/shared/Context.tsx
  70. 35 23
      dashboard/src/shared/api.tsx
  71. 18 17
      dashboard/src/shared/common.tsx
  72. 9 3
      dashboard/src/shared/routing.tsx
  73. 1 0
      dashboard/src/shared/types.tsx
  74. 1 1
      dashboard/tsconfig.json
  75. 23 25
      dashboard/webpack.config.js
  76. 4 4
      docker-compose.dev.yaml
  77. 2 2
      docs/GCR.md
  78. 17 18
      docs/GETTING_STARTED.md
  79. 5 7
      helm/templates/service.yaml
  80. 9 5
      helm/values.yaml
  81. 1 1
      internal/config/config.go
  82. 7 0
      internal/forms/git_action.go
  83. 14 12
      internal/forms/registry.go
  84. 3 4
      internal/forms/release.go
  85. 9 11
      internal/helm/grapher/test_yaml/cassandra.yaml
  86. 41 41
      internal/helm/grapher/test_yaml/ingress.yaml
  87. 32 30
      internal/helm/grapher/test_yaml/kafka.yaml
  88. 1 1
      internal/helm/grapher/test_yaml/volumes.yaml
  89. 1 1
      internal/integrations/ci/actions/actions.go
  90. 10 7
      internal/integrations/ci/actions/steps.go
  91. 0 2
      internal/kubernetes/agent.go
  92. 13 4
      internal/kubernetes/config.go
  93. 0 4
      internal/kubernetes/provisioner/global_stream.go
  94. 0 2
      internal/kubernetes/provisioner/resource_stream.go
  95. 2 0
      internal/models/cluster.go
  96. 13 11
      internal/models/integrations/integration.go
  97. 8 3
      internal/models/registry.go
  98. 252 0
      internal/registry/registry.go
  99. 30 12
      internal/repository/gorm/cluster.go
  100. 5 1213
      package-lock.json

+ 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"`
 }

+ 3 - 0
dashboard/.prettierignore

@@ -0,0 +1,3 @@
+# Ignore artifacts:
+build
+coverage

+ 1 - 0
dashboard/.prettierrc.json

@@ -0,0 +1 @@
+{}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 10251
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -64,6 +64,7 @@
     "@types/styled-components": "^5.1.3",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
+    "prettier": "2.2.1",
     "qs": "^6.9.4",
     "source-map-loader": "^1.1.0",
     "ts-loader": "^8.0.4",

+ 0 - 1
dashboard/src/App.tsx

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

BIN=BIN
dashboard/src/assets/DaGsIs8VwAAGHM1.jpg


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

+ 1 - 0
dashboard/src/assets/discord.svg

@@ -0,0 +1 @@
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><style>.st0{fill:#FFFFFF;}</style><path class="st0" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path class="st0" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>

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

@@ -259,6 +259,7 @@ const StatusColor = styled.div`
 
 const ResourceName = styled.div`
   color: #ffffff;
+  max-width: 40%;
   margin-left: ${(props: { showKindLabels: boolean }) =>
     props.showKindLabels ? "10px" : ""};
   text-transform: none;

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

@@ -77,7 +77,7 @@ const Div = styled.div`
 `;
 
 const TabContents = styled.div`
-  height: calc(100% - 60px);
+  height: calc(100% - 80px);
 `;
 
 const Gap = styled.div`

+ 2 - 2
dashboard/src/components/TabSelector.tsx

@@ -95,10 +95,10 @@ const Tab = styled.div`
 
 const StyledTabSelector = styled.div`
   display: flex;
-  width: calc(100% - 4px);
+  width: calc(100% - 2px);
   align-items: center;
   border-bottom: 1px solid #aaaabb55;
   padding-bottom: 1px;
-  margin-left: 2px;
+  margin-left: 1px;
   position: relative;
 `;

+ 12 - 4
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;
@@ -25,7 +26,7 @@ type StateType = {
   images: ImageType[];
 };
 
-export default class ImageSelector extends Component<PropsType, StateType> {
+export default class ImageList extends Component<PropsType, StateType> {
   state = {
     loading: true,
     error: false,
@@ -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 })
@@ -97,7 +99,12 @@ export default class ImageSelector extends Component<PropsType, StateType> {
                         loading: false,
                         error,
                       });
+                    } else {
+                      this.setState({
+                        images,
+                      });
                     }
+
                     resolveToNextController();
                   });
               }
@@ -216,7 +223,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>
@@ -245,7 +253,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
   }
 }
 
-ImageSelector.contextType = Context;
+ImageList.contextType = Context;
 
 const BackButton = styled.div`
   display: flex;
@@ -278,7 +286,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;

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

@@ -3,13 +3,11 @@ import styled from "styled-components";
 import info from "assets/info.svg";
 import edit from "assets/edit.svg";
 
-import api from "shared/api";
 import { integrationList } from "shared/common";
 import { Context } from "shared/Context";
 import { ImageType } from "shared/types";
 
 import Loading from "../Loading";
-import TagList from "./TagList";
 import ImageList from "./ImageList";
 
 type PropsType = {
@@ -18,6 +16,7 @@ type PropsType = {
   selectedTag: string | null;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
+  noTagSelection?: boolean;
 };
 
 type StateType = {
@@ -37,81 +36,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 +172,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 +230,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 +293,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 +352,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: any;
 };
 
 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;
+  }
+`;

+ 45 - 40
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;

+ 156 - 0
dashboard/src/components/values-form/InputArray.tsx

@@ -0,0 +1,156 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  label?: string;
+  values: string[];
+  setValues: (x: string[]) => void;
+  width?: string;
+};
+
+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 = (values: string[]) => {
+    return (
+      <>
+        {values.map((value: string, i: number) => {
+          return (
+            <InputWrapper>
+              <Input
+                placeholder=""
+                width="270px"
+                value={value}
+                onChange={(e: any) => {
+                  let v = [...values];
+                  v[i] = e.target.value;
+                  this.props.setValues(v);
+                }}
+              />
+              <DeleteButton
+                onClick={() => {
+                  let v = [...values];
+                  v.splice(i, 1);
+                  this.props.setValues(v);
+                }}
+              >
+                <i className="material-icons">cancel</i>
+              </DeleteButton>
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  render() {
+    let { values } = this.props;
+
+    if (!Array.isArray(values)) {
+      values = this.dict2arr(values);
+    }
+
+    return (
+      <StyledInputArray>
+        <Label>{this.props.label}</Label>
+        {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>
+    );
+  }
+}
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 30px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

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

@@ -96,5 +96,5 @@ const Label = styled.div`
 
 const StyledInputRow = styled.div`
   margin-bottom: 15px;
-  margin-top: 20px;
+  margin-top: 22px;
 `;

+ 185 - 0
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -0,0 +1,185 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  label?: string;
+  values: any;
+  setValues: (x: any) => void;
+  width?: string;
+};
+
+type StateType = {
+  values: any[];
+};
+
+export default class KeyValueArray extends Component<PropsType, StateType> {
+  state = {
+    values: [] as any[],
+  };
+
+  componentDidMount() {
+    let arr = [] as any[];
+    Object.keys(this.props.values).forEach((key: string, i: number) => {
+      arr.push({ key, value: this.props.values[key] });
+    });
+    this.setState({ values: arr });
+  }
+
+  valuesToObject = () => {
+    let obj = {} as any;
+    this.state.values.forEach((entry: any, i: number) => {
+      obj[entry.key] = entry.value;
+    });
+    return obj;
+  };
+
+  renderInputList = () => {
+    return (
+      <>
+        {this.state.values.map((entry: any, i: number) => {
+          return (
+            <InputWrapper key={i}>
+              <Input
+                placeholder="ex: key"
+                width="270px"
+                value={entry.key}
+                onChange={(e: any) => {
+                  this.state.values[i].key = e.target.value;
+                  this.setState({ values: this.state.values });
+
+                  let obj = this.valuesToObject();
+                  this.props.setValues(obj);
+                }}
+              />
+              <Spacer />
+              <Input
+                placeholder="ex: value"
+                width="270px"
+                value={entry.value}
+                onChange={(e: any) => {
+                  this.state.values[i].value = e.target.value;
+                  this.setState({ values: this.state.values });
+
+                  let obj = this.valuesToObject();
+                  this.props.setValues(obj);
+                }}
+              />
+              <DeleteButton
+                onClick={() => {
+                  this.state.values.splice(i, 1);
+                  this.setState({ values: this.state.values });
+
+                  let obj = this.valuesToObject();
+                  this.props.setValues(obj);
+                }}
+              >
+                <i className="material-icons">cancel</i>
+              </DeleteButton>
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  render() {
+    return (
+      <StyledInputArray>
+        <Label>{this.props.label}</Label>
+        {this.state.values.length === 0 ? <></> : this.renderInputList()}
+        <AddRowButton
+          onClick={() => {
+            this.state.values.push({ key: "", value: "" });
+            this.setState({ values: this.state.values });
+          }}
+        >
+          <i className="material-icons">add</i> Add Row
+        </AddRowButton>
+      </StyledInputArray>
+    );
+  }
+}
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+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"
         />

+ 35 - 7
dashboard/src/components/values-form/ValuesForm.tsx

@@ -12,6 +12,8 @@ import Helper from "./Helper";
 import Heading from "./Heading";
 import ExpandableResource from "../ExpandableResource";
 import VeleroForm from "../forms/VeleroForm";
+import InputArray from "./InputArray";
+import KeyValueArray from "./KeyValueArray";
 
 type PropsType = {
   sections?: Section[];
@@ -21,11 +23,12 @@ type PropsType = {
 
 type StateType = any;
 
+// Requires an internal representation unlike other values components because metaState value underdetermines input order
 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];
     }
@@ -69,7 +72,28 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               label={item.label}
             />
           );
+        case "key-value-array":
+          return (
+            <KeyValueArray
+              values={this.props.metaState[key]}
+              setValues={(x: any) => {
+                this.props.setMetaState({ [key]: x });
+              }}
+              label={item.label}
+            />
+          );
         case "array-input":
+          return (
+            <InputArray
+              key={i}
+              values={this.props.metaState[key]}
+              setValues={(x: string[]) => {
+                this.props.setMetaState({ [key]: x });
+              }}
+              label={item.label}
+            />
+          );
+        case "string-input":
           return (
             <InputRow
               key={i}
@@ -77,20 +101,24 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               type="text"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
-                this.props.setMetaState({ [key]: [x] });
+                if (item.settings && item.settings.unit && x !== "") {
+                  x = x + item.settings.unit;
+                }
+                this.props.setMetaState({ [key]: x });
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
             />
           );
-        case "string-input":
+        case "string-input-password":
           return (
             <InputRow
               key={i}
               isRequired={item.required}
-              type="text"
+              type="password"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
+                console.log("string input", x);
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                 }
@@ -143,9 +171,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}

+ 9 - 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
@@ -52,9 +55,14 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
               case "string-input":
                 metaState[key] = def ? def : "";
                 break;
+              case "string-input-password":
+                metaState[key] = def ? def : item.settings.default;
               case "array-input":
                 metaState[key] = def ? def : [];
                 break;
+              case "key-value-array":
+                metaState[key] = def ? def : {};
+                break;
               case "number-input":
                 metaState[key] = def.toString() ? def : "";
                 break;

+ 40 - 26
dashboard/src/index.html

@@ -3,31 +3,45 @@
   <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>
-
-  <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>
+  <body>
+    <div id="output"></div>
+  </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

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

@@ -223,6 +223,7 @@ 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]);
     }
@@ -361,14 +362,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Append universal tabs
     tabOptions.push(
       { label: "Status", value: "status" },
-      //{ label: "Metrics", value: "metrics" },
+      { label: "Metrics", value: "metrics" },
       { label: "Chart Overview", value: "graph" }
     );
 
     if (this.state.devOpsMode) {
       tabOptions.push(
         { label: "Manifests", value: "list" },
-        { label: "Raw Values", value: "values" }
+        { label: "Helm Values", value: "values" }
       );
     }
 

+ 20 - 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: "",
@@ -142,6 +145,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     </Helper>
   */
   renderSourceSection = () => {
+    if (!this.props.currentChart.form.hasSource) {
+      return;
+    }
+
     if (this.state.action.git_repo.length > 0) {
       return (
         <>
@@ -219,12 +226,19 @@ export default class SettingsSection extends Component<PropsType, StateType> {
           setActionConfig={(actionConfig: ActionConfigType) =>
             this.setState({ actionConfig })
           }
+          resetActionConfig={() =>
+            this.setState({ actionConfig: defaultActionConfig })
+          }
         />
       </>
     );
   };
 
   renderWebhookSection = () => {
+    if (!this.props.currentChart.form.hasSource) {
+      return;
+    }
+
     if (true || this.state.webhookToken) {
       let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=???&repository=???'`;
       return (

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

+ 75 - 48
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,44 +1,53 @@
-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';
 export const accentColorDark = '#949eff';
 */
 
-type TooltipData = AppleStock;
+interface MetricsData {
+  date: number; // unix timestamp
+  value: number; // value 
+}
 
-const stock = appleStock.slice(800);
-export const background = '#3b697800';
-export const background2 = '#20405100';
-export const accentColor = '#949eff';
-export const accentColorDark = '#949eff';
+type TooltipData = MetricsData;
+
+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
-const formatDate = timeFormat("%b %d, '%y");
+const formatDate = timeFormat("%H:%M:%S %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 getDate = (d: MetricsData) => new Date(d.date*1000);
+const getValue = (d: MetricsData) => d.value;
+const bisectDate = bisector<MetricsData, Date>((d) => new Date(d.date*1000)).left;
 
 export type AreaProps = {
+  data: MetricsData[],
   width: number;
   height: number;
   margin?: { top: number; right: number; bottom: number; left: number };
@@ -46,6 +55,7 @@ export type AreaProps = {
 
 export default withTooltip<AreaProps, TooltipData>(
   ({
+    data,
     width,
     height,
     margin = { top: 0, right: 0, bottom: 0, left: 0 },
@@ -66,39 +76,47 @@ export default withTooltip<AreaProps, TooltipData>(
       () =>
         scaleTime({
           range: [margin.left, innerWidth + margin.left],
-          domain: extent(stock, getDate) as [Date, Date],
+          domain: extent(data, getDate) as [Date, Date],
         }),
-      [innerWidth, margin.left],
+      [innerWidth, margin.left]
     );
     const stockValueScale = useMemo(
       () =>
         scaleLinear({
           range: [innerHeight + margin.top, margin.top],
-          domain: [0, (max(stock, getStockValue) || 0) + innerHeight / 3],
+          domain: [0, 1.25 * max(data, getValue)],
           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);
-        const d0 = stock[index - 1];
-        const d1 = stock[index];
+        const index = bisectDate(data, x0, 1);
+        const d0 = data[index - 1];
+        const d1 = data[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,
           tooltipLeft: x,
-          tooltipTop: stockValueScale(getStockValue(d)),
+          tooltipTop: stockValueScale(getValue(d)),
         });
       },
-      [showTooltip, stockValueScale, dateScale],
+      [showTooltip, stockValueScale, dateScale]
     );
 
     return (
@@ -112,12 +130,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} />
-          <AreaClosed<AppleStock>
-            data={stock}
-            x={d => dateScale(getDate(d)) ?? 0}
-            y={d => stockValueScale(getStockValue(d)) ?? 0}
+          <LinearGradient
+            id="area-background-gradient"
+            from={background}
+            to={background2}
+          />
+          <LinearGradient
+            id="area-gradient"
+            from={accentColor}
+            to={accentColor}
+            toOpacity={0}
+          />
+          <AreaClosed<MetricsData>
+            data={data}
+            x={(d) => dateScale(getDate(d)) ?? 0}
+            y={(d) => stockValueScale(getValue(d)) ?? 0}
             yScale={stockValueScale}
             strokeWidth={1}
             stroke="url(#area-gradient)"
@@ -177,19 +204,19 @@ export default withTooltip<AreaProps, TooltipData>(
               left={tooltipLeft + 12}
               style={tooltipStyles}
             >
-              {`$${getStockValue(tooltipData)}`}
+              {getValue(tooltipData)}
             </TooltipWithBounds>
             <Tooltip
               top={-10}
               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 +225,5 @@ export default withTooltip<AreaProps, TooltipData>(
         )}
       </div>
     );
-  },
-);
+  }
+);

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

@@ -1,7 +1,8 @@
 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 api from "shared/api";
 import { Context } from "shared/Context";
 import { ChartType } from "shared/types";
 
@@ -13,26 +14,68 @@ type PropsType = {
 };
 
 type StateType = {
-  selectedRange: string,
-  selectedMetricLabel: string,
-  dropdownExpanded: boolean,
+  selectedRange: string;
+  selectedMetricLabel: string;
+  dropdownExpanded: boolean;
 };
 
+var fakeData = [{
+  date: 1613512500,
+  value: 0.00017923172010701633,
+},
+{
+  date: 1613513100,
+  value: 0.00018,
+},
+{
+  date: 1613513700,
+  value: 0.0001923,
+}]
+
 export default class ListSection extends Component<PropsType, StateType> {
   state = {
-    selectedRange: '1H',
-    selectedMetricLabel: 'CPU Utilization',
+    selectedRange: "1H",
+    selectedMetricLabel: "CPU Utilization",
     dropdownExpanded: false,
+  };
+
+  componentDidMount() {
+    const { selectors, currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api
+      .getChartControllers(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        this.setState({ controllers: res.data, loading: false });
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        this.setState({ controllers: [], loading: 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 +87,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) => {
@@ -67,10 +110,12 @@ export default class ListSection extends Component<PropsType, StateType> {
     return (
       <StyledMetricsSection>
         <ParentSize>
-          {({ width, height }) => <AreaChart width={width} height={height} />}
+          {({ width, height }) => <AreaChart data={fakeData} 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 +124,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 })}

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

@@ -73,6 +73,11 @@ export default class ControllerTab extends Component<PropsType, StateType> {
         this.setState({ pods, raw: res.data, showTooltip });
 
         if (isFirst) {
+          let pod = res.data[0];
+          let status = this.getPodStatus(pod.status);
+          status === "failed" &&
+            pod.status?.message &&
+            this.props.setPodError(pod.status?.message);
           selectPod(res.data[0]);
         }
       })
@@ -104,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") {
@@ -155,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>;

+ 1 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -79,7 +79,7 @@ class Dashboard extends Component<PropsType, StateType> {
             <>
               <Banner>
                 <i className="material-icons">error_outline</i>
-                This project currently has no clusters conncted.
+                This project currently has no clusters connected.
               </Banner>
               <ProvisionerSettings infras={this.state.infras} />
             </>

+ 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: "New Application", value: "docker" },
+  { label: "Community Add-ons", 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;
 `;

+ 16 - 4
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}
         />
@@ -117,7 +129,7 @@ const LoadingWrapper = styled.div`
 `;
 
 const StyledExpandedTemplate = styled.div`
-  width: calc(90% - 150px);
+  width: 100%;
   min-width: 300px;
-  padding-top: 75px;
+  padding-top: 30px;
 `;

+ 219 - 164
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,
   };
@@ -100,7 +103,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   onSubmitAddon = (wildcard?: any) => {
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
     let name =
       this.state.templateName || randomWords({ exactly: 3, join: "-" });
     this.setState({ saveValuesStatus: "loading" });
@@ -143,6 +146,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
+        setCurrentError(err.response.data.errors[0]);
         posthog.capture("Failed to deploy template", {
           name: this.props.currentTemplate.name,
           namespace: this.state.selectedNamespace,
@@ -176,12 +180,32 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }
 
     if (this.state.sourceType === "repo") {
-      imageUrl = "hello-world";
+      imageUrl = "porterdev/hello-porter";
       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 +235,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 +243,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);
+        }
       });
   };
 
@@ -275,7 +308,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     if (this.props.currentTemplate.name !== "docker") {
       this.setState({ saveValuesStatus: "" });
     }
-
     // Retrieve tab options
     let tabOptions = [] as ChoiceType[];
     this.props.form?.tabs.map((tab: any, i: number) => {
@@ -283,7 +315,11 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         tabOptions.push({ value: tab.name, label: tab.label });
       }
     });
-    this.setState({ tabOptions, currentTab: tabOptions[0]["value"] });
+
+    this.setState({
+      tabOptions,
+      currentTab: tabOptions[0] && tabOptions[0]["value"],
+    });
 
     // TODO: query with selected filter once implemented
     let { currentProject, currentCluster } = this.context;
@@ -350,7 +386,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     );
   };
 
-  renderTabRegion = () => {
+  renderSettingsRegion = () => {
     if (this.state.tabOptions.length > 0) {
       return (
         <>
@@ -391,80 +427,84 @@ 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.state.sourceType === "registry") {
+      return (
+        <>
+          <Subtitle>
+            Select the container image you would like to connect to this
+            template
+            {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
+              link a git repository
+            </Highlight> */}
+            .<Required>*</Required>
+          </Subtitle>
+          <DarkMatter />
+          <ImageSelector
+            selectedTag={this.state.selectedTag}
+            selectedImageUrl={this.state.selectedImageUrl}
+            setSelectedImageUrl={this.setSelectedImageUrl}
+            setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
+            forceExpanded={true}
+          />
+          <br />
+        </>
+      );
+    } else {
+      return (
+        <>
+          <Subtitle>
+            Select a repo to connect to, then a Dockerfile to build from.
+            <Required>*</Required>
+          </Subtitle>
+          <ActionConfEditor
+            actionConfig={this.state.actionConfig}
+            branch={this.state.branch}
+            pathIsSet={this.state.pathIsSet}
+            setActionConfig={(actionConfig: ActionConfigType) =>
+              this.setState({ actionConfig }, () => {
+                this.setSelectedImageUrl(
+                  this.state.actionConfig.image_repo_uri
+                );
+              })
+            }
+            setBranch={(branch: string) => this.setState({ branch })}
+            setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+            reset={() => {
+              this.setState({
+                actionConfig: { ...defaultActionConfig },
+                branch: "",
+                pathIsSet: false,
+              });
+            }}
+          />
+          <br />
+        </>
+      );
+    }
+  };
 
-    if (this.props.form?.hasSource) {
-      if (this.state.sourceType === "registry") {
-        return (
-          <>
-            <Subtitle>
-              Select the container image you would like to connect to this
-              template
-              {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
-                link a git repository
-              </Highlight> */}
-              .<Required>*</Required>
-            </Subtitle>
-            <DarkMatter />
-            <ImageSelector
-              selectedTag={this.state.selectedTag}
-              selectedImageUrl={this.state.selectedImageUrl}
-              setSelectedImageUrl={this.setSelectedImageUrl}
-              setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
-              forceExpanded={true}
-            />
-            <br />
-          </>
-        );
-      } else {
-        return (
-          <>
-            <Subtitle>
-              Select a repo to connect to. You can
-              <A
-                padRight={true}
-                href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
-              >
-                log in with GitHub
-              </A>{" "}
-              or
-              <Highlight
-                onClick={() =>
-                  this.setState({
-                    sourceType: "registry",
-                    actionConfig: {
-                      git_repo: "",
-                      image_repo_uri: "",
-                      git_repo_id: 0,
-                      dockerfile_path: "",
-                    } as ActionConfigType,
-                  })
-                }
-              >
-                link an image registry
-              </Highlight>
-              .<Required>*</Required>
-            </Subtitle>
-            <ActionConfEditor
-              actionConfig={this.state.actionConfig}
-              branch={this.state.branch}
-              pathIsSet={this.state.pathIsSet}
-              setActionConfig={(actionConfig: ActionConfigType) =>
-                this.setState({ actionConfig }, () => {
-                  this.setSelectedImageUrl(
-                    this.state.actionConfig.image_repo_uri
-                  );
-                })
-              }
-              setBranch={(branch: string) => this.setState({ branch })}
-              setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
-            />
-            <br />
-          </>
-        );
-      }
+  renderSourceSelector = () => {
+    if (!this.props.form?.hasSource) {
+      return;
     }
+
+    return (
+      <>
+        <TabRegion
+          options={[
+            { label: "Registry", value: "registry" },
+            { label: "Github", value: "repo" },
+          ]}
+          currentTab={this.state.sourceType}
+          setCurrentTab={(x) => this.setState({ sourceType: x })}
+        >
+          <StyledSourceBox>
+            {this.renderSourceSelectorContent()}
+          </StyledSourceBox>
+        </TabRegion>
+      </>
+    );
   };
 
   render() {
@@ -473,22 +513,44 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
     return (
       <StyledLaunchTemplate>
-        <TitleSection>
-          <Flex>
+        {name !== "docker" && (
+          <HeaderSection>
             <i className="material-icons" onClick={this.props.hideLaunch}>
               keyboard_backspace
             </i>
-            <Title>Launch Template</Title>
-          </Flex>
-        </TitleSection>
-        <ClusterSection>
-          <Template>
             {icon
               ? this.renderIcon(icon)
               : this.renderIcon(currentTemplate.icon)}
-            {name}
-          </Template>
-          <i className="material-icons">arrow_right_alt</i>
+            <Title>{name}</Title>
+          </HeaderSection>
+        )}
+        <DarkMatter antiHeight="-13px" />
+        <Heading isAtTop={name !== "docker"}>Name</Heading>
+        <Subtitle>
+          Randomly generated if left blank.
+          <Warning
+            highlight={
+              !isAlphanumeric(this.state.templateName) &&
+              this.state.templateName !== ""
+            }
+          >
+            Lowercase letters, numbers, and "-" only.
+          </Warning>
+        </Subtitle>
+        <DarkMatter antiHeight="-29px" />
+        <InputRow
+          type="text"
+          value={this.state.templateName}
+          setValue={(x: string) => this.setState({ templateName: x })}
+          placeholder="ex: doctor-scientist"
+          width="100%"
+        />
+        <Heading>Destination</Heading>
+        <Subtitle>
+          Specify the cluster and namespace you would like to deploy your
+          application to.
+        </Subtitle>
+        <ClusterSection>
           <ClusterLabel>
             <i className="material-icons">device_hub</i>Cluster
           </ClusterLabel>
@@ -520,28 +582,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
-        <Subtitle>
-          Template name
-          <Warning
-            highlight={
-              !isAlphanumeric(this.state.templateName) &&
-              this.state.templateName !== ""
-            }
-          >
-            (lowercase letters, numbers, and "-" only)
-          </Warning>
-          . (Optional)
-        </Subtitle>
-        <DarkMatter antiHeight="-27px" />
-        <InputRow
-          type="text"
-          value={this.state.templateName}
-          setValue={(x: string) => this.setState({ templateName: x })}
-          placeholder="ex: doctor-scientist"
-          width="100%"
-        />
         {this.renderSourceSelector()}
-        {this.renderTabRegion()}
+        {this.renderSettingsRegion()}
       </StyledLaunchTemplate>
     );
   }
@@ -549,6 +591,41 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
 LaunchTemplate.contextType = Context;
 
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 10px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const HeaderSection = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "30px" : "10px")};
+  display: flex;
+  align-items: center;
+`;
+
 const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
   color: ${(props) => (props.highlight ? "#f5cb42" : "")};
   margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
@@ -563,13 +640,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;
@@ -596,7 +666,7 @@ const DarkMatter = styled.div<{ antiHeight?: string }>`
 `;
 
 const Subtitle = styled.div`
-  padding: 11px 0px 20px;
+  padding: 11px 0px 16px;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   color: #aaaabb;
@@ -653,9 +723,9 @@ const ClusterSection = styled.div`
   color: #ffffff;
   font-family: "Work Sans", sans-serif;
   font-size: 14px;
+  margin-top: 2px;
   font-weight: 500;
-  margin-top: 20px;
-  margin-bottom: 15px;
+  margin-bottom: 22px;
 
   > i {
     font-size: 25px;
@@ -680,25 +750,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;
@@ -713,11 +764,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;
 `;

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

@@ -297,11 +297,11 @@ 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;
-  width: calc(100% + 42px);
+  width: 100%;
   align-items: center;
 `;
 

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


+ 4 - 17
dashboard/src/main/home/modals/Modal.tsx

@@ -46,7 +46,7 @@ export default class Modal extends Component<PropsType, StateType> {
 }
 
 const Overlay = styled.div`
-  position: absolute;
+  position: fixed;
   margin: 0;
   padding: 0;
   top: 0;
@@ -55,26 +55,13 @@ const Overlay = styled.div`
   height: 100%;
   background-color: rgba(0, 0, 0, 0.6);
   z-index: 3;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 `;
 
 const StyledModal = styled.div`
   position: absolute;
-  top: calc(
-    50% -
-      (
-        ${(props: { width?: string; height?: string }) =>
-            props.height ? props.height : "425px"} / 2
-      )
-  );
-  left: calc(
-    50% -
-      (
-        ${(props: { width?: string; height?: string }) =>
-            props.width ? props.width : "760px"} / 2
-      )
-  );
-  display: flex;
-  justify-content: center;
   width: ${(props: { width?: string; height?: string }) =>
     props.width ? props.width : "760px"};
   max-width: 80vw;

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

@@ -395,6 +395,7 @@ const MailTd = styled(Td)`
   max-width: 186px;
   min-width: 186px;
   overflow: hidden;
+  color: #aaaabb;
   text-overflow: ellipsis;
 `;
 

+ 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}

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

@@ -2,8 +2,9 @@ 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 discordLogo from "assets/discord.svg";
 
 import { Context } from "shared/Context";
 
@@ -112,19 +113,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", {});
             }}
@@ -189,6 +188,11 @@ class Sidebar extends Component<PropsType, StateType> {
           <br />
 
           {this.renderProjectContents()}
+
+          <DiscordButton href="https://discord.gg/Tky6bzHVHd" target="_blank">
+            <Icon src={discordLogo} />
+            Join Our Discord
+          </DiscordButton>
         </StyledSidebar>
       </>
     );
@@ -199,6 +203,14 @@ Sidebar.contextType = Context;
 
 export default withRouter(Sidebar);
 
+const Icon = styled.img`
+  height: 25px;
+  width: 25px;
+  opacity: 30%;
+  margin-left: 7px;
+  margin-right: 5px;
+`;
+
 const ProjectPlaceholder = styled.div`
   background: #ffffff11;
   border-radius: 5px;
@@ -269,6 +281,31 @@ const BottomSection = styled.div`
   bottom: 10px;
 `;
 
+const DiscordButton = styled.a`
+  position: absolute;
+  text-decoration: none;
+  bottom: 15px;
+  display: flex;
+  align-items: center;
+  width: calc(100% - 30px);
+  left: 15px;
+  border: 2px solid #ffffff44;
+  border-radius: 3px;
+  color: #ffffff44;
+  height: 40px;
+  font-family: Work Sans, sans-serif;
+  font-size: 14px;
+  font-weight: bold;
+  cursor: pointer;
+  :hover {
+    > img {
+      opacity: 60%;
+    }
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;
+
 const LogOutButton = styled(NavButton)`
   width: calc(100% - 55px);
   border-top-right-radius: 3px;

+ 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 - 0
dashboard/src/shared/types.tsx

@@ -82,6 +82,7 @@ export interface FormYAML {
   name?: string;
   icon?: string;
   description?: string;
+  hasSource?: string;
   tags?: string[];
   tabs?: {
     name: string;

+ 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

+ 1 - 1
internal/integrations/ci/actions/actions.go

@@ -117,7 +117,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		},
 		Name: "Deploy to Porter",
 		Jobs: map[string]GithubActionYAMLJob{
-			"porter-deploy": GithubActionYAMLJob{
+			"porter-deploy": {
 				RunsOn: "ubuntu-latest",
 				Steps: []GithubActionYAMLStep{
 					getCheckoutCodeStep(),

+ 10 - 7
internal/integrations/ci/actions/steps.go

@@ -1,6 +1,9 @@
 package actions
 
-import "fmt"
+import (
+	"fmt"
+	"path/filepath"
+)
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
 	return GithubActionYAMLStep{
@@ -28,8 +31,8 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 }
 
 const configure string = `
-porter auth login --token ${{secrets.%s}}
-porter docker configure
+sudo porter auth login --token ${{secrets.%s}}
+sudo porter docker configure
 `
 
 func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
@@ -41,20 +44,20 @@ func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
 }
 
 const dockerBuildPush string = `
-docker build . --file %s -t %s:$(git rev-parse --short HEAD)
-docker push %s:$(git rev-parse --short HEAD)
+docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
+sudo docker push %s:$(git rev-parse --short HEAD)
 `
 
 func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Docker build, push",
 		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(dockerBuildPush, dockerFilePath, repoURL, repoURL),
+		Run:  fmt.Sprintf(dockerBuildPush, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
 	}
 }
 
 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))
 }

+ 30 - 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,20 @@ 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
 	}
 
+	// delete the existing token cache first
+	if err := ctxDB.Where("id = ?", tokenCache.ID).Unscoped().Delete(&cluster.TokenCache).Error; err != nil {
+		return nil, err
+	}
+
+	// set the new token cache
 	cluster.TokenCache.Token = tokenCache.Token
 	cluster.TokenCache.Expiry = tokenCache.Expiry
 
-	if err := repo.db.Save(cluster).Error; err != nil {
+	if err := ctxDB.Save(cluster).Error; err != nil {
 		return nil, err
 	}
 
@@ -347,14 +365,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
     }
   }
 }

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio