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

Merge branch 'beta.2.integration-ui' into dev

Jo Chuang 5 лет назад
Родитель
Сommit
0ed1c481a4
90 измененных файлов с 1996 добавлено и 12275 удалено
  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. 44 44
      .github/workflows/release.yaml
  6. 1 2
      .github/workflows/staging.yaml
  7. 16 7
      README.md
  8. 76 0
      cli/cmd/api/registry.go
  9. 3 0
      cli/cmd/auth.go
  10. 52 0
      cli/cmd/connect.go
  11. 76 0
      cli/cmd/connect/dockerhub.go
  12. 76 0
      cli/cmd/connect/registry.go
  13. 50 1
      cli/cmd/docker.go
  14. 1 1
      cli/cmd/version.go
  15. 1 1
      cmd/docker-credential-porter/main.go
  16. 3 0
      dashboard/.prettierignore
  17. 1 0
      dashboard/.prettierrc.json
  18. 1 10251
      dashboard/package-lock.json
  19. 1 0
      dashboard/package.json
  20. 0 1
      dashboard/src/App.tsx
  21. BIN
      dashboard/src/assets/DaGsIs8VwAAGHM1.jpg
  22. 11 11
      dashboard/src/assets/GithubIcon.tsx
  23. 1 0
      dashboard/src/assets/discord.svg
  24. 1 0
      dashboard/src/components/ResourceTab.tsx
  25. 1 1
      dashboard/src/components/TabRegion.tsx
  26. 2 2
      dashboard/src/components/TabSelector.tsx
  27. 9 3
      dashboard/src/components/image-selector/ImageList.tsx
  28. 75 77
      dashboard/src/components/image-selector/ImageSelector.tsx
  29. 1 1
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  30. 9 26
      dashboard/src/components/repo-selector/ActionDetails.tsx
  31. 156 0
      dashboard/src/components/values-form/InputArray.tsx
  32. 1 1
      dashboard/src/components/values-form/InputRow.tsx
  33. 185 0
      dashboard/src/components/values-form/KeyValueArray.tsx
  34. 6 11
      dashboard/src/components/values-form/RangeSlider.tsx
  35. 35 7
      dashboard/src/components/values-form/ValuesForm.tsx
  36. 9 1
      dashboard/src/components/values-form/ValuesWrapper.tsx
  37. 40 26
      dashboard/src/index.html
  38. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  39. 8 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  40. 3 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx
  41. 13 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  42. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  43. 89 74
      dashboard/src/main/home/integrations/IntegrationList.tsx
  44. 2 2
      dashboard/src/main/home/launch/Launch.tsx
  45. 1 1
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  46. 160 97
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  47. 1 1
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  48. 4 17
      dashboard/src/main/home/modals/Modal.tsx
  49. 1 0
      dashboard/src/main/home/project-settings/InviteList.tsx
  50. 18 12
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  51. 91 73
      dashboard/src/main/home/provisioner/ProvisionerLogs.tsx
  52. 39 0
      dashboard/src/main/home/sidebar/Sidebar.tsx
  53. 2 2
      dashboard/src/shared/Context.tsx
  54. 35 23
      dashboard/src/shared/api.tsx
  55. 18 17
      dashboard/src/shared/common.tsx
  56. 1 0
      dashboard/src/shared/types.tsx
  57. 1 1
      dashboard/tsconfig.json
  58. 4 4
      docker-compose.dev.yaml
  59. 2 2
      docs/GCR.md
  60. 17 18
      docs/GETTING_STARTED.md
  61. 5 7
      helm/templates/service.yaml
  62. 9 5
      helm/values.yaml
  63. 1 1
      internal/config/config.go
  64. 14 12
      internal/forms/registry.go
  65. 0 4
      internal/forms/release.go
  66. 9 11
      internal/helm/grapher/test_yaml/cassandra.yaml
  67. 41 41
      internal/helm/grapher/test_yaml/ingress.yaml
  68. 32 30
      internal/helm/grapher/test_yaml/kafka.yaml
  69. 1 1
      internal/helm/grapher/test_yaml/volumes.yaml
  70. 1 1
      internal/integrations/ci/actions/actions.go
  71. 6 3
      internal/integrations/ci/actions/steps.go
  72. 0 2
      internal/kubernetes/agent.go
  73. 13 4
      internal/kubernetes/config.go
  74. 0 4
      internal/kubernetes/provisioner/global_stream.go
  75. 0 2
      internal/kubernetes/provisioner/resource_stream.go
  76. 13 12
      internal/models/integrations/integration.go
  77. 8 3
      internal/models/registry.go
  78. 252 0
      internal/registry/registry.go
  79. 24 12
      internal/repository/gorm/cluster.go
  80. 5 1213
      package-lock.json
  81. 0 3
      server/api/cluster_handler.go
  82. 2 2
      server/api/git_action_handler.go
  83. 0 2
      server/api/git_repo_handler.go
  84. 3 9
      server/api/integration_handler.go
  85. 0 5
      server/api/integration_handler_test.go
  86. 45 28
      server/api/registry_handler.go
  87. 0 3
      server/api/release_handler_test.go
  88. 19 0
      server/api/user_handler.go
  89. 1 3
      server/router/middleware/auth.go
  90. 10 0
      server/router/router.go

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

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

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

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

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

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

+ 8 - 5
.github/PULL_REQUEST_TEMPLATE.md

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

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

+ 1 - 2
.github/workflows/staging.yaml

@@ -43,5 +43,4 @@ jobs:
         gcloud container clusters get-credentials \
           staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
           
-        kubectl rollout restart deployment/porter
-    
+        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

+ 3 - 0
dashboard/.prettierignore

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

+ 1 - 0
dashboard/.prettierrc.json

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

Разница между файлами не показана из-за своего большого размера
+ 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
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;
 `;

+ 9 - 3
dashboard/src/components/image-selector/ImageList.tsx

@@ -26,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,
@@ -37,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 })
@@ -98,7 +99,12 @@ export default class ImageSelector extends Component<PropsType, StateType> {
                         loading: false,
                         error,
                       });
+                    } else {
+                      this.setState({
+                        images,
+                      });
                     }
+
                     resolveToNextController();
                   });
               }
@@ -247,7 +253,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
   }
 }
 
-ImageSelector.contextType = Context;
+ImageList.contextType = Context;
 
 const BackButton = styled.div`
   display: flex;
@@ -280,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;

+ 75 - 77
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 = {
@@ -38,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')}>

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

@@ -16,7 +16,7 @@ type PropsType = {
   setActionConfig: (x: ActionConfigType) => void;
   setBranch: (x: string) => void;
   setPath: (x: boolean) => void;
-  reset: () => void;
+  reset: any;
 };
 
 type StateType = {

+ 9 - 26
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -45,31 +45,6 @@ export default class ActionDetails extends Component<PropsType, StateType> {
   };
 
   renderConfirmation = () => {
-    var imageComponent
-
-    if (!this.props.actionConfig.image_repo_uri) {
-      imageComponent = <div>
-          <Label>Target Image URL</Label>
-          <ImageSelector
-            selectedTag="latest"
-            selectedImageUrl={this.props.actionConfig.image_repo_uri}
-            setSelectedImageUrl={this.setURL}
-            setSelectedTag={() => null}
-            forceExpanded={true}
-            noTagSelection={true}
-          />
-        </div>
-    } else {
-      imageComponent = <InputRow
-        disabled={true}
-        label="Target Image URL"
-        type="text"
-        width="100%"
-        value={this.props.actionConfig.image_repo_uri}
-        setValue={(x: string) => console.log(x)}
-      />
-    }
-
     return (
       <Holder>
         <InputRow
@@ -88,7 +63,15 @@ export default class ActionDetails extends Component<PropsType, StateType> {
           value={this.props.actionConfig.dockerfile_path}
           setValue={(x: string) => console.log(x)}
         />
-        {imageComponent}
+        <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>
     );
   };

+ 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 - 1
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]);
     }
@@ -368,7 +369,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     if (this.state.devOpsMode) {
       tabOptions.push(
         { label: "Manifests", value: "list" },
-        { label: "Raw Values", value: "values" }
+        { label: "Helm Values", value: "values" }
       );
     }
 

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -145,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 (
         <>
@@ -231,6 +235,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
   };
 
   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>
     );

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

+ 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 - 2
dashboard/src/main/home/launch/Launch.tsx

@@ -13,8 +13,8 @@ import hardcodedNames from "./hardcodedNameDict";
 import { Link } from "react-router-dom";
 
 const tabOptions = [
-  { label: "Launch service", value: "docker" },
-  { label: "Community Templates", value: "community" },
+  { label: "New Application", value: "docker" },
+  { label: "Community Add-ons", value: "community" },
 ];
 
 type PropsType = {};

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

@@ -129,7 +129,7 @@ const LoadingWrapper = styled.div`
 `;
 
 const StyledExpandedTemplate = styled.div`
-  width: calc(90% - 150px);
+  width: 100%;
   min-width: 300px;
   padding-top: 30px;
 `;

+ 160 - 97
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -103,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" });
@@ -131,8 +131,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
-        console.log("ST");
-        console.log(this.state.sourceType);
         if (this.state.sourceType === "repo") {
           this.createGHAction(name, this.state.selectedNamespace);
         }
@@ -148,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,
@@ -181,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
@@ -289,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) => {
@@ -297,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;
@@ -406,65 +428,67 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelectorContent = () => {
-    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, 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.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 />
+        </>
+      );
     }
   };
 
   renderSourceSelector = () => {
+    if (!this.props.form?.hasSource) {
+      return;
+    }
+
     return (
       <>
         <TabRegion
@@ -489,21 +513,44 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
     return (
       <StyledLaunchTemplate>
-        <ClusterSection>
-          {this.props.hideBackButton ? null : (
-            <Flex>
-              <i className="material-icons" onClick={this.props.hideLaunch}>
-                keyboard_backspace
-              </i>
-            </Flex>
-          )}
-          <Template>
+        {name !== "docker" && (
+          <HeaderSection>
+            <i className="material-icons" onClick={this.props.hideLaunch}>
+              keyboard_backspace
+            </i>
             {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>
@@ -535,26 +582,6 @@ 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.renderSettingsRegion()}
       </StyledLaunchTemplate>
@@ -564,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")};
@@ -604,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;
@@ -661,8 +723,9 @@ const ClusterSection = styled.div`
   color: #ffffff;
   font-family: "Work Sans", sans-serif;
   font-size: 14px;
+  margin-top: 2px;
   font-weight: 500;
-  margin-bottom: 15px;
+  margin-bottom: 22px;
 
   > i {
     font-size: 25px;

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

@@ -301,7 +301,7 @@ const TitleSection = styled.div`
   flex-direction: row;
   height: 40px;
   justify-content: space-between;
-  width: calc(100% + 42px);
+  width: 100%;
   align-items: center;
 `;
 

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

+ 39 - 0
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -4,6 +4,7 @@ import category from "assets/category.svg";
 import integrations from "assets/integrations.svg";
 import rocket from "assets/rocket.png";
 import settings from "assets/settings.svg";
+import discordLogo from "assets/discord.svg";
 
 import { Context } from "shared/Context";
 
@@ -187,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>
       </>
     );
@@ -197,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;
@@ -267,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())
   ];
-}
+};

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

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

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

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

+ 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(),

+ 6 - 3
internal/integrations/ci/actions/steps.go

@@ -1,6 +1,9 @@
 package actions
 
-import "fmt"
+import (
+	"fmt"
+	"path/filepath"
+)
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
 	return GithubActionYAMLStep{
@@ -41,7 +44,7 @@ func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
 }
 
 const dockerBuildPush string = `
-docker build . --file %s -t %s:$(git rev-parse --short HEAD)
+docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
 docker push %s:$(git rev-parse --short HEAD)
 `
 
@@ -49,7 +52,7 @@ 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),
 	}
 }
 

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

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

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

+ 8 - 3
internal/models/registry.go

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

+ 252 - 0
internal/registry/registry.go

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

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

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

+ 5 - 1213
package-lock.json

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

+ 0 - 3
server/api/cluster_handler.go

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

+ 2 - 2
server/api/git_action_handler.go

@@ -121,7 +121,7 @@ func (app *App) createGitActionFromForm(
 	})
 
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		app.handleErrorInternal(err, w)
 		return nil
 	}
 
@@ -143,7 +143,7 @@ func (app *App) createGitActionFromForm(
 	_, err = gaRunner.Setup()
 
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		app.handleErrorInternal(err, w)
 		return nil
 	}
 

+ 0 - 2
server/api/git_repo_handler.go

@@ -134,7 +134,6 @@ func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 	// List all branches for a specified repo
 	branches, _, err := client.Repositories.ListBranches(context.Background(), owner, name, nil)
 	if err != nil {
-		fmt.Println(err)
 		return
 	}
 
@@ -185,7 +184,6 @@ func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request)
 
 	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
 	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
-	// fmt.Println(res)
 	json.NewEncoder(w).Encode(res)
 }
 

+ 3 - 9
server/api/integration_handler.go

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

+ 0 - 5
server/api/integration_handler_test.go

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

+ 45 - 28
server/api/registry_handler.go

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

+ 0 - 3
server/api/release_handler_test.go

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

+ 19 - 0
server/api/user_handler.go

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

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

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

+ 10 - 0
server/router/router.go

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

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