Explorar el Código

Merge branch 'master' into belanger/api-tokens-refactor-2

Mohammed Nafees hace 4 años
padre
commit
341c335f1c
Se han modificado 100 ficheros con 3993 adiciones y 1237 borrados
  1. 7 3
      .github/workflows/dev.yaml
  2. 51 5
      .github/workflows/prerelease.yaml
  3. 10 5
      .github/workflows/production.yaml
  4. 9 4
      .github/workflows/staging.yaml
  5. 3 4
      api/client/environment.go
  6. 19 0
      api/client/registry.go
  7. 19 1
      api/server/handlers/infra/create.go
  8. 221 0
      api/server/handlers/infra/forms.go
  9. 4 0
      api/server/handlers/infra/get_template.go
  10. 16 0
      api/server/handlers/infra/list_templates.go
  11. 52 0
      api/server/handlers/project/create_tag.go
  12. 1 1
      api/server/handlers/project/list.go
  13. 37 0
      api/server/handlers/project/list_tags.go
  14. 66 0
      api/server/handlers/project_integration/create_azure.go
  15. 44 0
      api/server/handlers/project_integration/list_azure.go
  16. 37 0
      api/server/handlers/registry/create.go
  17. 1 1
      api/server/handlers/registry/create_repository.go
  18. 54 0
      api/server/handlers/registry/get_token.go
  19. 43 0
      api/server/handlers/release/create.go
  20. 3 1
      api/server/handlers/release/update_rollback.go
  21. 88 0
      api/server/handlers/release/update_tags.go
  22. 57 12
      api/server/handlers/webhook/github_incoming.go
  23. 83 0
      api/server/router/project.go
  24. 55 0
      api/server/router/project_integration.go
  25. 32 0
      api/server/router/release.go
  26. 1 0
      api/server/shared/config/env/envconfs.go
  27. 1 0
      api/server/shared/config/loader/loader.go
  28. 1 0
      api/types/cluster.go
  29. 9 3
      api/types/infra.go
  30. 34 0
      api/types/project_integration.go
  31. 9 0
      api/types/registry.go
  32. 7 0
      api/types/release.go
  33. 6 0
      api/types/tag.go
  34. 1 1
      build/Dockerfile.osx
  35. 1 1
      build/Dockerfile.win
  36. 2 1
      cli/cmd/apply.go
  37. 10 25
      cli/cmd/delete.go
  38. 3 1
      cli/cmd/deploy/create.go
  39. 30 0
      cli/cmd/docker/auth.go
  40. 1 1
      cli/cmd/pack/pack.go
  41. 4 2
      cli/cmd/preview/build_image_driver.go
  42. 44 0
      cli/cmd/preview/os_env_driver.go
  43. 74 0
      cli/cmd/preview/push_image_driver.go
  44. 50 3
      cli/cmd/preview/update_config_driver.go
  45. 127 3
      dashboard/package-lock.json
  46. 6 0
      dashboard/package.json
  47. 279 0
      dashboard/src/assets/devicons-name-list.ts
  48. 12 7
      dashboard/src/components/ProvisionerStatus.tsx
  49. 228 0
      dashboard/src/components/SearchSelector.tsx
  50. 31 67
      dashboard/src/components/form-components/KeyValueArray.tsx
  51. 64 5
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  52. 5 2
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  53. 204 130
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  54. 7 0
      dashboard/src/components/porter-form/types.ts
  55. 10 9
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  56. 123 176
      dashboard/src/hosted.index.html
  57. 6 0
      dashboard/src/index.tsx
  58. 1 6
      dashboard/src/main/Main.tsx
  59. 49 59
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  60. 2 0
      dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx
  61. 71 0
      dashboard/src/main/home/cluster-dashboard/TagFilter.tsx
  62. 12 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  63. 33 34
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  64. 0 49
      dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts
  65. 9 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  66. 16 36
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  67. 46 39
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  68. 45 29
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  69. 349 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/TagSelector.tsx
  70. 7 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  71. 23 53
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  72. 0 195
      dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx
  73. 53 18
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  74. 101 27
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  75. 2 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  76. 10 8
      dashboard/src/main/home/infrastructure/ExpandedInfra.tsx
  77. 4 1
      dashboard/src/main/home/infrastructure/components/DeployList.tsx
  78. 45 15
      dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx
  79. 41 23
      dashboard/src/main/home/infrastructure/components/InfraSettings.tsx
  80. 17 0
      dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx
  81. 140 0
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx
  82. 110 0
      dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialList.tsx
  83. 20 26
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  84. 16 6
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  85. 24 13
      dashboard/src/main/home/onboarding/Routes.tsx
  86. 45 59
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  87. 4 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  88. 4 1
      dashboard/src/main/home/sidebar/Drawer.tsx
  89. 16 4
      dashboard/src/main/home/sidebar/Sidebar.tsx
  90. 51 5
      dashboard/src/shared/api.tsx
  91. 7 0
      dashboard/src/shared/baseApi.ts
  92. 12 1
      dashboard/src/shared/common.tsx
  93. 65 0
      dashboard/src/shared/error_handling/logger.ts
  94. 24 1
      dashboard/src/shared/error_handling/sentry/setup.ts
  95. 35 25
      dashboard/src/shared/hooks/useChart.ts
  96. 7 0
      dashboard/src/shared/release/utils.ts
  97. 42 0
      dashboard/src/shared/string_utils.ts
  98. 18 0
      dashboard/src/shared/types.tsx
  99. 13 2
      dashboard/webpack.config.js
  100. 2 1
      docker/Dockerfile

+ 7 - 3
.github/workflows/dev.yaml

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -37,7 +39,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=development
+          SENTRY_ENV=frontend-development
           EOL
       - name: Build
         run: |
@@ -66,7 +68,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout

+ 51 - 5
.github/workflows/prerelease.yaml

@@ -4,7 +4,7 @@ on:
       - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
 name: Create prerelease w/ binaries and docker image
 jobs:
-  docker-build-push:
+  build-push-porter:
     runs-on: ubuntu-latest
     steps:
       - name: Get tag name
@@ -36,6 +36,48 @@ jobs:
       - name: Push
         run: |
           docker push porter1/porter:${{steps.tag_name.outputs.tag}}
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Push to ECR public
+        run: |
+          docker tag porter1/porter:${{steps.tag_name.outputs.tag}} public.ecr.aws/o1j4x7p4/porter:${{steps.tag_name.outputs.tag}}
+          docker push public.ecr.aws/o1j4x7p4/porter:${{steps.tag_name.outputs.tag}}
+  build-push-provisioner:
+    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: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t public.ecr.aws/o1j4x7p4/provisioner-service:${{steps.tag_name.outputs.tag}} -f ./ee/docker/provisioner.Dockerfile
+      - name: Push to ECR public
+        run: |
+          docker push public.ecr.aws/o1j4x7p4/provisioner-service:${{steps.tag_name.outputs.tag}}
   build-linux:
     name: Build Linux binaries
     runs-on: ubuntu-latest
@@ -52,7 +94,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v2
         with:
-          go-version: 1.17
+          go-version: 1.18
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL
@@ -119,7 +161,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v2
         with:
-          go-version: 1.17
+          go-version: 1.18
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL
@@ -251,8 +293,8 @@ jobs:
     name: Zip binaries, create release and upload assets
     runs-on: ubuntu-latest
     needs:
-    - notarize
-    - build-linux
+      - notarize
+      - build-linux
     steps:
       - name: Get tag name
         id: tag_name
@@ -517,3 +559,7 @@ jobs:
         run: gh workflow run porter_test_docker_production.yml --repo porter-dev/new-release-tests
         env:
           GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}
+      - name: Run test_porter_cli.yml workflow
+        run: gh workflow run test_porter_cli.yml --repo porter-dev/new-release-tests
+        env:
+          GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}

+ 10 - 5
.github/workflows/production.yaml

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -35,7 +37,8 @@ jobs:
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           IS_HOSTED=true
-          COHERE_KEY=${{secrets.COHERE_KEY}}
+          ENABLE_COHERE=true
+          COHERE_API_KEY=${{secrets.COHERE_KEY}}
           INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
           INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
           SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
@@ -44,7 +47,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=production
+          SENTRY_ENV=frontend-production
           EOL
       - name: Build
         run: |
@@ -73,7 +76,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -88,4 +93,4 @@ jobs:
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
             
-          kubectl rollout restart deployment/provisioner
+          kubectl rollout restart deployment/provisioner

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

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -34,7 +36,8 @@ jobs:
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           IS_HOSTED=true
-          COHERE_KEY=${{secrets.COHERE_KEY}}
+          ENABLE_COHERE=true
+          COHERE_API_KEY=${{secrets.COHERE_KEY}}
           INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
           INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
           SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
@@ -43,7 +46,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=staging
+          SENTRY_ENV=frontend-staging
           EOL
       - name: Build
         run: |
@@ -72,7 +75,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout

+ 3 - 4
api/client/environment.go

@@ -109,13 +109,12 @@ func (c *Client) FinalizeDeployment(
 
 func (c *Client) DeleteDeployment(
 	ctx context.Context,
-	projID, clusterID uint,
-	envID, gitRepoOwner, gitRepoName, prNumber string,
+	projID, clusterID, deploymentID uint,
 ) error {
 	return c.deleteRequest(
 		fmt.Sprintf(
-			"/projects/%d/clusters/%d/deployments/%s/%s/%s/%s",
-			projID, clusterID, envID, gitRepoOwner, gitRepoName, prNumber,
+			"/projects/%d/clusters/%d/deployments/%d",
+			projID, clusterID, deploymentID,
 		),
 		nil, nil,
 	)

+ 19 - 0
api/client/registry.go

@@ -123,6 +123,25 @@ func (c *Client) GetGCRAuthorizationToken(
 	return resp, err
 }
 
+// GetACRAuthorizationToken gets a ACR authorization token
+func (c *Client) GetACRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/acr/token",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
 // GetDockerhubAuthorizationToken gets a Docker Hub authorization token
 func (c *Client) GetDockerhubAuthorizationToken(
 	ctx context.Context,

+ 19 - 1
api/server/handlers/infra/create.go

@@ -148,6 +148,7 @@ func checkInfraCredentials(config *config.Config, proj *models.Project, infra *m
 		infra.DOIntegrationID = req.DOIntegrationID
 		infra.AWSIntegrationID = 0
 		infra.GCPIntegrationID = 0
+		infra.AzureIntegrationID = 0
 	} else if req.AWSIntegrationID != 0 {
 		_, err := config.Repo.AWSIntegration().ReadAWSIntegration(proj.ID, req.AWSIntegrationID)
 
@@ -158,6 +159,7 @@ func checkInfraCredentials(config *config.Config, proj *models.Project, infra *m
 		infra.DOIntegrationID = 0
 		infra.AWSIntegrationID = req.AWSIntegrationID
 		infra.GCPIntegrationID = 0
+		infra.AzureIntegrationID = 0
 	} else if req.GCPIntegrationID != 0 {
 		_, err := config.Repo.GCPIntegration().ReadGCPIntegration(proj.ID, req.GCPIntegrationID)
 
@@ -168,9 +170,21 @@ func checkInfraCredentials(config *config.Config, proj *models.Project, infra *m
 		infra.DOIntegrationID = 0
 		infra.AWSIntegrationID = 0
 		infra.GCPIntegrationID = req.GCPIntegrationID
+		infra.AzureIntegrationID = 0
+	} else if req.AzureIntegrationID != 0 {
+		_, err := config.Repo.AzureIntegration().ReadAzureIntegration(proj.ID, req.AzureIntegrationID)
+
+		if err != nil {
+			return fmt.Errorf("azure integration id %d not found in project %d", req.AzureIntegrationID, proj.ID)
+		}
+
+		infra.DOIntegrationID = 0
+		infra.AWSIntegrationID = 0
+		infra.GCPIntegrationID = 0
+		infra.AzureIntegrationID = req.AzureIntegrationID
 	}
 
-	if infra.DOIntegrationID == 0 && infra.AWSIntegrationID == 0 && infra.GCPIntegrationID == 0 {
+	if infra.DOIntegrationID == 0 && infra.AWSIntegrationID == 0 && infra.GCPIntegrationID == 0 && infra.AzureIntegrationID == 0 {
 		return fmt.Errorf("at least one integration id must be set")
 	}
 
@@ -195,6 +209,10 @@ func getSourceLinkAndVersion(kind types.InfraKind) (string, string) {
 		return "porter/do/docr", "v0.1.0"
 	case types.InfraDOKS:
 		return "porter/do/doks", "v0.1.0"
+	case types.InfraAKS:
+		return "porter/azure/aks", "v0.1.0"
+	case types.InfraACR:
+		return "porter/azure/acr", "v0.1.0"
 	}
 
 	return "porter/test", "v0.1.0"

+ 221 - 0
api/server/handlers/infra/forms.go

@@ -361,16 +361,22 @@ tabs:
         options:
         - label: t2.medium
           value: t2.medium
+        - label: t2.large
+          value: t2.large
         - label: t2.xlarge
           value: t2.xlarge
         - label: t2.2xlarge
           value: t2.2xlarge
         - label: t3.medium
           value: t3.medium
+        - label: t3.large
+          value: t3.large
         - label: t3.xlarge
           value: t3.xlarge
         - label: t3.2xlarge
           value: t3.2xlarge
+        - label: c6i.2xlarge
+          value: c6i.2xlarge
     - type: string-input
       label: 👤 Issuer Email
       required: true
@@ -381,12 +387,121 @@ tabs:
       required: true
       placeholder: my-cluster
       variable: cluster_name
+    - type: number-input
+      label: Minimum number of EC2 instances to create in the application autoscaling group.
+      variable: min_instances
+      placeholder: "ex: 1"
+      settings:
+        default: 1
     - type: number-input
       label: Maximum number of EC2 instances to create in the application autoscaling group.
       variable: max_instances
       placeholder: "ex: 10"
       settings:
         default: 10
+- name: additional_nodegroup
+  label: Additional Node Groups
+  sections:
+  - name: is_additional_enabled
+    contents:
+    - type: heading
+      label: Additional Node Groups
+    - type: checkbox
+      variable: additional_nodegroup_enabled
+      label: Enable an additional node group for this cluster.
+      settings:
+        default: false
+  - name: additional_settings
+    show_if: additional_nodegroup_enabled
+    contents:
+    - type: string-input
+      label: Label for this node group.
+      variable: additional_nodegroup_label
+      placeholder: "ex: porter.run/workload-kind=job"
+      settings:
+        default: porter.run/workload-kind=database
+    - type: string-input
+      label: Taint for this node group.
+      variable: additional_nodegroup_taint
+      placeholder: "ex: porter.run/workload-kind=job:NoSchedule"
+      settings:
+        default: porter.run/workload-kind=database:NoSchedule
+    - type: checkbox
+      variable: additional_stateful_nodegroup_enabled
+      label: Stateful Workload
+      settings:
+        default: false
+    - type: select
+      label: ⚙️ AWS System Machine Type
+      variable: additional_nodegroup_machine_type
+      settings:
+        default: t2.medium
+        options:
+        - label: t2.medium
+          value: t2.medium
+        - label: t2.large
+          value: t2.large
+        - label: t2.xlarge
+          value: t2.xlarge
+        - label: t2.2xlarge
+          value: t2.2xlarge
+        - label: t3.medium
+          value: t3.medium
+        - label: t3.large
+          value: t3.large
+        - label: t3.xlarge
+          value: t3.xlarge
+        - label: t3.2xlarge
+          value: t3.2xlarge
+        - label: c6i.2xlarge
+          value: c6i.2xlarge
+    - type: number-input
+      label: Minimum number of EC2 instances to create in the application autoscaling group.
+      variable: additional_nodegroup_min_instances
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: number-input
+      label: Maximum number of EC2 instances to create in the application autoscaling group.
+      variable: additional_nodegroup_max_instances
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+- name: advanced
+  label: Advanced
+  sections:
+  - name: system_machine_type
+    contents:
+    - type: heading
+      label: System Machine Type Settings
+    - type: select
+      label: ⚙️ AWS System Machine Type
+      variable: system_machine_type
+      settings:
+        default: t2.medium
+        options:
+        - label: t2.medium
+          value: t2.medium
+        - label: t2.large
+          value: t2.large
+        - label: t2.xlarge
+          value: t2.xlarge
+        - label: t2.2xlarge
+          value: t2.2xlarge
+        - label: t3.medium
+          value: t3.medium
+        - label: t3.large
+          value: t3.large
+        - label: t3.xlarge
+          value: t3.xlarge
+        - label: t3.2xlarge
+          value: t3.2xlarge
+        - label: c6i.2xlarge
+          value: c6i.2xlarge
+  - name: spot_instance_should_enable
+    contents:
+    - type: heading
+      label: Spot Instance Settings
     - type: checkbox
       variable: spot_instances_enabled
       label: Enable spot instances for this cluster.
@@ -399,6 +514,25 @@ tabs:
       label: Assign a bid price for the spot instance (optional).
       variable: spot_price
       placeholder: "ex: 0.05"
+  - name: net_settings
+    contents:
+    - type: heading
+      label: Networking Settings
+    - type: string-input
+      label: "Add a different CIDR range prefix (first two octets: for example 10.99 will create a VPC with CIDR range 10.99.0.0/16)."
+      variable: cluster_vpc_cidr_octets
+      placeholder: "ex: 10.99"
+      settings:
+        default: "10.99"
+  - name: nginx_settings
+    contents:
+    - type: heading
+      label: NGINX Settings
+    - type: checkbox
+      variable: disable_nginx_load_balancer
+      label: Disable NGINX load balancer and expose NGINX only on a cluster IP address.
+      settings:
+        default: false
 `
 
 const gcrForm = `name: GCR
@@ -629,3 +763,90 @@ tabs:
       placeholder: my-cluster
       variable: cluster_name
 `
+
+const acrForm = `name: ACR
+hasSource: false
+includeHiddenFields: true
+isClusterScoped: false
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: ACR Configuration
+    - type: select
+      label: 📍 Azure Region
+      variable: aks_region
+      settings:
+        default: East US
+        options:
+        - label: East US
+          value: East US
+        - label: East US 2
+          value: East US 2
+        - label: West US 2
+          value: West US 2
+        - label: West US 3
+          value: West US 3
+        - label: Norway East
+          value: Norway East
+    - type: string-input
+      label: ACR Name
+      required: true
+      placeholder: my-registry
+      variable: acr_name
+`
+
+const aksForm = `name: AKS
+hasSource: false
+includeHiddenFields: true
+isClusterScoped: false
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: AKS Configuration
+    - type: select
+      label: 📍 Azure Region
+      variable: aks_region
+      settings:
+        default: East US
+        options:
+        - label: East US
+          value: East US
+        - label: East US 2
+          value: East US 2
+        - label: West US 2
+          value: West US 2
+        - label: West US 3
+          value: West US 3
+        - label: Norway East
+          value: Norway East
+    - type: select
+      label: ⚙️ Application Machine Type
+      variable: app_machine_type
+      settings:
+        default: Standard_A2_v2
+        options:
+        - label: Standard A2
+          value: Standard_A2_v2
+        - label: Standard A4
+          value: Standard_A4_v2
+        - label: Standard D2
+          value: Standard_D2_v3
+    - type: string-input
+      label: 👤 Issuer Email
+      required: true
+      placeholder: example@example.com
+      variable: issuer_email
+    - type: string-input
+      label: AKS Cluster Name
+      required: true
+      placeholder: my-cluster
+      variable: cluster_name
+`

+ 4 - 0
api/server/handlers/infra/get_template.go

@@ -77,6 +77,10 @@ func getFormBytesFromKind(kind string) []byte {
 		formBytes = []byte(docrForm)
 	case "doks":
 		formBytes = []byte(doksForm)
+	case "aks":
+		formBytes = []byte(aksForm)
+	case "acr":
+		formBytes = []byte(acrForm)
 	}
 
 	return formBytes

+ 16 - 0
api/server/handlers/infra/list_templates.go

@@ -97,4 +97,20 @@ var templateMap = map[string]*types.InfraTemplateMeta{
 		Kind:               "doks",
 		RequiredCredential: "do_integration_id",
 	},
+	"acr": {
+		Icon:               "",
+		Description:        "Create an Azure Container Registry.",
+		Name:               "ACR",
+		Version:            "v0.1.0",
+		Kind:               "acr",
+		RequiredCredential: "azure_integration_id",
+	},
+	"aks": {
+		Icon:               "",
+		Description:        "Create an Azure Kubernetes Service cluster",
+		Name:               "AKS",
+		Version:            "v0.1.0",
+		Kind:               "aks",
+		RequiredCredential: "azure_integration_id",
+	},
 }

+ 52 - 0
api/server/handlers/project/create_tag.go

@@ -0,0 +1,52 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateTagHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateTagHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateTagHandler {
+	return &CreateTagHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	newTag := &types.CreateTagRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, newTag); !ok {
+		return
+	}
+
+	tag, err := c.Repo().Tag().CreateTag(&models.Tag{
+		Name:      newTag.Name,
+		Color:     newTag.Color,
+		ProjectID: project.ID,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, tag)
+}

+ 1 - 1
api/server/handlers/project/list.go

@@ -29,7 +29,7 @@ func (p *ProjectListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 
 	// read all projects for this user
-	projects, err := p.Config().Repo.Project().ListProjectsByUserID(user.ID)
+	projects, err := p.Repo().Project().ListProjectsByUserID(user.ID)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 37 - 0
api/server/handlers/project/list_tags.go

@@ -0,0 +1,37 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetTagsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewGetTagsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetTagsHandler {
+	return &GetTagsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *GetTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	tags, err := p.Repo().Tag().ListTagsByProjectId(proj.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	p.WriteResult(w, r, tags)
+}

+ 66 - 0
api/server/handlers/project_integration/create_azure.go

@@ -0,0 +1,66 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type CreateAzureHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateAzureHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateAzureHandler {
+	return &CreateAzureHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreateAzureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateAzureRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	az := CreateAzureIntegration(request, project.ID, user.ID)
+
+	az, err := p.Repo().AzureIntegration().CreateAzureIntegration(az)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateAzureResponse{
+		AzureIntegration: az.ToAzureIntegrationType(),
+	}
+
+	p.WriteResult(w, r, res)
+}
+
+func CreateAzureIntegration(request *types.CreateAzureRequest, projectID, userID uint) *ints.AzureIntegration {
+	resp := &ints.AzureIntegration{
+		UserID:                 userID,
+		ProjectID:              projectID,
+		AzureClientID:          request.AzureClientID,
+		AzureSubscriptionID:    request.AzureSubscriptionID,
+		AzureTenantID:          request.AzureTenantID,
+		ServicePrincipalSecret: []byte(request.ServicePrincipalKey),
+	}
+
+	return resp
+}

+ 44 - 0
api/server/handlers/project_integration/list_azure.go

@@ -0,0 +1,44 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListAzureHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListAzureHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListAzureHandler {
+	return &ListAzureHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ListAzureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	azInts, err := p.Repo().AzureIntegration().ListAzureIntegrationsByProjectID(project.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListAzureResponse = make([]*types.AzureIntegration, 0)
+
+	for _, azInt := range azInts {
+		res = append(res, azInt.ToAzureIntegrationType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 37 - 0
api/server/handlers/registry/create.go

@@ -1,6 +1,7 @@
 package registry
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -57,6 +58,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		AWSIntegrationID:   request.AWSIntegrationID,
 		DOIntegrationID:    request.DOIntegrationID,
 		BasicIntegrationID: request.BasicIntegrationID,
+		AzureIntegrationID: request.AzureIntegrationID,
 	}
 
 	if regModel.URL == "" && regModel.AWSIntegrationID != 0 {
@@ -68,6 +70,41 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 
 		regModel.URL = url
+	} else if request.AzureIntegrationID != 0 {
+		// if azure integration id is non-zero check that resource group name and repo name are set
+		if request.ACRName == "" {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("acr_name must be set if azure_integration_id is not 0"),
+				http.StatusBadRequest,
+			))
+
+			return
+		} else if request.ACRResourceGroupName == "" {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("acr_resource_group_name must be set if azure_integration_id is not 0"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		// get the azure integration and overwrite the names
+		az, err := p.Repo().AzureIntegration().ReadAzureIntegration(proj.ID, request.AzureIntegrationID)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		az.ACRName = request.ACRName
+		az.ACRResourceGroupName = request.ACRResourceGroupName
+
+		az, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	// handle write to the database

+ 1 - 1
api/server/handlers/registry/create_repository.go

@@ -43,7 +43,7 @@ func (p *RegistryCreateRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 	// parse the name from the registry
 	nameSpl := strings.Split(request.ImageRepoURI, "/")
-	repoName := strings.ReplaceAll(nameSpl[len(nameSpl)-1], "_", "-")
+	repoName := strings.ToLower(strings.ReplaceAll(nameSpl[len(nameSpl)-1], "_", "-"))
 
 	err := regAPI.CreateRepository(p.Repo(), repoName)
 

+ 54 - 0
api/server/handlers/registry/get_token.go

@@ -293,3 +293,57 @@ func (c *RegistryGetDockerhubTokenHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 	c.WriteResult(w, r, resp)
 }
+
+type RegistryGetACRTokenHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRegistryGetACRTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RegistryGetACRTokenHandler {
+	return &RegistryGetACRTokenHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RegistryGetACRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// list registries and find one that matches the region
+	regs, err := c.Repo().Registry().ListRegistriesByProjectID(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var token string
+	var expiresAt *time.Time
+
+	for _, reg := range regs {
+		if reg.AzureIntegrationID != 0 && strings.Contains(reg.URL, "azurecr.io") {
+			_reg := registry.Registry(*reg)
+
+			username, pw, err := _reg.GetACRCredentials(c.Repo())
+
+			if err != nil {
+				continue
+			}
+
+			token = base64.StdEncoding.EncodeToString([]byte(string(username) + ":" + string(pw)))
+
+			// 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 := &types.GetRegistryTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 43 - 0
api/server/handlers/release/create.go

@@ -25,6 +25,7 @@ import (
 	"golang.org/x/crypto/bcrypt"
 	"gopkg.in/yaml.v2"
 	"helm.sh/helm/v3/pkg/release"
+	v1 "k8s.io/api/core/v1"
 )
 
 type CreateReleaseHandler struct {
@@ -113,6 +114,29 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	k8sAgent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMaps := make([]*v1.ConfigMap, 0)
+
+	if request.SyncedEnvGroups != nil && len(request.SyncedEnvGroups) > 0 {
+		for _, envGroupName := range request.SyncedEnvGroups {
+			// read the attached configmap
+			cm, _, err := k8sAgent.GetLatestVersionedConfigMap(envGroupName, namespace)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Couldn't find the env group"), http.StatusNotFound))
+				return
+			}
+
+			configMaps = append(configMaps, cm)
+		}
+	}
+
 	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
 
 	if err != nil {
@@ -120,6 +144,25 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	if len(configMaps) > 0 {
+		for _, cm := range configMaps {
+
+			_, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, release.Name)
+
+			if err != nil {
+				c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf("Couldn't add %s to the config map %s", release.Name, cm.Name)))
+			}
+		}
+	}
+
+	if request.Tags != nil {
+		tags, err := c.Repo().Tag().LinkTagsToRelease(request.Tags, release)
+
+		if err == nil {
+			release.Tags = append(release.Tags, tags...)
+		}
+	}
+
 	if request.GithubActionConfig != nil {
 		_, _, err := createGitAction(
 			c.Config(),

+ 3 - 1
api/server/handlers/release/update_rollback.go

@@ -118,7 +118,9 @@ func updateReleaseRepo(config *config.Config, release *models.Release, helmRelea
 		return fmt.Errorf("Could not find field repository in config")
 	}
 
-	if repoStr != release.ImageRepoURI {
+	if repoStr != release.ImageRepoURI &&
+		repoStr != "public.ecr.aws/o1j4x7p4/hello-porter" &&
+		repoStr != "public.ecr.aws/o1j4x7p4/hello-porter-job" {
 		release.ImageRepoURI = repoStr
 		_, err := config.Repo.Release().UpdateRelease(release)
 

+ 88 - 0
api/server/handlers/release/update_tags.go

@@ -0,0 +1,88 @@
+package release
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UpdateReleaseTagsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateReleaseTagsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateReleaseTagsHandler {
+	return &UpdateReleaseTagsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateReleaseTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.PatchUpdateReleaseTags{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	tagsToDelete := difference(release.ToReleaseType().Tags, request.Tags)
+
+	err = c.Repo().Tag().UnlinkTagsFromRelease(tagsToDelete, release)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = c.Repo().Tag().LinkTagsToRelease(request.Tags, release)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	release, err = c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, release)
+}
+
+func difference(a, b []string) []string {
+	mb := make(map[string]struct{}, len(b))
+	for _, x := range b {
+		mb[x] = struct{}{}
+	}
+	var diff []string
+	for _, x := range a {
+		if _, found := mb[x]; !found {
+			diff = append(diff, x)
+		}
+	}
+	return diff
+}

+ 57 - 12
api/server/handlers/webhook/github_incoming.go

@@ -1,9 +1,11 @@
 package webhook
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
@@ -126,18 +128,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					return err
 				}
 			} else {
-				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
-					r.Context(), owner, repo, fmt.Sprintf("porter_%s_delete_env.yml", env.Name),
-					github.CreateWorkflowDispatchEventRequest{
-						Ref: event.PullRequest.GetHead().GetRef(),
-						Inputs: map[string]interface{}{
-							"environment_id": strconv.FormatUint(uint64(depl.EnvironmentID), 10),
-							"repo_owner":     owner,
-							"repo_name":      repo,
-							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
-						},
-					},
-				)
+				err = c.deleteDeployment(r, depl, env, client)
 
 				if err != nil {
 					return err
@@ -149,6 +140,60 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	return nil
 }
 
+func (c *GithubIncomingWebhookHandler) deleteDeployment(
+	r *http.Request,
+	depl *models.Deployment,
+	env *models.Environment,
+	client *github.Client,
+) error {
+	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
+
+	if err != nil {
+		return err
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		return err
+	}
+
+	// make sure we don't delete default or kube-system by checking for prefix, for now
+	if strings.Contains(depl.Namespace, "pr-") {
+		err = agent.DeleteNamespace(depl.Namespace)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// Create new deployment status to indicate deployment is ready
+	state := "inactive"
+
+	deploymentStatusRequest := github.DeploymentStatusRequest{
+		State: &state,
+	}
+
+	client.Repositories.CreateDeploymentStatus(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		depl.GHDeploymentID,
+		&deploymentStatusRequest,
+	)
+
+	depl.Status = types.DeploymentStatusInactive
+
+	// update the deployment to mark it inactive
+	_, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

+ 83 - 0
api/server/router/project.go

@@ -609,6 +609,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	//  GET /api/projects/{project_id}/registries/acr/token -> registry.NewRegistryGetACRTokenHandler
+	getACRTokenEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/registries/acr/token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getACRTokenHandler := registry.NewRegistryGetACRTokenHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getACRTokenEndpoint,
+		Handler:  getACRTokenHandler,
+		Router:   r,
+	})
+
 	//  GET /api/projects/{project_id}/registries/dockerhub/token -> registry.NewRegistryGetDockerhubTokenHandler
 	getDockerhubTokenEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -1151,5 +1179,60 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/tags -> project.NewGetTagsHandler
+	getTagsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getTagsHandler := project.NewGetTagsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getTagsEndpoint,
+		Handler:  getTagsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/tags -> project.NewCreateTagHandler
+	createTagEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createTagHandler := project.NewCreateTagHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createTagEndpoint,
+		Handler:  createTagHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 55 - 0
api/server/router/project_integration.go

@@ -217,6 +217,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/integrations/azure -> project_integration.NewListAzureHandler
+	listAzureEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/azure",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listAzureHandler := project_integration.NewListAzureHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listAzureEndpoint,
+		Handler:  listAzureHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/integrations/gcp -> project_integration.NewCreateGCPHandler
 	createGCPEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -272,5 +299,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/integrations/azure -> project_integration.NewCreateAzureHandler
+	createAzureEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/azure",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createAzureHandler := project_integration.NewCreateAzureHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createAzureEndpoint,
+		Handler:  createAzureHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 32 - 0
api/server/router/release.go

@@ -782,5 +782,37 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/update_tags ->
+	// release.NewGetLatestJobRunHandler
+	updateReleaseTagsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/update_tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	updateReleaseTagsHandler := release.NewUpdateReleaseTagsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateReleaseTagsEndpoint,
+		Handler:  updateReleaseTagsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 0
api/server/shared/config/env/envconfs.go

@@ -20,6 +20,7 @@ type ServerConf struct {
 	StaticFilePath       string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	CookieName           string        `env:"COOKIE_NAME,default=porter"`
 	CookieSecrets        []string      `env:"COOKIE_SECRETS,default=random_hash_key_;random_block_key"`
+	CookieInsecure       bool          `env:"COOKIE_INSECURE,default=false"`
 	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"`

+ 1 - 0
api/server/shared/config/loader/loader.go

@@ -91,6 +91,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		&sessionstore.NewStoreOpts{
 			SessionRepository: res.Repo.Session(),
 			CookieSecrets:     envConf.ServerConf.CookieSecrets,
+			Insecure:          envConf.ServerConf.CookieInsecure,
 		},
 	)
 

+ 1 - 0
api/types/cluster.go

@@ -167,6 +167,7 @@ const (
 	DOKS ClusterService = "doks"
 	GKE  ClusterService = "gke"
 	Kube ClusterService = "kube"
+	AKS  ClusterService = "aks"
 )
 
 // ClusterResolverName is the name for a cluster resolve

+ 9 - 3
api/types/infra.go

@@ -26,6 +26,8 @@ const (
 	InfraGKE  InfraKind = "gke"
 	InfraDOCR InfraKind = "docr"
 	InfraDOKS InfraKind = "doks"
+	InfraAKS  InfraKind = "aks"
+	InfraACR  InfraKind = "acr"
 
 	InfraRDS InfraKind = "rds"
 )
@@ -59,6 +61,9 @@ type Infra struct {
 	// this points to an OAuthIntegrationID
 	DOIntegrationID uint `json:"do_integration_id,omitempty"`
 
+	// The Azure integration that was used to create the infra
+	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
+
 	// The last-applied, non-sensitive input variables to the provisioner. For now,
 	// this is a map[string]string since we marshal into env vars anyway, but
 	// eventually this config will be more complex.
@@ -70,9 +75,10 @@ type Infra struct {
 }
 
 type InfraCredentials struct {
-	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
-	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
-	DOIntegrationID  uint `json:"do_integration_id,omitempty"`
+	AWSIntegrationID   uint `json:"aws_integration_id,omitempty"`
+	GCPIntegrationID   uint `json:"gcp_integration_id,omitempty"`
+	DOIntegrationID    uint `json:"do_integration_id,omitempty"`
+	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
 }
 
 type CreateInfraRequest struct {

+ 34 - 0
api/types/project_integration.go

@@ -125,3 +125,37 @@ type CreateGCPRequest struct {
 type CreateGCPResponse struct {
 	*GCPIntegration
 }
+
+type AzureIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+
+	ID uint `json:"id"`
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The Azure client ID that this is linked to
+	AzureClientID string `json:"azure_client_id"`
+
+	// The Azure subscription ID that this is linked to
+	AzureSubscriptionID string `json:"azure_subscription_id"`
+
+	// The Azure tenant ID that this is linked to
+	AzureTenantID string `json:"azure_tenant_id"`
+}
+
+type CreateAzureRequest struct {
+	AzureClientID       string `json:"azure_client_id" form:"required"`
+	AzureSubscriptionID string `json:"azure_subscription_id" form:"required"`
+	AzureTenantID       string `json:"azure_tenant_id" form:"required"`
+	ServicePrincipalKey string `json:"service_principal_key" form:"required"`
+}
+
+type CreateAzureResponse struct {
+	*AzureIntegration
+}
+
+type ListAzureResponse []*AzureIntegration

+ 9 - 0
api/types/registry.go

@@ -27,6 +27,9 @@ type Registry struct {
 	// The AWS integration that was used to create or connect the registry
 	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
 
+	// The Azure integration that was used to create or connect the registry
+	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
+
 	// The GCP integration that was used to create or connect the registry
 	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
 
@@ -73,6 +76,7 @@ type RegistryService string
 const (
 	GCR       RegistryService = "gcr"
 	ECR       RegistryService = "ecr"
+	ACR       RegistryService = "acr"
 	DOCR      RegistryService = "docr"
 	DockerHub RegistryService = "dockerhub"
 )
@@ -86,6 +90,11 @@ type CreateRegistryRequest struct {
 	AWSIntegrationID   uint   `json:"aws_integration_id"`
 	DOIntegrationID    uint   `json:"do_integration_id"`
 	BasicIntegrationID uint   `json:"basic_integration_id"`
+	AzureIntegrationID uint   `json:"azure_integration_id"`
+
+	// Additional Azure-specific fields
+	ACRResourceGroupName string `json:"acr_resource_group_name"`
+	ACRName              string `json:"acr_name"`
 }
 
 type CreateRegistryRepositoryRequest struct {

+ 7 - 0
api/types/release.go

@@ -21,6 +21,7 @@ type PorterRelease struct {
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
 	ImageRepoURI    string           `json:"image_repo_uri"`
 	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
+	Tags            []string         `json:"tags,omitempty"`
 }
 
 type GetReleaseResponse Release
@@ -47,6 +48,8 @@ type CreateReleaseRequest struct {
 	ImageURL           string                        `json:"image_url" form:"required"`
 	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
 	BuildConfig        *CreateBuildConfigRequest     `json:"build_config,omitempty"`
+	Tags               []string                      `json:"tags,omitempty"`
+	SyncedEnvGroups    []string                      `json:"synced_env_groups,omitempty"`
 }
 
 type CreateAddonRequest struct {
@@ -136,3 +139,7 @@ type DNSRecord struct {
 }
 
 type GetReleaseAllPodsResponse []v1.Pod
+
+type PatchUpdateReleaseTags struct {
+	Tags []string `json:"tags"`
+}

+ 6 - 0
api/types/tag.go

@@ -0,0 +1,6 @@
+package types
+
+type CreateTagRequest struct {
+	Name  string `json:"name" form:"required"`
+	Color string `json:"color" form:"required"`
+}

+ 1 - 1
build/Dockerfile.osx

@@ -1,4 +1,4 @@
-ARG GO_VERSION=1.17
+ARG GO_VERSION=1.18
 
 FROM golang:${GO_VERSION}
 

+ 1 - 1
build/Dockerfile.win

@@ -1,4 +1,4 @@
-ARG GO_VERSION=1.17
+ARG GO_VERSION=1.18
 
 FROM golang:${GO_VERSION}
 

+ 2 - 1
cli/cmd/apply.go

@@ -104,6 +104,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []str
 	worker.RegisterDriver("update-config", preview.NewUpdateConfigDriver)
 	worker.RegisterDriver("random-string", preview.NewRandomStringDriver)
 	worker.RegisterDriver("env-group", preview.NewEnvGroupDriver)
+	worker.RegisterDriver("os-env", preview.NewOSEnvDriver)
 
 	worker.SetDefaultDriver("deploy")
 
@@ -450,7 +451,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-			repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-")
+			repoSuffix = strings.ToLower(strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-"))
 		}
 	}
 

+ 10 - 25
cli/cmd/delete.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"strconv"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
@@ -112,38 +113,22 @@ func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []st
 		return fmt.Errorf("cluster id must be set")
 	}
 
-	var environmentID string
-	var gitRepoName string
-	var gitRepoOwner string
-	var gitPRNumber string
+	var deploymentID uint
 
-	if envID := os.Getenv("PORTER_ENVIRONMENT_ID"); envID != "" {
-		environmentID = envID
-	} else {
-		return fmt.Errorf("Environment ID must be defined, set by PORTER_ENVIRONMENT_ID")
-	}
-
-	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
-		gitRepoName = repoName
-	} else {
-		return fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
-	}
+	if deplIDStr := os.Getenv("PORTER_DEPLOYMENT_ID"); deplIDStr != "" {
+		deplID, err := strconv.ParseUint(deplIDStr, 10, 32)
 
-	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-		gitRepoOwner = repoOwner
-	} else {
-		return fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
-	}
+		if err != nil {
+			return fmt.Errorf("error parsing deployment ID: %s", deplIDStr)
+		}
 
-	if prNumber := os.Getenv("PORTER_PR_NUMBER"); prNumber != "" {
-		gitPRNumber = prNumber
+		deploymentID = uint(deplID)
 	} else {
-		return fmt.Errorf("Pull request number must be defined, set by PORTER_PR_NUMBER")
+		return fmt.Errorf("Deployment ID must be defined, set by PORTER_DEPLOYMENT_ID")
 	}
 
 	return client.DeleteDeployment(
-		context.Background(), projectID, clusterID, environmentID,
-		gitRepoOwner, gitRepoName, gitPRNumber,
+		context.Background(), projectID, clusterID, deploymentID,
 	)
 }
 

+ 3 - 1
cli/cmd/deploy/create.go

@@ -310,7 +310,9 @@ func (c *CreateAgent) CreateFromDocker(
 	}
 
 	if opts.Method == DeployBuildTypeDocker {
-		basePath, err := filepath.Abs(".")
+		var basePath string
+
+		basePath, err = filepath.Abs(".")
 
 		if err != nil {
 			return "", err

+ 30 - 0
cli/cmd/docker/auth.go

@@ -55,6 +55,8 @@ func (a *AuthGetter) GetCredentials(serverURL string) (user string, secret strin
 		return a.GetDOCRCredentials(serverURL, a.ProjectID)
 	} else if strings.Contains(serverURL, "index.docker.io") {
 		return a.GetDockerHubCredentials(serverURL, a.ProjectID)
+	} else if strings.Contains(serverURL, "azurecr.io") {
+		return a.GetACRCredentials(serverURL, a.ProjectID)
 	}
 
 	return a.GetECRCredentials(serverURL, a.ProjectID)
@@ -204,6 +206,34 @@ func (a *AuthGetter) GetDockerHubCredentials(serverURL string, projID uint) (use
 	return decodeDockerToken(token)
 }
 
+func (a *AuthGetter) GetACRCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	cachedEntry := a.Cache.Get(serverURL)
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		// get a token from the server
+		tokenResp, err := a.Client.GetACRAuthorizationToken(context.Background(), projID)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		a.Cache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return decodeDockerToken(token)
+}
+
 func decodeDockerToken(token string) (string, string, error) {
 	decodedToken, err := base64.StdEncoding.DecodeString(token)
 

+ 1 - 1
cli/cmd/pack/pack.go

@@ -140,7 +140,7 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig, ca
 	}
 
 	if len(buildOpts.Buildpacks) > 0 && strings.HasPrefix(buildOpts.Builder, "heroku") {
-		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile@1.0.0")
+		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile@1.0.1")
 	}
 
 	return sharedPackClient.Build(context.Background(), buildOpts)

+ 4 - 2
cli/cmd/preview/build_image_driver.go

@@ -128,7 +128,7 @@ func (d *BuildDriver) Apply(resource *models.Resource) (*models.Resource, error)
 
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-			repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-")
+			repoSuffix = strings.ToLower(strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-"))
 		}
 	}
 
@@ -280,7 +280,9 @@ func (d *BuildDriver) Apply(resource *models.Resource) (*models.Resource, error)
 	}
 
 	if d.config.Build.Method == string(deploy.DeployBuildTypeDocker) {
-		basePath, err := filepath.Abs(".")
+		var basePath string
+
+		basePath, err = filepath.Abs(".")
 
 		if err != nil {
 			return nil, err

+ 44 - 0
cli/cmd/preview/os_env_driver.go

@@ -0,0 +1,44 @@
+package preview
+
+import (
+	"os"
+	"strings"
+
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+type OSEnvDriver struct {
+	output map[string]interface{}
+}
+
+func NewOSEnvDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	return &OSEnvDriver{
+		output: make(map[string]interface{}),
+	}, nil
+}
+
+func (d *OSEnvDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *OSEnvDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	for _, key := range os.Environ() {
+		keyVal := strings.Split(key, "=")
+
+		if len(keyVal) == 2 && keyVal[0] != "" && keyVal[1] != "" &&
+			strings.HasPrefix(keyVal[0], "PORTER_APPLY_") {
+			envName := strings.TrimPrefix(keyVal[0], "PORTER_APPLY_")
+
+			if len(envName) > 0 {
+				d.output[envName] = keyVal[1]
+			}
+		}
+	}
+
+	return resource, nil
+}
+
+func (d *OSEnvDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}

+ 74 - 0
cli/cmd/preview/push_image_driver.go

@@ -1,10 +1,15 @@
 package preview
 
 import (
+	"context"
 	"fmt"
+	"os"
+	"strings"
 
 	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
@@ -69,6 +74,75 @@ func (d *PushDriver) Apply(resource *models.Resource) (*models.Resource, error)
 		return nil, err
 	}
 
+	_, err = client.GetRelease(
+		context.Background(),
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+		d.target.AppName,
+	)
+
+	shouldCreate := err != nil
+
+	if shouldCreate {
+		regList, err := client.ListRegistries(context.Background(), d.target.Project)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var registryURL string
+
+		if len(*regList) == 0 {
+			return nil, fmt.Errorf("no registry found")
+		} else {
+			registryURL = (*regList)[0].URL
+		}
+
+		var repoSuffix string
+
+		if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+			if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+				repoSuffix = strings.ToLower(strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-"))
+			}
+		}
+
+		sharedOpts := &deploy.SharedOpts{
+			ProjectID: d.target.Project,
+			ClusterID: d.target.Cluster,
+			Namespace: d.target.Namespace,
+		}
+
+		createAgent := &deploy.CreateAgent{
+			Client: client,
+			CreateOpts: &deploy.CreateOpts{
+				SharedOpts:  sharedOpts,
+				ReleaseName: d.target.AppName,
+				RegistryURL: registryURL,
+				RepoSuffix:  repoSuffix,
+			},
+		}
+
+		regID, imageURL, err := createAgent.GetImageRepoURL(d.target.AppName, sharedOpts.Namespace)
+
+		if err != nil {
+			return nil, err
+		}
+
+		err = client.CreateRepository(
+			context.Background(),
+			sharedOpts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	err = agent.PushImage(d.config.Push.Image)
 	if err != nil {
 		return nil, err

+ 50 - 3
cli/cmd/preview/update_config_driver.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"strings"
 
 	"github.com/cli/cli/git"
 	"github.com/fatih/color"
@@ -27,6 +28,7 @@ type UpdateConfigDriverConfig struct {
 
 	UpdateConfig struct {
 		Image string
+		Tag   string
 	} `mapstructure:"update_config"`
 
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
@@ -93,9 +95,12 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 
 	shouldCreate := err != nil
 
-	// FIXME: give tag option in config build, but override if PORTER_TAG is present
 	tag := os.Getenv("PORTER_TAG")
 
+	if tag == "" {
+		tag = d.config.UpdateConfig.Tag
+	}
+
 	if tag == "" {
 		commit, err := git.LastCommit()
 
@@ -106,6 +111,28 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 		tag = commit.Sha[:7]
 	}
 
+	regList, err := client.ListRegistries(context.Background(), d.target.Project)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var registryURL string
+
+	if len(*regList) == 0 {
+		return nil, fmt.Errorf("no registry found")
+	} else {
+		registryURL = (*regList)[0].URL
+	}
+
+	var repoSuffix string
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+			repoSuffix = strings.ToLower(strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-"))
+		}
+	}
+
 	sharedOpts := &deploy.SharedOpts{
 		ProjectID:   d.target.Project,
 		ClusterID:   d.target.Cluster,
@@ -116,7 +143,7 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 	}
 
 	if shouldCreate {
-		color.New(color.FgYellow).Printf("Could not read release %s/%s (%s): attempting creation\n", d.target.Namespace, d.target.AppName, err.Error())
+		color.New(color.FgYellow).Printf("Could not read release %s/%s: attempting creation\n", d.target.Namespace, d.target.AppName)
 
 		createAgent := &deploy.CreateAgent{
 			Client: client,
@@ -124,15 +151,35 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 				SharedOpts:  sharedOpts,
 				Kind:        d.source.Name,
 				ReleaseName: d.target.AppName,
+				RegistryURL: registryURL,
+				RepoSuffix:  repoSuffix,
 			},
 		}
 
-		_, err := createAgent.CreateFromRegistry(d.config.UpdateConfig.Image, d.config.Values)
+		regID, imageURL, err := createAgent.GetImageRepoURL(d.target.AppName, sharedOpts.Namespace)
 
 		if err != nil {
 			return nil, err
 		}
 
+		err = client.CreateRepository(
+			context.Background(),
+			sharedOpts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		_, err = createAgent.CreateFromRegistry(d.config.UpdateConfig.Image, d.config.Values)
+
+		if err != nil {
+			return nil, err
+		}
 	} else {
 		updateAgent, err := deploy.NewDeployAgent(client, d.target.AppName, &deploy.DeployOpts{
 			SharedOpts: sharedOpts,

+ 127 - 3
dashboard/package-lock.json

@@ -1227,6 +1227,11 @@
       "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==",
       "dev": true
     },
+    "@icons/material": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw=="
+    },
     "@ironplans/api": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@ironplans/api/-/api-0.4.1.tgz",
@@ -1305,6 +1310,18 @@
         "react-transition-group": "^4.4.0"
       }
     },
+    "@material-ui/lab": {
+      "version": "4.0.0-alpha.61",
+      "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz",
+      "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==",
+      "requires": {
+        "@babel/runtime": "^7.4.4",
+        "@material-ui/utils": "^4.11.3",
+        "clsx": "^1.0.4",
+        "prop-types": "^15.7.2",
+        "react-is": "^16.8.0 || ^17.0.0"
+      }
+    },
     "@material-ui/styles": {
       "version": "4.11.4",
       "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz",
@@ -1345,9 +1362,9 @@
       "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A=="
     },
     "@material-ui/utils": {
-      "version": "4.11.2",
-      "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz",
-      "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==",
+      "version": "4.11.3",
+      "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz",
+      "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==",
       "requires": {
         "@babel/runtime": "^7.4.4",
         "prop-types": "^15.7.2",
@@ -1947,6 +1964,16 @@
         "@types/react": "*"
       }
     },
+    "@types/react-color": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz",
+      "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*",
+        "@types/reactcss": "*"
+      }
+    },
     "@types/react-dom": {
       "version": "16.9.14",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.14.tgz",
@@ -2002,6 +2029,15 @@
         "@types/react": "*"
       }
     },
+    "@types/reactcss": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz",
+      "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/scheduler": {
       "version": "0.16.2",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@@ -3634,6 +3670,11 @@
       "resolved": "https://registry.npmjs.org/cohere-js/-/cohere-js-1.0.19.tgz",
       "integrity": "sha512-2XVX2LUKHjbJ4GCsnizXnAVHZfq9RM1RmHl8zE4G2ORdXmDpzSx5i0UIj/0GZ3AwjKIlYsrGA4kdCGT+WapjPQ=="
     },
+    "cohere-sentry": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/cohere-sentry/-/cohere-sentry-1.0.1.tgz",
+      "integrity": "sha512-OHdKcc8LED8X/JQKlMD0Zapb4rcOkPu0m11+okHouMDep1/MvyOG4JXcK4Mo3sabJT65yozc9Uo+nJfSWzaFcg=="
+    },
     "collection-visit": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
@@ -3644,6 +3685,30 @@
         "object-visit": "^1.0.0"
       }
     },
+    "color": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+      "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+      "requires": {
+        "color-convert": "^2.0.1",
+        "color-string": "^1.9.0"
+      },
+      "dependencies": {
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        }
+      }
+    },
     "color-convert": {
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3657,6 +3722,15 @@
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
       "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
     },
+    "color-string": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+      "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+      "requires": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
     "commander": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -5937,6 +6011,11 @@
         "has-tostringtag": "^1.0.0"
       }
     },
+    "is-arrayish": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+      "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
+    },
     "is-bigint": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
@@ -6427,6 +6506,11 @@
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
+    "lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
     "lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -6532,6 +6616,11 @@
       "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz",
       "integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w=="
     },
+    "material-colors": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
+      "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg=="
+    },
     "math-expression-evaluator": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.3.8.tgz",
@@ -7821,6 +7910,20 @@
         "prop-types": "^15.7.2"
       }
     },
+    "react-color": {
+      "version": "2.19.3",
+      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
+      "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
+      "requires": {
+        "@icons/material": "^0.2.4",
+        "lodash": "^4.17.15",
+        "lodash-es": "^4.17.15",
+        "material-colors": "^1.2.1",
+        "prop-types": "^15.5.10",
+        "reactcss": "^1.2.0",
+        "tinycolor2": "^1.4.1"
+      }
+    },
     "react-dom": {
       "version": "16.14.0",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
@@ -7937,6 +8040,14 @@
         "debounce": "^1.2.0"
       }
     },
+    "reactcss": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+      "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
+      "requires": {
+        "lodash": "^4.0.1"
+      }
+    },
     "readable-stream": {
       "version": "2.3.7",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
@@ -8561,6 +8672,14 @@
       "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==",
       "dev": true
     },
+    "simple-swizzle": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+      "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
+      "requires": {
+        "is-arrayish": "^0.3.1"
+      }
+    },
     "sirv": {
       "version": "1.0.18",
       "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.18.tgz",
@@ -9380,6 +9499,11 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "tinycolor2": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
+      "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="
+    },
     "to-arraybuffer": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",

+ 6 - 0
dashboard/package.json

@@ -6,6 +6,7 @@
     "@ironplans/react": "^0.4.0",
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
+    "@material-ui/lab": "^4.0.0-alpha.61",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@visx/axis": "^1.6.1",
@@ -24,6 +25,8 @@
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",
+    "cohere-sentry": "^1.0.1",
+    "color": "^4.2.3",
     "core-js": "^3.16.1",
     "cron-parser": "^4.3.0",
     "cron-validator": "^1.3.1",
@@ -41,6 +44,7 @@
     "random-words": "^1.1.1",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
+    "react-color": "^2.19.3",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
     "react-infinite-scroll-component": "^6.1.0",
@@ -70,6 +74,7 @@
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
+    "@types/color": "^3.0.3",
     "@types/d3-array": "^2.9.0",
     "@types/d3-time-format": "^3.0.0",
     "@types/jest": "^24.0.0",
@@ -83,6 +88,7 @@
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
     "@types/react": "^16.14.14",
+    "@types/react-color": "^3.0.6",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",

+ 279 - 0
dashboard/src/assets/devicons-name-list.ts

@@ -0,0 +1,279 @@
+export const DeviconsNameList = [
+  { name: "adonisjs" },
+  { name: "aftereffects" },
+  { name: "amazonwebservices" },
+  { name: "android" },
+  { name: "androidstudio" },
+  { name: "aarch64" },
+  { name: "angularjs" },
+  { name: "ansible" },
+  { name: "apache" },
+  { name: "apachekafka" },
+  { name: "appcelerator" },
+  { name: "apple" },
+  { name: "appwrite" },
+  { name: "arduino" },
+  { name: "atom" },
+  { name: "azure" },
+  { name: "babel" },
+  { name: "backbonejs" },
+  { name: "bamboo" },
+  { name: "bash" },
+  { name: "behance" },
+  { name: "bitbucket" },
+  { name: "bootstrap" },
+  { name: "bulma" },
+  { name: "bower" },
+  { name: "c" },
+  { name: "cakephp" },
+  { name: "canva" },
+  { name: "centos" },
+  { name: "ceylon" },
+  { name: "chrome" },
+  { name: "circleci" },
+  { name: "clojure" },
+  { name: "cmake" },
+  { name: "clojurescript" },
+  { name: "codecov" },
+  { name: "codeigniter" },
+  { name: "codepen" },
+  { name: "coffeescript" },
+  { name: "composer" },
+  { name: "confluence" },
+  { name: "couchdb" },
+  { name: "cplusplus" },
+  { name: "csharp" },
+  { name: "css3" },
+  { name: "cucumber" },
+  { name: "crystal" },
+  { name: "d3js" },
+  { name: "dart" },
+  { name: "debian" },
+  { name: "denojs" },
+  { name: "devicon" },
+  { name: "django" },
+  { name: "docker" },
+  { name: "doctrine" },
+  { name: "dot-net" },
+  { name: "dotnetcore" },
+  { name: "drupal" },
+  { name: "digitalocean" },
+  { name: "discordjs" },
+  { name: "electron" },
+  { name: "eleventy" },
+  { name: "elixir" },
+  { name: "elm" },
+  { name: "ember" },
+  { name: "embeddedc" },
+  { name: "erlang" },
+  { name: "eslint" },
+  { name: "express" },
+  { name: "facebook" },
+  { name: "feathersjs" },
+  { name: "figma" },
+  { name: "filezilla" },
+  { name: "firebase" },
+  { name: "firefox" },
+  { name: "flask" },
+  { name: "flutter" },
+  { name: "foundation" },
+  { name: "fsharp" },
+  { name: "gatling" },
+  { name: "gatsby" },
+  { name: "rect" },
+  { name: "gcc" },
+  { name: "gentoo" },
+  { name: "gimp" },
+  { name: "git" },
+  { name: "github" },
+  { name: "gitlab" },
+  { name: "gitter" },
+  { name: "go" },
+  { name: "google" },
+  { name: "googlecloud" },
+  { name: "gradle" },
+  { name: "grafana" },
+  { name: "grails" },
+  { name: "graphql" },
+  { name: "groovy" },
+  { name: "grunt" },
+  { name: "gulp" },
+  { name: "godot" },
+  { name: "haskell" },
+  { name: "handlebars" },
+  { name: "haxe" },
+  { name: "heroku" },
+  { name: "html5" },
+  { name: "hugo" },
+  { name: "ie10" },
+  { name: "ifttt" },
+  { name: "illustrator" },
+  { name: "inkscape" },
+  { name: "intellij" },
+  { name: "ionic" },
+  { name: "jamstack" },
+  { name: "jasmine" },
+  { name: "java" },
+  { name: "javascript" },
+  { name: "jeet" },
+  { name: "jest" },
+  { name: "jenkins" },
+  { name: "jetbrains" },
+  { name: "jira" },
+  { name: "jquery" },
+  { name: "julia" },
+  { name: "jupyter" },
+  { name: "kaggle" },
+  { name: "karma" },
+  { name: "kotlin" },
+  { name: "knockout" },
+  { name: "krakenjs" },
+  { name: "kubernetes" },
+  { name: "labview" },
+  { name: "laravel" },
+  { name: "latex" },
+  { name: "less" },
+  { name: "linkedin" },
+  { name: "lua" },
+  { name: "linux" },
+  { name: "materialui" },
+  { name: "matlab" },
+  { name: "magento" },
+  { name: "markdown" },
+  { name: "maya" },
+  { name: "meteor" },
+  { name: "minitab" },
+  { name: "mocha" },
+  { name: "modx" },
+  { name: "mongodb" },
+  { name: "moodle" },
+  { name: "msdos" },
+  { name: "mysql" },
+  { name: "neo4j" },
+  { name: "nestjs" },
+  { name: "networkx" },
+  { name: "nextjs" },
+  { name: "nginx" },
+  { name: "nixos" },
+  { name: "nodejs" },
+  { name: "nodewebkit" },
+  { name: "npm" },
+  { name: "nuget" },
+  { name: "numpy" },
+  { name: "nuxtjs" },
+  { name: "objectivec" },
+  { name: "opera" },
+  { name: "ocaml" },
+  { name: "openal" },
+  { name: "opengl" },
+  { name: "opensuse" },
+  { name: "oracle" },
+  { name: "pandas" },
+  { name: "perl" },
+  { name: "phalcon" },
+  { name: "photoshop" },
+  { name: "php" },
+  { name: "phpstorm" },
+  { name: "podman" },
+  { name: "polygon" },
+  { name: "postgresql" },
+  { name: "premierepro" },
+  { name: "processing" },
+  { name: "protractor" },
+  { name: "putty" },
+  { name: "pycharm" },
+  { name: "python" },
+  { name: "pytorch" },
+  { name: "raspberrypi" },
+  { name: "phoenix" },
+  { name: "qt" },
+  { name: "r" },
+  { name: "rails" },
+  { name: "react" },
+  { name: "redhat" },
+  { name: "redis" },
+  { name: "redux" },
+  { name: "rocksdb" },
+  { name: "ruby" },
+  { name: "rubymine" },
+  { name: "rust" },
+  { name: "safari" },
+  { name: "salesforce" },
+  { name: "sdl" },
+  { name: "rstudio" },
+  { name: "sass" },
+  { name: "scala" },
+  { name: "selenium" },
+  { name: "sequelize" },
+  { name: "shopware" },
+  { name: "shotgrid" },
+  { name: "sketch" },
+  { name: "slack" },
+  { name: "socketio" },
+  { name: "solidity" },
+  { name: "sourcetree" },
+  { name: "spring" },
+  { name: "spss" },
+  { name: "sqlalchemy" },
+  { name: "sqlite" },
+  { name: "subversion" },
+  { name: "microsoftsqlserver" },
+  { name: "ssh" },
+  { name: "stylus" },
+  { name: "svelte" },
+  { name: "swift" },
+  { name: "symfony" },
+  { name: "storybook" },
+  { name: "tailwindcss" },
+  { name: "tensorflow" },
+  { name: "terraform" },
+  { name: "threejs" },
+  { name: "tomcat" },
+  { name: "tortoisegit" },
+  { name: "towergit" },
+  { name: "travis" },
+  { name: "thealgorithms" },
+  { name: "trello" },
+  { name: "twitter" },
+  { name: "typescript" },
+  { name: "typo3" },
+  { name: "ubuntu" },
+  { name: "unity" },
+  { name: "unix" },
+  { name: "unrealengine" },
+  { name: "uwsgi" },
+  { name: "vagrant" },
+  { name: "vim" },
+  { name: "visualstudio" },
+  { name: "vuejs" },
+  { name: "vuestorefront" },
+  { name: "vscode" },
+  { name: "webflow" },
+  { name: "weblate" },
+  { name: "webpack" },
+  { name: "webstorm" },
+  { name: "windows8" },
+  { name: "woocommerce" },
+  { name: "wordpress" },
+  { name: "xamarin" },
+  { name: "xcode" },
+  { name: "xd" },
+  { name: "yarn" },
+  { name: "yii" },
+  { name: "yunohost" },
+  { name: "zend" },
+  { name: "zig" },
+  { name: "pytest" },
+  { name: "opencv" },
+  { name: "fastapi" },
+  { name: "k3s" },
+  { name: "packer" },
+  { name: "anaconda" },
+  { name: "rspec" },
+  { name: "argocd" },
+  { name: "prometheus" },
+  { name: "blender" },
+  { name: "dropwizard" },
+  { name: "vuetify" },
+  { name: "fedora" },
+];

+ 12 - 7
dashboard/src/components/ProvisionerStatus.tsx

@@ -394,16 +394,21 @@ type OperationDetailsProps = {
   infra: Infrastructure;
   can_delete?: boolean;
   refreshInfra: (completed?: boolean, errored?: boolean) => void;
+  useOperation?: Operation;
+  padding?: string;
 };
 
-const OperationDetails: React.FunctionComponent<OperationDetailsProps> = ({
+export const OperationDetails: React.FunctionComponent<OperationDetailsProps> = ({
   infra,
   can_delete,
   refreshInfra,
+  useOperation,
+  padding,
 }) => {
-  const [isLoading, setIsLoading] = useState(true);
+  const [isLoading, setIsLoading] = useState(!useOperation);
   const [hasError, setHasError] = useState(false);
-  const [operation, setOperation] = useState<Operation>(null);
+  const [operation, setOperation] = useState<Operation>(useOperation);
+
   const [infraState, setInfraState] = useState<TFState>(null);
   const [infraStateInitialized, setInfraStateInitialized] = useState(false);
   const { currentProject, setCurrentError } = useContext(Context);
@@ -526,7 +531,7 @@ const OperationDetails: React.FunctionComponent<OperationDetailsProps> = ({
         {
           project_id: currentProject.id,
           infra_id: infra.id,
-          operation_id: infra.latest_operation.id,
+          operation_id: useOperation?.id || infra.latest_operation.id,
         }
       )
       .then(({ data }) => {
@@ -760,7 +765,7 @@ const OperationDetails: React.FunctionComponent<OperationDetailsProps> = ({
   };
 
   return (
-    <StyledCard>
+    <StyledCard padding={padding}>
       {renderLoadingBar(
         createdResources.length + deletedResources.length,
         createdResources.length +
@@ -780,8 +785,8 @@ const OperationDetails: React.FunctionComponent<OperationDetailsProps> = ({
   );
 };
 
-const StyledCard = styled.div`
-  padding: 12px 20px;
+const StyledCard = styled.div<{ padding?: string }>`
+  padding: ${(props) => props.padding || "12px 20px"};
   max-height: 300px;
   overflow-y: auto;
 `;

+ 228 - 0
dashboard/src/components/SearchSelector.tsx

@@ -0,0 +1,228 @@
+import _ from "lodash";
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  options: any[];
+  onSelect: (option: any) => void;
+  label?: string;
+  dropdownLabel?: string;
+  getOptionLabel?: (option: any) => string;
+  filterBy?: ((option: any) => string) | string;
+  noOptionsText?: string;
+  dropdownMaxHeight?: string;
+  renderAddButton?: any;
+  className?: string;
+  renderOptionIcon?: (option: any) => React.ReactNode;
+};
+
+const SearchSelector = ({
+  options,
+  onSelect,
+  label,
+  dropdownLabel,
+  getOptionLabel,
+  filterBy,
+  noOptionsText,
+  dropdownMaxHeight,
+  renderAddButton,
+  className,
+  renderOptionIcon,
+}: Props) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [filter, setFilter] = useState("");
+
+  const handleOptionClick = (e: any, option: any) => {
+    setIsExpanded(false);
+    onSelect(option);
+    setFilter("");
+  };
+
+  const getLabel = (option: any) => {
+    if (typeof getOptionLabel === "function") {
+      return getOptionLabel(option);
+    }
+
+    return React.isValidElement(option) ? option : "";
+  };
+
+  const filteredOptions = useMemo(() => {
+    if (typeof filterBy === "function") {
+      return options.filter((option) => filterBy(option).includes(filter));
+    }
+
+    if (typeof filterBy === "string") {
+      return options.filter((option) =>
+        _.get(option, filterBy).includes(filter)
+      );
+    }
+
+    return options.filter((option) => option.includes(filter));
+  }, [filter, options]);
+
+  return (
+    <>
+      {label?.length ? <Label>{label}</Label> : null}
+      <InputWrapper
+        onBlur={() => {
+          setIsExpanded(false);
+        }}
+        className={className}
+      >
+        <Input
+          value={filter}
+          placeholder="Find or add a tag..."
+          onClick={(e) => {
+            setIsExpanded(false);
+            e.stopPropagation();
+            setIsExpanded(true);
+          }}
+          onChange={(e) => setFilter(e.target.value)}
+        />
+        {isExpanded ? (
+          <DropdownWrapper>
+            <Dropdown dropdownMaxHeight={dropdownMaxHeight}>
+              {!filteredOptions.length ? (
+                <>
+                  {!renderAddButton ? (
+                    <DropdownLabel>
+                      {noOptionsText || "No options available for this filter"}
+                    </DropdownLabel>
+                  ) : (
+                    <div
+                      onMouseDown={(e) => {
+                        e.stopPropagation();
+                        e.preventDefault();
+                        setFilter("");
+                      }}
+                    >
+                      {renderAddButton()}
+                    </div>
+                  )}
+                </>
+              ) : (
+                <>
+                  {renderAddButton && (
+                    <div
+                      onMouseDown={(e) => {
+                        e.stopPropagation();
+                        e.preventDefault();
+                        setFilter("");
+                      }}
+                    >
+                      {renderAddButton()}
+                    </div>
+                  )}
+                  {!renderAddButton && dropdownLabel && (
+                    <DropdownLabel>{dropdownLabel}</DropdownLabel>
+                  )}
+                  {filteredOptions.map((option, i) => (
+                    <Option
+                      key={i}
+                      onMouseDown={(e) => {
+                        e.stopPropagation();
+                        e.preventDefault();
+                      }}
+                      onClick={(e) => handleOptionClick(e, option)}
+                    >
+                      {typeof renderOptionIcon === "function"
+                        ? renderOptionIcon(option)
+                        : null}
+                      {getLabel(option)}
+                    </Option>
+                  ))}
+                </>
+              )}
+            </Dropdown>
+          </DropdownWrapper>
+        ) : null}
+      </InputWrapper>
+    </>
+  );
+};
+
+export default SearchSelector;
+
+const InputWrapper = styled.div`
+  display: flex;
+  margin-bottom: -1px;
+  align-items: center;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  background: #ffffff11;
+  position: relative;
+  width: 100%;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  color: #ffffff;
+  padding: 5px 10px;
+  min-height: 35px;
+  max-height: 45px;
+  width: 100%;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const DropdownWrapper = styled.div`
+  position: absolute;
+  width: 100%;
+  right: 0;
+  z-index: 9999;
+  top: calc(100% + 5px);
+`;
+
+const Dropdown = styled.div`
+  background: #26282f;
+
+  max-height: ${(props: { dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 8px 20px 0px #00000088;
+`;
+
+const DropdownLabel = styled.div`
+  font-size: 13px;
+  color: #ffffff44;
+  font-weight: 500;
+  margin: 10px 13px;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff15;
+  min-height: 35px;
+  font-size: 13px;
+  align-items: center;
+  display: flex;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :last-child {
+    border-bottom: 1px solid #ffffff00;
+  }
+
+  :hover {
+    background: #ffffff22;
+  }
+`;

+ 31 - 67
dashboard/src/components/form-components/KeyValueArray.tsx

@@ -3,9 +3,11 @@ import styled from "styled-components";
 import Modal from "../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../main/home/modals/EnvEditorModal";
+import { dotenv_parse } from "shared/string_utils";
 
 import sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
+import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
 
 export type KeyValue = {
   key: string;
@@ -136,23 +138,34 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                 spellCheck={false}
               />
               <Spacer />
-              <Input
-                placeholder="ex: value"
-                width="270px"
-                value={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);
-                }}
-                disabled={
-                  this.props.disabled || value?.includes("PORTERSECRET")
-                }
-                type={value?.includes("PORTERSECRET") ? "password" : "text"}
-                spellCheck={false}
-              />
+              {value?.includes("PORTERSECRET") ? (
+                <Input
+                  placeholder="ex: value"
+                  width="270px"
+                  value={value}
+                  disabled
+                  type={"password"}
+                  spellCheck={false}
+                />
+              ) : (
+                <MultiLineInput
+                  placeholder="ex: value"
+                  width="270px"
+                  value={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);
+                  }}
+                  disabled={
+                    this.props.disabled || value?.includes("PORTERSECRET")
+                  }
+                  spellCheck={false}
+                  rows={value?.split("\n").length}
+                />
+              )}
               {this.renderDeleteButton(i)}
               {this.renderHiddenOption(value?.includes("PORTERSECRET"), i)}
             </InputWrapper>
@@ -204,57 +217,8 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     }
   };
 
-  // Parses src into an Object
-  parseEnv = (src: any, options: any) => {
-    const debug = Boolean(options && options.debug);
-    const obj = {} as Record<string, string>;
-    const NEWLINE = "\n";
-    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-    const RE_NEWLINES = /\\n/g;
-    const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-    // convert Buffers before splitting into lines and processing
-    src
-      .toString()
-      .split(NEWLINES_MATCH)
-      .forEach(function (line: any, idx: any) {
-        // matching "KEY' and 'VAL' in 'KEY=VAL'
-        const keyValueArr = line.match(RE_INI_KEY_VAL);
-        // matched?
-        if (keyValueArr != null) {
-          const key = keyValueArr[1];
-          // default undefined or missing values to empty string
-          let val = keyValueArr[2] || "";
-          const end = val.length - 1;
-          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-          const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-          // if single or double quoted, remove quotes
-          if (isSingleQuoted || isDoubleQuoted) {
-            val = val.substring(1, end);
-
-            // if double quoted, expand newlines
-            if (isDoubleQuoted) {
-              val = val.replace(RE_NEWLINES, NEWLINE);
-            }
-          } else {
-            // remove surrounding whitespace
-            val = val.trim();
-          }
-
-          obj[key] = val;
-        } else if (debug) {
-          console.log(
-            `did not match key and value when parsing line ${idx + 1}: ${line}`
-          );
-        }
-      });
-
-    return obj;
-  };
-
   readFile = (env: string) => {
-    let envObj = this.parseEnv(env, null);
+    let envObj = dotenv_parse(env);
     let push = true;
 
     for (let key in envObj) {

+ 64 - 5
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -1,6 +1,7 @@
 import React, { createContext, useContext, useReducer } from "react";
 import {
   GetFinalVariablesFunction,
+  GetMetadataFunction,
   PorterFormAction,
   PorterFormData,
   PorterFormState,
@@ -15,16 +16,17 @@ import {
   ShowIfOr,
 } from "../../shared/types";
 import { getFinalVariablesForStringInput } from "./field-components/Input";
-import { getFinalVariablesForKeyValueArray } from "./field-components/KeyValueArray";
+import {
+  getFinalVariablesForKeyValueArray,
+  getMetadata as getMetadataForKeyValueArray,
+} from "./field-components/KeyValueArray";
 import { Context } from "../../shared/Context";
 import { getFinalVariablesForArrayInput } from "./field-components/ArrayInput";
 import { getFinalVariablesForCheckbox } from "./field-components/Checkbox";
 import { getFinalVariablesForSelect } from "./field-components/Select";
-import api from "shared/api";
 
-interface Props {
+export interface BaseProps {
   rawFormData: PorterFormData;
-  onSubmit: (vars: PorterFormVariableList, cb?: () => void) => void;
   initialVariables?: PorterFormVariableList;
   overrideVariables?: PorterFormVariableList;
   includeHiddenFields?: boolean;
@@ -32,6 +34,21 @@ interface Props {
   doDebug?: boolean;
 }
 
+export interface PropsWithMetadata extends BaseProps {
+  onSubmit: (
+    data: { vars: PorterFormVariableList; metadata: PorterFormVariableList },
+    cb?: () => void
+  ) => void;
+  includeMetadata: true;
+}
+
+export interface PropsWithoutMetadata extends BaseProps {
+  onSubmit: (vars: PorterFormVariableList, cb?: () => void) => void;
+  includeMetadata: false;
+}
+
+export type Props = PropsWithMetadata | PropsWithoutMetadata;
+
 interface ContextProps {
   formData: PorterFormData;
   formState: PorterFormState;
@@ -43,7 +60,7 @@ interface ContextProps {
 }
 
 export const PorterFormContext = createContext<ContextProps | undefined>(
-  undefined!
+  undefined
 );
 const { Provider } = PorterFormContext;
 
@@ -453,6 +470,48 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
         })
       )
     );
+
+    if (props.includeMetadata) {
+      const metadataFunctions: Record<string, GetMetadataFunction> = {
+        "key-value-array": getMetadataForKeyValueArray,
+      };
+      const metadataList: PorterFormVariableList[] = [];
+      data?.tabs?.map((tab) =>
+        tab.sections?.map((section) =>
+          section.contents?.map((field) => {
+            if (metadataFunctions[field?.type]) {
+              metadataList.push(
+                metadataFunctions[field?.type](
+                  state.variables,
+                  field,
+                  state.components[field.id]?.state,
+                  context
+                )
+              );
+            }
+          })
+        )
+      );
+
+      if (props.doDebug)
+        console.log({
+          values: Object.assign.apply({}, varList),
+          metadata: Object.assign.apply({}, metadataList),
+        });
+
+      if (!metadataList.length) {
+        return {
+          values: Object.assign.apply({}, varList),
+          metadata: {},
+        };
+      }
+
+      return {
+        values: Object.assign.apply({}, varList),
+        metadata: Object.assign.apply({}, metadataList),
+      };
+    }
+
     if (props.doDebug) console.log(Object.assign.apply({}, varList));
 
     return Object.assign.apply({}, varList);

+ 5 - 2
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -8,7 +8,7 @@ type PropsType = {
   formData: any;
   valuesToOverride?: any;
   isReadOnly?: boolean;
-  onSubmit?: (values: any) => void;
+  onSubmit?: (values: any, cb?: () => void) => void;
   renderTabContents?: (currentTab: string, submitValues?: any) => any;
   leftTabOptions?: { value: string; label: string }[];
   rightTabOptions?: { value: string; label: string }[];
@@ -22,9 +22,10 @@ type PropsType = {
   includeHiddenFields?: boolean;
   hideBottomSpacer?: boolean;
   redirectTabAfterSave?: string;
+  includeMetadata?: boolean;
 };
 
-const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
+const PorterFormWrapper: React.FC<PropsType> = ({
   formData,
   valuesToOverride,
   isReadOnly,
@@ -42,6 +43,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
   includeHiddenFields,
   hideBottomSpacer,
   redirectTabAfterSave,
+  includeMetadata,
 }) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
@@ -79,6 +81,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
         isReadOnly={isReadOnly}
         onSubmit={onSubmit}
         includeHiddenFields={includeHiddenFields}
+        includeMetadata={includeMetadata}
       >
         <PorterForm
           showStateDebugger={showStateDebugger}

+ 204 - 130
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -1,6 +1,7 @@
 import React, { useContext, useEffect, useState } from "react";
 import {
   GetFinalVariablesFunction,
+  GetMetadataFunction,
   KeyValueArrayField,
   KeyValueArrayFieldState,
   PopulatedEnvGroup,
@@ -13,12 +14,13 @@ import Modal from "../../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
 import { hasSetValue } from "../utils";
-import _, { omit } from "lodash";
+import _, { isObject, differenceBy, omit } from "lodash";
 import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Loading from "components/Loading";
 import api from "shared/api";
 import { Context } from "shared/Context";
+import { dotenv_parse } from "shared/string_utils";
 
 interface Props extends KeyValueArrayField {
   id: string;
@@ -101,51 +103,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
   }
 
   const parseEnv = (src: any, options: any) => {
-    const debug = Boolean(options && options.debug);
-    const obj = {} as Record<string, string>;
-    const NEWLINE = "\n";
-    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-    const RE_NEWLINES = /\\n/g;
-    const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-    // convert Buffers before splitting into lines and processing
-    src
-      .toString()
-      .split(NEWLINES_MATCH)
-      .forEach(function (line: any, idx: any) {
-        // matching "KEY' and 'VAL' in 'KEY=VAL'
-        const keyValueArr = line.match(RE_INI_KEY_VAL);
-        // matched?
-        if (keyValueArr != null) {
-          const key = keyValueArr[1];
-          // default undefined or missing values to empty string
-          let val = keyValueArr[2] || "";
-          const end = val.length - 1;
-          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-          const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-          // if single or double quoted, remove quotes
-          if (isSingleQuoted || isDoubleQuoted) {
-            val = val.substring(1, end);
-
-            // if double quoted, expand newlines
-            if (isDoubleQuoted) {
-              val = val.replace(RE_NEWLINES, NEWLINE);
-            }
-          } else {
-            // remove surrounding whitespace
-            val = val.trim();
-          }
-
-          obj[key] = val;
-        } else if (debug) {
-          console.log(
-            `did not match key and value when parsing line ${idx + 1}: ${line}`
-          );
-        }
-      });
-
-    return obj;
+    return dotenv_parse(src);
   };
 
   const readFile = (env: string) => {
@@ -332,7 +290,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
 
           return (
             <InputWrapper key={i}>
-              <Input
+              <KeyInput
                 placeholder="ex: key"
                 width="270px"
                 value={entry.key}
@@ -359,30 +317,40 @@ const KeyValueArray: React.FC<Props> = (props) => {
                 }
               />
               <Spacer />
-              <Input
-                placeholder="ex: value"
-                width="270px"
-                value={value}
-                onChange={(e: any) => {
-                  e.persist();
-                  setState((prev) => {
-                    return {
-                      values: prev.values?.map((t, j) => {
-                        if (j == i) {
-                          return {
-                            ...t,
-                            value: e.target.value,
-                          };
-                        }
-                        return t;
-                      }),
-                    };
-                  });
-                }}
-                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
-                type={value.includes("PORTERSECRET") ? "password" : "text"}
-                spellCheck={false}
-              />
+              {value?.includes("PORTERSECRET") ? (
+                <KeyInput
+                  placeholder="ex: value"
+                  width="270px"
+                  disabled
+                  type={"password"}
+                  spellCheck={false}
+                />
+              ) : (
+                <MultiLineInput
+                  placeholder="ex: value"
+                  width="270px"
+                  value={value}
+                  onChange={(e: any) => {
+                    e.persist();
+                    setState((prev) => {
+                      return {
+                        values: prev.values?.map((t, j) => {
+                          if (j == i) {
+                            return {
+                              ...t,
+                              value: e.target.value,
+                            };
+                          }
+                          return t;
+                        }),
+                      };
+                    });
+                  }}
+                  disabled={props.isReadOnly}
+                  spellCheck={false}
+                  rows={value?.split("\n").length}
+                />
+              )}
               {renderDeleteButton(i)}
               {renderHiddenOption(value.includes("PORTERSECRET"), i)}
               {checkOverridedKey(entry.key)}
@@ -485,24 +453,27 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   }
 
+  const isNumber = (s: string) => {
+    return !isNaN(!s ? NaN : Number(String(s).trim()));
+  };
+
+  const rg = /(?:^|[^\\])(\\n)/g;
+  const fixNewlines = (s: string) => {
+    while (rg.test(s)) {
+      s = s.replace(rg, (str) => {
+        if (str.length == 2) return "\n";
+        if (str[0] != "\\") return str[0] + "\n";
+        return "\\n";
+      });
+    }
+    return s;
+  };
+
   if (props.variable.includes("env")) {
     let obj = {
       normal: {},
     } as any;
-    const rg = /(?:^|[^\\])(\\n)/g;
-    const fixNewlines = (s: string) => {
-      while (rg.test(s)) {
-        s = s.replace(rg, (str) => {
-          if (str.length == 2) return "\n";
-          if (str[0] != "\\") return str[0] + "\n";
-          return "\\n";
-        });
-      }
-      return s;
-    };
-    const isNumber = (s: string) => {
-      return !isNaN(!s ? NaN : Number(String(s).trim()));
-    };
+
     state.values.forEach((entry: any, i: number) => {
       if (isNumber(entry.value)) {
         obj.normal[entry.key] = entry.value;
@@ -538,20 +509,7 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   } else {
     let obj = {} as any;
-    const rg = /(?:^|[^\\])(\\n)/g;
-    const fixNewlines = (s: string) => {
-      while (rg.test(s)) {
-        s = s.replace(rg, (str) => {
-          if (str.length == 2) return "\n";
-          if (str[0] != "\\") return str[0] + "\n";
-          return "\\n";
-        });
-      }
-      return s;
-    };
-    const isNumber = (s: string) => {
-      return !isNaN(!s ? NaN : Number(String(s).trim()));
-    };
+
     state.values.forEach((entry: any, i: number) => {
       if (isNumber(entry.value)) {
         obj[entry.key] = entry.value;
@@ -565,6 +523,51 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
   }
 };
 
+type KeyValueArrayMetadata = {
+  [variable: string]: {
+    added: { name: string }[];
+    deleted: { name: string }[];
+  };
+};
+
+export const getMetadata: GetMetadataFunction<KeyValueArrayMetadata> = (
+  vars,
+  props: KeyValueArrayField,
+  state: KeyValueArrayFieldState
+) => {
+  // We don't need any metadata for other key-value-array fields yet so we return null for that variable
+  if (!state || !props?.variable?.includes("env")) {
+    return {
+      [props.variable]: null,
+    };
+  }
+
+  const originalSyncedEnvGroups: { name: string }[] =
+    props.value[0]?.synced || [];
+  const currSynced = state?.synced_env_groups || [];
+
+  let obj: KeyValueArrayMetadata[""] = {
+    added: [],
+    deleted: [],
+  };
+
+  obj.added = differenceBy(currSynced, originalSyncedEnvGroups, "name");
+  obj.deleted = differenceBy(originalSyncedEnvGroups, currSynced, "name");
+
+  // This will assure that the variable is always "container.env" and not "container.env.normal" as it is
+  // for some old versions of the jobs chart.
+  const variableContent = props.variable.split(".");
+  let variable = props.variable;
+
+  if (variable.includes("normal")) {
+    variable = `${variableContent[0]}.${variableContent[1]}`;
+  }
+
+  return {
+    [variable]: obj,
+  };
+};
+
 export default KeyValueArray;
 
 const ExpandableEnvGroup: React.FC<{
@@ -595,37 +598,59 @@ const ExpandableEnvGroup: React.FC<{
         {isExpanded && (
           <>
             <Buffer />
-            {Object.entries(envGroup.variables || {})?.map(
-              ([key, value], i: number) => {
-                // Preprocess non-string env values set via raw Helm values
-                if (typeof value === "object") {
-                  value = JSON.stringify(value);
-                } else {
-                  value = String(value);
-                }
-
-                return (
-                  <InputWrapper key={i}>
-                    <Input
-                      placeholder="ex: key"
-                      width="270px"
-                      value={key}
-                      disabled
-                    />
-                    <Spacer />
-                    <Input
-                      placeholder="ex: value"
-                      width="270px"
-                      value={value}
-                      disabled
-                      type={
-                        value.includes("PORTERSECRET") ? "password" : "text"
-                      }
-                    />
-                  </InputWrapper>
-                );
-              }
+            {isObject(envGroup.variables) ? (
+              <>
+                {Object.entries(envGroup.variables || {})?.map(
+                  ([key, value], i: number) => {
+                    // Preprocess non-string env values set via raw Helm values
+                    if (typeof value === "object") {
+                      value = JSON.stringify(value);
+                    } else {
+                      value = String(value);
+                    }
+
+                    return (
+                      <InputWrapper key={i}>
+                        <KeyInput
+                          placeholder="ex: key"
+                          width="270px"
+                          value={key}
+                          disabled
+                        />
+                        <Spacer />
+                        {value?.includes("PORTERSECRET") ? (
+                          <KeyInput
+                            placeholder="ex: value"
+                            width="270px"
+                            value={value}
+                            disabled
+                            type={
+                              value.includes("PORTERSECRET")
+                                ? "password"
+                                : "text"
+                            }
+                          />
+                        ) : (
+                          <MultiLineInput
+                            placeholder="ex: value"
+                            width="270px"
+                            value={value}
+                            disabled
+                            rows={value?.split("\n").length}
+                            spellCheck={false}
+                          ></MultiLineInput>
+                        )}
+                      </InputWrapper>
+                    );
+                  }
+                )}
+              </>
+            ) : (
+              <NoVariablesTextWrapper>
+                This env group has no variables yet
+              </NoVariablesTextWrapper>
             )}
+
             <Br />
           </>
         )}
@@ -767,7 +792,7 @@ type InputProps = {
   borderColor?: string;
 };
 
-const Input = styled.input<InputProps>`
+const KeyInput = styled.input<InputProps>`
   outline: none;
   border: none;
   margin-bottom: 5px;
@@ -782,6 +807,48 @@ const Input = styled.input<InputProps>`
   height: 35px;
 `;
 
+export const MultiLineInput = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;
+
 const Label = styled.div`
   color: #ffffff;
   margin-bottom: 10px;
@@ -873,3 +940,10 @@ const ActionButton = styled.button`
     font-size: 20px;
   }
 `;
+
+const NoVariablesTextWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff99;
+`;

+ 7 - 0
dashboard/src/components/porter-form/types.ts

@@ -301,3 +301,10 @@ export type GetFinalVariablesFunction = (
   state: PorterFormFieldFieldState,
   context: Partial<ContextProps>
 ) => PorterFormVariableList;
+
+export type GetMetadataFunction<T = unknown> = (
+  vars: PorterFormVariableList,
+  props: FormField,
+  state: PorterFormFieldFieldState,
+  context: Partial<ContextProps>
+) => T;

+ 10 - 9
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -1,3 +1,4 @@
+import { DeviconsNameList } from "assets/devicons-name-list";
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
 import SelectRow from "components/form-components/SelectRow";
@@ -12,8 +13,6 @@ const DEFAULT_BUILDER_NAME = "heroku";
 const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
 const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
 
-const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
-
 type BuildConfig = {
   builder: string;
   buildpacks: string[];
@@ -179,9 +178,16 @@ export const BuildpackSelection: React.FC<{
     action: "remove" | "add"
   ) => {
     return buildpacks?.map((buildpack) => {
-      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
+
       let disableIcon = false;
-      if (URLRegex.test(buildpack.buildpack)) {
+      if (!devicon) {
         disableIcon = true;
       }
 
@@ -314,11 +320,6 @@ export const AddCustomBuildpackForm: React.FC<{
   const [error, setError] = useState(false);
 
   const handleAddCustomBuildpack = () => {
-    if (!URLRegex.test(buildpackUrl)) {
-      setError(true);
-      return;
-    }
-
     const buildpack: Buildpack = {
       buildpack: buildpackUrl,
       name: buildpackUrl,

+ 123 - 176
dashboard/src/hosted.index.html

@@ -1,186 +1,133 @@
 <!DOCTYPE html>
 <html lang="en">
-  <head>
-    <title>Porter | Dashboard</title>
 
-    <script>
-      !(function () {
-        var e = (window.Cohere = window.Cohere || []);
-        if (e.invoked) console.error("Tried to load Cohere twice");
-        else {
-          (e.invoked = !0),
-            (e.snippet = "0.2"),
-            (e.methods = [
-              "init",
-              "identify",
-              "stop",
-              "showCode",
-              "getSessionUrl",
-              "makeCall",
-              "addCallStatusListener",
-              "removeCallStatusListener",
-              "widget",
-            ]),
-            e.methods.forEach(function (o) {
-              e[o] = function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(o), e.push(t);
-              };
-            });
-          var o = document.createElement("script");
-          (o.type = "text/javascript"),
-            (o.async = !0),
-            (o.src = "https://static.cohere.so/main.js"),
-            (o.crossOrigin = "anonymous");
-          var t = document.getElementsByTagName("script")[0];
-          t.parentNode.insertBefore(o, t);
-        }
-      })();
-      window.Cohere.init("<%= htmlWebpackPlugin.options.cohereKey %>");
-    </script>
+<head>
+  <title>Porter | Dashboard</title>
 
-    <script>
-      window.intercomSettings = {
-        app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
-        custom_launcher_selector: "#intercom_help",
-      };
-    </script>
+  <script>
+    window.intercomSettings = {
+      app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
+      custom_launcher_selector: "#intercom_help",
+    };
+  </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);
+  <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 = "<%= htmlWebpackPlugin.options.intercomSrc %>";
+          var x = d.getElementsByTagName("script")[0];
+          x.parentNode.insertBefore(s, x);
+        };
+        if (document.readyState === "complete") {
+          l();
+        } else if (w.attachEvent) {
+          w.attachEvent("onload", l);
         } 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 = "<%= htmlWebpackPlugin.options.intercomSrc %>";
-            var x = d.getElementsByTagName("script")[0];
-            x.parentNode.insertBefore(s, x);
-          };
-          if (document.readyState === "complete") {
-            l();
-          } else if (w.attachEvent) {
-            w.attachEvent("onload", l);
-          } else {
-            w.addEventListener("load", l, false);
-          }
+          w.addEventListener("load", l, false);
         }
-      })();
-    </script>
+      }
+    })();
+  </script>
 
-    <script>
-      !(function () {
-        var analytics = (window.analytics = window.analytics || []);
-        if (!analytics.initialize)
-          if (analytics.invoked)
-            window.console &&
-              console.error &&
-              console.error("Segment snippet included twice.");
-          else {
-            analytics.invoked = !0;
-            analytics.methods = [
-              "trackSubmit",
-              "trackClick",
-              "trackLink",
-              "trackForm",
-              "pageview",
-              "identify",
-              "reset",
-              "group",
-              "track",
-              "ready",
-              "alias",
-              "debug",
-              "page",
-              "once",
-              "off",
-              "on",
-              "addSourceMiddleware",
-              "addIntegrationMiddleware",
-              "setAnonymousId",
-              "addDestinationMiddleware",
-            ];
-            analytics.factory = function (e) {
-              return function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(e);
-                analytics.push(t);
-                return analytics;
-              };
-            };
-            for (var e = 0; e < analytics.methods.length; e++) {
-              var key = analytics.methods[e];
-              analytics[key] = analytics.factory(key);
-            }
-            analytics.load = function (key, e) {
-              var t = document.createElement("script");
-              t.type = "text/javascript";
-              t.async = !0;
-              t.src =
-                "https://cdn.segment.com/analytics.js/v1/" +
-                key +
-                "/analytics.min.js";
-              var n = document.getElementsByTagName("script")[0];
-              n.parentNode.insertBefore(t, n);
-              analytics._loadOptions = e;
+  <script>
+    !(function () {
+      var analytics = (window.analytics = window.analytics || []);
+      if (!analytics.initialize)
+        if (analytics.invoked)
+          window.console &&
+            console.error &&
+            console.error("Segment snippet included twice.");
+        else {
+          analytics.invoked = !0;
+          analytics.methods = [
+            "trackSubmit",
+            "trackClick",
+            "trackLink",
+            "trackForm",
+            "pageview",
+            "identify",
+            "reset",
+            "group",
+            "track",
+            "ready",
+            "alias",
+            "debug",
+            "page",
+            "once",
+            "off",
+            "on",
+            "addSourceMiddleware",
+            "addIntegrationMiddleware",
+            "setAnonymousId",
+            "addDestinationMiddleware",
+          ];
+          analytics.factory = function (e) {
+            return function () {
+              var t = Array.prototype.slice.call(arguments);
+              t.unshift(e);
+              analytics.push(t);
+              return analytics;
             };
-            analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
-            analytics.SNIPPET_VERSION = "4.13.2";
-            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
-            analytics.page();
+          };
+          for (var e = 0; e < analytics.methods.length; e++) {
+            var key = analytics.methods[e];
+            analytics[key] = analytics.factory(key);
           }
-      })();
-    </script>
-    <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
-    <meta
-      name="description"
-      content="Kubernetes powered PaaS that runs in your own cloud."
-    />
-    <meta property="og:title" content="Porter" />
-    <meta
-      property="og:image"
-      content="https://i.ibb.co/52g2g7C/porter-wide.png"
-    />
-    <meta
-      property="og:description"
-      content="Kubernetes powered PaaS that runs in your own cloud."
-    />
-    <meta property="og:url" content="https://porter.run" />
-    <link
-      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
-      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|Material+Icons+Outlined|Material+Icons+Round"
-      rel="stylesheet"
-    />
-    <!-- Coding languages icons -->
-    <link
-      rel="stylesheet"
-      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
-    />
-  </head>
-  <body>
-    <div id="output"></div>
-    <div id="modal-root"></div>
-  </body>
-</html>
+          analytics.load = function (key, e) {
+            var t = document.createElement("script");
+            t.type = "text/javascript";
+            t.async = !0;
+            t.src =
+              "https://cdn.segment.com/analytics.js/v1/" +
+              key +
+              "/analytics.min.js";
+            var n = document.getElementsByTagName("script")[0];
+            n.parentNode.insertBefore(t, n);
+            analytics._loadOptions = e;
+          };
+          analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
+          analytics.SNIPPET_VERSION = "4.13.2";
+          analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
+          analytics.page();
+        }
+    })();
+  </script>
+  <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
+  <meta name="description" content="Kubernetes powered PaaS that runs in your own cloud." />
+  <meta property="og:title" content="Porter" />
+  <meta property="og:image" content="https://i.ibb.co/52g2g7C/porter-wide.png" />
+  <meta property="og:description" content="Kubernetes powered PaaS that runs in your own cloud." />
+  <meta property="og:url" content="https://porter.run" />
+  <link href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600" 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|Material+Icons+Outlined|Material+Icons+Round"
+    rel="stylesheet" />
+  <!-- Coding languages icons -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css" />
+</head>
+
+<body>
+  <div id="output"></div>
+  <div id="modal-root"></div>
+</body>
+
+</html>

+ 6 - 0
dashboard/src/index.tsx

@@ -3,6 +3,7 @@ import "regenerator-runtime/runtime";
 
 import * as React from "react";
 import * as ReactDOM from "react-dom";
+import Cohere from "cohere-js";
 import App from "./App";
 import { SetupSentry } from "shared/error_handling/sentry/setup";
 import { EnableErrorHandling } from "shared/error_handling/window_error_handling";
@@ -12,6 +13,11 @@ declare global {
     analytics: any;
   }
 }
+
+if (process.env.ENABLE_COHERE && process.env.COHERE_API_KEY) {
+  Cohere.init(process.env.COHERE_API_KEY);
+}
+
 if (process.env.ENABLE_SENTRY) {
   SetupSentry();
 }

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

@@ -4,11 +4,6 @@ import { Route, Redirect, Switch } from "react-router-dom";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import Cohere from "cohere-js";
-
-if (window.location.href.includes("dashboard.getporter.dev")) {
-  Cohere.init(process.env.COHERE_API_KEY);
-}
-
 import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
 import Login from "./auth/Login";
@@ -47,7 +42,7 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .then((res) => {
         if (res && res?.data) {
-          if (window.location.href.includes("dashboard.getporter.dev")) {
+          if (process.env.ENABLE_COHERE) {
             Cohere.identify(res?.data?.id, {
               displayName: res?.data?.email,
               email: res?.data?.email,

+ 49 - 59
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -32,6 +32,7 @@ import JobRunTable from "./chart/JobRunTable";
 import SwitchBase from "@material-ui/core/internal/SwitchBase";
 import Selector from "components/Selector";
 import TabSelector from "components/TabSelector";
+import TagFilter from "./TagFilter";
 
 // @ts-ignore
 const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
@@ -60,6 +61,7 @@ type StateType = {
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
   showRuns: boolean;
+  selectedTag: any;
 };
 
 // TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
@@ -73,6 +75,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
     showRuns: false,
+    selectedTag: "none",
   };
 
   componentDidMount() {
@@ -114,7 +117,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         () => pushQueryParams(this.props, { namespace: "default" })
       );
     }
-
     if (prevProps.currentView !== this.props.currentView) {
       let params = this.props.match.params as any;
       let currentNamespace = params.namespace;
@@ -136,12 +138,34 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
   }
 
-  getDescription = (currentView: string): string => {
-    if (currentView === "jobs") {
-      return "Scripts and tasks that run once or on a repeating interval.";
-    } else {
-      return "Continuously running web services, workers, and add-ons.";
-    }
+  renderCommonFilters = () => {
+    const { currentView } = this.props;
+
+    return (
+      <>
+        <TagFilter
+          onSelect={(newSelectedTag) =>
+            this.setState({ selectedTag: newSelectedTag })
+          }
+        />
+        <NamespaceSelector
+          setNamespace={(namespace) =>
+            this.setState({ namespace }, () => {
+              console.log(window.location, namespace);
+              pushQueryParams(this.props, {
+                namespace: this.state.namespace || "ALL",
+              });
+            })
+          }
+          namespace={this.state.namespace}
+        />
+        <SortSelector
+          setSortType={(sortType) => this.setState({ sortType })}
+          sortType={this.state.sortType}
+          currentView={currentView}
+        />
+      </>
+    );
   };
 
   renderBodyForApps = () => {
@@ -151,6 +175,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       [],
       ["get", "create"]
     );
+
     return (
       <>
         <ControlRow>
@@ -163,31 +188,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               <i className="material-icons">add</i> Launch Template
             </Button>
           )}
-          <SortFilterWrapper>
-            {currentView === "jobs" && (
-              <LastRunStatusSelector
-                lastRunStatus={this.state.lastRunStatus}
-                setLastRunStatus={(lastRunStatus: JobStatusType) => {
-                  this.setState({ lastRunStatus });
-                }}
-              />
-            )}
-            <NamespaceSelector
-              setNamespace={(namespace) =>
-                this.setState({ namespace }, () => {
-                  pushQueryParams(this.props, {
-                    namespace: this.state.namespace || "ALL",
-                  });
-                })
-              }
-              namespace={this.state.namespace}
-            />
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-              currentView={currentView}
-            />
-          </SortFilterWrapper>
+          <SortFilterWrapper>{this.renderCommonFilters()}</SortFilterWrapper>
         </ControlRow>
 
         <ChartList
@@ -196,6 +197,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           lastRunStatus={this.state.lastRunStatus}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
+          selectedTag={this.state.selectedTag}
         />
       </>
     );
@@ -208,6 +210,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       [],
       ["get", "create"]
     );
+
     return (
       <>
         <TabSelector
@@ -235,29 +238,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </Button>
           )}
           <SortFilterWrapper>
-            {currentView === "jobs" && (
-              <LastRunStatusSelector
-                lastRunStatus={this.state.lastRunStatus}
-                setLastRunStatus={(lastRunStatus: JobStatusType) => {
-                  this.setState({ lastRunStatus });
-                }}
-              />
-            )}
-            <NamespaceSelector
-              setNamespace={(namespace) =>
-                this.setState({ namespace }, () => {
-                  pushQueryParams(this.props, {
-                    namespace: this.state.namespace || "ALL",
-                  });
-                })
-              }
-              namespace={this.state.namespace}
-            />
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-              currentView={currentView}
+            <LastRunStatusSelector
+              lastRunStatus={this.state.lastRunStatus}
+              setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                this.setState({ lastRunStatus });
+              }}
             />
+            {this.renderCommonFilters()}
           </SortFilterWrapper>
         </ControlRow>
         <HidableElement show={this.state.showRuns}>
@@ -274,6 +261,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             lastRunStatus={this.state.lastRunStatus}
             namespace={this.state.namespace}
             sortType={this.state.sortType}
+            selectedTag={this.state.selectedTag}
           />
         </HidableElement>
       </>
@@ -303,7 +291,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           <DashboardHeader
             image={monojob}
             title={currentView}
-            description={this.getDescription(currentView)}
+            description="Scripts and tasks that run once or on a repeating interval."
             disableLineBreak
           />
 
@@ -315,11 +303,10 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {/* {this.renderContents()} */}
           <DashboardHeader
             image={monoweb}
             title={currentView}
-            description={this.getDescription(currentView)}
+            description="Continuously running web services, workers, and add-ons."
           />
 
           {this.renderBodyForApps()}
@@ -361,7 +348,7 @@ const ControlRow = styled.div`
   margin-left: auto;
   justify-content: space-between;
   align-items: center;
-  margin-bottom: 35px;
+  flex-wrap: wrap;
   padding-left: 0px;
 `;
 
@@ -409,7 +396,9 @@ const Button = styled.div`
   border-radius: 20px;
   color: white;
   height: 35px;
+  margin-bottom: 35px;
   padding: 0px 8px;
+  min-width: 155px;
   padding-bottom: 1px;
   margin-right: 10px;
   font-weight: 500;
@@ -506,7 +495,8 @@ const Img = styled.img`
 const SortFilterWrapper = styled.div`
   display: flex;
   justify-content: space-between;
+  margin-bottom: 35px;
   > div:not(:first-child) {
     margin-left: 30px;
   }
-`;
+`;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx

@@ -46,6 +46,7 @@ export default LastRunStatusSelector;
 const Label = styled.div`
   display: flex;
   align-items: center;
+  min-width: 130px;
   margin-right: 12px;
 
   > i {
@@ -57,5 +58,6 @@ const Label = styled.div`
 const StyledLastRunStatusSelector = styled.div`
   display: flex;
   align-items: center;
+  margin-right: -3px;
   font-size: 13px;
 `;

+ 71 - 0
dashboard/src/main/home/cluster-dashboard/TagFilter.tsx

@@ -0,0 +1,71 @@
+import Selector from "components/Selector";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+const TagFilter = ({ onSelect }: { onSelect: (tag: any) => void }) => {
+  const { currentProject } = useContext(Context);
+  const [selectedTag, setSelectedTag] = useState("none");
+  const [tags, setTags] = useState([]);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getTagsByProjectId("<token>", {}, { project_id: currentProject.id })
+      .then((res) => {
+        const newTags = res.data;
+
+        setTags(newTags);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject]);
+
+  useEffect(() => {
+    const currentTag = tags.find((tag) => tag.name === selectedTag);
+    onSelect(currentTag);
+  }, [selectedTag]);
+
+  return (
+    <StyledTagSelector>
+      <Label>
+        <i className="material-icons">tag</i>
+        Tag
+      </Label>
+      <Selector
+        activeValue={selectedTag}
+        options={[{ label: "No tag selected", value: "none" }].concat(
+          tags.map((tag) => ({
+            value: tag.name,
+            label: tag.name,
+          }))
+        )}
+        setActiveValue={(newVal) => setSelectedTag(newVal)}
+        width={"150px"}
+        dropdownWidth="fit-content"
+      />
+    </StyledTagSelector>
+  );
+};
+
+export default TagFilter;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledTagSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

+ 12 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -27,6 +27,7 @@ type Props = {
   currentView: PorterUrl;
   disableBottomPadding?: boolean;
   closeChartRedirectUrl?: string;
+  selectedTag?: any;
 };
 
 interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
@@ -40,6 +41,7 @@ const ChartList: React.FunctionComponent<Props> = ({
   currentView,
   disableBottomPadding,
   closeChartRedirectUrl,
+  selectedTag,
 }) => {
   const {
     newWebsocket,
@@ -324,6 +326,15 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
 
     const result = charts
+      .filter((chart) => {
+        if (!selectedTag) {
+          return true;
+        }
+
+        return !!selectedTag.releases?.find((release: ChartType) => {
+          return release.name === chart.name;
+        });
+      })
       .filter((chart: ChartType) => {
         return (
           (currentView == "jobs" && chart.chart.metadata.name == "job") ||
@@ -399,7 +410,7 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
 
     return result;
-  }, [charts, sortType, jobStatus, lastRunStatus]);
+  }, [charts, sortType, jobStatus, lastRunStatus, selectedTag]);
 
   const renderChartList = () => {
     if (isLoading || (!namespace && namespace !== "")) {

+ 33 - 34
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -4,7 +4,8 @@ import Modal from "main/home/modals/Modal";
 import EnvEditorModal from "main/home/modals/EnvEditorModal";
 
 import upload from "assets/upload.svg";
-import { parseStringToEnvObject } from "./utils";
+import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
+import { dotenv_parse } from "shared/string_utils";
 
 export type KeyValueType = {
   key: string;
@@ -44,7 +45,7 @@ const EnvGroupArray = ({
   }, [values]);
 
   const readFile = (env: string) => {
-    const envObj = parseStringToEnvObject(env, null);
+    const envObj = dotenv_parse(env);
     const _values = values;
 
     for (const key in envObj) {
@@ -99,19 +100,36 @@ const EnvGroupArray = ({
                     spellCheck={false}
                   />
                   <Spacer />
-                  <Input
-                    placeholder="ex: value"
-                    width="270px"
-                    value={entry.value}
-                    onChange={(e: any) => {
-                      let _values = values;
-                      _values[i].value = e.target.value;
-                      setValues(_values);
-                    }}
-                    disabled={disabled || entry.locked}
-                    type={entry.hidden ? "password" : "text"}
-                    spellCheck={false}
-                  />
+
+                  {entry.hidden ? (
+                    <Input
+                      placeholder="ex: value"
+                      width="270px"
+                      value={entry.value}
+                      onChange={(e: any) => {
+                        let _values = values;
+                        _values[i].value = e.target.value;
+                        setValues(_values);
+                      }}
+                      disabled={disabled || entry.locked}
+                      type={entry.hidden ? "password" : "text"}
+                      spellCheck={false}
+                    />
+                  ) : (
+                    <MultiLineInput
+                      placeholder="ex: value"
+                      width="270px"
+                      value={entry.value}
+                      onChange={(e: any) => {
+                        let _values = values;
+                        _values[i].value = e.target.value;
+                        setValues(_values);
+                      }}
+                      rows={entry.value?.split("\n").length}
+                      disabled={disabled || entry.locked}
+                      spellCheck={false}
+                    />
+                  )}
 
                   {secretOption && (
                     <HideButton
@@ -227,25 +245,6 @@ const AddRowButton = styled.div`
   }
 `;
 
-const LoadButton = styled(AddRowButton)`
-  background: none;
-  border: 1px solid #ffffff55;
-  > i {
-    color: #ffffff44;
-    font-size: 16px;
-    margin-left: 8px;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-  > img {
-    width: 14px;
-    margin-left: 10px;
-    margin-right: 12px;
-  }
-`;
-
 const UploadButton = styled(AddRowButton)`
   background: none;
   position: relative;

+ 0 - 49
dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts

@@ -1,49 +0,0 @@
-export const parseStringToEnvObject = (src: any, options: any) => {
-  const debug = Boolean(options && options.debug);
-  const obj = {} as Record<string, string>;
-  const NEWLINE = "\n";
-  const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-  const RE_NEWLINES = /\\n/g;
-  const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-  // convert Buffers before splitting into lines and processing
-  src
-    .toString()
-    .split(NEWLINES_MATCH)
-    .forEach(function (line: any, idx: any) {
-      // matching "KEY' and 'VAL' in 'KEY=VAL'
-      const keyValueArr = line.match(RE_INI_KEY_VAL);
-      // matched?
-      if (keyValueArr != null) {
-        const key = keyValueArr[1];
-        // default undefined or missing values to empty string
-        let val = keyValueArr[2] || "";
-        const end = val.length - 1;
-        const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-        const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-        // if single or double quoted, remove quotes
-        if (isSingleQuoted || isDoubleQuoted) {
-          val = val.substring(1, end);
-
-          // if double quoted, expand newlines
-          if (isDoubleQuoted) {
-            val = val.replace(RE_NEWLINES, NEWLINE);
-          }
-        } else {
-          // remove surrounding whitespace
-          val = val.trim();
-        }
-
-        obj[key] = val;
-      } else if (debug) {
-        /*
-        console.log(
-          `did not match key and value when parsing line ${idx + 1}: ${line}`
-        );
-        */
-      }
-    });
-
-  return obj;
-};

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

@@ -15,13 +15,9 @@ import {
 } from "shared/types";
 import styled, { keyframes } from "styled-components";
 import yaml from "js-yaml";
-import DynamicLink from "components/DynamicLink";
 import { AxiosError } from "axios";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
-
-const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
-const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
-const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+import { DeviconsNameList } from "assets/devicons-name-list";
 
 type Buildpack = {
   name: string;
@@ -565,13 +561,16 @@ const BuildpackConfigSection: React.FC<{
     }
 
     return buildpacks?.map((buildpack, i) => {
-      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
 
       let disableIcon = false;
-      if (
-        URLRegex.test(buildpack.buildpack) &&
-        !buildpack.buildpack.includes("gcr.io/paketo-buildpacks")
-      ) {
+      if (!devicon) {
         disableIcon = true;
       }
 

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

@@ -1,14 +1,8 @@
-import React, {
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-  useState,
-} from "react";
+import React, { useCallback, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
 import backArrow from "assets/back_arrow.png";
-import _ from "lodash";
+import _, { cloneDeep } from "lodash";
 import loadingSrc from "assets/loading.gif";
 
 import { ChartType, ClusterType, ResourceType } from "shared/types";
@@ -28,7 +22,6 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
-import { onlyInLeft } from "shared/array_utils";
 import IncidentsTab from "./incidents/IncidentsTab";
 import BuildSettingsTab from "./BuildSettingsTab";
 
@@ -221,13 +214,15 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
   };
 
-  const onSubmit = async (rawValues: any) => {
+  const onSubmit = async (props: any) => {
+    const rawValues = props.values;
+
     // console.log("raw", rawValues);
     // Convert dotted keys to nested objects
     let values: any = {};
 
     // Weave in preexisting values and convert to yaml
-    if (props.currentChart.config) {
+    if (props?.currentChart?.config) {
       values = props.currentChart.config;
     }
 
@@ -244,29 +239,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
       ...values,
     });
 
-    const oldSyncedEnvGroups =
-      props.currentChart.config?.container?.env?.synced || [];
-    const newSyncedEnvGroups = values?.container?.env?.synced || [];
-
-    const deletedEnvGroups = onlyInLeft<{
-      keys: Array<any>;
-      name: string;
-      version: number;
-    }>(
-      oldSyncedEnvGroups,
-      newSyncedEnvGroups,
-      (oldVal, newVal) => oldVal.name === newVal.name
-    );
+    const syncedEnvGroups = props?.metadata
+      ? props?.metadata["container.env"]
+      : {};
 
-    const addedEnvGroups = onlyInLeft<{
-      keys: Array<any>;
-      name: string;
-      version: number;
-    }>(
-      newSyncedEnvGroups,
-      oldSyncedEnvGroups,
-      (oldVal, newVal) => oldVal.name === newVal.name
-    );
+    const deletedEnvGroups = syncedEnvGroups?.deleted || [];
+
+    const addedEnvGroups = syncedEnvGroups?.added || [];
 
     const addApplicationToEnvGroupPromises = addedEnvGroups.map(
       (envGroup: any) => {
@@ -350,9 +329,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
         err = parsedErr;
       }
 
-      setSaveValueStatus(err);
+      setSaveValueStatus("The api answered with an error");
 
-      setCurrentError(parsedErr);
+      setCurrentError(JSON.stringify(parsedErr));
 
       window.analytics?.track("Failed to Upgrade Chart", {
         chart: currentChart.name,
@@ -857,7 +836,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
               {(isPreview || leftTabOptions.length > 0) && (
                 <BodyWrapper>
                   <PorterFormWrapper
-                    formData={currentChart.form}
+                    formData={cloneDeep(currentChart.form)}
                     valuesToOverride={{
                       namespace: props.namespace,
                       clusterId: currentCluster.id,
@@ -869,6 +848,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                       !isAuthorized("application", "", ["get", "update"])
                     }
                     onSubmit={onSubmit}
+                    includeMetadata
                     rightTabOptions={rightTabOptions}
                     leftTabOptions={leftTabOptions}
                     color={isPreview ? "#f5cb42" : null}

+ 46 - 39
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -1,9 +1,9 @@
-import React, { useContext, useEffect, useRef, useState } from "react";
+import React, { useContext, useMemo, useState } from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
 
 import backArrow from "assets/back_arrow.png";
-import { merge, set } from "lodash";
+import { cloneDeep, set } from "lodash";
 import loading from "assets/loading.gif";
 
 import { ChartType, ClusterType } from "shared/types";
@@ -94,10 +94,11 @@ export const ExpandedJobChartFC: React.FC<{
 
   const leftTabOptions = [{ label: "Jobs", value: "jobs" }];
 
-  const processValuesToUpdateChart = (newConfig?: any) => (
-    currentChart: ChartType
-  ) => {
-    // return "";
+  const processValuesToUpdateChart = (props?: {
+    values: any;
+    metadata: any;
+  }) => (currentChart: ChartType) => {
+    const newConfig = props.values;
     let conf: string;
     let values = currentChart.config;
 
@@ -117,7 +118,7 @@ export const ExpandedJobChartFC: React.FC<{
       conf = yaml.dump(values, { forceQuotes: true });
     }
 
-    return conf;
+    return { yaml: conf, metadata: props.metadata };
   };
 
   const handleDeleteChart = async () => {
@@ -230,8 +231,8 @@ export const ExpandedJobChartFC: React.FC<{
                 }}
                 isDeployedFromGithub={!!chart?.git_action_config?.git_repo}
                 repositoryUrl={chart?.git_action_config?.git_repo}
-                currentChartVersion={Number(chart.version)}
-                latestChartVersion={Number(chart.latest_version)}
+                currentChartVersion={Number(chart?.version)}
+                latestChartVersion={Number(chart?.latest_version)}
               />
             </>
           )}
@@ -250,7 +251,7 @@ export const ExpandedJobChartFC: React.FC<{
     }
 
     if (currentTab === "build-settings") {
-      return <BuildSettingsTab chart={chart} />;
+      return <BuildSettingsTab chart={chart} isPreviousVersion={disableForm} />;
     }
 
     if (
@@ -264,7 +265,7 @@ export const ExpandedJobChartFC: React.FC<{
           setShowDeleteOverlay={(showOverlay: boolean) => {
             if (showOverlay) {
               setCurrentOverlay({
-                message: `Are you sure you want to delete ${chart.name}?`,
+                message: `Are you sure you want to delete ${chart?.name}?`,
                 onYes: handleDeleteChart,
                 onNo: () => setCurrentOverlay(null),
               });
@@ -280,6 +281,8 @@ export const ExpandedJobChartFC: React.FC<{
     return null;
   };
 
+  const formData = useMemo(() => cloneDeep(chart?.form || {}), [chart]);
+
   if (status === "loading") {
     return <Loading />;
   }
@@ -290,6 +293,7 @@ export const ExpandedJobChartFC: React.FC<{
         <ExpandedJobHeader
           chart={chart}
           jobs={jobs}
+          disableRevisions
           closeChart={closeChart}
           refreshChart={refreshChart}
           upgradeChart={upgradeChart}
@@ -300,7 +304,7 @@ export const ExpandedJobChartFC: React.FC<{
         <Placeholder>
           <TextWrap>
             <Header>
-              <Spinner src={loading} /> Deleting "{chart.name}"
+              <Spinner src={loading} /> Deleting "{chart?.name}"
             </Header>
             You will be automatically redirected after deletion is complete.
           </TextWrap>
@@ -319,8 +323,6 @@ export const ExpandedJobChartFC: React.FC<{
     );
   }
 
-  const formData = { ...chart.form };
-
   return (
     <>
       <ConnectToJobInstructionsModal
@@ -345,7 +347,7 @@ export const ExpandedJobChartFC: React.FC<{
             <PorterFormWrapper
               formData={formData}
               valuesToOverride={{
-                namespace: chart.namespace,
+                namespace: chart?.namespace,
                 clusterId: currentCluster?.id,
               }}
               renderTabContents={renderTabContents}
@@ -357,6 +359,7 @@ export const ExpandedJobChartFC: React.FC<{
               onSubmit={(formValues) =>
                 updateChart(processValuesToUpdateChart(formValues))
               }
+              includeMetadata
               leftTabOptions={leftTabOptions}
               rightTabOptions={rightTabOptions}
               saveValuesStatus={saveStatus}
@@ -391,6 +394,7 @@ const ExpandedJobHeader: React.FC<{
   upgradeChart: () => Promise<void>;
   loadChartWithSpecificRevision: (revision: number) => void;
   setDisableForm: (disable: boolean) => void;
+  disableRevisions?: boolean;
 }> = ({
   chart,
   closeChart,
@@ -399,13 +403,14 @@ const ExpandedJobHeader: React.FC<{
   upgradeChart,
   loadChartWithSpecificRevision,
   setDisableForm,
+  disableRevisions,
 }) => (
   <HeaderWrapper>
     <BackButton onClick={closeChart}>
       <BackButtonImg src={backArrow} />
     </BackButton>
-    <TitleSection icon={chart.chart.metadata.icon} iconWidth="33px">
-      {chart.name}
+    <TitleSection icon={chart?.chart.metadata.icon} iconWidth="33px">
+      {chart?.name}
       <DeploymentType currentChart={chart} />
       <TagWrapper>
         Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
@@ -421,28 +426,30 @@ const ExpandedJobHeader: React.FC<{
         {" " + readableDate(chart.info.last_deployed)}
       </LastDeployed>
     </InfoWrapper>
-    <RevisionSection
-      chart={chart}
-      refreshChart={() => refreshChart()}
-      setRevision={(chart, isCurrent) => {
-        loadChartWithSpecificRevision(chart?.version);
-        setDisableForm(!isCurrent);
-      }}
-      forceRefreshRevisions={false}
-      refreshRevisionsOff={() => {}}
-      shouldUpdate={
-        chart.latest_version &&
-        chart.latest_version !== chart.chart.metadata.version
-      }
-      latestVersion={chart.latest_version}
-      upgradeVersion={(_version, cb) => {
-        upgradeChart().then(() => {
-          if (typeof cb === "function") {
-            cb();
-          }
-        });
-      }}
-    />
+    {!disableRevisions ? (
+      <RevisionSection
+        chart={chart}
+        refreshChart={() => refreshChart()}
+        setRevision={(chart, isCurrent) => {
+          loadChartWithSpecificRevision(chart?.version);
+          setDisableForm(!isCurrent);
+        }}
+        forceRefreshRevisions={false}
+        refreshRevisionsOff={() => {}}
+        shouldUpdate={
+          chart?.latest_version &&
+          chart?.latest_version !== chart?.chart.metadata.version
+        }
+        latestVersion={chart?.latest_version}
+        upgradeVersion={(_version, cb) => {
+          upgradeChart().then(() => {
+            if (typeof cb === "function") {
+              cb();
+            }
+          });
+        }}
+      />
+    ) : null}
   </HeaderWrapper>
 );
 

+ 45 - 29
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -16,10 +16,12 @@ import useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
 import NotificationSettingsSection from "./NotificationSettingsSection";
 import { Link } from "react-router-dom";
+import { isDeployedFromGithub } from "shared/release/utils";
+import TagSelector from "./TagSelector";
 
 type PropsType = {
   currentChart: ChartType;
-  refreshChart: () => void;
+  refreshChart: () => Promise<void>;
   setShowDeleteOverlay: (x: boolean) => void;
   saveButtonText?: string | null;
 };
@@ -53,6 +55,7 @@ const SettingsSection: React.FC<PropsType> = ({
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
   );
+
   const [isAuthorized] = useAuth();
 
   useEffect(() => {
@@ -84,7 +87,9 @@ const SettingsSection: React.FC<PropsType> = ({
       .catch(console.log)
       .finally(() => setLoadingWebhookToken(false));
 
-    return () => (isSubscribed = false);
+    return () => {
+      isSubscribed = false;
+    };
   }, [currentChart, currentCluster, currentProject]);
 
   const handleSubmit = async () => {
@@ -212,33 +217,35 @@ const SettingsSection: React.FC<PropsType> = ({
 
     return (
       <>
-        <>
-          <Heading>Source Settings</Heading>
-          <Helper>Specify an image tag to use.</Helper>
-          <ImageSelector
-            selectedTag={selectedTag}
-            selectedImageUrl={selectedImageUrl}
-            setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
-            setSelectedTag={(x: string) => setSelectedTag(x)}
-            forceExpanded={true}
-            disableImageSelect={true}
-          />
-          {!loadingWebhookToken && (
-            <>
-              <Br />
-              <Br />
-              <Br />
-              <SaveButton
-                clearPosition={true}
-                statusPosition="right"
-                text="Save Source Settings"
-                status={saveValuesStatus}
-                onClick={handleSubmit}
-              />
-            </>
-          )}
-          <Br />
-        </>
+        {!isDeployedFromGithub(currentChart) ? (
+          <>
+            <Heading>Source Settings</Heading>
+            <Helper>Specify an image tag to use.</Helper>
+            <ImageSelector
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+              setSelectedTag={(x: string) => setSelectedTag(x)}
+              forceExpanded={true}
+              disableImageSelect={true}
+            />
+            {!loadingWebhookToken && (
+              <>
+                <Br />
+                <Br />
+                <Br />
+                <SaveButton
+                  clearPosition={true}
+                  statusPosition="right"
+                  text="Save Source Settings"
+                  status={saveValuesStatus}
+                  onClick={handleSubmit}
+                />
+              </>
+            )}
+            <Br />
+          </>
+        ) : null}
 
         <>
           <Heading>Redeploy Webhook</Heading>
@@ -273,6 +280,9 @@ const SettingsSection: React.FC<PropsType> = ({
             </Webhook>
           )}
         </>
+        <Heading>Application Tags</Heading>
+        <Helper>Add tags for filtering applications.</Helper>
+        <TagSelector release={currentChart} onSave={(val) => refreshChart()} />
       </>
     );
   };
@@ -334,6 +344,12 @@ const SettingsSection: React.FC<PropsType> = ({
 
 export default SettingsSection;
 
+const DarkMatter = styled.div`
+  width: 100%;
+  height: 0;
+  margin-top: -10px;
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 10px;

+ 349 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/TagSelector.tsx

@@ -0,0 +1,349 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import { Tooltip } from "@material-ui/core";
+import Modal from "main/home/modals/Modal";
+import { TwitterPicker } from "react-color";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import api from "shared/api";
+import Color from "color";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import Helper from "components/form-components/Helper";
+import { differenceBy } from "lodash";
+import SearchSelector from "components/SearchSelector";
+
+type Props = {
+  onSave: ((values: any[]) => void) | ((values: any[]) => Promise<void>);
+  release: ChartType;
+};
+
+const TagSelector = ({ onSave, release }: Props) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [values, setValues] = useState([]);
+  const [availableTags, setAvailableTags] = useState([]);
+  const [openModal, setOpenModal] = useState(false);
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const onDelete = (index: number) => {
+    setValues((prev) => {
+      const newValues = [...prev];
+      const removedTag = newValues.splice(index, 1);
+      setAvailableTags((prevAt) => [...prevAt, ...removedTag]);
+      return newValues;
+    });
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+
+    try {
+      await api.updateReleaseTags(
+        "<token>",
+        { tags: [...values.map((tag) => tag.name)] },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: release.namespace,
+          release_name: release.name,
+        }
+      );
+      await onSave(values);
+      setButtonStatus("successful");
+    } catch (error) {
+      console.log(error);
+      setCurrentError(
+        "We couldn't link the tag to the release, please try again."
+      );
+      setButtonStatus("Couldn't link the tag to the release");
+      return;
+    } finally {
+      setTimeout(() => {
+        setButtonStatus("");
+      }, 800);
+    }
+  };
+
+  useEffect(() => {
+    api
+      .getTagsByProjectId<any[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      )
+      .then(({ data }) => {
+        const releaseTags = data.filter((tag) =>
+          release.tags?.includes(tag.name)
+        );
+        const tmpAvailableTags = differenceBy(data, releaseTags, "name");
+
+        setValues(releaseTags);
+        setAvailableTags(tmpAvailableTags);
+      });
+  }, [currentProject]);
+
+  const hasUnsavedChanges = useMemo(() => {
+    const hasAddedSomething = !!differenceBy(
+      values,
+      release.tags?.map((tagName: string) => ({ name: tagName })) || [],
+      "name"
+    ).length;
+
+    const hasDeletedSomething = !!differenceBy(
+      release.tags?.map((tagName: string) => ({ name: tagName })) || [],
+      values,
+      "name"
+    ).length;
+
+    return hasAddedSomething || hasDeletedSomething;
+  }, [values, release]);
+
+  return (
+    <>
+      {openModal ? (
+        <CreateTagModal
+          onSave={async (newTag) => {
+            const newValues = [...values, newTag];
+            await onSave(newValues);
+            setValues(newValues);
+          }}
+          onClose={() => setOpenModal(false)}
+          release={release}
+        />
+      ) : null}
+      <Flex>
+        {values.map((val, index) => {
+          return (
+            <Tag color={val.color} key={index}>
+              <Tooltip title={val.name}>
+                <TagText>{val.name}</TagText>
+              </Tooltip>
+              <i className="material-icons" onClick={() => onDelete(index)}>
+                cancel
+              </i>
+            </Tag>
+          );
+        })}
+      </Flex>
+      <SearchSelector
+        options={availableTags}
+        dropdownLabel="Select a tag"
+        renderAddButton={() => (
+          <AddTagButton
+            onClick={(e) => {
+              setOpenModal((prev) => !prev);
+            }}
+          >
+            + Create a new tag
+          </AddTagButton>
+        )}
+        filterBy="name"
+        onSelect={(value) => {
+          console.log(value);
+          setAvailableTags((prev) =>
+            prev.filter((prevVal) => prevVal.name !== value.name)
+          );
+          setValues((prev) => [...prev, value]);
+        }}
+        getOptionLabel={(option) => option.name}
+        renderOptionIcon={(option) => <TagColorBox color={option.color} />}
+      />
+      <Flex
+        style={{
+          marginTop: "25px",
+        }}
+      >
+        <SaveButton
+          helper={hasUnsavedChanges ? "Unsaved changes" : ""}
+          clearPosition
+          statusPosition="right"
+          text="Save changes"
+          onClick={() => handleSave()}
+          status={buttonStatus}
+          disabled={!hasUnsavedChanges || buttonStatus === "loading"}
+        ></SaveButton>
+      </Flex>
+      <Br />
+    </>
+  );
+};
+
+const AddTagButton = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  padding: 10px 0;
+  z-index: 999;
+  padding-left: 12px;
+  cursor: pointer;
+  :hover {
+    color: white;
+  }
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
+const CreateTagModal = ({
+  onSave,
+  onClose,
+  release,
+}: {
+  onSave: ((tag: any) => void) | ((tag: any) => Promise<void>);
+  onClose: () => void;
+  release: ChartType;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [color, setColor] = useState("#ffffff");
+  const [name, setName] = useState("some-random-tag");
+
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const createTag = async () => {
+    setButtonStatus("loading");
+    try {
+      await api.createTag(
+        "<token>",
+        { name, color },
+        {
+          project_id: currentProject.id,
+        }
+      );
+    } catch (error) {
+      setCurrentError(error);
+      setButtonStatus("Couldn't create the tag");
+      return;
+    }
+
+    try {
+      await api.updateReleaseTags(
+        "<token>",
+        { tags: [...(release.tags || []), name] },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: release.namespace,
+          release_name: release.name,
+        }
+      );
+      setButtonStatus("successful");
+      await onSave({ name, color });
+      setTimeout(() => {
+        onClose();
+      }, 800);
+    } catch (error) {
+      console.log(error);
+      setCurrentError(
+        "We couldn't link the tag to the release, please link it manually from the settings tab."
+      );
+      setButtonStatus("Couldn't link the tag to the release");
+      return;
+    }
+  };
+
+  return (
+    <Modal title="Create a new tag" onRequestClose={onClose} height="auto">
+      <Helper>
+        Create a new tag and link the release you're currently at to the brand
+        new tag.
+      </Helper>
+
+      <InputRow
+        type="text"
+        label="Tag name"
+        value={name}
+        setValue={(val) => setName(val as string)}
+        isRequired
+        width="300px"
+      ></InputRow>
+      <Label>Tag color</Label>
+      <TwitterPicker
+        triangle="hide"
+        color={color}
+        onChange={(newColor) => setColor(newColor.hex)}
+      ></TwitterPicker>
+
+      <Label style={{ marginTop: "15px" }}>Result</Label>
+      <Tag color={color} style={{ maxWidth: "none", marginTop: "0px" }}>
+        <TagText>{name}</TagText>
+      </Tag>
+      <Flex
+        style={{
+          justifyContent: "flex-end",
+        }}
+      >
+        <SaveButton
+          clearPosition
+          onClick={() => createTag()}
+          text={"Create Tag"}
+          disabled={!name.length || buttonStatus === "loading"}
+        ></SaveButton>
+      </Flex>
+    </Modal>
+  );
+};
+
+export default TagSelector;
+
+const Flex = styled.div`
+  display: flex;
+  position: relative;
+`;
+
+const Tag = styled.div<{ color: string }>`
+  display: inline-flex;
+  color: ${(props) => Color(props.color).darken(0.4).string() || "inherit"};
+  user-select: none;
+  border: 1px solid ${(props) => Color(props.color).darken(0.4).string()};
+  border-radius: 5px;
+  padding: 4px 8px;
+  position: relative;
+  margin-bottom: 20px;
+  text-align: center;
+  align-items: center;
+  font-size: 13px;
+  background-color: ${(props) => props.color || "inherit"};
+
+  max-width: 150px;
+  min-width: 60px;
+
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+
+  > .material-icons {
+    font-size: 16px;
+    :hover {
+      cursor: pointer;
+    }
+  }
+`;
+
+const TagText = styled.span`
+  overflow-x: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const TagColorBox = styled.div`
+  width: 15px;
+  height: 15px;
+  margin-right: 10px;
+  border-radius: 0px;
+  background-color: ${(props: { color: string }) => props.color};
+`;

+ 7 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -26,7 +26,6 @@ const JobListFC = (props: PropsType): JSX.Element => {
     setCurrentOverlay,
     setCurrentError,
   } = useContext(Context);
-  const [deletionCandidate, setDeletionCandidate] = useState(null);
   const [deletionJob, setDeletionJob] = useState(null);
 
   const {
@@ -36,8 +35,6 @@ const JobListFC = (props: PropsType): JSX.Element => {
     page,
     prevPage,
     totalPages,
-    pageSize,
-    setPageSize,
     canNextPage,
     canPreviousPage,
   } = usePagination({
@@ -45,8 +42,7 @@ const JobListFC = (props: PropsType): JSX.Element => {
     initialPageSize: 30,
   });
 
-  const deleteJob = () => {
-    let job = deletionCandidate;
+  const deleteJob = (job: any) => {
     setCurrentOverlay(null);
     api
       .deleteJob(
@@ -60,8 +56,7 @@ const JobListFC = (props: PropsType): JSX.Element => {
         }
       )
       .then((res) => {
-        setDeletionJob(deletionCandidate);
-        setDeletionCandidate(null);
+        setDeletionJob(job);
       })
       .catch((err) => {
         let parsedErr = err?.response?.data?.error;
@@ -69,6 +64,9 @@ const JobListFC = (props: PropsType): JSX.Element => {
           err = parsedErr;
         }
         setCurrentError(err);
+      })
+      .finally(() => {
+        setCurrentOverlay(null);
       });
   };
 
@@ -95,14 +93,10 @@ const JobListFC = (props: PropsType): JSX.Element => {
                 expandJob={props.expandJob}
                 job={job}
                 handleDelete={() => {
-                  setDeletionCandidate(job);
                   setCurrentOverlay({
                     message: "Are you sure you want to delete this job run?",
-                    onYes: deleteJob,
-                    onNo: () => {
-                      setDeletionCandidate(null);
-                      setCurrentOverlay(null);
-                    },
+                    onYes: () => deleteJob(job),
+                    onNo: () => setCurrentOverlay(null),
                   });
                 }}
                 deleting={deletionJob?.metadata?.name == job.metadata?.name}

+ 23 - 53
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -3,7 +3,7 @@ import { useContext, useEffect, useRef, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
-import { ChartType } from "shared/types";
+import { ChartType, ChartTypeWithExtendedConfig } from "shared/types";
 import yaml from "js-yaml";
 import { usePrevious } from "shared/hooks/usePrevious";
 import { useRouting } from "shared/routing";
@@ -41,6 +41,27 @@ export const useJobs = (chart: ChartType) => {
     closeWebsocket,
   } = useWebsockets();
 
+  const isBeingDeployed = (latestJob: any) => {
+    const currentChart: ChartTypeWithExtendedConfig = chart;
+    const chartImage = currentChart.config.image.repository;
+
+    let latestImageDetected =
+      latestJob?.spec?.template?.spec?.containers[0]?.image;
+
+    if (!PORTER_IMAGE_TEMPLATES.includes(chartImage)) {
+      return false;
+    }
+
+    if (
+      latestImageDetected &&
+      !PORTER_IMAGE_TEMPLATES.includes(latestImageDetected)
+    ) {
+      return false;
+    }
+
+    return true;
+  };
+
   const sortJobsAndSave = (newJobs: any[]) => {
     // Set job run from URL if needed
     const urlParams = new URLSearchParams(location.search);
@@ -51,10 +72,7 @@ export const useJobs = (chart: ChartType) => {
 
     newJobs.sort((job1, job2) => getTime(job2) - getTime(job1));
 
-    let latestImageDetected =
-      newJobs[0]?.spec?.template?.spec?.containers[0]?.image;
-    if (!PORTER_IMAGE_TEMPLATES.includes(latestImageDetected)) {
-      // this.setState({ jobs, newestImage, imageIsPlaceholder: false });
+    if (isBeingDeployed(newJobs[0])) {
       setHasPorterImageTemplate(false);
     }
     jobsRef.current = newJobs;
@@ -236,51 +254,6 @@ export const useJobs = (chart: ChartType) => {
     setSelectedJob(job);
   };
 
-  // useEffect(() => {
-  //   let isSubscribed = true;
-
-  //   if (!chart) {
-  //     return () => {
-  //       isSubscribed = false;
-  //     };
-  //   }
-
-  //   if (
-  //     previousChart?.name === chart?.name &&
-  //     previousChart?.namespace === chart?.namespace
-  //   ) {
-  //     return () => {
-  //       isSubscribed = false;
-  //     };
-  //   }
-
-  //   setStatus("loading");
-  //   const newestImage = chart?.config?.image?.repository;
-
-  //   setHasPorterImageTemplate(PORTER_IMAGE_TEMPLATES.includes(newestImage));
-
-  //   api
-  //     .getJobs(
-  //       "<token>",
-  //       {},
-  //       {
-  //         id: currentProject?.id,
-  //         cluster_id: currentCluster?.id,
-  //         namespace: chart.namespace,
-  //         release_name: chart.name,
-  //       }
-  //     )
-  //     .then((res) => {
-  //       if (isSubscribed) {
-  //         sortJobsAndSave(res.data);
-  //         setStatus("ready");
-  //       }
-  //     });
-  //   return () => {
-  //     isSubscribed = false;
-  //   };
-  // }, [chart]);
-
   useEffect(() => {
     if (!chart || !chart.namespace || !chart.name) {
       return () => {};
@@ -406,9 +379,6 @@ export const useJobs = (chart: ChartType) => {
           err = parsedErr;
         }
 
-        // this.setState({
-        //   saveValuesStatus: parsedErr,
-        // });
         setTriggerRunStatus("Couldn't trigger a new run for this job.");
         setTimeout(() => setTriggerRunStatus(""), 500);
         setCurrentError(parsedErr);

+ 0 - 195
dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx

@@ -1,195 +0,0 @@
-import Loading from "components/Loading";
-import React, { useCallback, useContext, useEffect, useState } from "react";
-import { useHistory, useLocation } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { useRouting } from "shared/routing";
-import styled from "styled-components";
-import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
-import DashboardHeader from "../DashboardHeader";
-import PullRequestIcon from "assets/pull_request_icon.svg";
-import DeploymentList from "./deployments/DeploymentList";
-import EnvironmentsList from "./environments/EnvironmentsList";
-import { environments } from "./mocks";
-import { PreviewEnvironmentsHeader } from "./components/PreviewEnvironmentsHeader";
-
-const PreviewEnvironmentsHome = () => {
-  const { currentCluster, currentProject } = useContext(Context);
-
-  const [hasGHAccountsLinked, setHasGHAccountsLinked] = useState(false);
-  const [hasEnvironments, setHasEnvironments] = useState(false);
-  const [isLoading, setIsLoading] = useState(true);
-  const [environments, setEnvironments] = useState([]);
-  const [selectedRepo, setSelectedRepo] = useState("");
-
-  const { getQueryParam } = useRouting();
-  const location = useLocation();
-  const history = useHistory();
-
-  const getAccounts = async () => {
-    try {
-      const res = await api.getGithubAccounts("<token>", {}, {});
-      if (res.status !== 200) {
-        throw new Error("Not authorized");
-      }
-
-      return res.data;
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const getEnvironments = async () => {
-    try {
-      const { data } = await api.listEnvironments(
-        "<token>",
-        {},
-        {
-          project_id: currentProject?.id,
-          cluster_id: currentCluster?.id,
-        }
-      );
-
-      return data;
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const checkPreviewEnvironmentsEnabling = async (subscribeStauts: {
-    subscribed: boolean;
-  }) => {
-    try {
-      await getAccounts();
-
-      const envs = await getEnvironments();
-      // const envs = await mockRequest();
-
-      if (!subscribeStauts.subscribed) {
-        return;
-      }
-
-      if (!Array.isArray(envs)) {
-        setHasGHAccountsLinked(true);
-        return;
-      }
-
-      setHasGHAccountsLinked(true);
-      setHasEnvironments(true);
-      setEnvironments(envs);
-    } catch (error) {
-      setHasGHAccountsLinked(false);
-    }
-  };
-
-  useEffect(() => {
-    let subscribedStatus = { subscribed: true };
-
-    setIsLoading(true);
-
-    checkPreviewEnvironmentsEnabling(subscribedStatus).finally(() => {
-      if (subscribedStatus.subscribed) {
-        setIsLoading(false);
-      }
-    });
-
-    return () => {
-      subscribedStatus.subscribed = false;
-    };
-  }, [currentCluster, currentProject]);
-
-  useEffect(() => {
-    const current_repo = getQueryParam("repository");
-    setSelectedRepo(current_repo);
-  }, [location.search, history]);
-
-  if (isLoading) {
-    return (
-      <>
-        <PreviewEnvironmentsHeader />
-        <Placeholder>
-          <Loading />
-        </Placeholder>
-      </>
-    );
-  }
-
-  if (!hasGHAccountsLinked) {
-    return (
-      <>
-        <PreviewEnvironmentsHeader />
-        <Placeholder>
-          <Title>There are no repositories linked</Title>
-          <Subtitle>
-            In order to use preview environments, you must install the porter
-            app in at least one repository.
-          </Subtitle>
-          <ButtonEnablePREnvironments />
-        </Placeholder>
-      </>
-    );
-  }
-
-  if (!hasEnvironments) {
-    return (
-      <>
-        <PreviewEnvironmentsHeader />
-
-        <Placeholder>
-          <Title>Preview environments are not enabled on this cluster</Title>
-          <Subtitle>
-            In order to use preview environments, you must enable preview
-            environments on this cluster.
-          </Subtitle>
-          <ButtonEnablePREnvironments />
-        </Placeholder>
-      </>
-    );
-  }
-
-  return (
-    <>
-      <PreviewEnvironmentsHeader />
-      <EnvironmentsList
-        environments={environments}
-        setEnvironments={setEnvironments}
-      />
-    </>
-  );
-};
-
-export default PreviewEnvironmentsHome;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Title = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-  width: 50%;
-`;
-
-const Subtitle = styled.div`
-  width: 50%;
-`;

+ 53 - 18
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -7,8 +7,12 @@ import { Link } from "react-router-dom";
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
 
+type Props = {
+  setIsReady: (status: boolean) => void;
+};
+
 // TODO: Billing is still not capable to show if a user can use or not PR environments, add that instead of "hasBillingEnabled"
-const ButtonEnablePREnvironments = () => {
+const ButtonEnablePREnvironments = ({ setIsReady }: Props) => {
   // const { hasBillingEnabled } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [hasGHAccountConnected, setHasGHAccountConnected] = useState(false);
@@ -46,6 +50,10 @@ const ButtonEnablePREnvironments = () => {
     };
   }, []);
 
+  useEffect(() => {
+    setIsReady(!isLoading);
+  }, [isLoading]);
+
   const getButtonProps = () => {
     const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
 
@@ -87,7 +95,7 @@ const ButtonEnablePREnvironments = () => {
         <Container>
           <Button {...getButtonProps()}>
             <img src={pr_icon} alt="Pull request icon" />
-            Connect repositories
+            Connect GitHub account
           </Button>
         </Container>
       </>
@@ -98,8 +106,7 @@ const ButtonEnablePREnvironments = () => {
     <>
       <Container>
         <Button {...getButtonProps()}>
-          <img src={pr_icon} alt="Pull request icon" />
-          Enable Preview Environments
+          <i className="material-icons">add</i> Add Repository
         </Button>
       </Container>
     </>
@@ -109,29 +116,57 @@ const ButtonEnablePREnvironments = () => {
 export default ButtonEnablePREnvironments;
 
 const Button = styled(DynamicLink)`
-  background-color: #616feecc;
-  border: none;
-  border-radius: 6px;
-  color: white;
   display: flex;
+  flex-direction: row;
   align-items: center;
-  justify-content: center;
-  padding: 8px 12px;
-  font-size: 14px;
+  justify-content: space-between;
+  font-size: 13px;
   cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
   img {
-    margin-right: 10px;
-    width: 20px;
-    height: 20px;
+    margin-left: 2px;
+    margin-right: 5px;
+    width: 18px;
+    height: 18px;
   }
-  transition: background-color 150ms ease-out;
-  :hover {
-    background-color: #616feefb;
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
   }
 `;
 
 const Container = styled.div`
   width: 50%;
   display: flex;
-  margin-top: 20px;
 `;

+ 101 - 27
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -4,51 +4,117 @@ import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import styled from "styled-components";
-import { deployments, environments } from "../mocks";
+import ButtonEnablePREnvironments from "../components/ButtonEnablePREnvironments";
+import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import { Environment } from "../types";
 import EnvironmentCard from "./EnvironmentCard";
 
-type Props = {
-  environments: Environment[];
-  setEnvironments: (
-    setFunction: (prev: Environment[]) => Environment[]
-  ) => void;
-};
+const EnvironmentsList = () => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [buttonIsReady, setButtonIsReady] = useState(false);
+
+  const [environments, setEnvironments] = useState<Environment[]>([]);
 
-const EnvironmentsList = ({ environments, setEnvironments }: Props) => {
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
     setEnvironments((prev) => {
       return prev.filter((env) => env.id !== deletedEnv.id);
     });
   };
 
+  const getEnvironments = async () => {
+    try {
+      const { data } = await api.listEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      return data;
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const checkPreviewEnvironmentsEnabling = async (subscribeStauts: {
+    subscribed: boolean;
+  }) => {
+    try {
+      const envs = await getEnvironments();
+      // const envs = await mockRequest();
+
+      if (!subscribeStauts.subscribed) {
+        return;
+      }
+
+      if (!Array.isArray(envs)) {
+        return;
+      }
+
+      setEnvironments(envs);
+    } catch (error) {
+      setEnvironments([]);
+    }
+  };
+
+  useEffect(() => {
+    let subscribedStatus = { subscribed: true };
+
+    setIsLoading(true);
+
+    checkPreviewEnvironmentsEnabling(subscribedStatus).finally(() => {
+      if (subscribedStatus.subscribed) {
+        setIsLoading(false);
+      }
+    });
+
+    return () => {
+      subscribedStatus.subscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
   return (
     <>
-      <ControlRow>
-        <Button to={`/preview-environments/connect-repo`}>
-          <i className="material-icons">add</i> Add Repository
-        </Button>
-      </ControlRow>
-      {environments.length === 0 && (
-        <Placeholder>
-          No repositories found with Preview Environments enabled.
-        </Placeholder>
-      )}
-      <EnvironmentsGrid>
-        {environments.map((env) => (
-          <EnvironmentCard
-            key={env.id}
-            environment={env}
-            onDelete={removeEnvironmentFromList}
-          />
-        ))}
-      </EnvironmentsGrid>
+      <PreviewEnvironmentsHeader />
+      <Relative>
+        {isLoading || !buttonIsReady ? (
+          <FloatingPlaceholder>
+            <Loading />
+          </FloatingPlaceholder>
+        ) : null}
+
+        <ControlRow>
+          <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
+        </ControlRow>
+        {environments.length === 0 ? (
+          <Placeholder>
+            No repositories found with Preview Environments enabled.
+          </Placeholder>
+        ) : (
+          <EnvironmentsGrid>
+            {environments.map((env) => (
+              <EnvironmentCard
+                key={env.id}
+                environment={env}
+                onDelete={removeEnvironmentFromList}
+              />
+            ))}
+          </EnvironmentsGrid>
+        )}
+      </Relative>
     </>
   );
 };
 
 export default EnvironmentsList;
 
+const Relative = styled.div`
+  position: relative;
+`;
+
 const Placeholder = styled.div`
   padding: 30px;
   margin-top: 35px;
@@ -71,6 +137,14 @@ const Placeholder = styled.div`
   }
 `;
 
+const FloatingPlaceholder = styled(Placeholder)`
+  position: absolute;
+  background: #3d3f42;
+  width: 100%;
+  height: 100%;
+  margin-top: 0px;
+`;
+
 const EnvironmentsGrid = styled.div`
   margin-top: 32px;
   padding-bottom: 150px;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -4,7 +4,7 @@ import { Context } from "shared/Context";
 import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentList from "./deployments/DeploymentList";
-import PreviewEnvironmentsHome from "./PreviewEnvironmentsHome";
+import EnvironmentsList from "./environments/EnvironmentsList";
 
 export const Routes = () => {
   const { path } = useRouteMatch();
@@ -29,7 +29,7 @@ export const Routes = () => {
           <DeploymentList />
         </Route>
         <Route path={`${path}/`}>
-          <PreviewEnvironmentsHome />
+          <EnvironmentsList />
         </Route>
       </Switch>
     </>

+ 10 - 8
dashboard/src/main/home/infrastructure/ExpandedInfra.tsx

@@ -37,8 +37,10 @@ const ExpandedInfra: React.FunctionComponent = () => {
       return;
     }
 
-    let isSubscribed = true;
+    refreshInfra();
+  }, [currentProject, infra_id]);
 
+  const refreshInfra = () => {
     api
       .getInfraByID(
         "<token>",
@@ -49,10 +51,6 @@ const ExpandedInfra: React.FunctionComponent = () => {
         }
       )
       .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
         setInfra(data);
       })
       .catch((err) => {
@@ -60,7 +58,7 @@ const ExpandedInfra: React.FunctionComponent = () => {
         setHasError(true);
         setCurrentError(err.response?.data?.error);
       });
-  }, [currentProject, infra_id]);
+  };
 
   useEffect(() => {
     if (!currentProject || !infra) {
@@ -132,12 +130,16 @@ const ExpandedInfra: React.FunctionComponent = () => {
     switch (newTab) {
       case "deploys":
         return (
-          <DeployList infra={infra} setLatestOperation={setLatestOperation} />
+          <DeployList
+            infra={infra}
+            setLatestOperation={setLatestOperation}
+            refreshInfra={refreshInfra}
+          />
         );
       case "resources":
         return <InfraResourceList infra_id={infra_id} />;
       case "settings":
-        return <InfraSettings infra_id={infra_id} onDelete={() => {}} />;
+        return <InfraSettings infra_id={infra_id} onDelete={refreshInfra} />;
     }
   };
 

+ 4 - 1
dashboard/src/main/home/infrastructure/components/DeployList.tsx

@@ -17,11 +17,13 @@ import ExpandedOperation from "./ExpandedOperation";
 type Props = {
   infra: Infrastructure;
   setLatestOperation: (operation: Operation) => void;
+  refreshInfra: () => void;
 };
 
 const DeployList: React.FunctionComponent<Props> = ({
   infra,
   setLatestOperation,
+  refreshInfra,
 }) => {
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
@@ -180,8 +182,9 @@ const DeployList: React.FunctionComponent<Props> = ({
       return (
         <ExpandedOperation
           operation_id={selectedOperation.id}
-          infra_id={selectedOperation.infra_id}
+          infra={infra}
           back={backFromExpandedOperation}
+          refreshInfra={refreshInfra}
         />
       );
     }

+ 45 - 15
dashboard/src/main/home/infrastructure/components/ExpandedOperation.tsx

@@ -3,7 +3,12 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Loading from "components/Loading";
-import { Operation, OperationStatus, OperationType } from "shared/types";
+import {
+  Infrastructure,
+  Operation,
+  OperationStatus,
+  OperationType,
+} from "shared/types";
 import { readableDate } from "shared/string_utils";
 import Placeholder from "components/Placeholder";
 import { useWebsockets } from "shared/hooks/useWebsockets";
@@ -11,17 +16,20 @@ import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import Description from "components/Description";
+import { OperationDetails } from "components/ProvisionerStatus";
 
 type Props = {
-  infra_id: number;
+  infra: Infrastructure;
   operation_id: string;
   back: (operation?: Operation) => void;
+  refreshInfra: () => void;
 };
 
 const ExpandedOperation: React.FunctionComponent<Props> = ({
-  infra_id,
+  infra,
   operation_id,
   back,
+  refreshInfra,
 }) => {
   const [isLoading, setIsLoading] = useState(true);
   const [hasError, setHasError] = useState(false);
@@ -38,7 +46,7 @@ const ExpandedOperation: React.FunctionComponent<Props> = ({
         {},
         {
           project_id: currentProject.id,
-          infra_id: infra_id,
+          infra_id: infra.id,
           operation_id: operation_id,
         }
       )
@@ -109,7 +117,7 @@ const ExpandedOperation: React.FunctionComponent<Props> = ({
           {},
           {
             project_id: currentProject.id,
-            infra_id: infra_id,
+            infra_id: infra.id,
             operation_id: operation_id,
           }
         )
@@ -129,7 +137,7 @@ const ExpandedOperation: React.FunctionComponent<Props> = ({
   const retry = () => {
     let pathParams = {
       project_id: currentProject.id,
-      infra_id: infra_id,
+      infra_id: infra.id,
     };
 
     let apiCall = api.updateInfra;
@@ -240,6 +248,35 @@ const ExpandedOperation: React.FunctionComponent<Props> = ({
     return logs.map((l, i) => <Log key={i}>{l}</Log>);
   };
 
+  const renderOperationDetails = () => {
+    if (infra.latest_operation.id == operation.id) {
+      return (
+        <>
+          <Description>Infrastructure progress:</Description>
+          <OperationDetails
+            infra={infra}
+            refreshInfra={refreshInfra}
+            useOperation={operation}
+            padding={"12px 0"}
+          />
+        </>
+      );
+    }
+
+    return (
+      <>
+        <Description>
+          {getOperationDescription(
+            operation.type,
+            operation.status,
+            operation.last_updated
+          )}
+        </Description>
+        <Br />
+      </>
+    );
+  };
+
   return (
     <StyledCard>
       <BackArrowContainer>
@@ -250,14 +287,7 @@ const ExpandedOperation: React.FunctionComponent<Props> = ({
       </BackArrowContainer>
       <MetadataContainer>
         <Heading>Deployment Summary</Heading>
-        <Description>
-          {getOperationDescription(
-            operation.type,
-            operation.status,
-            operation.last_updated
-          )}
-        </Description>
-        <Br />
+        {renderOperationDetails()}
         {renderRerunButton()}
       </MetadataContainer>
       <MetadataContainer>
@@ -332,7 +362,7 @@ const MetadataContainer = styled.div`
   margin-bottom: 3px;
   border-radius: 6px;
   background: #2e3135;
-  padding: 0 20px;
+  padding: 0 20px 16px 20px;
   overflow-y: auto;
   min-height: 180px;
   font-size: 13px;

+ 41 - 23
dashboard/src/main/home/infrastructure/components/InfraSettings.tsx

@@ -1,18 +1,24 @@
-import React, { useContext } from "react";
+import React, { useContext, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import styled from "styled-components";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import Description from "components/Description";
+import ConfirmOverlay from "components/ConfirmOverlay";
 
 type Props = {
   infra_id: number;
   onDelete: () => void;
 };
 
-const InfraSettings: React.FunctionComponent<Props> = ({ infra_id }) => {
-  const { currentProject, setCurrentError } = useContext(Context);
+const InfraSettings: React.FunctionComponent<Props> = ({
+  infra_id,
+  onDelete,
+}) => {
+  const { currentProject, setCurrentError, setCurrentOverlay } = useContext(
+    Context
+  );
 
   const deleteInfra = () => {
     api
@@ -24,7 +30,10 @@ const InfraSettings: React.FunctionComponent<Props> = ({ infra_id }) => {
           infra_id: infra_id,
         }
       )
-      .then()
+      .then(() => {
+        setCurrentOverlay(null);
+        onDelete();
+      })
       .catch((err) => {
         console.error(err);
         setCurrentError(err.response?.data?.error);
@@ -32,25 +41,34 @@ const InfraSettings: React.FunctionComponent<Props> = ({ infra_id }) => {
   };
 
   return (
-    <StyledCard>
-      <MetadataContainer>
-        <Heading>Delete Infrastructure</Heading>
-        <Description>
-          This will destroy all of the existing cloud infrastructure attached to
-          this module.
-        </Description>
-        <Br />
-        <SaveButton
-          onClick={deleteInfra}
-          text="Delete Infrastructure"
-          color="#b91133"
-          disabled={false}
-          makeFlush={true}
-          clearPosition={true}
-          saveText="Deletion process started, see the Deploys tab for info."
-        />
-      </MetadataContainer>
-    </StyledCard>
+    <>
+      <StyledCard>
+        <MetadataContainer>
+          <Heading>Delete Infrastructure</Heading>
+          <Description>
+            This will destroy all of the existing cloud infrastructure attached
+            to this module.
+          </Description>
+          <Br />
+
+          <SaveButton
+            onClick={() =>
+              setCurrentOverlay({
+                message: `Are you sure you want to delete this infrastructure?`,
+                onYes: deleteInfra,
+                onNo: () => setCurrentOverlay(null),
+              })
+            }
+            text="Delete Infrastructure"
+            color="#b91133"
+            disabled={false}
+            makeFlush={true}
+            clearPosition={true}
+            saveText="Deletion process started, see the Deploys tab for info."
+          />
+        </MetadataContainer>
+      </StyledCard>
+    </>
   );
 };
 

+ 17 - 0
dashboard/src/main/home/infrastructure/components/ProvisionInfra.tsx

@@ -25,6 +25,7 @@ import Select from "components/porter-form/field-components/Select";
 import ClusterList from "./credentials/ClusterList";
 import { useLocation, useParams } from "react-router";
 import qs from "qs";
+import AzureCredentialsList from "./credentials/AzureCredentialList";
 
 type Props = {};
 
@@ -128,6 +129,7 @@ const ProvisionInfra: React.FunctionComponent<Props> = () => {
           aws_integration_id: currentCredential["aws_integration_id"],
           do_integration_id: currentCredential["do_integration_id"],
           gcp_integration_id: currentCredential["gcp_integration_id"],
+          azure_integration_id: currentCredential["azure_integration_id"],
           cluster_id: selectedClusterID || null,
         },
         {
@@ -248,6 +250,21 @@ const ProvisionInfra: React.FunctionComponent<Props> = () => {
             />
           </ActionContainer>
         );
+      } else if (
+        currentTemplate.required_credential == "azure_integration_id"
+      ) {
+        return (
+          <ActionContainer>
+            <Heading>Step 1 of {numSteps} - Link Azure Credentials</Heading>
+            <AzureCredentialsList
+              selectCredential={(i) =>
+                setCurrentCredential({
+                  azure_integration_id: i,
+                })
+              }
+            />
+          </ActionContainer>
+        );
       }
     }
 

+ 140 - 0
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialForm.tsx

@@ -0,0 +1,140 @@
+import React, { useContext, useState } from "react";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+
+type Props = {
+  setCreatedCredential: (aws_integration_id: number) => void;
+  cancel: () => void;
+};
+
+const AzureCredentialForm: React.FunctionComponent<Props> = ({
+  setCreatedCredential,
+}) => {
+  const { currentProject, setCurrentError } = useContext(Context);
+  const [clientId, setClientId] = useState("");
+  const [servicePrincipalKey, setServicePrincipalKey] = useState("");
+  const [tenantId, setTenantId] = useState("");
+  const [subscriptionId, setSubscriptionId] = useState("");
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const [hasError, setHasError] = useState(false);
+
+  const submit = () => {
+    setIsLoading(true);
+
+    api
+      .createAzureIntegration(
+        "<token>",
+        {
+          azure_client_id: clientId,
+          azure_subscription_id: subscriptionId,
+          azure_tenant_id: tenantId,
+          service_principal_key: servicePrincipalKey,
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setCreatedCredential(data.id);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        setHasError(true);
+        setCurrentError(err.response?.data?.error);
+        setIsLoading(false);
+      });
+  };
+
+  if (hasError) {
+    return <Placeholder>Error</Placeholder>;
+  }
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        value={clientId}
+        setValue={(x: string) => {
+          setClientId(x);
+        }}
+        label="👤 Azure Client ID"
+        placeholder="ex. 12345678-abcd-1234-abcd-12345678abcd"
+        width="100%"
+        isRequired={true}
+      />
+      <InputRow
+        type="password"
+        value={servicePrincipalKey}
+        setValue={(x: string) => {
+          setServicePrincipalKey(x);
+        }}
+        label="🔒 Azure Service Principal Key"
+        placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+        width="100%"
+        isRequired={true}
+      />
+      <InputRow
+        type="text"
+        value={tenantId}
+        setValue={(x: string) => {
+          setTenantId(x);
+        }}
+        label="Azure Tenant ID"
+        placeholder="ex. 12345678-abcd-1234-abcd-12345678abcd"
+        width="100%"
+        isRequired={true}
+      />
+      <InputRow
+        type="text"
+        value={subscriptionId}
+        setValue={(x: string) => {
+          setSubscriptionId(x);
+        }}
+        label="Azure Subscription ID"
+        placeholder="ex. 12345678-abcd-1234-abcd-12345678abcd"
+        width="100%"
+        isRequired={true}
+      />
+      <Flex>
+        <SaveButton
+          text="Continue"
+          disabled={false}
+          onClick={submit}
+          makeFlush={true}
+          clearPosition={true}
+          status={buttonStatus}
+          statusPosition={"right"}
+        />
+      </Flex>
+    </>
+  );
+};
+
+export default AzureCredentialForm;
+
+const Flex = styled.div`
+  display: flex;
+  color: #ffffff;
+  align-items: center;
+  > i {
+    color: #aaaabb;
+    font-size: 20px;
+    margin-right: 10px;
+  }
+`;

+ 110 - 0
dashboard/src/main/home/infrastructure/components/credentials/AzureCredentialList.tsx

@@ -0,0 +1,110 @@
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import AzureCredentialForm from "./AzureCredentialForm";
+import CredentialList from "./CredentialList";
+import Description from "components/Description";
+
+type Props = {
+  selectCredential: (azure_integration_id: number) => void;
+};
+
+type AzureCredential = {
+  created_at: string;
+  id: number;
+  user_id: number;
+  project_id: number;
+  azure_client_id: string;
+};
+
+const AzureCredentialsList: React.FunctionComponent<Props> = ({
+  selectCredential,
+}) => {
+  const { currentProject, setCurrentError } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [azCredentials, setAzureCredentials] = useState<AzureCredential[]>(
+    null
+  );
+  const [shouldCreateCred, setShouldCreateCred] = useState(false);
+  const [hasError, setHasError] = useState(false);
+
+  useEffect(() => {
+    api
+      .getAzureIntegration(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+
+        setAzureCredentials(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        setHasError(true);
+        setCurrentError(err.response?.data?.error);
+        setIsLoading(false);
+      });
+  }, [currentProject]);
+
+  if (hasError) {
+    return <Placeholder>Error</Placeholder>;
+  }
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  const renderContents = () => {
+    if (shouldCreateCred) {
+      return (
+        <AzureCredentialForm
+          setCreatedCredential={selectCredential}
+          cancel={() => {}}
+        />
+      );
+    }
+
+    return (
+      <>
+        <Description>
+          Select your credentials from the list below, or create a new
+          credential:
+        </Description>
+        <CredentialList
+          credentials={azCredentials.map((cred) => {
+            return {
+              id: cred.id,
+              display_name: cred.azure_client_id,
+              created_at: cred.created_at,
+            };
+          })}
+          selectCredential={selectCredential}
+          shouldCreateCred={() => setShouldCreateCred(true)}
+          addNewText="Add New Azure Credential"
+        />
+      </>
+    );
+  };
+
+  return <AzureCredentialWrapper>{renderContents()}</AzureCredentialWrapper>;
+};
+
+export default AzureCredentialsList;
+
+const AzureCredentialWrapper = styled.div`
+  margin-top: 20px;
+`;

+ 20 - 26
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -224,10 +224,27 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       case "doks":
         provider = "digitalocean";
         break;
+      case "aks":
+        provider = "azure";
+        break;
+      case "vke":
+        provider = "vultr";
+        break;
       default:
         provider = "";
     }
 
+    // Check the server URL to see if we can detect the cluster provider.
+    // There's no standard URL format for GCP that's why it's not currently included
+    if (provider === "") {
+      const server = currentCluster.server;
+
+      if (server.includes("eks")) provider = "eks";
+      else if (server.includes("ondigitalocean")) provider = "digitalocean";
+      else if (server.includes("azmk8s")) provider = "azure";
+      else if (server.includes("vultr")) provider = "vultr";
+    }
+
     // don't overwrite for templates that already have a source (i.e. non-Docker templates)
     if (url && tag) {
       _.set(values, "image.repository", url);
@@ -284,6 +301,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       }
     }
 
+    const synced = values?.container?.env?.synced || [];
+
     try {
       await api.deployTemplate(
         "<token>",
@@ -295,6 +314,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           name: release_name,
           github_action_config: githubActionConfig,
           build_config: buildConfig,
+          synced_env_groups: synced.map((s: any) => s.name),
         },
         {
           id: currentProject.id,
@@ -312,32 +332,6 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       return;
     }
 
-    // Save application into synced groups
-    const synced = values?.container?.env?.synced || [];
-
-    const addApplicationToEnvGroupPromises = synced.map((envGroup: any) => {
-      return api.addApplicationToEnvGroup(
-        "<token>",
-        {
-          name: envGroup?.name,
-          app_name: release_name,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: selectedNamespace,
-        }
-      );
-    });
-
-    try {
-      await Promise.all(addApplicationToEnvGroupPromises);
-    } catch (error) {
-      setCurrentError(
-        "We coudln't sync the env group to the application, please go to your recently deployed application and try again through the environment tab."
-      );
-    }
-
     setSaveValuesStatus("successful");
     // redirect to dashboard with namespace
     setTimeout(() => {

+ 16 - 6
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -20,6 +20,7 @@ import {
 } from "components/porter-form/types";
 import Helper from "components/form-components/Helper";
 import DocsHelper from "components/DocsHelper";
+import { isObject } from "lodash";
 
 type PropsType = {
   namespace: string;
@@ -157,6 +158,9 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
   };
 
   potentiallyOverriddenKeys(incoming: Record<string, string>): KeyValue[] {
+    if (!incoming) {
+      return [];
+    }
     // console.log(incoming, this.props.existingValues);
     return Object.entries(incoming)
       .filter(([key]) => this.props.existingValues[key])
@@ -227,12 +231,18 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           {this.state.selectedEnvGroup && (
             <SidebarSection>
               <GroupEnvPreview>
-                {Object.entries(this.state.selectedEnvGroup.variables)
-                  .map(
-                    ([key, value]) =>
-                      `${key}=${formattedEnvironmentValue(value)}`
-                  )
-                  .join("\n")}
+                {isObject(this.state.selectedEnvGroup.variables) ? (
+                  <>
+                    {Object.entries(this.state.selectedEnvGroup.variables || {})
+                      .map(
+                        ([key, value]) =>
+                          `${key}=${formattedEnvironmentValue(value)}`
+                      )
+                      .join("\n")}
+                  </>
+                ) : (
+                  <>This environment group has no variables</>
+                )}
               </GroupEnvPreview>
               {clashingKeys?.length > 0 && (
                 <>

+ 24 - 13
dashboard/src/main/home/onboarding/Routes.tsx

@@ -1,5 +1,6 @@
 import React from "react";
 import { Route, Switch } from "react-router";
+import PorterErrorBoundary from "shared/error_handling/PorterErrorBoundary";
 import { OFState } from "./state";
 import ConnectRegistry from "./steps/ConnectRegistry/ConnectRegistry";
 import ConnectSource from "./steps/ConnectSource";
@@ -8,19 +9,29 @@ import ProvisionResources from "./steps/ProvisionResources/ProvisionResources";
 export const Routes = () => {
   return (
     <>
-      <Switch>
-        <Route path={`/onboarding/source`}>
-          <ConnectSource
-            onSuccess={(data) => OFState.actions.nextStep("continue", data)}
-          />
-        </Route>
-        <Route path={["/onboarding/registry/:step?"]}>
-          <ConnectRegistry />
-        </Route>
-        <Route path={[`/onboarding/provision/:step?`]}>
-          <ProvisionResources />
-        </Route>
-      </Switch>
+      <PorterErrorBoundary
+        errorBoundaryLocation="onboarding"
+        tags={{ scope: "onboarding" }}
+      >
+        <Switch>
+          <Route path={`/onboarding/source`}>
+            <ConnectSource
+              onSuccess={(data) => OFState.actions.nextStep("continue", data)}
+            />
+          </Route>
+          <Route path={["/onboarding/registry/:step?"]}>
+            <ConnectRegistry />
+          </Route>
+          <Route path={[`/onboarding/provision/:step?`]}>
+            <PorterErrorBoundary
+              errorBoundaryLocation="onboarding.provision_resources"
+              tags={{ scope: "onboarding.provision_resources" }}
+            >
+              <ProvisionResources />
+            </PorterErrorBoundary>
+          </Route>
+        </Switch>
+      </PorterErrorBoundary>
     </>
   );
 };

+ 45 - 59
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -1,7 +1,8 @@
 import Helper from "components/form-components/Helper";
 import SaveButton from "components/SaveButton";
 import TitleSection from "components/TitleSection";
-import React, { useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
+import Cohere from "cohere-js";
 import { useParams } from "react-router";
 import styled from "styled-components";
 import ProviderSelector, {
@@ -22,12 +23,11 @@ import api from "shared/api";
 import Placeholder from "components/Placeholder";
 import Loading from "components/Loading";
 import MultiSaveButton from "components/MultiSaveButton";
+import buildLogger from "shared/error_handling/logger";
 
-type Props = {};
+const ProvisionResourcesLogger = buildLogger("onboarding.provision_resources");
 
-type SaveButtonOptions = "retry" | "delete_all" | "back";
-
-const ProvisionResources: React.FC<Props> = () => {
+const ProvisionResources: React.FC<{}> = () => {
   const snap = useSnapshot(OFState);
   const { step } = useParams<{ step: any }>();
   const [infraStatus, setInfraStatus] = useState<{
@@ -35,10 +35,6 @@ const ProvisionResources: React.FC<Props> = () => {
     errored_infras: number[];
     description?: string;
   }>(null);
-  const [
-    failedSaveButtonOption,
-    setFailedSaveButtonOption,
-  ] = useState<SaveButtonOptions>("retry");
 
   const [isLoading, setIsLoading] = useState(false);
 
@@ -134,49 +130,6 @@ const ProvisionResources: React.FC<Props> = () => {
       });
   };
 
-  const getFailedSaveButton = () => {
-    switch (failedSaveButtonOption) {
-      case "retry":
-        return (
-          <SaveButton
-            text="Retry"
-            disabled={false}
-            onClick={retryFailedInfras}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-          />
-        );
-      case "delete_all":
-        return (
-          <SaveButton
-            text="Delete All Infrastructure"
-            disabled={false}
-            onClick={deleteAllInfras}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-          />
-        );
-      case "back":
-        return (
-          <SaveButton
-            text="Configure Settings"
-            disabled={false}
-            onClick={() => {
-              handleGoBack("");
-            }}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-          />
-        );
-    }
-  };
-
   const renderSaveButton = () => {
     if (typeof infraStatus?.hasError !== "boolean") {
       return;
@@ -237,15 +190,15 @@ const ProvisionResources: React.FC<Props> = () => {
     }
   };
 
-  const getDescription = () => {
-    if (infraStatus && infraStatus.hasError) {
+  const description = useMemo(() => {
+    if (infraStatus?.hasError) {
       return "Error while creating infrastructure. Please select an option below to continue.";
     }
 
     return "Note: Provisioning can take up to 15 minutes.";
-  };
+  }, [infraStatus]);
 
-  const getFilterOpts = (): string[] => {
+  const filterOpts = useMemo(() => {
     switch (provider) {
       case "aws":
         return ["eks", "ecr"];
@@ -256,7 +209,40 @@ const ProvisionResources: React.FC<Props> = () => {
     }
 
     return [];
-  };
+  }, [provider]);
+
+  useEffect(() => {
+    if (!infraStatus) return;
+
+    if (typeof infraStatus.hasError !== "boolean") return;
+
+    if (infraStatus.hasError) {
+      Cohere.widget("show");
+      Cohere.widget("expand");
+
+      const cause = new Error(
+        JSON.stringify({
+          description: infraStatus.description,
+          errored_infras: infraStatus.errored_infras,
+        })
+      );
+
+      ProvisionResourcesLogger.critical(
+        new Error(
+          `Provisioner error detected ${snap.StateHandler.project.id}`,
+          { cause }
+        )
+      );
+    } else {
+      Cohere.widget("hide");
+    }
+  }, [infraStatus]);
+
+  useEffect(() => {
+    return () => {
+      Cohere.widget("hide");
+    };
+  }, []);
 
   const Content = () => {
     switch (step) {
@@ -276,7 +262,7 @@ const ProvisionResources: React.FC<Props> = () => {
           <>
             <StatusPage
               project_id={project?.id}
-              filter={getFilterOpts()}
+              filter={filterOpts}
               setInfraStatus={setInfraStatus}
               filterLatest
               auto_expanded
@@ -285,7 +271,7 @@ const ProvisionResources: React.FC<Props> = () => {
               can_delete={false}
             />
             <Br />
-            <Helper>{getDescription()}</Helper>
+            <Helper>{description}</Helper>
             {renderSaveButton()}
           </>
         );

+ 4 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -10,6 +10,7 @@ import Drawer from "./Drawer";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered } from "shared/routing";
 import { NavLink } from "react-router-dom";
+import { Tooltip } from "@material-ui/core";
 
 type PropsType = RouteComponentProps & {
   forceCloseDrawer: boolean;
@@ -177,7 +178,9 @@ class ClusterSection extends Component<PropsType, StateType> {
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
             </ClusterIcon>
-            <ClusterName>{currentCluster && currentCluster.name}</ClusterName>
+            <Tooltip title={currentCluster?.name}>
+              <ClusterName>{currentCluster?.name}</ClusterName>
+            </Tooltip>
           </LinkWrapper>
           <DrawerButton
             onClick={(e) => {

+ 4 - 1
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -6,6 +6,7 @@ import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered } from "shared/routing";
+import { Tooltip } from "@material-ui/core";
 
 type PropsType = RouteComponentProps & {
   toggleDrawer: () => void;
@@ -44,7 +45,9 @@ class Drawer extends Component<PropsType, StateType> {
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
             </ClusterIcon>
-            <ClusterName>{cluster.name}</ClusterName>
+            <Tooltip title={cluster?.name}>
+              <ClusterName>{cluster.name}</ClusterName>
+            </Tooltip>
           </ClusterOption>
         );
       });

+ 16 - 4
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -14,7 +14,7 @@ import { Context } from "shared/Context";
 import ClusterSection from "./ClusterSection";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import { RouteComponentProps, withRouter } from "react-router";
-import { pushFiltered } from "shared/routing";
+import { getQueryParam, pushFiltered } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { NavLink } from "react-router-dom";
 
@@ -115,8 +115,12 @@ class Sidebar extends Component<PropsType, StateType> {
               let pathNamespace = params.namespace;
               let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
+              if (!pathNamespace) {
+                pathNamespace = getQueryParam(this.props, "namespace");
+              }
+
               if (pathNamespace) {
-                search.concat(`&namespace=${pathNamespace}`);
+                search = search.concat(`&namespace=${pathNamespace}`);
               }
 
               return {
@@ -135,8 +139,12 @@ class Sidebar extends Component<PropsType, StateType> {
               let pathNamespace = params.namespace;
               let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
+              if (!pathNamespace) {
+                pathNamespace = getQueryParam(this.props, "namespace");
+              }
+
               if (pathNamespace) {
-                search.concat(`&namespace=${pathNamespace}`);
+                search = search.concat(`&namespace=${pathNamespace}`);
               }
 
               return {
@@ -155,8 +163,12 @@ class Sidebar extends Component<PropsType, StateType> {
               let pathNamespace = params.namespace;
               let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
+              if (!pathNamespace) {
+                pathNamespace = getQueryParam(this.props, "namespace");
+              }
+
               if (pathNamespace) {
-                search.concat(`&namespace=${pathNamespace}`);
+                search = search.concat(`&namespace=${pathNamespace}`);
               }
 
               return {

+ 51 - 5
dashboard/src/shared/api.tsx

@@ -57,6 +57,11 @@ const getGCPIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/gcp`
 );
 
+const getAzureIntegration = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/integrations/azure`
+);
+
 const createAWSIntegration = baseApi<
   {
     aws_region: string;
@@ -83,6 +88,18 @@ const overwriteAWSIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/integrations/aws/overwrite`;
 });
 
+const createAzureIntegration = baseApi<
+  {
+    azure_client_id: string;
+    azure_subscription_id: string;
+    azure_tenant_id: string;
+    service_principal_key: string;
+  },
+  { id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/integrations/azure`;
+});
+
 const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
   return `/api/email/verify/initiate`;
 });
@@ -371,11 +388,7 @@ const deletePRDeployment = baseApi<
     deployment_id: number;
   }
 >("DELETE", (pathParams) => {
-  const {
-    cluster_id,
-    project_id,
-    deployment_id,
-  } = pathParams;
+  const { cluster_id, project_id, deployment_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}`;
 });
 
@@ -418,6 +431,7 @@ const deployTemplate = baseApi<
     name: string;
     github_action_config?: FullActionConfigType;
     build_config?: any;
+    synced_env_groups?: string[];
   },
   {
     id: number;
@@ -714,6 +728,7 @@ const provisionInfra = baseApi<
     aws_integration_id?: number;
     gcp_integration_id?: number;
     do_integration_id?: number;
+    azure_integration_id?: number;
     cluster_id?: number;
   },
   {
@@ -1791,6 +1806,32 @@ const triggerPreviewEnvWorkflow = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}/trigger_workflow`
 );
 
+const getTagsByProjectId = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/tags`
+);
+
+const createTag = baseApi<
+  { name: string; color: string },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/tags`);
+
+const updateReleaseTags = baseApi<
+  {
+    tags: string[];
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, release_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_tags`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1799,8 +1840,10 @@ export default {
   connectDORegistry,
   getAWSIntegration,
   getGCPIntegration,
+  getAzureIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
+  createAzureIntegration,
   createEmailVerification,
   createEnvironment,
   deleteEnvironment,
@@ -1960,4 +2003,7 @@ export default {
   updateBuildConfig,
   reRunGHWorkflow,
   triggerPreviewEnvWorkflow,
+  getTagsByProjectId,
+  createTag,
+  updateReleaseTags,
 };

+ 7 - 0
dashboard/src/shared/baseApi.ts

@@ -58,6 +58,13 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     };
   }
 
+  if (method.toUpperCase() === "PATCH") {
+    return {
+      ...config,
+      data: params,
+    };
+  }
+
   return config;
 };
 

+ 12 - 1
dashboard/src/shared/common.tsx

@@ -25,7 +25,8 @@ export const integrationList: any = {
     buttonText: "Link a Github Account",
   },
   slack: {
-    icon: "https://user-images.githubusercontent.com/5147537/54070671-0a173780-4263-11e9-8946-09ac0e37d8c6.png",
+    icon:
+      "https://user-images.githubusercontent.com/5147537/54070671-0a173780-4263-11e9-8946-09ac0e37d8c6.png",
     label: "Slack",
     buttonText: "Install Application",
   },
@@ -78,6 +79,16 @@ export const integrationList: any = {
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
     label: "Digital Ocean Container Registry (DOCR)",
   },
+  aks: {
+    icon:
+      "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
+    label: "Azure Kubernetes Service (AKS)",
+  },
+  acr: {
+    icon:
+      "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
+    label: "Azure Container Registry (ACR)",
+  },
   aws: {
     icon: aws,
     label: "AWS",

+ 65 - 0
dashboard/src/shared/error_handling/logger.ts

@@ -0,0 +1,65 @@
+import * as Sentry from "@sentry/react";
+import Cohere from "cohere-js";
+import { isEmpty } from "lodash";
+
+type LogFunction = (error: Error, tags?: { [key: string]: string }) => void;
+type LogFunctions = {
+  [key in Sentry.Severity]: LogFunction;
+};
+
+type LogFunctionBuilder = (
+  scope: string,
+  severity: Sentry.Severity
+) => LogFunction;
+
+const logFunctionBuilder: LogFunctionBuilder = (scope, severity) => (
+  error,
+  tags
+) => {
+  if (process.env.ENABLE_COHERE) {
+    Cohere.getSessionUrl((sessionUrl) => {
+      Sentry.withScope((sentryScope) => {
+        sentryScope.setTag("scope", scope);
+        sentryScope.setTag("cohere_link", sessionUrl);
+        sentryScope.setLevel(severity);
+
+        if (!isEmpty(tags)) {
+          sentryScope.setTags(tags);
+        }
+
+        Sentry.captureException(error);
+      });
+    });
+  } else {
+    Sentry.withScope((sentryScope) => {
+      sentryScope.setTag("scope", scope);
+      sentryScope.setLevel(severity);
+
+      if (!isEmpty(tags)) {
+        sentryScope.setTags(tags);
+      }
+
+      Sentry.captureException(error);
+    });
+  }
+};
+
+function buildLogger(scope: string = "global") {
+  const logFunctions = Object.values(Sentry.Severity).reduce<LogFunctions>(
+    (acc, currentSeverity) => {
+      if (typeof currentSeverity === "string") {
+        acc[currentSeverity] = logFunctionBuilder(
+          scope,
+          Sentry.Severity.fromString(currentSeverity)
+        );
+      }
+
+      return acc;
+    },
+    {} as LogFunctions
+  );
+
+  return logFunctions;
+}
+
+export default buildLogger;

+ 24 - 1
dashboard/src/shared/error_handling/sentry/setup.ts

@@ -1,8 +1,13 @@
 import * as Sentry from "@sentry/react";
 import { Integrations } from "@sentry/tracing";
+import Cohere from "cohere-js";
+import CohereSentry from "cohere-sentry";
 
 const SENTRY_DSN = process.env.SENTRY_DSN;
 const SENTRY_ENV = process.env.SENTRY_ENV || "development";
+const COHERE_INTEGRATION = process.env.ENABLE_COHERE
+  ? [new CohereSentry()]
+  : [];
 
 export const SetupSentry = () => {
   if (!SENTRY_DSN) {
@@ -10,9 +15,27 @@ export const SetupSentry = () => {
   }
   Sentry.init({
     dsn: SENTRY_DSN,
-    integrations: [new Integrations.BrowserTracing()],
+    integrations: [new Integrations.BrowserTracing(), ...COHERE_INTEGRATION],
     environment: SENTRY_ENV,
     // Check out https://docs.sentry.io/platforms/javascript/guides/react/configuration/sampling/ for a more refined sample rate
     tracesSampleRate: 1,
   });
+
+  if (process.env.ENABLE_COHERE) {
+    const sessionUrlListener = (sessionUrl: string) => {
+      Sentry.configureScope((scope) => {
+        scope.addEventProcessor((event) => {
+          event.tags = {
+            ...event.tags,
+            cohere_link: `${sessionUrl}${
+              event.timestamp ? `?ts=${event.timestamp * 1000}` : ""
+            }`,
+          };
+
+          return event;
+        });
+      });
+    };
+    Cohere.addSessionUrlListener(sessionUrlListener);
+  }
 };

+ 35 - 25
dashboard/src/shared/hooks/useChart.ts

@@ -100,7 +100,34 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
    * Delete/Uninstall chart
    */
   const deleteChart = async () => {
+    setStatus("deleting");
     try {
+      const syncedEnvGroups = chart.config?.container?.env?.synced || [];
+      const removeApplicationToEnvGroupPromises = syncedEnvGroups.map(
+        (envGroup: any) => {
+          return api.removeApplicationFromEnvGroup(
+            "<token>",
+            {
+              name: envGroup?.name,
+              app_name: chart.name,
+            },
+            {
+              project_id: currentProject.id,
+              cluster_id: currentCluster.id,
+              namespace: chart.namespace,
+            }
+          );
+        }
+      );
+      try {
+        await Promise.all(removeApplicationToEnvGroupPromises);
+      } catch (error) {
+        setCurrentError(
+          "We coudln't remove the synced env group from the application, please remove it manually before uninstalling the chart, or try again."
+        );
+        return;
+      }
+
       await api.uninstallTemplate(
         "<token>",
         {},
@@ -124,35 +151,18 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
    * Update chart values
    */
   const updateChart = async (
-    processValues:
-      | ((chart: ChartType) => string)
-      | ((chart: ChartType, oldChart?: ChartType) => string)
+    processValues: (
+      chart: ChartType,
+      oldChart?: ChartType
+    ) => { yaml: string; metadata: any }
   ) => {
     setSaveStatus("loading");
-    const values = processValues(chart, oldChart);
-
-    const oldSyncedEnvGroups = oldChart.config?.container?.env?.synced || [];
-    const newSyncedEnvGroups = chart.config?.container?.env?.synced || [];
+    const { yaml: values, metadata } = processValues(chart, oldChart);
 
-    const deletedEnvGroups = onlyInLeft<{
-      keys: Array<any>;
-      name: string;
-      version: number;
-    }>(
-      oldSyncedEnvGroups,
-      newSyncedEnvGroups,
-      (oldVal, newVal) => oldVal.name === newVal.name
-    );
+    const syncEnvGroups = metadata ? metadata["container.env"] : {};
 
-    const addedEnvGroups = onlyInLeft<{
-      keys: Array<any>;
-      name: string;
-      version: number;
-    }>(
-      newSyncedEnvGroups,
-      oldSyncedEnvGroups,
-      (oldVal, newVal) => oldVal.name === newVal.name
-    );
+    const addedEnvGroups = syncEnvGroups?.added || [];
+    const deletedEnvGroups = syncEnvGroups?.deleted || [];
 
     const addApplicationToEnvGroupPromises = addedEnvGroups.map(
       (envGroup: any) => {

+ 7 - 0
dashboard/src/shared/release/utils.ts

@@ -0,0 +1,7 @@
+import { ChartTypeWithExtendedConfig } from "shared/types";
+
+export const isDeployedFromGithub = (release: ChartTypeWithExtendedConfig) => {
+  const githubRepository = release?.git_action_config?.git_repo;
+
+  return !!githubRepository?.length;
+};

+ 42 - 0
dashboard/src/shared/string_utils.ts

@@ -11,3 +11,45 @@ export const readableDate = (s: string) => {
 export const capitalize = (s: string) => {
   return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
 };
+
+const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
+
+export const dotenv_parse = (src: string): Record<string, string> => {
+  // Parser src into an Object
+
+  const obj = {} as Record<string, string>;
+
+  // Convert buffer to string
+  let lines = src.toString();
+
+  // Convert line breaks to same format
+  lines = lines.replace(/\r\n?/gm, "\n");
+
+  let match;
+  while ((match = LINE.exec(lines)) != null) {
+    const key = match[1];
+
+    // Default undefined or null to empty string
+    let value = match[2] || "";
+
+    // Remove whitespace
+    value = value.trim();
+
+    // Check if double quoted
+    const maybeQuote = value[0];
+
+    // Remove surrounding quotes
+    value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
+
+    // Expand newlines if double quoted
+    if (maybeQuote === '"') {
+      value = value.replace(/\\n/g, "\n");
+      value = value.replace(/\\r/g, "\r");
+    }
+
+    // Add to object
+    obj[key] = value;
+  }
+
+  return obj;
+};

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

@@ -49,6 +49,7 @@ export interface ChartType {
   version: number;
   namespace: string;
   latest_version: string;
+  tags: any;
 }
 
 export interface ChartTypeWithExtendedConfig extends ChartType {
@@ -381,6 +382,8 @@ export type InfraKind =
   | "gcr"
   | "doks"
   | "docr"
+  | "aks"
+  | "acr"
   | "test";
 
 export type OperationStatus = "starting" | "completed" | "errored";
@@ -508,6 +511,20 @@ export const KindMap: ProviderInfoMap = {
     resource_link: "/dashboard",
     provider_name: "Digital Ocean Kubernetes Service (DOKS)",
   },
+  aks: {
+    provider: "azure",
+    source: "porter/azure/aks",
+    resource_name: "Cluster",
+    resource_link: "/dashboard",
+    provider_name: "Azure Kubernetes Service (AKS)",
+  },
+  acr: {
+    provider: "azure",
+    source: "porter/azure/acr",
+    resource_name: "Registry",
+    resource_link: "/integrations/registry",
+    provider_name: "Azure Container Registry (ACR)",
+  },
   test: {
     provider: "aws",
     source: "porter/test",
@@ -540,6 +557,7 @@ export type InfraCredentialOptions =
   | "aws_integration_id"
   | "gcp_integration_id"
   | "do_integration_id"
+  | "azure_integration_id"
   | "";
 
 export type InfraCredentials = {

+ 13 - 2
dashboard/webpack.config.js

@@ -12,13 +12,25 @@ const TerserPlugin = require("terser-webpack-plugin");
 
 module.exports = () => {
   let env = dotenv.config().parsed;
+
   if (!env) {
     env = process.env;
   }
   const envKeys = Object.keys(env).reduce((prev, next) => {
-    prev[`process.env.${next}`] = JSON.stringify(env[next]);
+    const varName = `process.env.${next}`;
+    if (typeof env[next] !== "string") return prev;
+
+    if (env[next].toLowerCase() === "true") {
+      prev[varName] = true;
+    } else if (env[next].toLowerCase() === "false") {
+      prev[varName] = false;
+    } else {
+      prev[varName] = JSON.stringify(env[next]);
+    }
+
     return prev;
   }, {});
+
   // Check first the env file and if it's empty, check out the node env of the process.
   let isDevelopment = env.NODE_ENV !== "production";
   if (process.env.NODE_ENV !== env.NODE_ENV) {
@@ -32,7 +44,6 @@ module.exports = () => {
   if (env.IS_HOSTED) {
     htmlPluginOpts = {
       template: path.resolve(__dirname, "src", "hosted.index.html"),
-      cohereKey: `${env.COHERE_KEY}`,
       intercomAppId: `${env.INTERCOM_APP_ID}`,
       intercomSrc: `${process.env.INTERCOM_SRC}`,
       segmentWriteKey: `${process.env.SEGMENT_WRITE_KEY}`,

+ 2 - 1
docker/Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.17-alpine as base
+FROM golang:1.18-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc
@@ -13,6 +13,7 @@ COPY /internal ./internal
 COPY /api ./api
 COPY /scripts ./scripts
 COPY /provisioner ./provisioner
+COPY /pkg ./pkg
 
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio